From 1f6f7a4f7aaaef343d52473020a0c0282bef6c2c Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 12:16:10 +0200 Subject: [PATCH 01/26] feat(framework): Add flower-agent --- framework/docs/source/ref-api-cli.rst | 10 + framework/docs/source/ref-exit-codes/602.rst | 11 +- framework/py/flwr/common/exit/exit.py | 2 + framework/py/flwr/common/telemetry.py | 4 + framework/py/flwr/supercore/agent/__init__.py | 22 +++ .../flwr/supercore/agent/run_flower_agent.py | 45 +++++ .../supercore/agent/run_flower_agent_test.py | 57 ++++++ framework/py/flwr/supercore/cli/__init__.py | 2 + .../py/flwr/supercore/cli/flower_agent.py | 107 ++++++++++ .../flwr/supercore/cli/flower_agent_test.py | 184 ++++++++++++++++++ .../supercore/superexec/plugin/__init__.py | 2 + .../superexec/plugin/agent_exec_plugin.py | 44 +++++ .../superexec/plugin/base_exec_plugin_test.py | 20 ++ framework/pyproject.toml | 1 + 14 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 framework/py/flwr/supercore/agent/__init__.py create mode 100644 framework/py/flwr/supercore/agent/run_flower_agent.py create mode 100644 framework/py/flwr/supercore/agent/run_flower_agent_test.py create mode 100644 framework/py/flwr/supercore/cli/flower_agent.py create mode 100644 framework/py/flwr/supercore/cli/flower_agent_test.py create mode 100644 framework/py/flwr/supercore/superexec/plugin/agent_exec_plugin.py diff --git a/framework/docs/source/ref-api-cli.rst b/framework/docs/source/ref-api-cli.rst index 0650c45c8261..8e7bc9aa8370 100644 --- a/framework/docs/source/ref-api-cli.rst +++ b/framework/docs/source/ref-api-cli.rst @@ -39,6 +39,16 @@ Advanced Commands ******************* +.. _flower-agent-apiref: + +``flower-agent`` +================ + +.. argparse:: + :module: flwr.supercore.cli.flower_agent + :func: _parse_args + :prog: flower-agent + .. _flower-superexec-apiref: ``flower-superexec`` diff --git a/framework/docs/source/ref-exit-codes/602.rst b/framework/docs/source/ref-exit-codes/602.rst index 45fde2d36b97..91c7d3bb6340 100644 --- a/framework/docs/source/ref-exit-codes/602.rst +++ b/framework/docs/source/ref-exit-codes/602.rst @@ -6,11 +6,12 @@ Description ************* -The ``flower-superexec``, ``flwr-serverapp``, ``flwr-simulation``, and -``flwr-clientapp`` do not currently support TLS, as they are assumed to be executed -within the same network as their respective long-running processes: ``flower-superlink`` -and ``flower-supernode``. Please refer to the `Flower Network Communication -<../ref-flower-network-communication.html>`_ guide for further details. +The ``flower-agent``, ``flower-superexec``, ``flwr-serverapp``, +``flwr-simulation``, and ``flwr-clientapp`` do not currently support TLS, as they +are assumed to be executed within the same network as their respective long-running +processes: ``flower-superlink`` and ``flower-supernode``. Please refer to the +`Flower Network Communication <../ref-flower-network-communication.html>`_ guide +for further details. **************** How to Resolve diff --git a/framework/py/flwr/common/exit/exit.py b/framework/py/flwr/common/exit/exit.py index 6f04bb1a5c96..357353afa818 100644 --- a/framework/py/flwr/common/exit/exit.py +++ b/framework/py/flwr/common/exit/exit.py @@ -124,6 +124,8 @@ def _try_obtain_telemetry_event() -> EventType | None: return EventType.RUN_SUPERLINK_LEAVE if sys.argv[0].endswith("flower-supernode"): return EventType.RUN_SUPERNODE_LEAVE + if sys.argv[0].endswith("flower-agent"): + return EventType.RUN_AGENT_LEAVE if sys.argv[0].endswith("flwr-serverapp"): return EventType.FLWR_SERVERAPP_RUN_LEAVE if sys.argv[0].endswith("flwr-clientapp"): diff --git a/framework/py/flwr/common/telemetry.py b/framework/py/flwr/common/telemetry.py index 64f6d6d67177..29b9c5a0ea5f 100644 --- a/framework/py/flwr/common/telemetry.py +++ b/framework/py/flwr/common/telemetry.py @@ -172,6 +172,10 @@ def _generate_next_value_(name: str, start: int, count: int, last_values: list[A RUN_SUPERNODE_ENTER = auto() RUN_SUPERNODE_LEAVE = auto() + # CLI: `flower-agent` + RUN_AGENT_ENTER = auto() + RUN_AGENT_LEAVE = auto() + # CLI: `flower-superexec` RUN_SUPEREXEC_ENTER = auto() RUN_SUPEREXEC_LEAVE = auto() diff --git a/framework/py/flwr/supercore/agent/__init__.py b/framework/py/flwr/supercore/agent/__init__.py new file mode 100644 index 000000000000..f2c04d88c04c --- /dev/null +++ b/framework/py/flwr/supercore/agent/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower Agent.""" + + +from .run_flower_agent import run_flower_agent + +__all__ = [ + "run_flower_agent", +] diff --git a/framework/py/flwr/supercore/agent/run_flower_agent.py b/framework/py/flwr/supercore/agent/run_flower_agent.py new file mode 100644 index 000000000000..720fb95454d0 --- /dev/null +++ b/framework/py/flwr/supercore/agent/run_flower_agent.py @@ -0,0 +1,45 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower Agent runtime stub.""" + + +from flwr.common import EventType +from flwr.common.constant import RUNTIME_DEPENDENCY_INSTALL +from flwr.common.exit import ExitCode, flwr_exit + + +def run_flower_agent( + appio_api_address: str, + parent_pid: int | None = None, + health_server_address: str | None = None, + superexec_auth_secret: bytes | None = None, + runtime_dependency_install: bool = RUNTIME_DEPENDENCY_INSTALL, +) -> None: + """Run Flower Agent. + + This runtime is intentionally a stub until AgentApp execution support is added. + """ + _ = ( + appio_api_address, + parent_pid, + health_server_address, + superexec_auth_secret, + runtime_dependency_install, + ) + flwr_exit( + ExitCode.SERVERAPP_EXCEPTION, + "`flower-agent` is not implemented yet.", + event_type=EventType.RUN_AGENT_LEAVE, + ) diff --git a/framework/py/flwr/supercore/agent/run_flower_agent_test.py b/framework/py/flwr/supercore/agent/run_flower_agent_test.py new file mode 100644 index 000000000000..033195aaa265 --- /dev/null +++ b/framework/py/flwr/supercore/agent/run_flower_agent_test.py @@ -0,0 +1,57 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Tests for the Flower Agent runtime stub.""" + + +import importlib + +import pytest + +from flwr.common import EventType +from flwr.common.exit import ExitCode + +run_flower_agent_module = importlib.import_module( + "flwr.supercore.agent.run_flower_agent" +) + + +def test_run_flower_agent_exits_with_stub_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The runtime stub should fail fast with a clear message.""" + captured: dict[str, object] = {} + + def _flwr_exit( + code: int, + message: str | None = None, + event_type: EventType | None = None, + ) -> None: + captured["code"] = code + captured["message"] = message + captured["event_type"] = event_type + raise SystemExit(1) + + monkeypatch.setattr(run_flower_agent_module, "flwr_exit", _flwr_exit) + + with pytest.raises(SystemExit): + run_flower_agent_module.run_flower_agent( + appio_api_address="127.0.0.1:9091", + ) + + assert captured == { + "code": ExitCode.SERVERAPP_EXCEPTION, + "message": "`flower-agent` is not implemented yet.", + "event_type": EventType.RUN_AGENT_LEAVE, + } diff --git a/framework/py/flwr/supercore/cli/__init__.py b/framework/py/flwr/supercore/cli/__init__.py index 894726d36891..03d9c032c3b7 100644 --- a/framework/py/flwr/supercore/cli/__init__.py +++ b/framework/py/flwr/supercore/cli/__init__.py @@ -15,8 +15,10 @@ """Flower command line interface for shared infrastructure components.""" +from .flower_agent import flower_agent from .flower_superexec import flower_superexec __all__ = [ + "flower_agent", "flower_superexec", ] diff --git a/framework/py/flwr/supercore/cli/flower_agent.py b/framework/py/flwr/supercore/cli/flower_agent.py new file mode 100644 index 000000000000..f1d74b1125ba --- /dev/null +++ b/framework/py/flwr/supercore/cli/flower_agent.py @@ -0,0 +1,107 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""`flower-agent` command.""" + + +import argparse +from logging import INFO + +from flwr.common import EventType, event +from flwr.common.args import add_args_runtime_dependency_install +from flwr.common.exit import ExitCode, flwr_exit +from flwr.common.logger import log +from flwr.supercore.auth import ( + add_superexec_auth_secret_args, + load_superexec_auth_secret, +) +from flwr.supercore.grpc_health import add_args_health +from flwr.supercore.agent.run_flower_agent import run_flower_agent +from flwr.supercore.update_check import warn_if_flwr_update_available +from flwr.supercore.utils import disable_process_dumping +from flwr.supercore.version import package_version + + +def flower_agent() -> None: + """Run `flower-agent` command.""" + disable_process_dumping(strict=False) + warn_if_flwr_update_available(process_name="flower-agent") + args = _parse_args().parse_args() + + if not args.insecure: + flwr_exit( + ExitCode.COMMON_TLS_NOT_SUPPORTED, + "`flower-agent` does not support TLS yet.", + ) + + log(INFO, "Starting Flower Agent") + + event(EventType.RUN_AGENT_ENTER) + + superexec_auth_secret = None + if args.superexec_auth_secret_file is not None: + try: + superexec_auth_secret = load_superexec_auth_secret( + secret_file=args.superexec_auth_secret_file, + ) + except ValueError as err: + flwr_exit( + ExitCode.SUPEREXEC_AUTH_SECRET_LOAD_FAILED, + f"Failed to load Flower Agent authentication secret: {err}", + ) + + run_flower_agent( + appio_api_address=args.appio_api_address, + parent_pid=args.parent_pid, + health_server_address=args.health_server_address, + superexec_auth_secret=superexec_auth_secret, + runtime_dependency_install=args.runtime_dependency_install, + ) + + +def _parse_args() -> argparse.ArgumentParser: + """Parse `flower-agent` command line arguments.""" + parser = argparse.ArgumentParser( + description="Run Flower Agent.", + ) + parser.add_argument( + "-V", + "--version", + action="version", + version=f"Flower version: {package_version}", + ) + parser.add_argument( + "--appio-api-address", + type=str, + required=True, + help="Address of the AppIO API", + ) + parser.add_argument( + "--insecure", + action="store_true", + help="Connect to the AppIO API without TLS. " + "Data transmitted between the client and server is not encrypted. " + "Use this flag only if you understand the risks.", + ) + parser.add_argument( + "--parent-pid", + type=int, + default=None, + help="The PID of the parent process. When set, the process will terminate " + "when the parent process exits.", + ) + add_superexec_auth_secret_args(parser) + add_args_health(parser) + add_args_runtime_dependency_install(parser) + return parser diff --git a/framework/py/flwr/supercore/cli/flower_agent_test.py b/framework/py/flwr/supercore/cli/flower_agent_test.py new file mode 100644 index 000000000000..4214ec460513 --- /dev/null +++ b/framework/py/flwr/supercore/cli/flower_agent_test.py @@ -0,0 +1,184 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Tests for Flower Agent CLI argument parsing and wiring.""" + + +import importlib +from types import SimpleNamespace + +import pytest + +from flwr.common import EventType +from flwr.supercore.version import package_version + +from .flower_agent import _parse_args + +flower_agent_module = importlib.import_module("flwr.supercore.cli.flower_agent") + + +@pytest.mark.parametrize("flag", ["--version", "-V"]) +def test_parse_agent_version_flag( + flag: str, capsys: pytest.CaptureFixture[str] +) -> None: + """The version flags should print the package version and exit.""" + with pytest.raises(SystemExit) as exc_info: + _parse_args().parse_args([flag]) + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert captured.out == f"Flower version: {package_version}\n" + + +def test_flower_agent_checks_for_update( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Flower Agent should run the startup update check before parsing args.""" + + class _SentinelError(Exception): + pass + + class _Parser: + def parse_args(self) -> SimpleNamespace: + """Return parsed arguments for the test path.""" + return SimpleNamespace(insecure=True) + + def _parse_args() -> _Parser: + return _Parser() + + captured: list[str] = [] + + def _raise_sentinel(process_name: str | None = None) -> None: + captured.append("update") + if process_name is not None: + captured.append(process_name) + raise _SentinelError() + + def _unexpected_parse_args() -> _Parser: + captured.append("parse") + return _parse_args() + + monkeypatch.setattr(flower_agent_module, "_parse_args", _unexpected_parse_args) + monkeypatch.setattr( + flower_agent_module, "disable_process_dumping", lambda **_: None + ) + monkeypatch.setattr( + flower_agent_module, "warn_if_flwr_update_available", _raise_sentinel + ) + + with pytest.raises(_SentinelError): + flower_agent_module.flower_agent() + + assert captured == ["update", "flower-agent"] + + +def test_flower_agent_forwards_cli_args( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Flower Agent CLI should forward parsed arguments to the runtime stub.""" + args = SimpleNamespace( + insecure=True, + appio_api_address="127.0.0.1:9091", + parent_pid=4321, + health_server_address="127.0.0.1:9099", + superexec_auth_secret_file="/tmp/agent-secret", + runtime_dependency_install=True, + ) + captured: dict[str, object] = {} + events: list[tuple[EventType, dict[str, object] | None]] = [] + + class _Parser: + def parse_args(self) -> SimpleNamespace: + """Return parsed arguments for the test path.""" + return args + + def _run_flower_agent(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr( + flower_agent_module, "disable_process_dumping", lambda **_: None + ) + monkeypatch.setattr( + flower_agent_module, + "warn_if_flwr_update_available", + lambda **_: None, + ) + monkeypatch.setattr( + flower_agent_module, "load_superexec_auth_secret", lambda **_: b"secret" + ) + monkeypatch.setattr( + flower_agent_module, + "event", + lambda event_type, event_details=None: events.append( + (event_type, event_details) + ), + ) + monkeypatch.setattr(flower_agent_module, "_parse_args", _Parser) + monkeypatch.setattr( + flower_agent_module, "run_flower_agent", _run_flower_agent + ) + + flower_agent_module.flower_agent() + + assert events == [(EventType.RUN_AGENT_ENTER, None)] + assert captured == { + "appio_api_address": "127.0.0.1:9091", + "parent_pid": 4321, + "health_server_address": "127.0.0.1:9099", + "superexec_auth_secret": b"secret", + "runtime_dependency_install": True, + } + + +def test_flower_agent_allows_missing_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Flower Agent should allow missing auth secret like Flower SuperExec.""" + args = SimpleNamespace( + insecure=True, + appio_api_address="127.0.0.1:9091", + superexec_auth_secret_file=None, + parent_pid=None, + health_server_address=None, + runtime_dependency_install=False, + ) + captured: dict[str, object] = {} + + class _Parser: + def parse_args(self) -> SimpleNamespace: + """Return parsed arguments for the test path.""" + return args + + def _run_flower_agent(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr( + flower_agent_module, "disable_process_dumping", lambda **_: None + ) + monkeypatch.setattr( + flower_agent_module, + "warn_if_flwr_update_available", + lambda **_: None, + ) + monkeypatch.setattr(flower_agent_module, "_parse_args", _Parser) + monkeypatch.setattr( + flower_agent_module, "event", lambda *_args, **_kwargs: None + ) + monkeypatch.setattr( + flower_agent_module, "run_flower_agent", _run_flower_agent + ) + + flower_agent_module.flower_agent() + + assert captured["superexec_auth_secret"] is None diff --git a/framework/py/flwr/supercore/superexec/plugin/__init__.py b/framework/py/flwr/supercore/superexec/plugin/__init__.py index c38024ac8976..04920bb126c6 100644 --- a/framework/py/flwr/supercore/superexec/plugin/__init__.py +++ b/framework/py/flwr/supercore/superexec/plugin/__init__.py @@ -15,12 +15,14 @@ """Flower SuperExec plugins.""" +from .agent_exec_plugin import AgentExecPlugin from .clientapp_exec_plugin import ClientAppExecPlugin from .exec_plugin import ExecPlugin from .serverapp_ephemeral_exec_plugin import ServerAppEphemeralExecPlugin from .serverapp_exec_plugin import ServerAppExecPlugin __all__ = [ + "AgentExecPlugin", "ClientAppExecPlugin", "ExecPlugin", "ServerAppEphemeralExecPlugin", diff --git a/framework/py/flwr/supercore/superexec/plugin/agent_exec_plugin.py b/framework/py/flwr/supercore/superexec/plugin/agent_exec_plugin.py new file mode 100644 index 000000000000..dbdea22c92de --- /dev/null +++ b/framework/py/flwr/supercore/superexec/plugin/agent_exec_plugin.py @@ -0,0 +1,44 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Stub Flower SuperExec plugin for Agent.""" + + +import os +import subprocess +from typing import Any + +from .base_exec_plugin import BaseExecPlugin + + +class AgentExecPlugin(BaseExecPlugin): + """Stub Flower SuperExec plugin for Agent.""" + + command = "flower-agent" + appio_api_address_arg = "--appio-api-address" + + def launch_app(self, token: str, run_id: int) -> None: + """Launch Flower Agent using the same surface as Flower SuperExec.""" + _ = (token, run_id) + cmds = [self.command, "--insecure"] + cmds += [self.appio_api_address_arg, self.appio_api_address] + cmds += ["--parent-pid", str(os.getpid())] + if self.runtime_dependency_install: + cmds += ["--allow-runtime-dependency-installation"] + # pylint: disable-next=consider-using-with + subprocess.Popen(cmds, **self.get_popen_kwargs()) + + def get_popen_kwargs(self) -> dict[str, Any]: + """Return subprocess keyword arguments when launching agent processes.""" + return {} diff --git a/framework/py/flwr/supercore/superexec/plugin/base_exec_plugin_test.py b/framework/py/flwr/supercore/superexec/plugin/base_exec_plugin_test.py index 7d7da6e9cf2f..46da28071b65 100644 --- a/framework/py/flwr/supercore/superexec/plugin/base_exec_plugin_test.py +++ b/framework/py/flwr/supercore/superexec/plugin/base_exec_plugin_test.py @@ -19,6 +19,7 @@ from unittest.mock import Mock, patch from flwr.common.typing import Run +from flwr.supercore.superexec.plugin.agent_exec_plugin import AgentExecPlugin from flwr.supercore.superexec.plugin.base_exec_plugin import BaseExecPlugin from flwr.supercore.superexec.plugin.clientapp_exec_plugin import ClientAppExecPlugin @@ -58,6 +59,25 @@ def test_serverapp_launch_isolates_stdio() -> None: assert popen.call_args.kwargs["stderr"] is subprocess.DEVNULL +def test_agent_launch_uses_flower_agent_command() -> None: + """Agent launch should invoke the flower-agent command.""" + plugin = AgentExecPlugin( + appio_api_address="127.0.0.1:9091", + get_run=_get_run, + ) + + with patch("subprocess.Popen") as popen: + plugin.launch_app(token="token", run_id=3) + + assert popen.call_args.args[0][:4] == [ + "flower-agent", + "--insecure", + "--appio-api-address", + "127.0.0.1:9091", + ] + assert "--token" not in popen.call_args.args[0] + + class DummyExecPlugin(BaseExecPlugin): """Minimal plugin for testing command construction.""" diff --git a/framework/pyproject.toml b/framework/pyproject.toml index 8550e9534100..6e9b2ec122a7 100644 --- a/framework/pyproject.toml +++ b/framework/pyproject.toml @@ -80,6 +80,7 @@ flwr-simulation = "flwr.simulation.app:flwr_simulation" flower-superlink = "flwr.server.app:run_superlink" flower-supernode = "flwr.supernode.cli:flower_supernode" flower-superexec = "flwr.supercore.cli:flower_superexec" +flower-agent = "flwr.supercore.cli:flower_agent" flwr-serverapp = "flwr.server.serverapp:flwr_serverapp" flwr-clientapp = "flwr.supernode.cli:flwr_clientapp" From ae795a576ffae994b307351d696bdc03c4d9ca32 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 12:25:08 +0200 Subject: [PATCH 02/26] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- framework/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/framework/pyproject.toml b/framework/pyproject.toml index 6e9b2ec122a7..8550e9534100 100644 --- a/framework/pyproject.toml +++ b/framework/pyproject.toml @@ -80,7 +80,6 @@ flwr-simulation = "flwr.simulation.app:flwr_simulation" flower-superlink = "flwr.server.app:run_superlink" flower-supernode = "flwr.supernode.cli:flower_supernode" flower-superexec = "flwr.supercore.cli:flower_superexec" -flower-agent = "flwr.supercore.cli:flower_agent" flwr-serverapp = "flwr.server.serverapp:flwr_serverapp" flwr-clientapp = "flwr.supernode.cli:flwr_clientapp" From 34e8ec1c736fc69b73acc0123d75c9a36736a628 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 12:32:24 +0200 Subject: [PATCH 03/26] Format --- framework/docs/source/ref-api-cli.rst | 10 ----- framework/docs/source/ref-exit-codes/602.rst | 11 +++-- .../py/flwr/supercore/cli/flower_agent.py | 2 +- .../flwr/supercore/cli/flower_agent_test.py | 12 ++--- .../supercore/superexec/plugin/__init__.py | 1 - .../superexec/plugin/agent_exec_plugin.py | 44 ------------------- .../superexec/plugin/base_exec_plugin_test.py | 20 --------- 7 files changed, 9 insertions(+), 91 deletions(-) delete mode 100644 framework/py/flwr/supercore/superexec/plugin/agent_exec_plugin.py diff --git a/framework/docs/source/ref-api-cli.rst b/framework/docs/source/ref-api-cli.rst index 8e7bc9aa8370..0650c45c8261 100644 --- a/framework/docs/source/ref-api-cli.rst +++ b/framework/docs/source/ref-api-cli.rst @@ -39,16 +39,6 @@ Advanced Commands ******************* -.. _flower-agent-apiref: - -``flower-agent`` -================ - -.. argparse:: - :module: flwr.supercore.cli.flower_agent - :func: _parse_args - :prog: flower-agent - .. _flower-superexec-apiref: ``flower-superexec`` diff --git a/framework/docs/source/ref-exit-codes/602.rst b/framework/docs/source/ref-exit-codes/602.rst index 91c7d3bb6340..45fde2d36b97 100644 --- a/framework/docs/source/ref-exit-codes/602.rst +++ b/framework/docs/source/ref-exit-codes/602.rst @@ -6,12 +6,11 @@ Description ************* -The ``flower-agent``, ``flower-superexec``, ``flwr-serverapp``, -``flwr-simulation``, and ``flwr-clientapp`` do not currently support TLS, as they -are assumed to be executed within the same network as their respective long-running -processes: ``flower-superlink`` and ``flower-supernode``. Please refer to the -`Flower Network Communication <../ref-flower-network-communication.html>`_ guide -for further details. +The ``flower-superexec``, ``flwr-serverapp``, ``flwr-simulation``, and +``flwr-clientapp`` do not currently support TLS, as they are assumed to be executed +within the same network as their respective long-running processes: ``flower-superlink`` +and ``flower-supernode``. Please refer to the `Flower Network Communication +<../ref-flower-network-communication.html>`_ guide for further details. **************** How to Resolve diff --git a/framework/py/flwr/supercore/cli/flower_agent.py b/framework/py/flwr/supercore/cli/flower_agent.py index f1d74b1125ba..47eec4b894b6 100644 --- a/framework/py/flwr/supercore/cli/flower_agent.py +++ b/framework/py/flwr/supercore/cli/flower_agent.py @@ -22,12 +22,12 @@ from flwr.common.args import add_args_runtime_dependency_install from flwr.common.exit import ExitCode, flwr_exit from flwr.common.logger import log +from flwr.supercore.agent.run_flower_agent import run_flower_agent from flwr.supercore.auth import ( add_superexec_auth_secret_args, load_superexec_auth_secret, ) from flwr.supercore.grpc_health import add_args_health -from flwr.supercore.agent.run_flower_agent import run_flower_agent from flwr.supercore.update_check import warn_if_flwr_update_available from flwr.supercore.utils import disable_process_dumping from flwr.supercore.version import package_version diff --git a/framework/py/flwr/supercore/cli/flower_agent_test.py b/framework/py/flwr/supercore/cli/flower_agent_test.py index 4214ec460513..3fab88f91b01 100644 --- a/framework/py/flwr/supercore/cli/flower_agent_test.py +++ b/framework/py/flwr/supercore/cli/flower_agent_test.py @@ -125,9 +125,7 @@ def _run_flower_agent(**kwargs: object) -> None: ), ) monkeypatch.setattr(flower_agent_module, "_parse_args", _Parser) - monkeypatch.setattr( - flower_agent_module, "run_flower_agent", _run_flower_agent - ) + monkeypatch.setattr(flower_agent_module, "run_flower_agent", _run_flower_agent) flower_agent_module.flower_agent() @@ -172,12 +170,8 @@ def _run_flower_agent(**kwargs: object) -> None: lambda **_: None, ) monkeypatch.setattr(flower_agent_module, "_parse_args", _Parser) - monkeypatch.setattr( - flower_agent_module, "event", lambda *_args, **_kwargs: None - ) - monkeypatch.setattr( - flower_agent_module, "run_flower_agent", _run_flower_agent - ) + monkeypatch.setattr(flower_agent_module, "event", lambda *_args, **_kwargs: None) + monkeypatch.setattr(flower_agent_module, "run_flower_agent", _run_flower_agent) flower_agent_module.flower_agent() diff --git a/framework/py/flwr/supercore/superexec/plugin/__init__.py b/framework/py/flwr/supercore/superexec/plugin/__init__.py index 04920bb126c6..51710095e843 100644 --- a/framework/py/flwr/supercore/superexec/plugin/__init__.py +++ b/framework/py/flwr/supercore/superexec/plugin/__init__.py @@ -15,7 +15,6 @@ """Flower SuperExec plugins.""" -from .agent_exec_plugin import AgentExecPlugin from .clientapp_exec_plugin import ClientAppExecPlugin from .exec_plugin import ExecPlugin from .serverapp_ephemeral_exec_plugin import ServerAppEphemeralExecPlugin diff --git a/framework/py/flwr/supercore/superexec/plugin/agent_exec_plugin.py b/framework/py/flwr/supercore/superexec/plugin/agent_exec_plugin.py deleted file mode 100644 index dbdea22c92de..000000000000 --- a/framework/py/flwr/supercore/superexec/plugin/agent_exec_plugin.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright 2026 Flower Labs GmbH. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ============================================================================== -"""Stub Flower SuperExec plugin for Agent.""" - - -import os -import subprocess -from typing import Any - -from .base_exec_plugin import BaseExecPlugin - - -class AgentExecPlugin(BaseExecPlugin): - """Stub Flower SuperExec plugin for Agent.""" - - command = "flower-agent" - appio_api_address_arg = "--appio-api-address" - - def launch_app(self, token: str, run_id: int) -> None: - """Launch Flower Agent using the same surface as Flower SuperExec.""" - _ = (token, run_id) - cmds = [self.command, "--insecure"] - cmds += [self.appio_api_address_arg, self.appio_api_address] - cmds += ["--parent-pid", str(os.getpid())] - if self.runtime_dependency_install: - cmds += ["--allow-runtime-dependency-installation"] - # pylint: disable-next=consider-using-with - subprocess.Popen(cmds, **self.get_popen_kwargs()) - - def get_popen_kwargs(self) -> dict[str, Any]: - """Return subprocess keyword arguments when launching agent processes.""" - return {} diff --git a/framework/py/flwr/supercore/superexec/plugin/base_exec_plugin_test.py b/framework/py/flwr/supercore/superexec/plugin/base_exec_plugin_test.py index 46da28071b65..7d7da6e9cf2f 100644 --- a/framework/py/flwr/supercore/superexec/plugin/base_exec_plugin_test.py +++ b/framework/py/flwr/supercore/superexec/plugin/base_exec_plugin_test.py @@ -19,7 +19,6 @@ from unittest.mock import Mock, patch from flwr.common.typing import Run -from flwr.supercore.superexec.plugin.agent_exec_plugin import AgentExecPlugin from flwr.supercore.superexec.plugin.base_exec_plugin import BaseExecPlugin from flwr.supercore.superexec.plugin.clientapp_exec_plugin import ClientAppExecPlugin @@ -59,25 +58,6 @@ def test_serverapp_launch_isolates_stdio() -> None: assert popen.call_args.kwargs["stderr"] is subprocess.DEVNULL -def test_agent_launch_uses_flower_agent_command() -> None: - """Agent launch should invoke the flower-agent command.""" - plugin = AgentExecPlugin( - appio_api_address="127.0.0.1:9091", - get_run=_get_run, - ) - - with patch("subprocess.Popen") as popen: - plugin.launch_app(token="token", run_id=3) - - assert popen.call_args.args[0][:4] == [ - "flower-agent", - "--insecure", - "--appio-api-address", - "127.0.0.1:9091", - ] - assert "--token" not in popen.call_args.args[0] - - class DummyExecPlugin(BaseExecPlugin): """Minimal plugin for testing command construction.""" From f7557de63f58f735f4ccdf8373c2b0d03d30d92a Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 12:36:40 +0200 Subject: [PATCH 04/26] Format --- framework/py/flwr/supercore/superexec/plugin/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/framework/py/flwr/supercore/superexec/plugin/__init__.py b/framework/py/flwr/supercore/superexec/plugin/__init__.py index 51710095e843..c38024ac8976 100644 --- a/framework/py/flwr/supercore/superexec/plugin/__init__.py +++ b/framework/py/flwr/supercore/superexec/plugin/__init__.py @@ -21,7 +21,6 @@ from .serverapp_exec_plugin import ServerAppExecPlugin __all__ = [ - "AgentExecPlugin", "ClientAppExecPlugin", "ExecPlugin", "ServerAppEphemeralExecPlugin", From b54814378a170bd8c5d507bf53d311815c93d4e3 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 13:14:35 +0200 Subject: [PATCH 05/26] Update framework/py/flwr/supercore/cli/flower_agent.py Co-authored-by: Javier --- framework/py/flwr/supercore/cli/flower_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/py/flwr/supercore/cli/flower_agent.py b/framework/py/flwr/supercore/cli/flower_agent.py index 47eec4b894b6..08d9415e5837 100644 --- a/framework/py/flwr/supercore/cli/flower_agent.py +++ b/framework/py/flwr/supercore/cli/flower_agent.py @@ -33,7 +33,7 @@ from flwr.supercore.version import package_version -def flower_agent() -> None: +def flwr_agent() -> None: """Run `flower-agent` command.""" disable_process_dumping(strict=False) warn_if_flwr_update_available(process_name="flower-agent") From 0fd81a9d1c1c7eeb361a21849d941709552af6bf Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 14:10:20 +0200 Subject: [PATCH 06/26] Rename --- .../agent/{run_flower_agent.py => run_agentapp.py} | 0 .../{run_flower_agent_test.py => run_agentapp_test.py} | 8 ++++---- framework/py/flwr/supercore/cli/__init__.py | 2 +- .../supercore/cli/{flower_agent.py => flwr_agent.py} | 10 +++++----- .../cli/{flower_agent_test.py => flwr_agent_test.py} | 2 +- framework/pyproject.toml | 1 + 6 files changed, 12 insertions(+), 11 deletions(-) rename framework/py/flwr/supercore/agent/{run_flower_agent.py => run_agentapp.py} (100%) rename framework/py/flwr/supercore/agent/{run_flower_agent_test.py => run_agentapp_test.py} (88%) rename framework/py/flwr/supercore/cli/{flower_agent.py => flwr_agent.py} (93%) rename framework/py/flwr/supercore/cli/{flower_agent_test.py => flwr_agent_test.py} (99%) diff --git a/framework/py/flwr/supercore/agent/run_flower_agent.py b/framework/py/flwr/supercore/agent/run_agentapp.py similarity index 100% rename from framework/py/flwr/supercore/agent/run_flower_agent.py rename to framework/py/flwr/supercore/agent/run_agentapp.py diff --git a/framework/py/flwr/supercore/agent/run_flower_agent_test.py b/framework/py/flwr/supercore/agent/run_agentapp_test.py similarity index 88% rename from framework/py/flwr/supercore/agent/run_flower_agent_test.py rename to framework/py/flwr/supercore/agent/run_agentapp_test.py index 033195aaa265..a7e8087cd4cb 100644 --- a/framework/py/flwr/supercore/agent/run_flower_agent_test.py +++ b/framework/py/flwr/supercore/agent/run_agentapp_test.py @@ -22,8 +22,8 @@ from flwr.common import EventType from flwr.common.exit import ExitCode -run_flower_agent_module = importlib.import_module( - "flwr.supercore.agent.run_flower_agent" +run_agentapp_module = importlib.import_module( + "flwr.supercore.agent.run_agentapp" ) @@ -43,10 +43,10 @@ def _flwr_exit( captured["event_type"] = event_type raise SystemExit(1) - monkeypatch.setattr(run_flower_agent_module, "flwr_exit", _flwr_exit) + monkeypatch.setattr(run_agentapp_module, "flwr_exit", _flwr_exit) with pytest.raises(SystemExit): - run_flower_agent_module.run_flower_agent( + run_agentapp_module.run_flower_agent( appio_api_address="127.0.0.1:9091", ) diff --git a/framework/py/flwr/supercore/cli/__init__.py b/framework/py/flwr/supercore/cli/__init__.py index 03d9c032c3b7..d601431768c4 100644 --- a/framework/py/flwr/supercore/cli/__init__.py +++ b/framework/py/flwr/supercore/cli/__init__.py @@ -19,6 +19,6 @@ from .flower_superexec import flower_superexec __all__ = [ - "flower_agent", + "flwr_agent", "flower_superexec", ] diff --git a/framework/py/flwr/supercore/cli/flower_agent.py b/framework/py/flwr/supercore/cli/flwr_agent.py similarity index 93% rename from framework/py/flwr/supercore/cli/flower_agent.py rename to framework/py/flwr/supercore/cli/flwr_agent.py index 47eec4b894b6..a2e13fc021fb 100644 --- a/framework/py/flwr/supercore/cli/flower_agent.py +++ b/framework/py/flwr/supercore/cli/flwr_agent.py @@ -33,8 +33,8 @@ from flwr.supercore.version import package_version -def flower_agent() -> None: - """Run `flower-agent` command.""" +def flwr_agent() -> None: + """Run `flwr-agent` command.""" disable_process_dumping(strict=False) warn_if_flwr_update_available(process_name="flower-agent") args = _parse_args().parse_args() @@ -45,7 +45,7 @@ def flower_agent() -> None: "`flower-agent` does not support TLS yet.", ) - log(INFO, "Starting Flower Agent") + log(INFO, "Starting flwr-agent") event(EventType.RUN_AGENT_ENTER) @@ -58,7 +58,7 @@ def flower_agent() -> None: except ValueError as err: flwr_exit( ExitCode.SUPEREXEC_AUTH_SECRET_LOAD_FAILED, - f"Failed to load Flower Agent authentication secret: {err}", + f"Failed to load flwr-agent authentication secret: {err}", ) run_flower_agent( @@ -73,7 +73,7 @@ def flower_agent() -> None: def _parse_args() -> argparse.ArgumentParser: """Parse `flower-agent` command line arguments.""" parser = argparse.ArgumentParser( - description="Run Flower Agent.", + description="Run flwr-agent.", ) parser.add_argument( "-V", diff --git a/framework/py/flwr/supercore/cli/flower_agent_test.py b/framework/py/flwr/supercore/cli/flwr_agent_test.py similarity index 99% rename from framework/py/flwr/supercore/cli/flower_agent_test.py rename to framework/py/flwr/supercore/cli/flwr_agent_test.py index 3fab88f91b01..2f2ca9212116 100644 --- a/framework/py/flwr/supercore/cli/flower_agent_test.py +++ b/framework/py/flwr/supercore/cli/flwr_agent_test.py @@ -23,7 +23,7 @@ from flwr.common import EventType from flwr.supercore.version import package_version -from .flower_agent import _parse_args +from .flwr_agent import _parse_args flower_agent_module = importlib.import_module("flwr.supercore.cli.flower_agent") diff --git a/framework/pyproject.toml b/framework/pyproject.toml index 8550e9534100..c612c52b40ea 100644 --- a/framework/pyproject.toml +++ b/framework/pyproject.toml @@ -82,6 +82,7 @@ flower-supernode = "flwr.supernode.cli:flower_supernode" flower-superexec = "flwr.supercore.cli:flower_superexec" flwr-serverapp = "flwr.server.serverapp:flwr_serverapp" flwr-clientapp = "flwr.supernode.cli:flwr_clientapp" +flwr-agent = "flwr.supercore.cli:flwr_agent" [project.urls] homepage = "https://flower.ai" From 502175966466c3c89036da9b6d537aaee4de8d4c Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 14:13:23 +0200 Subject: [PATCH 07/26] Format --- framework/py/flwr/supercore/agent/__init__.py | 4 ++-- framework/py/flwr/supercore/cli/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/framework/py/flwr/supercore/agent/__init__.py b/framework/py/flwr/supercore/agent/__init__.py index f2c04d88c04c..04291443de3c 100644 --- a/framework/py/flwr/supercore/agent/__init__.py +++ b/framework/py/flwr/supercore/agent/__init__.py @@ -15,8 +15,8 @@ """Flower Agent.""" -from .run_flower_agent import run_flower_agent +from .run_agentapp import run_agentapp __all__ = [ - "run_flower_agent", + "run_agentapp", ] diff --git a/framework/py/flwr/supercore/cli/__init__.py b/framework/py/flwr/supercore/cli/__init__.py index d601431768c4..9af95868ff7d 100644 --- a/framework/py/flwr/supercore/cli/__init__.py +++ b/framework/py/flwr/supercore/cli/__init__.py @@ -15,7 +15,7 @@ """Flower command line interface for shared infrastructure components.""" -from .flower_agent import flower_agent +from .flwr_agent import flwr_agent from .flower_superexec import flower_superexec __all__ = [ From e970ef33f0ef1017030bd98e9b9936b1944869e0 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 14:28:01 +0200 Subject: [PATCH 08/26] Refactor --- framework/py/flwr/common/exit/exit.py | 2 +- framework/py/flwr/supercore/agent/__init__.py | 2 +- .../py/flwr/supercore/agent/run_agentapp.py | 49 ++++- .../flwr/supercore/agent/run_agentapp_test.py | 12 +- framework/py/flwr/supercore/cli/flwr_agent.py | 99 +++------ .../py/flwr/supercore/cli/flwr_agent_test.py | 205 +++++++----------- 6 files changed, 162 insertions(+), 207 deletions(-) diff --git a/framework/py/flwr/common/exit/exit.py b/framework/py/flwr/common/exit/exit.py index 357353afa818..e5c60d2b8942 100644 --- a/framework/py/flwr/common/exit/exit.py +++ b/framework/py/flwr/common/exit/exit.py @@ -124,7 +124,7 @@ def _try_obtain_telemetry_event() -> EventType | None: return EventType.RUN_SUPERLINK_LEAVE if sys.argv[0].endswith("flower-supernode"): return EventType.RUN_SUPERNODE_LEAVE - if sys.argv[0].endswith("flower-agent"): + if sys.argv[0].endswith("flwr-agent"): return EventType.RUN_AGENT_LEAVE if sys.argv[0].endswith("flwr-serverapp"): return EventType.FLWR_SERVERAPP_RUN_LEAVE diff --git a/framework/py/flwr/supercore/agent/__init__.py b/framework/py/flwr/supercore/agent/__init__.py index 04291443de3c..719c244bbf27 100644 --- a/framework/py/flwr/supercore/agent/__init__.py +++ b/framework/py/flwr/supercore/agent/__init__.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== -"""Flower Agent.""" +"""Flower AgentApp components.""" from .run_agentapp import run_agentapp diff --git a/framework/py/flwr/supercore/agent/run_agentapp.py b/framework/py/flwr/supercore/agent/run_agentapp.py index c1a9e24ad7be..bb644e538533 100644 --- a/framework/py/flwr/supercore/agent/run_agentapp.py +++ b/framework/py/flwr/supercore/agent/run_agentapp.py @@ -12,34 +12,63 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== -"""Flower Agent runtime stub.""" +"""Flower AgentApp process.""" +from pathlib import Path +from queue import Queue + from flwr.common import EventType from flwr.common.constant import RUNTIME_DEPENDENCY_INSTALL -from flwr.common.exit import ExitCode, flwr_exit +from flwr.common.exit import ExitCode, flwr_exit, register_signal_handlers +from flwr.common.logger import stop_log_uploader +from flwr.supercore.app_utils import start_parent_process_monitor +from flwr.supercore.superexec.dependency_installer import ( + cleanup_app_runtime_environment, +) def run_agentapp( - appio_api_address: str, + serverappio_api_address: str, + log_queue: Queue[str | None], + token: str, + certificates: bytes | None = None, parent_pid: int | None = None, - health_server_address: str | None = None, - superexec_auth_secret: bytes | None = None, runtime_dependency_install: bool = RUNTIME_DEPENDENCY_INSTALL, ) -> None: - """Run AgentApp. + """Run Flower AgentApp process. This runtime is intentionally a stub until AgentApp execution support is added. """ + # Monitor the main process in case of SIGKILL + if parent_pid is not None: + start_parent_process_monitor(parent_pid) + + log_uploader = None + runtime_env_dir: Path | None = None + + def on_exit() -> None: + if log_uploader: + stop_log_uploader(log_queue, log_uploader) + cleanup_app_runtime_environment(runtime_env_dir) + + register_signal_handlers( + event_type=EventType.RUN_AGENT_LEAVE, + exit_message="Run stopped by user.", + exit_handlers=[on_exit], + ) + _ = ( - appio_api_address, + serverappio_api_address, + log_queue, + token, + certificates, parent_pid, - health_server_address, - superexec_auth_secret, runtime_dependency_install, ) flwr_exit( ExitCode.SERVERAPP_EXCEPTION, - "`flower-agent` is not implemented yet.", + "`flwr-agent` is not implemented yet.", event_type=EventType.RUN_AGENT_LEAVE, + event_details={"success": False}, ) diff --git a/framework/py/flwr/supercore/agent/run_agentapp_test.py b/framework/py/flwr/supercore/agent/run_agentapp_test.py index a7e8087cd4cb..ead29ba923a1 100644 --- a/framework/py/flwr/supercore/agent/run_agentapp_test.py +++ b/framework/py/flwr/supercore/agent/run_agentapp_test.py @@ -16,6 +16,7 @@ import importlib +from queue import Queue import pytest @@ -37,21 +38,26 @@ def _flwr_exit( code: int, message: str | None = None, event_type: EventType | None = None, + event_details: dict[str, object] | None = None, ) -> None: captured["code"] = code captured["message"] = message captured["event_type"] = event_type + captured["event_details"] = event_details raise SystemExit(1) monkeypatch.setattr(run_agentapp_module, "flwr_exit", _flwr_exit) with pytest.raises(SystemExit): - run_agentapp_module.run_flower_agent( - appio_api_address="127.0.0.1:9091", + run_agentapp_module.run_agentapp( + serverappio_api_address="127.0.0.1:9091", + log_queue=Queue(), + token="test-token", ) assert captured == { "code": ExitCode.SERVERAPP_EXCEPTION, - "message": "`flower-agent` is not implemented yet.", + "message": "`flwr-agent` is not implemented yet.", "event_type": EventType.RUN_AGENT_LEAVE, + "event_details": {"success": False}, } diff --git a/framework/py/flwr/supercore/cli/flwr_agent.py b/framework/py/flwr/supercore/cli/flwr_agent.py index 9197791f987d..4e5a00ddc208 100644 --- a/framework/py/flwr/supercore/cli/flwr_agent.py +++ b/framework/py/flwr/supercore/cli/flwr_agent.py @@ -12,96 +12,65 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== -"""`flower-agent` command.""" +"""`flwr-agent` command.""" import argparse -from logging import INFO +from logging import DEBUG, INFO +from queue import Queue -from flwr.common import EventType, event -from flwr.common.args import add_args_runtime_dependency_install +from flwr.common.args import add_args_flwr_app_common +from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS from flwr.common.exit import ExitCode, flwr_exit -from flwr.common.logger import log +from flwr.common.logger import log, mirror_output_to_queue, restore_output from flwr.supercore.agent.run_agentapp import run_agentapp -from flwr.supercore.auth import ( - add_superexec_auth_secret_args, - load_superexec_auth_secret, -) -from flwr.supercore.grpc_health import add_args_health -from flwr.supercore.update_check import warn_if_flwr_update_available -from flwr.supercore.utils import disable_process_dumping -from flwr.supercore.version import package_version def flwr_agent() -> None: - """Run `flwr-agent` command.""" - disable_process_dumping(strict=False) - warn_if_flwr_update_available(process_name="flwr-agent") - args = _parse_args().parse_args() + """Run process-isolated Flower AgentApp.""" + args = _parse_args_run_flwr_agent().parse_args() if not args.insecure: flwr_exit( ExitCode.COMMON_TLS_NOT_SUPPORTED, - "`flower-agent` does not support TLS yet.", + "`flwr-agent` does not support TLS yet.", ) - log(INFO, "Starting flwr-agent") - - event(EventType.RUN_AGENT_ENTER) - - superexec_auth_secret = None - if args.superexec_auth_secret_file is not None: - try: - superexec_auth_secret = load_superexec_auth_secret( - secret_file=args.superexec_auth_secret_file, - ) - except ValueError as err: - flwr_exit( - ExitCode.SUPEREXEC_AUTH_SECRET_LOAD_FAILED, - f"Failed to load flwr-agent authentication secret: {err}", - ) + # Capture stdout/stderr + log_queue: Queue[str | None] = Queue() + mirror_output_to_queue(log_queue) + log(INFO, "Start `flwr-agent` process") + log( + DEBUG, + "`flwr-agent` will attempt to connect to SuperLink's " + "ServerAppIo API at %s", + args.serverappio_api_address, + ) run_agentapp( - appio_api_address=args.appio_api_address, + serverappio_api_address=args.serverappio_api_address, + log_queue=log_queue, + token=args.token, + certificates=None, parent_pid=args.parent_pid, - health_server_address=args.health_server_address, - superexec_auth_secret=superexec_auth_secret, runtime_dependency_install=args.runtime_dependency_install, ) + # Restore stdout/stderr + restore_output() -def _parse_args() -> argparse.ArgumentParser: - """Parse `flower-agent` command line arguments.""" + +def _parse_args_run_flwr_agent() -> argparse.ArgumentParser: + """Parse `flwr-agent` command line arguments.""" parser = argparse.ArgumentParser( - description="Run flwr-agent.", - ) - parser.add_argument( - "-V", - "--version", - action="version", - version=f"Flower version: {package_version}", + description="Run a Flower AgentApp", ) parser.add_argument( - "--appio-api-address", + "--serverappio-api-address", + default=SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS, type=str, - required=True, - help="Address of the AppIO API", - ) - parser.add_argument( - "--insecure", - action="store_true", - help="Connect to the AppIO API without TLS. " - "Data transmitted between the client and server is not encrypted. " - "Use this flag only if you understand the risks.", - ) - parser.add_argument( - "--parent-pid", - type=int, - default=None, - help="The PID of the parent process. When set, the process will terminate " - "when the parent process exits.", + help="Address of SuperLink's ServerAppIo API (IPv4, IPv6, or a domain name)." + f"By default, it is set to {SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS}.", ) - add_superexec_auth_secret_args(parser) - add_args_health(parser) - add_args_runtime_dependency_install(parser) + add_args_flwr_app_common(parser=parser) return parser diff --git a/framework/py/flwr/supercore/cli/flwr_agent_test.py b/framework/py/flwr/supercore/cli/flwr_agent_test.py index 2f2ca9212116..65de1d653dcd 100644 --- a/framework/py/flwr/supercore/cli/flwr_agent_test.py +++ b/framework/py/flwr/supercore/cli/flwr_agent_test.py @@ -12,167 +12,118 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== -"""Tests for Flower Agent CLI argument parsing and wiring.""" +"""Tests for AgentApp process CLI parsing and wiring.""" import importlib from types import SimpleNamespace +from unittest.mock import Mock, patch import pytest -from flwr.common import EventType -from flwr.supercore.version import package_version +from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS -from .flwr_agent import _parse_args +from .flwr_agent import _parse_args_run_flwr_agent -flower_agent_module = importlib.import_module("flwr.supercore.cli.flower_agent") +flwr_agent_module = importlib.import_module("flwr.supercore.cli.flwr_agent") -@pytest.mark.parametrize("flag", ["--version", "-V"]) -def test_parse_agent_version_flag( - flag: str, capsys: pytest.CaptureFixture[str] -) -> None: - """The version flags should print the package version and exit.""" - with pytest.raises(SystemExit) as exc_info: - _parse_args().parse_args([flag]) +def test_parse_flwr_agent_requires_token() -> None: + """The AgentApp process CLI should require a token.""" + with pytest.raises(SystemExit): + _parse_args_run_flwr_agent().parse_args([]) - assert exc_info.value.code == 0 - captured = capsys.readouterr() - assert captured.out == f"Flower version: {package_version}\n" +def test_parse_flwr_agent_rejects_run_once() -> None: + """The removed deprecated flag should no longer parse.""" + with pytest.raises(SystemExit): + _parse_args_run_flwr_agent().parse_args( + ["--token", "test-token", "--run-once"] + ) -def test_flower_agent_checks_for_update( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Flower Agent should run the startup update check before parsing args.""" - class _SentinelError(Exception): - pass - - class _Parser: - def parse_args(self) -> SimpleNamespace: - """Return parsed arguments for the test path.""" - return SimpleNamespace(insecure=True) - - def _parse_args() -> _Parser: - return _Parser() - - captured: list[str] = [] - - def _raise_sentinel(process_name: str | None = None) -> None: - captured.append("update") - if process_name is not None: - captured.append(process_name) - raise _SentinelError() - - def _unexpected_parse_args() -> _Parser: - captured.append("parse") - return _parse_args() - - monkeypatch.setattr(flower_agent_module, "_parse_args", _unexpected_parse_args) - monkeypatch.setattr( - flower_agent_module, "disable_process_dumping", lambda **_: None - ) - monkeypatch.setattr( - flower_agent_module, "warn_if_flwr_update_available", _raise_sentinel +def test_parse_flwr_agent_parses_tokenized_invocation() -> None: + """The AgentApp process CLI should still parse the supported flags.""" + args = _parse_args_run_flwr_agent().parse_args( + [ + "--token", + "test-token", + "--insecure", + "--parent-pid", + "1234", + "--allow-runtime-dependency-installation", + ] ) - with pytest.raises(_SentinelError): - flower_agent_module.flower_agent() - - assert captured == ["update", "flower-agent"] + assert args.serverappio_api_address == SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS + assert args.token == "test-token" + assert args.insecure is True + assert args.parent_pid == 1234 + assert args.runtime_dependency_install is True -def test_flower_agent_forwards_cli_args( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Flower Agent CLI should forward parsed arguments to the runtime stub.""" - args = SimpleNamespace( - insecure=True, - appio_api_address="127.0.0.1:9091", - parent_pid=4321, - health_server_address="127.0.0.1:9099", - superexec_auth_secret_file="/tmp/agent-secret", - runtime_dependency_install=True, - ) - captured: dict[str, object] = {} - events: list[tuple[EventType, dict[str, object] | None]] = [] +def test_flwr_agent_parses_args_before_mirroring_output() -> None: + """Argument parsing should happen before stdout/stderr redirection.""" class _Parser: def parse_args(self) -> SimpleNamespace: - """Return parsed arguments for the test path.""" - return args + """Raise a parser error before any side effects happen.""" + raise SystemExit(2) - def _run_flower_agent(**kwargs: object) -> None: - captured.update(kwargs) + mirror_output_to_queue = Mock() - monkeypatch.setattr( - flower_agent_module, "disable_process_dumping", lambda **_: None - ) - monkeypatch.setattr( - flower_agent_module, - "warn_if_flwr_update_available", - lambda **_: None, - ) - monkeypatch.setattr( - flower_agent_module, "load_superexec_auth_secret", lambda **_: b"secret" - ) - monkeypatch.setattr( - flower_agent_module, - "event", - lambda event_type, event_details=None: events.append( - (event_type, event_details) + with ( + patch.object(flwr_agent_module, "_parse_args_run_flwr_agent", _Parser), + patch.object( + flwr_agent_module, + "mirror_output_to_queue", + mirror_output_to_queue, ), - ) - monkeypatch.setattr(flower_agent_module, "_parse_args", _Parser) - monkeypatch.setattr(flower_agent_module, "run_flower_agent", _run_flower_agent) - - flower_agent_module.flower_agent() + pytest.raises(SystemExit), + ): + flwr_agent_module.flwr_agent() - assert events == [(EventType.RUN_AGENT_ENTER, None)] - assert captured == { - "appio_api_address": "127.0.0.1:9091", - "parent_pid": 4321, - "health_server_address": "127.0.0.1:9099", - "superexec_auth_secret": b"secret", - "runtime_dependency_install": True, - } + mirror_output_to_queue.assert_not_called() -def test_flower_agent_allows_missing_secret( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Flower Agent should allow missing auth secret like Flower SuperExec.""" +def test_flwr_agent_forwards_cli_args() -> None: + """The AgentApp CLI should forward parsed args to the runtime.""" args = SimpleNamespace( insecure=True, - appio_api_address="127.0.0.1:9091", - superexec_auth_secret_file=None, - parent_pid=None, - health_server_address=None, - runtime_dependency_install=False, + serverappio_api_address="127.0.0.1:9091", + token="test-token", + parent_pid=321, + runtime_dependency_install=True, ) - captured: dict[str, object] = {} class _Parser: def parse_args(self) -> SimpleNamespace: - """Return parsed arguments for the test path.""" + """Return a fixed namespace for CLI forwarding tests.""" return args - def _run_flower_agent(**kwargs: object) -> None: - captured.update(kwargs) - - monkeypatch.setattr( - flower_agent_module, "disable_process_dumping", lambda **_: None - ) - monkeypatch.setattr( - flower_agent_module, - "warn_if_flwr_update_available", - lambda **_: None, - ) - monkeypatch.setattr(flower_agent_module, "_parse_args", _Parser) - monkeypatch.setattr(flower_agent_module, "event", lambda *_args, **_kwargs: None) - monkeypatch.setattr(flower_agent_module, "run_flower_agent", _run_flower_agent) - - flower_agent_module.flower_agent() + mirror_output_to_queue = Mock() + restore_output = Mock() + run_agentapp = Mock() - assert captured["superexec_auth_secret"] is None + with ( + patch.object(flwr_agent_module, "_parse_args_run_flwr_agent", _Parser), + patch.object( + flwr_agent_module, + "mirror_output_to_queue", + mirror_output_to_queue, + ), + patch.object(flwr_agent_module, "restore_output", restore_output), + patch.object(flwr_agent_module, "run_agentapp", run_agentapp), + ): + flwr_agent_module.flwr_agent() + + mirror_output_to_queue.assert_called_once() + restore_output.assert_called_once_with() + run_agentapp.assert_called_once() + kwargs = run_agentapp.call_args.kwargs + assert kwargs["serverappio_api_address"] == "127.0.0.1:9091" + assert kwargs["log_queue"] is mirror_output_to_queue.call_args.args[0] + assert kwargs["token"] == "test-token" + assert kwargs["certificates"] is None + assert kwargs["parent_pid"] == 321 + assert kwargs["runtime_dependency_install"] is True From 434a5f998b78f8bb5ff6dc5d7e6fd1c16e5a7b5b Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 14:33:17 +0200 Subject: [PATCH 09/26] Format --- framework/py/flwr/supercore/agent/run_agentapp_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/framework/py/flwr/supercore/agent/run_agentapp_test.py b/framework/py/flwr/supercore/agent/run_agentapp_test.py index ead29ba923a1..ebe75da68f4d 100644 --- a/framework/py/flwr/supercore/agent/run_agentapp_test.py +++ b/framework/py/flwr/supercore/agent/run_agentapp_test.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== -"""Tests for the Flower Agent runtime stub.""" +"""Tests for the Flower AgentApp process.""" import importlib @@ -28,10 +28,10 @@ ) -def test_run_flower_agent_exits_with_stub_message( +def test_run_flwr_agent_exits_with_stub_message( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The runtime stub should fail fast with a clear message.""" + """The Flower AgentApp process should fail fast with a clear message.""" captured: dict[str, object] = {} def _flwr_exit( From 48ff2ed97a7b6f6d324ead73a03ad4cf44bbc51d Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 19:31:36 +0200 Subject: [PATCH 10/26] Refactor to agentapp --- framework/py/flwr/common/exit/exit.py | 2 +- .../py/flwr/supercore/agent/run_agentapp.py | 2 +- .../flwr/supercore/agent/run_agentapp_test.py | 8 +- framework/py/flwr/supercore/cli/__init__.py | 4 +- framework/py/flwr/supercore/cli/flwr_agent.py | 76 ----------- .../py/flwr/supercore/cli/flwr_agent_test.py | 129 ------------------ framework/pyproject.toml | 2 +- 7 files changed, 8 insertions(+), 215 deletions(-) delete mode 100644 framework/py/flwr/supercore/cli/flwr_agent.py delete mode 100644 framework/py/flwr/supercore/cli/flwr_agent_test.py diff --git a/framework/py/flwr/common/exit/exit.py b/framework/py/flwr/common/exit/exit.py index e5c60d2b8942..07bc3734fbd1 100644 --- a/framework/py/flwr/common/exit/exit.py +++ b/framework/py/flwr/common/exit/exit.py @@ -124,7 +124,7 @@ def _try_obtain_telemetry_event() -> EventType | None: return EventType.RUN_SUPERLINK_LEAVE if sys.argv[0].endswith("flower-supernode"): return EventType.RUN_SUPERNODE_LEAVE - if sys.argv[0].endswith("flwr-agent"): + if sys.argv[0].endswith("flwr-agentapp"): return EventType.RUN_AGENT_LEAVE if sys.argv[0].endswith("flwr-serverapp"): return EventType.FLWR_SERVERAPP_RUN_LEAVE diff --git a/framework/py/flwr/supercore/agent/run_agentapp.py b/framework/py/flwr/supercore/agent/run_agentapp.py index bb644e538533..2b45ea245df2 100644 --- a/framework/py/flwr/supercore/agent/run_agentapp.py +++ b/framework/py/flwr/supercore/agent/run_agentapp.py @@ -68,7 +68,7 @@ def on_exit() -> None: ) flwr_exit( ExitCode.SERVERAPP_EXCEPTION, - "`flwr-agent` is not implemented yet.", + "`flwr-agentapp` is not implemented yet.", event_type=EventType.RUN_AGENT_LEAVE, event_details={"success": False}, ) diff --git a/framework/py/flwr/supercore/agent/run_agentapp_test.py b/framework/py/flwr/supercore/agent/run_agentapp_test.py index ebe75da68f4d..5f878c831d8b 100644 --- a/framework/py/flwr/supercore/agent/run_agentapp_test.py +++ b/framework/py/flwr/supercore/agent/run_agentapp_test.py @@ -23,12 +23,10 @@ from flwr.common import EventType from flwr.common.exit import ExitCode -run_agentapp_module = importlib.import_module( - "flwr.supercore.agent.run_agentapp" -) +run_agentapp_module = importlib.import_module("flwr.supercore.agent.run_agentapp") -def test_run_flwr_agent_exits_with_stub_message( +def test_run_flwr_agentapp_exits_with_stub_message( monkeypatch: pytest.MonkeyPatch, ) -> None: """The Flower AgentApp process should fail fast with a clear message.""" @@ -57,7 +55,7 @@ def _flwr_exit( assert captured == { "code": ExitCode.SERVERAPP_EXCEPTION, - "message": "`flwr-agent` is not implemented yet.", + "message": "`flwr-agentapp` is not implemented yet.", "event_type": EventType.RUN_AGENT_LEAVE, "event_details": {"success": False}, } diff --git a/framework/py/flwr/supercore/cli/__init__.py b/framework/py/flwr/supercore/cli/__init__.py index 9af95868ff7d..679ec22284b8 100644 --- a/framework/py/flwr/supercore/cli/__init__.py +++ b/framework/py/flwr/supercore/cli/__init__.py @@ -15,10 +15,10 @@ """Flower command line interface for shared infrastructure components.""" -from .flwr_agent import flwr_agent from .flower_superexec import flower_superexec +from .flwr_agentapp import flwr_agentapp __all__ = [ - "flwr_agent", "flower_superexec", + "flwr_agentapp", ] diff --git a/framework/py/flwr/supercore/cli/flwr_agent.py b/framework/py/flwr/supercore/cli/flwr_agent.py deleted file mode 100644 index 4e5a00ddc208..000000000000 --- a/framework/py/flwr/supercore/cli/flwr_agent.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright 2026 Flower Labs GmbH. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ============================================================================== -"""`flwr-agent` command.""" - - -import argparse -from logging import DEBUG, INFO -from queue import Queue - -from flwr.common.args import add_args_flwr_app_common -from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS -from flwr.common.exit import ExitCode, flwr_exit -from flwr.common.logger import log, mirror_output_to_queue, restore_output -from flwr.supercore.agent.run_agentapp import run_agentapp - - -def flwr_agent() -> None: - """Run process-isolated Flower AgentApp.""" - args = _parse_args_run_flwr_agent().parse_args() - - if not args.insecure: - flwr_exit( - ExitCode.COMMON_TLS_NOT_SUPPORTED, - "`flwr-agent` does not support TLS yet.", - ) - - # Capture stdout/stderr - log_queue: Queue[str | None] = Queue() - mirror_output_to_queue(log_queue) - - log(INFO, "Start `flwr-agent` process") - log( - DEBUG, - "`flwr-agent` will attempt to connect to SuperLink's " - "ServerAppIo API at %s", - args.serverappio_api_address, - ) - run_agentapp( - serverappio_api_address=args.serverappio_api_address, - log_queue=log_queue, - token=args.token, - certificates=None, - parent_pid=args.parent_pid, - runtime_dependency_install=args.runtime_dependency_install, - ) - - # Restore stdout/stderr - restore_output() - - -def _parse_args_run_flwr_agent() -> argparse.ArgumentParser: - """Parse `flwr-agent` command line arguments.""" - parser = argparse.ArgumentParser( - description="Run a Flower AgentApp", - ) - parser.add_argument( - "--serverappio-api-address", - default=SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS, - type=str, - help="Address of SuperLink's ServerAppIo API (IPv4, IPv6, or a domain name)." - f"By default, it is set to {SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS}.", - ) - add_args_flwr_app_common(parser=parser) - return parser diff --git a/framework/py/flwr/supercore/cli/flwr_agent_test.py b/framework/py/flwr/supercore/cli/flwr_agent_test.py deleted file mode 100644 index 65de1d653dcd..000000000000 --- a/framework/py/flwr/supercore/cli/flwr_agent_test.py +++ /dev/null @@ -1,129 +0,0 @@ -# Copyright 2026 Flower Labs GmbH. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ============================================================================== -"""Tests for AgentApp process CLI parsing and wiring.""" - - -import importlib -from types import SimpleNamespace -from unittest.mock import Mock, patch - -import pytest - -from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS - -from .flwr_agent import _parse_args_run_flwr_agent - -flwr_agent_module = importlib.import_module("flwr.supercore.cli.flwr_agent") - - -def test_parse_flwr_agent_requires_token() -> None: - """The AgentApp process CLI should require a token.""" - with pytest.raises(SystemExit): - _parse_args_run_flwr_agent().parse_args([]) - - -def test_parse_flwr_agent_rejects_run_once() -> None: - """The removed deprecated flag should no longer parse.""" - with pytest.raises(SystemExit): - _parse_args_run_flwr_agent().parse_args( - ["--token", "test-token", "--run-once"] - ) - - -def test_parse_flwr_agent_parses_tokenized_invocation() -> None: - """The AgentApp process CLI should still parse the supported flags.""" - args = _parse_args_run_flwr_agent().parse_args( - [ - "--token", - "test-token", - "--insecure", - "--parent-pid", - "1234", - "--allow-runtime-dependency-installation", - ] - ) - - assert args.serverappio_api_address == SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS - assert args.token == "test-token" - assert args.insecure is True - assert args.parent_pid == 1234 - assert args.runtime_dependency_install is True - - -def test_flwr_agent_parses_args_before_mirroring_output() -> None: - """Argument parsing should happen before stdout/stderr redirection.""" - - class _Parser: - def parse_args(self) -> SimpleNamespace: - """Raise a parser error before any side effects happen.""" - raise SystemExit(2) - - mirror_output_to_queue = Mock() - - with ( - patch.object(flwr_agent_module, "_parse_args_run_flwr_agent", _Parser), - patch.object( - flwr_agent_module, - "mirror_output_to_queue", - mirror_output_to_queue, - ), - pytest.raises(SystemExit), - ): - flwr_agent_module.flwr_agent() - - mirror_output_to_queue.assert_not_called() - - -def test_flwr_agent_forwards_cli_args() -> None: - """The AgentApp CLI should forward parsed args to the runtime.""" - args = SimpleNamespace( - insecure=True, - serverappio_api_address="127.0.0.1:9091", - token="test-token", - parent_pid=321, - runtime_dependency_install=True, - ) - - class _Parser: - def parse_args(self) -> SimpleNamespace: - """Return a fixed namespace for CLI forwarding tests.""" - return args - - mirror_output_to_queue = Mock() - restore_output = Mock() - run_agentapp = Mock() - - with ( - patch.object(flwr_agent_module, "_parse_args_run_flwr_agent", _Parser), - patch.object( - flwr_agent_module, - "mirror_output_to_queue", - mirror_output_to_queue, - ), - patch.object(flwr_agent_module, "restore_output", restore_output), - patch.object(flwr_agent_module, "run_agentapp", run_agentapp), - ): - flwr_agent_module.flwr_agent() - - mirror_output_to_queue.assert_called_once() - restore_output.assert_called_once_with() - run_agentapp.assert_called_once() - kwargs = run_agentapp.call_args.kwargs - assert kwargs["serverappio_api_address"] == "127.0.0.1:9091" - assert kwargs["log_queue"] is mirror_output_to_queue.call_args.args[0] - assert kwargs["token"] == "test-token" - assert kwargs["certificates"] is None - assert kwargs["parent_pid"] == 321 - assert kwargs["runtime_dependency_install"] is True diff --git a/framework/pyproject.toml b/framework/pyproject.toml index c612c52b40ea..3babb071f2f4 100644 --- a/framework/pyproject.toml +++ b/framework/pyproject.toml @@ -82,7 +82,7 @@ flower-supernode = "flwr.supernode.cli:flower_supernode" flower-superexec = "flwr.supercore.cli:flower_superexec" flwr-serverapp = "flwr.server.serverapp:flwr_serverapp" flwr-clientapp = "flwr.supernode.cli:flwr_clientapp" -flwr-agent = "flwr.supercore.cli:flwr_agent" +flwr-agentapp = "flwr.supercore.cli:flwr_agentapp" [project.urls] homepage = "https://flower.ai" From 63a7b34f5fb744f8ea1ce3b43416a489e4257af8 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 19:35:52 +0200 Subject: [PATCH 11/26] Refactor telemetry name --- framework/py/flwr/common/exit/exit.py | 2 +- framework/py/flwr/common/telemetry.py | 8 ++++---- framework/py/flwr/supercore/agent/run_agentapp.py | 4 ++-- framework/py/flwr/supercore/agent/run_agentapp_test.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/framework/py/flwr/common/exit/exit.py b/framework/py/flwr/common/exit/exit.py index 07bc3734fbd1..106911a6e82a 100644 --- a/framework/py/flwr/common/exit/exit.py +++ b/framework/py/flwr/common/exit/exit.py @@ -125,7 +125,7 @@ def _try_obtain_telemetry_event() -> EventType | None: if sys.argv[0].endswith("flower-supernode"): return EventType.RUN_SUPERNODE_LEAVE if sys.argv[0].endswith("flwr-agentapp"): - return EventType.RUN_AGENT_LEAVE + return EventType.FLWR_AGENTAPP_RUN_LEAVE if sys.argv[0].endswith("flwr-serverapp"): return EventType.FLWR_SERVERAPP_RUN_LEAVE if sys.argv[0].endswith("flwr-clientapp"): diff --git a/framework/py/flwr/common/telemetry.py b/framework/py/flwr/common/telemetry.py index 29b9c5a0ea5f..32919911caca 100644 --- a/framework/py/flwr/common/telemetry.py +++ b/framework/py/flwr/common/telemetry.py @@ -156,6 +156,10 @@ def _generate_next_value_(name: str, start: int, count: int, last_values: list[A FLWR_CLIENTAPP_RUN_ENTER = auto() FLWR_CLIENTAPP_RUN_LEAVE = auto() + # CLI: flwr-agentapp + FLWR_AGENTAPP_RUN_ENTER = auto() + FLWR_AGENTAPP_RUN_LEAVE = auto() + # --- Simulation Engine ------------------------------------------------------------ # Python API: `run_simulation` @@ -172,10 +176,6 @@ def _generate_next_value_(name: str, start: int, count: int, last_values: list[A RUN_SUPERNODE_ENTER = auto() RUN_SUPERNODE_LEAVE = auto() - # CLI: `flower-agent` - RUN_AGENT_ENTER = auto() - RUN_AGENT_LEAVE = auto() - # CLI: `flower-superexec` RUN_SUPEREXEC_ENTER = auto() RUN_SUPEREXEC_LEAVE = auto() diff --git a/framework/py/flwr/supercore/agent/run_agentapp.py b/framework/py/flwr/supercore/agent/run_agentapp.py index 2b45ea245df2..5ffa2827b247 100644 --- a/framework/py/flwr/supercore/agent/run_agentapp.py +++ b/framework/py/flwr/supercore/agent/run_agentapp.py @@ -53,7 +53,7 @@ def on_exit() -> None: cleanup_app_runtime_environment(runtime_env_dir) register_signal_handlers( - event_type=EventType.RUN_AGENT_LEAVE, + event_type=EventType.FLWR_AGENTAPP_RUN_LEAVE, exit_message="Run stopped by user.", exit_handlers=[on_exit], ) @@ -69,6 +69,6 @@ def on_exit() -> None: flwr_exit( ExitCode.SERVERAPP_EXCEPTION, "`flwr-agentapp` is not implemented yet.", - event_type=EventType.RUN_AGENT_LEAVE, + event_type=EventType.FLWR_AGENTAPP_RUN_LEAVE, event_details={"success": False}, ) diff --git a/framework/py/flwr/supercore/agent/run_agentapp_test.py b/framework/py/flwr/supercore/agent/run_agentapp_test.py index 5f878c831d8b..28394f5c7c0e 100644 --- a/framework/py/flwr/supercore/agent/run_agentapp_test.py +++ b/framework/py/flwr/supercore/agent/run_agentapp_test.py @@ -56,6 +56,6 @@ def _flwr_exit( assert captured == { "code": ExitCode.SERVERAPP_EXCEPTION, "message": "`flwr-agentapp` is not implemented yet.", - "event_type": EventType.RUN_AGENT_LEAVE, + "event_type": EventType.FLWR_AGENTAPP_RUN_LEAVE, "event_details": {"success": False}, } From f0741deba74fab74b430688f4246b81d540a0aff Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 19:36:01 +0200 Subject: [PATCH 12/26] Refactor to agentapp --- .../py/flwr/supercore/cli/flwr_agentapp.py | 76 +++++++++++ .../flwr/supercore/cli/flwr_agentapp_test.py | 129 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 framework/py/flwr/supercore/cli/flwr_agentapp.py create mode 100644 framework/py/flwr/supercore/cli/flwr_agentapp_test.py diff --git a/framework/py/flwr/supercore/cli/flwr_agentapp.py b/framework/py/flwr/supercore/cli/flwr_agentapp.py new file mode 100644 index 000000000000..ef6a36f262ce --- /dev/null +++ b/framework/py/flwr/supercore/cli/flwr_agentapp.py @@ -0,0 +1,76 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""`flwr-agentapp` command.""" + + +import argparse +from logging import DEBUG, INFO +from queue import Queue + +from flwr.common.args import add_args_flwr_app_common +from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS +from flwr.common.exit import ExitCode, flwr_exit +from flwr.common.logger import log, mirror_output_to_queue, restore_output +from flwr.supercore.agent.run_agentapp import run_agentapp + + +def flwr_agentapp() -> None: + """Run process-isolated Flower AgentApp.""" + args = _parse_args_run_flwr_agentapp().parse_args() + + if not args.insecure: + flwr_exit( + ExitCode.COMMON_TLS_NOT_SUPPORTED, + "`flwr-agentapp` does not support TLS yet.", + ) + + # Capture stdout/stderr + log_queue: Queue[str | None] = Queue() + mirror_output_to_queue(log_queue) + + log(INFO, "Start `flwr-agentapp` process") + log( + DEBUG, + "`flwr-agentapp` will attempt to connect to SuperLink's " + "ServerAppIo API at %s", + args.serverappio_api_address, + ) + run_agentapp( + serverappio_api_address=args.serverappio_api_address, + log_queue=log_queue, + token=args.token, + certificates=None, + parent_pid=args.parent_pid, + runtime_dependency_install=args.runtime_dependency_install, + ) + + # Restore stdout/stderr + restore_output() + + +def _parse_args_run_flwr_agentapp() -> argparse.ArgumentParser: + """Parse `flwr-agentapp` command line arguments.""" + parser = argparse.ArgumentParser( + description="Run a Flower AgentApp", + ) + parser.add_argument( + "--serverappio-api-address", + default=SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS, + type=str, + help="Address of SuperLink's ServerAppIo API (IPv4, IPv6, or a domain name)." + f"By default, it is set to {SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS}.", + ) + add_args_flwr_app_common(parser=parser) + return parser diff --git a/framework/py/flwr/supercore/cli/flwr_agentapp_test.py b/framework/py/flwr/supercore/cli/flwr_agentapp_test.py new file mode 100644 index 000000000000..6e3c43359b71 --- /dev/null +++ b/framework/py/flwr/supercore/cli/flwr_agentapp_test.py @@ -0,0 +1,129 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Tests for AgentApp process CLI parsing and wiring.""" + + +import importlib +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS + +from .flwr_agentapp import _parse_args_run_flwr_agentapp + +flwr_agentapp_module = importlib.import_module("flwr.supercore.cli.flwr_agentapp") + + +def test_parse_flwr_agentapp_requires_token() -> None: + """The AgentApp process CLI should require a token.""" + with pytest.raises(SystemExit): + _parse_args_run_flwr_agentapp().parse_args([]) + + +def test_parse_flwr_agentapp_rejects_run_once() -> None: + """The removed deprecated flag should no longer parse.""" + with pytest.raises(SystemExit): + _parse_args_run_flwr_agentapp().parse_args( + ["--token", "test-token", "--run-once"] + ) + + +def test_parse_flwr_agentapp_parses_tokenized_invocation() -> None: + """The AgentApp process CLI should still parse the supported flags.""" + args = _parse_args_run_flwr_agentapp().parse_args( + [ + "--token", + "test-token", + "--insecure", + "--parent-pid", + "1234", + "--allow-runtime-dependency-installation", + ] + ) + + assert args.serverappio_api_address == SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS + assert args.token == "test-token" + assert args.insecure is True + assert args.parent_pid == 1234 + assert args.runtime_dependency_install is True + + +def test_flwr_agentapp_parses_args_before_mirroring_output() -> None: + """Argument parsing should happen before stdout/stderr redirection.""" + + class _Parser: + def parse_args(self) -> SimpleNamespace: + """Raise a parser error before any side effects happen.""" + raise SystemExit(2) + + mirror_output_to_queue = Mock() + + with ( + patch.object(flwr_agentapp_module, "_parse_args_run_flwr_agentapp", _Parser), + patch.object( + flwr_agentapp_module, + "mirror_output_to_queue", + mirror_output_to_queue, + ), + pytest.raises(SystemExit), + ): + flwr_agentapp_module.flwr_agentapp() + + mirror_output_to_queue.assert_not_called() + + +def test_flwr_agentapp_forwards_cli_args() -> None: + """The AgentApp CLI should forward parsed args to the runtime.""" + args = SimpleNamespace( + insecure=True, + serverappio_api_address="127.0.0.1:9091", + token="test-token", + parent_pid=321, + runtime_dependency_install=True, + ) + + class _Parser: + def parse_args(self) -> SimpleNamespace: + """Return a fixed namespace for CLI forwarding tests.""" + return args + + mirror_output_to_queue = Mock() + restore_output = Mock() + run_agentapp = Mock() + + with ( + patch.object(flwr_agentapp_module, "_parse_args_run_flwr_agentapp", _Parser), + patch.object( + flwr_agentapp_module, + "mirror_output_to_queue", + mirror_output_to_queue, + ), + patch.object(flwr_agentapp_module, "restore_output", restore_output), + patch.object(flwr_agentapp_module, "run_agentapp", run_agentapp), + ): + flwr_agentapp_module.flwr_agentapp() + + mirror_output_to_queue.assert_called_once() + restore_output.assert_called_once_with() + run_agentapp.assert_called_once() + kwargs = run_agentapp.call_args.kwargs + assert kwargs["serverappio_api_address"] == "127.0.0.1:9091" + assert kwargs["log_queue"] is mirror_output_to_queue.call_args.args[0] + assert kwargs["token"] == "test-token" + assert kwargs["certificates"] is None + assert kwargs["parent_pid"] == 321 + assert kwargs["runtime_dependency_install"] is True From 8a1ad146721924c22121463c4d356476af502428 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 19:41:13 +0200 Subject: [PATCH 13/26] Pylint fix --- framework/py/flwr/supercore/agent/run_agentapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/py/flwr/supercore/agent/run_agentapp.py b/framework/py/flwr/supercore/agent/run_agentapp.py index 5ffa2827b247..50e3af3337ad 100644 --- a/framework/py/flwr/supercore/agent/run_agentapp.py +++ b/framework/py/flwr/supercore/agent/run_agentapp.py @@ -28,7 +28,7 @@ ) -def run_agentapp( +def run_agentapp( # pylint: disable=R0913, R0917 serverappio_api_address: str, log_queue: Queue[str | None], token: str, From 5031b35cba8b46531a8fdf60d359cb2cc564f117 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 20:13:09 +0200 Subject: [PATCH 14/26] feat(framework): Add flwr-model --- framework/py/flwr/common/exit/exit.py | 2 + framework/py/flwr/common/telemetry.py | 4 + framework/py/flwr/supercore/agent/__init__.py | 2 + .../py/flwr/supercore/agent/run_model.py | 74 ++++++++++ .../py/flwr/supercore/agent/run_model_test.py | 61 +++++++++ framework/py/flwr/supercore/cli/__init__.py | 2 + framework/py/flwr/supercore/cli/flwr_model.py | 76 +++++++++++ .../py/flwr/supercore/cli/flwr_model_test.py | 129 ++++++++++++++++++ 8 files changed, 350 insertions(+) create mode 100644 framework/py/flwr/supercore/agent/run_model.py create mode 100644 framework/py/flwr/supercore/agent/run_model_test.py create mode 100644 framework/py/flwr/supercore/cli/flwr_model.py create mode 100644 framework/py/flwr/supercore/cli/flwr_model_test.py diff --git a/framework/py/flwr/common/exit/exit.py b/framework/py/flwr/common/exit/exit.py index 106911a6e82a..8f92b3f182ff 100644 --- a/framework/py/flwr/common/exit/exit.py +++ b/framework/py/flwr/common/exit/exit.py @@ -126,6 +126,8 @@ def _try_obtain_telemetry_event() -> EventType | None: return EventType.RUN_SUPERNODE_LEAVE if sys.argv[0].endswith("flwr-agentapp"): return EventType.FLWR_AGENTAPP_RUN_LEAVE + if sys.argv[0].endswith("flwr-model"): + return EventType.FLWR_MODEL_RUN_LEAVE if sys.argv[0].endswith("flwr-serverapp"): return EventType.FLWR_SERVERAPP_RUN_LEAVE if sys.argv[0].endswith("flwr-clientapp"): diff --git a/framework/py/flwr/common/telemetry.py b/framework/py/flwr/common/telemetry.py index 32919911caca..6018a072afef 100644 --- a/framework/py/flwr/common/telemetry.py +++ b/framework/py/flwr/common/telemetry.py @@ -160,6 +160,10 @@ def _generate_next_value_(name: str, start: int, count: int, last_values: list[A FLWR_AGENTAPP_RUN_ENTER = auto() FLWR_AGENTAPP_RUN_LEAVE = auto() + # CLI: flwr-model + FLWR_MODEL_RUN_ENTER = auto() + FLWR_MODEL_RUN_LEAVE = auto() + # --- Simulation Engine ------------------------------------------------------------ # Python API: `run_simulation` diff --git a/framework/py/flwr/supercore/agent/__init__.py b/framework/py/flwr/supercore/agent/__init__.py index 719c244bbf27..4b8ae24d580b 100644 --- a/framework/py/flwr/supercore/agent/__init__.py +++ b/framework/py/flwr/supercore/agent/__init__.py @@ -16,7 +16,9 @@ from .run_agentapp import run_agentapp +from .run_model import run_model __all__ = [ "run_agentapp", + "run_model", ] diff --git a/framework/py/flwr/supercore/agent/run_model.py b/framework/py/flwr/supercore/agent/run_model.py new file mode 100644 index 000000000000..df81d3fd4e34 --- /dev/null +++ b/framework/py/flwr/supercore/agent/run_model.py @@ -0,0 +1,74 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower ModelApp process.""" + + +from pathlib import Path +from queue import Queue + +from flwr.common import EventType +from flwr.common.constant import RUNTIME_DEPENDENCY_INSTALL +from flwr.common.exit import ExitCode, flwr_exit, register_signal_handlers +from flwr.common.logger import stop_log_uploader +from flwr.supercore.app_utils import start_parent_process_monitor +from flwr.supercore.superexec.dependency_installer import ( + cleanup_app_runtime_environment, +) + + +def run_model( # pylint: disable=R0913, R0917 + serverappio_api_address: str, + log_queue: Queue[str | None], + token: str, + certificates: bytes | None = None, + parent_pid: int | None = None, + runtime_dependency_install: bool = RUNTIME_DEPENDENCY_INSTALL, +) -> None: + """Run Flower ModelApp process. + + This runtime is intentionally a stub until ModelApp execution support is added. + """ + # Monitor the main process in case of SIGKILL + if parent_pid is not None: + start_parent_process_monitor(parent_pid) + + log_uploader = None + runtime_env_dir: Path | None = None + + def on_exit() -> None: + if log_uploader: + stop_log_uploader(log_queue, log_uploader) + cleanup_app_runtime_environment(runtime_env_dir) + + register_signal_handlers( + event_type=EventType.FLWR_MODEL_RUN_LEAVE, + exit_message="Run stopped by user.", + exit_handlers=[on_exit], + ) + + _ = ( + serverappio_api_address, + log_queue, + token, + certificates, + parent_pid, + runtime_dependency_install, + ) + flwr_exit( + ExitCode.SERVERAPP_EXCEPTION, + "`flwr-model` is not implemented yet.", + event_type=EventType.FLWR_MODEL_RUN_LEAVE, + event_details={"success": False}, + ) diff --git a/framework/py/flwr/supercore/agent/run_model_test.py b/framework/py/flwr/supercore/agent/run_model_test.py new file mode 100644 index 000000000000..957a7d6bc0cc --- /dev/null +++ b/framework/py/flwr/supercore/agent/run_model_test.py @@ -0,0 +1,61 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Tests for the Flower ModelApp process.""" + + +import importlib +from queue import Queue + +import pytest + +from flwr.common import EventType +from flwr.common.exit import ExitCode + +run_model_module = importlib.import_module("flwr.supercore.agent.run_model") + + +def test_run_flwr_model_exits_with_stub_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The Flower ModelApp process should fail fast with a clear message.""" + captured: dict[str, object] = {} + + def _flwr_exit( + code: int, + message: str | None = None, + event_type: EventType | None = None, + event_details: dict[str, object] | None = None, + ) -> None: + captured["code"] = code + captured["message"] = message + captured["event_type"] = event_type + captured["event_details"] = event_details + raise SystemExit(1) + + monkeypatch.setattr(run_model_module, "flwr_exit", _flwr_exit) + + with pytest.raises(SystemExit): + run_model_module.run_model( + serverappio_api_address="127.0.0.1:9091", + log_queue=Queue(), + token="test-token", + ) + + assert captured == { + "code": ExitCode.SERVERAPP_EXCEPTION, + "message": "`flwr-model` is not implemented yet.", + "event_type": EventType.FLWR_MODEL_RUN_LEAVE, + "event_details": {"success": False}, + } diff --git a/framework/py/flwr/supercore/cli/__init__.py b/framework/py/flwr/supercore/cli/__init__.py index 679ec22284b8..18a3ecab2e2e 100644 --- a/framework/py/flwr/supercore/cli/__init__.py +++ b/framework/py/flwr/supercore/cli/__init__.py @@ -17,8 +17,10 @@ from .flower_superexec import flower_superexec from .flwr_agentapp import flwr_agentapp +from .flwr_model import flwr_model __all__ = [ "flower_superexec", "flwr_agentapp", + "flwr_model", ] diff --git a/framework/py/flwr/supercore/cli/flwr_model.py b/framework/py/flwr/supercore/cli/flwr_model.py new file mode 100644 index 000000000000..040979760c34 --- /dev/null +++ b/framework/py/flwr/supercore/cli/flwr_model.py @@ -0,0 +1,76 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""`flwr-model` command.""" + + +import argparse +from logging import DEBUG, INFO +from queue import Queue + +from flwr.common.args import add_args_flwr_app_common +from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS +from flwr.common.exit import ExitCode, flwr_exit +from flwr.common.logger import log, mirror_output_to_queue, restore_output +from flwr.supercore.agent.run_model import run_model + + +def flwr_model() -> None: + """Run process-isolated Flower ModelApp.""" + args = _parse_args_run_flwr_model().parse_args() + + if not args.insecure: + flwr_exit( + ExitCode.COMMON_TLS_NOT_SUPPORTED, + "`flwr-model` does not support TLS yet.", + ) + + # Capture stdout/stderr + log_queue: Queue[str | None] = Queue() + mirror_output_to_queue(log_queue) + + log(INFO, "Start `flwr-model` process") + log( + DEBUG, + "`flwr-model` will attempt to connect to SuperLink's " + "ServerAppIo API at %s", + args.serverappio_api_address, + ) + run_model( + serverappio_api_address=args.serverappio_api_address, + log_queue=log_queue, + token=args.token, + certificates=None, + parent_pid=args.parent_pid, + runtime_dependency_install=args.runtime_dependency_install, + ) + + # Restore stdout/stderr + restore_output() + + +def _parse_args_run_flwr_model() -> argparse.ArgumentParser: + """Parse `flwr-model` command line arguments.""" + parser = argparse.ArgumentParser( + description="Run a Flower ModelApp", + ) + parser.add_argument( + "--serverappio-api-address", + default=SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS, + type=str, + help="Address of SuperLink's ServerAppIo API (IPv4, IPv6, or a domain name)." + f"By default, it is set to {SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS}.", + ) + add_args_flwr_app_common(parser=parser) + return parser diff --git a/framework/py/flwr/supercore/cli/flwr_model_test.py b/framework/py/flwr/supercore/cli/flwr_model_test.py new file mode 100644 index 000000000000..c4a3c3d39f1c --- /dev/null +++ b/framework/py/flwr/supercore/cli/flwr_model_test.py @@ -0,0 +1,129 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Tests for ModelApp process CLI parsing and wiring.""" + + +import importlib +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS + +from .flwr_model import _parse_args_run_flwr_model + +flwr_model_module = importlib.import_module("flwr.supercore.cli.flwr_model") + + +def test_parse_flwr_model_requires_token() -> None: + """The ModelApp process CLI should require a token.""" + with pytest.raises(SystemExit): + _parse_args_run_flwr_model().parse_args([]) + + +def test_parse_flwr_model_rejects_run_once() -> None: + """The removed deprecated flag should no longer parse.""" + with pytest.raises(SystemExit): + _parse_args_run_flwr_model().parse_args( + ["--token", "test-token", "--run-once"] + ) + + +def test_parse_flwr_model_parses_tokenized_invocation() -> None: + """The ModelApp process CLI should still parse the supported flags.""" + args = _parse_args_run_flwr_model().parse_args( + [ + "--token", + "test-token", + "--insecure", + "--parent-pid", + "1234", + "--allow-runtime-dependency-installation", + ] + ) + + assert args.serverappio_api_address == SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS + assert args.token == "test-token" + assert args.insecure is True + assert args.parent_pid == 1234 + assert args.runtime_dependency_install is True + + +def test_flwr_model_parses_args_before_mirroring_output() -> None: + """Argument parsing should happen before stdout/stderr redirection.""" + + class _Parser: + def parse_args(self) -> SimpleNamespace: + """Raise a parser error before any side effects happen.""" + raise SystemExit(2) + + mirror_output_to_queue = Mock() + + with ( + patch.object(flwr_model_module, "_parse_args_run_flwr_model", _Parser), + patch.object( + flwr_model_module, + "mirror_output_to_queue", + mirror_output_to_queue, + ), + pytest.raises(SystemExit), + ): + flwr_model_module.flwr_model() + + mirror_output_to_queue.assert_not_called() + + +def test_flwr_model_forwards_cli_args() -> None: + """The ModelApp CLI should forward parsed args to the runtime.""" + args = SimpleNamespace( + insecure=True, + serverappio_api_address="127.0.0.1:9091", + token="test-token", + parent_pid=321, + runtime_dependency_install=True, + ) + + class _Parser: + def parse_args(self) -> SimpleNamespace: + """Return a fixed namespace for CLI forwarding tests.""" + return args + + mirror_output_to_queue = Mock() + restore_output = Mock() + run_model = Mock() + + with ( + patch.object(flwr_model_module, "_parse_args_run_flwr_model", _Parser), + patch.object( + flwr_model_module, + "mirror_output_to_queue", + mirror_output_to_queue, + ), + patch.object(flwr_model_module, "restore_output", restore_output), + patch.object(flwr_model_module, "run_model", run_model), + ): + flwr_model_module.flwr_model() + + mirror_output_to_queue.assert_called_once() + restore_output.assert_called_once_with() + run_model.assert_called_once() + kwargs = run_model.call_args.kwargs + assert kwargs["serverappio_api_address"] == "127.0.0.1:9091" + assert kwargs["log_queue"] is mirror_output_to_queue.call_args.args[0] + assert kwargs["token"] == "test-token" + assert kwargs["certificates"] is None + assert kwargs["parent_pid"] == 321 + assert kwargs["runtime_dependency_install"] is True From a785c57007efbbc80c495f984fb07f9d29b463a2 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 20:20:37 +0200 Subject: [PATCH 15/26] feat(framework): Add flwr-connector --- framework/py/flwr/common/exit/exit.py | 2 + framework/py/flwr/common/telemetry.py | 4 + framework/py/flwr/supercore/agent/__init__.py | 4 +- .../py/flwr/supercore/agent/run_connector.py | 74 ++++++++++ .../supercore/agent/run_connector_test.py | 61 +++++++++ framework/py/flwr/supercore/cli/__init__.py | 2 + .../py/flwr/supercore/cli/flwr_connector.py | 76 +++++++++++ .../flwr/supercore/cli/flwr_connector_test.py | 129 ++++++++++++++++++ 8 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 framework/py/flwr/supercore/agent/run_connector.py create mode 100644 framework/py/flwr/supercore/agent/run_connector_test.py create mode 100644 framework/py/flwr/supercore/cli/flwr_connector.py create mode 100644 framework/py/flwr/supercore/cli/flwr_connector_test.py diff --git a/framework/py/flwr/common/exit/exit.py b/framework/py/flwr/common/exit/exit.py index 8f92b3f182ff..f5550071ba5d 100644 --- a/framework/py/flwr/common/exit/exit.py +++ b/framework/py/flwr/common/exit/exit.py @@ -128,6 +128,8 @@ def _try_obtain_telemetry_event() -> EventType | None: return EventType.FLWR_AGENTAPP_RUN_LEAVE if sys.argv[0].endswith("flwr-model"): return EventType.FLWR_MODEL_RUN_LEAVE + if sys.argv[0].endswith("flwr-connector"): + return EventType.FLWR_CONNECTOR_RUN_LEAVE if sys.argv[0].endswith("flwr-serverapp"): return EventType.FLWR_SERVERAPP_RUN_LEAVE if sys.argv[0].endswith("flwr-clientapp"): diff --git a/framework/py/flwr/common/telemetry.py b/framework/py/flwr/common/telemetry.py index 6018a072afef..1aa924abe3ab 100644 --- a/framework/py/flwr/common/telemetry.py +++ b/framework/py/flwr/common/telemetry.py @@ -164,6 +164,10 @@ def _generate_next_value_(name: str, start: int, count: int, last_values: list[A FLWR_MODEL_RUN_ENTER = auto() FLWR_MODEL_RUN_LEAVE = auto() + # CLI: flwr-connector + FLWR_CONNECTOR_RUN_ENTER = auto() + FLWR_CONNECTOR_RUN_LEAVE = auto() + # --- Simulation Engine ------------------------------------------------------------ # Python API: `run_simulation` diff --git a/framework/py/flwr/supercore/agent/__init__.py b/framework/py/flwr/supercore/agent/__init__.py index 4b8ae24d580b..aa17b7e8e4c6 100644 --- a/framework/py/flwr/supercore/agent/__init__.py +++ b/framework/py/flwr/supercore/agent/__init__.py @@ -12,13 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== -"""Flower AgentApp components.""" +"""Flower process-isolated app runtimes.""" from .run_agentapp import run_agentapp +from .run_connector import run_connector from .run_model import run_model __all__ = [ "run_agentapp", + "run_connector", "run_model", ] diff --git a/framework/py/flwr/supercore/agent/run_connector.py b/framework/py/flwr/supercore/agent/run_connector.py new file mode 100644 index 000000000000..8952119d57ae --- /dev/null +++ b/framework/py/flwr/supercore/agent/run_connector.py @@ -0,0 +1,74 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower ConnectorApp process.""" + + +from pathlib import Path +from queue import Queue + +from flwr.common import EventType +from flwr.common.constant import RUNTIME_DEPENDENCY_INSTALL +from flwr.common.exit import ExitCode, flwr_exit, register_signal_handlers +from flwr.common.logger import stop_log_uploader +from flwr.supercore.app_utils import start_parent_process_monitor +from flwr.supercore.superexec.dependency_installer import ( + cleanup_app_runtime_environment, +) + + +def run_connector( # pylint: disable=R0913, R0917 + serverappio_api_address: str, + log_queue: Queue[str | None], + token: str, + certificates: bytes | None = None, + parent_pid: int | None = None, + runtime_dependency_install: bool = RUNTIME_DEPENDENCY_INSTALL, +) -> None: + """Run Flower ConnectorApp process. + + This runtime is intentionally a stub until ConnectorApp execution support is added. + """ + # Monitor the main process in case of SIGKILL + if parent_pid is not None: + start_parent_process_monitor(parent_pid) + + log_uploader = None + runtime_env_dir: Path | None = None + + def on_exit() -> None: + if log_uploader: + stop_log_uploader(log_queue, log_uploader) + cleanup_app_runtime_environment(runtime_env_dir) + + register_signal_handlers( + event_type=EventType.FLWR_CONNECTOR_RUN_LEAVE, + exit_message="Run stopped by user.", + exit_handlers=[on_exit], + ) + + _ = ( + serverappio_api_address, + log_queue, + token, + certificates, + parent_pid, + runtime_dependency_install, + ) + flwr_exit( + ExitCode.SERVERAPP_EXCEPTION, + "`flwr-connector` is not implemented yet.", + event_type=EventType.FLWR_CONNECTOR_RUN_LEAVE, + event_details={"success": False}, + ) diff --git a/framework/py/flwr/supercore/agent/run_connector_test.py b/framework/py/flwr/supercore/agent/run_connector_test.py new file mode 100644 index 000000000000..2db614f77faa --- /dev/null +++ b/framework/py/flwr/supercore/agent/run_connector_test.py @@ -0,0 +1,61 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Tests for the Flower ConnectorApp process.""" + + +import importlib +from queue import Queue + +import pytest + +from flwr.common import EventType +from flwr.common.exit import ExitCode + +run_connector_module = importlib.import_module("flwr.supercore.agent.run_connector") + + +def test_run_flwr_connector_exits_with_stub_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The Flower ConnectorApp process should fail fast with a clear message.""" + captured: dict[str, object] = {} + + def _flwr_exit( + code: int, + message: str | None = None, + event_type: EventType | None = None, + event_details: dict[str, object] | None = None, + ) -> None: + captured["code"] = code + captured["message"] = message + captured["event_type"] = event_type + captured["event_details"] = event_details + raise SystemExit(1) + + monkeypatch.setattr(run_connector_module, "flwr_exit", _flwr_exit) + + with pytest.raises(SystemExit): + run_connector_module.run_connector( + serverappio_api_address="127.0.0.1:9091", + log_queue=Queue(), + token="test-token", + ) + + assert captured == { + "code": ExitCode.SERVERAPP_EXCEPTION, + "message": "`flwr-connector` is not implemented yet.", + "event_type": EventType.FLWR_CONNECTOR_RUN_LEAVE, + "event_details": {"success": False}, + } diff --git a/framework/py/flwr/supercore/cli/__init__.py b/framework/py/flwr/supercore/cli/__init__.py index 18a3ecab2e2e..a40979ddc116 100644 --- a/framework/py/flwr/supercore/cli/__init__.py +++ b/framework/py/flwr/supercore/cli/__init__.py @@ -17,10 +17,12 @@ from .flower_superexec import flower_superexec from .flwr_agentapp import flwr_agentapp +from .flwr_connector import flwr_connector from .flwr_model import flwr_model __all__ = [ "flower_superexec", "flwr_agentapp", + "flwr_connector", "flwr_model", ] diff --git a/framework/py/flwr/supercore/cli/flwr_connector.py b/framework/py/flwr/supercore/cli/flwr_connector.py new file mode 100644 index 000000000000..d64f2276a156 --- /dev/null +++ b/framework/py/flwr/supercore/cli/flwr_connector.py @@ -0,0 +1,76 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""`flwr-connector` command.""" + + +import argparse +from logging import DEBUG, INFO +from queue import Queue + +from flwr.common.args import add_args_flwr_app_common +from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS +from flwr.common.exit import ExitCode, flwr_exit +from flwr.common.logger import log, mirror_output_to_queue, restore_output +from flwr.supercore.agent.run_connector import run_connector + + +def flwr_connector() -> None: + """Run process-isolated Flower ConnectorApp.""" + args = _parse_args_run_flwr_connector().parse_args() + + if not args.insecure: + flwr_exit( + ExitCode.COMMON_TLS_NOT_SUPPORTED, + "`flwr-connector` does not support TLS yet.", + ) + + # Capture stdout/stderr + log_queue: Queue[str | None] = Queue() + mirror_output_to_queue(log_queue) + + log(INFO, "Start `flwr-connector` process") + log( + DEBUG, + "`flwr-connector` will attempt to connect to SuperLink's " + "ServerAppIo API at %s", + args.serverappio_api_address, + ) + run_connector( + serverappio_api_address=args.serverappio_api_address, + log_queue=log_queue, + token=args.token, + certificates=None, + parent_pid=args.parent_pid, + runtime_dependency_install=args.runtime_dependency_install, + ) + + # Restore stdout/stderr + restore_output() + + +def _parse_args_run_flwr_connector() -> argparse.ArgumentParser: + """Parse `flwr-connector` command line arguments.""" + parser = argparse.ArgumentParser( + description="Run a Flower ConnectorApp", + ) + parser.add_argument( + "--serverappio-api-address", + default=SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS, + type=str, + help="Address of SuperLink's ServerAppIo API (IPv4, IPv6, or a domain name)." + f"By default, it is set to {SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS}.", + ) + add_args_flwr_app_common(parser=parser) + return parser diff --git a/framework/py/flwr/supercore/cli/flwr_connector_test.py b/framework/py/flwr/supercore/cli/flwr_connector_test.py new file mode 100644 index 000000000000..8829f1ec3d1f --- /dev/null +++ b/framework/py/flwr/supercore/cli/flwr_connector_test.py @@ -0,0 +1,129 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Tests for ConnectorApp process CLI parsing and wiring.""" + + +import importlib +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS + +from .flwr_connector import _parse_args_run_flwr_connector + +flwr_connector_module = importlib.import_module("flwr.supercore.cli.flwr_connector") + + +def test_parse_flwr_connector_requires_token() -> None: + """The ConnectorApp process CLI should require a token.""" + with pytest.raises(SystemExit): + _parse_args_run_flwr_connector().parse_args([]) + + +def test_parse_flwr_connector_rejects_run_once() -> None: + """The removed deprecated flag should no longer parse.""" + with pytest.raises(SystemExit): + _parse_args_run_flwr_connector().parse_args( + ["--token", "test-token", "--run-once"] + ) + + +def test_parse_flwr_connector_parses_tokenized_invocation() -> None: + """The ConnectorApp process CLI should still parse the supported flags.""" + args = _parse_args_run_flwr_connector().parse_args( + [ + "--token", + "test-token", + "--insecure", + "--parent-pid", + "1234", + "--allow-runtime-dependency-installation", + ] + ) + + assert args.serverappio_api_address == SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS + assert args.token == "test-token" + assert args.insecure is True + assert args.parent_pid == 1234 + assert args.runtime_dependency_install is True + + +def test_flwr_connector_parses_args_before_mirroring_output() -> None: + """Argument parsing should happen before stdout/stderr redirection.""" + + class _Parser: + def parse_args(self) -> SimpleNamespace: + """Raise a parser error before any side effects happen.""" + raise SystemExit(2) + + mirror_output_to_queue = Mock() + + with ( + patch.object(flwr_connector_module, "_parse_args_run_flwr_connector", _Parser), + patch.object( + flwr_connector_module, + "mirror_output_to_queue", + mirror_output_to_queue, + ), + pytest.raises(SystemExit), + ): + flwr_connector_module.flwr_connector() + + mirror_output_to_queue.assert_not_called() + + +def test_flwr_connector_forwards_cli_args() -> None: + """The ConnectorApp CLI should forward parsed args to the runtime.""" + args = SimpleNamespace( + insecure=True, + serverappio_api_address="127.0.0.1:9091", + token="test-token", + parent_pid=321, + runtime_dependency_install=True, + ) + + class _Parser: + def parse_args(self) -> SimpleNamespace: + """Return a fixed namespace for CLI forwarding tests.""" + return args + + mirror_output_to_queue = Mock() + restore_output = Mock() + run_connector = Mock() + + with ( + patch.object(flwr_connector_module, "_parse_args_run_flwr_connector", _Parser), + patch.object( + flwr_connector_module, + "mirror_output_to_queue", + mirror_output_to_queue, + ), + patch.object(flwr_connector_module, "restore_output", restore_output), + patch.object(flwr_connector_module, "run_connector", run_connector), + ): + flwr_connector_module.flwr_connector() + + mirror_output_to_queue.assert_called_once() + restore_output.assert_called_once_with() + run_connector.assert_called_once() + kwargs = run_connector.call_args.kwargs + assert kwargs["serverappio_api_address"] == "127.0.0.1:9091" + assert kwargs["log_queue"] is mirror_output_to_queue.call_args.args[0] + assert kwargs["token"] == "test-token" + assert kwargs["certificates"] is None + assert kwargs["parent_pid"] == 321 + assert kwargs["runtime_dependency_install"] is True From fe6f70985809ca080b5493d044f0ac4b3b3e4a5f Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 20:35:32 +0200 Subject: [PATCH 16/26] Format --- framework/py/flwr/supercore/cli/flwr_model.py | 3 +-- framework/py/flwr/supercore/cli/flwr_model_test.py | 4 +--- framework/pyproject.toml | 2 ++ 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/framework/py/flwr/supercore/cli/flwr_model.py b/framework/py/flwr/supercore/cli/flwr_model.py index 040979760c34..5ef4edf4eec6 100644 --- a/framework/py/flwr/supercore/cli/flwr_model.py +++ b/framework/py/flwr/supercore/cli/flwr_model.py @@ -43,8 +43,7 @@ def flwr_model() -> None: log(INFO, "Start `flwr-model` process") log( DEBUG, - "`flwr-model` will attempt to connect to SuperLink's " - "ServerAppIo API at %s", + "`flwr-model` will attempt to connect to SuperLink's ServerAppIo API at %s", args.serverappio_api_address, ) run_model( diff --git a/framework/py/flwr/supercore/cli/flwr_model_test.py b/framework/py/flwr/supercore/cli/flwr_model_test.py index c4a3c3d39f1c..9e33a1bc1656 100644 --- a/framework/py/flwr/supercore/cli/flwr_model_test.py +++ b/framework/py/flwr/supercore/cli/flwr_model_test.py @@ -37,9 +37,7 @@ def test_parse_flwr_model_requires_token() -> None: def test_parse_flwr_model_rejects_run_once() -> None: """The removed deprecated flag should no longer parse.""" with pytest.raises(SystemExit): - _parse_args_run_flwr_model().parse_args( - ["--token", "test-token", "--run-once"] - ) + _parse_args_run_flwr_model().parse_args(["--token", "test-token", "--run-once"]) def test_parse_flwr_model_parses_tokenized_invocation() -> None: diff --git a/framework/pyproject.toml b/framework/pyproject.toml index 3babb071f2f4..c946150d211c 100644 --- a/framework/pyproject.toml +++ b/framework/pyproject.toml @@ -83,6 +83,8 @@ flower-superexec = "flwr.supercore.cli:flower_superexec" flwr-serverapp = "flwr.server.serverapp:flwr_serverapp" flwr-clientapp = "flwr.supernode.cli:flwr_clientapp" flwr-agentapp = "flwr.supercore.cli:flwr_agentapp" +flwr-model = "flwr.supercore.cli:flwr_model" +flwr-connector = "flwr.supercore.cli:flwr_connector" [project.urls] homepage = "https://flower.ai" From 817fa69816a9a0301686cd96c98aac7dcf15be04 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 20:39:19 +0200 Subject: [PATCH 17/26] Format --- framework/py/flwr/supercore/cli/flwr_model.py | 3 +-- framework/py/flwr/supercore/cli/flwr_model_test.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/framework/py/flwr/supercore/cli/flwr_model.py b/framework/py/flwr/supercore/cli/flwr_model.py index 040979760c34..5ef4edf4eec6 100644 --- a/framework/py/flwr/supercore/cli/flwr_model.py +++ b/framework/py/flwr/supercore/cli/flwr_model.py @@ -43,8 +43,7 @@ def flwr_model() -> None: log(INFO, "Start `flwr-model` process") log( DEBUG, - "`flwr-model` will attempt to connect to SuperLink's " - "ServerAppIo API at %s", + "`flwr-model` will attempt to connect to SuperLink's ServerAppIo API at %s", args.serverappio_api_address, ) run_model( diff --git a/framework/py/flwr/supercore/cli/flwr_model_test.py b/framework/py/flwr/supercore/cli/flwr_model_test.py index c4a3c3d39f1c..9e33a1bc1656 100644 --- a/framework/py/flwr/supercore/cli/flwr_model_test.py +++ b/framework/py/flwr/supercore/cli/flwr_model_test.py @@ -37,9 +37,7 @@ def test_parse_flwr_model_requires_token() -> None: def test_parse_flwr_model_rejects_run_once() -> None: """The removed deprecated flag should no longer parse.""" with pytest.raises(SystemExit): - _parse_args_run_flwr_model().parse_args( - ["--token", "test-token", "--run-once"] - ) + _parse_args_run_flwr_model().parse_args(["--token", "test-token", "--run-once"]) def test_parse_flwr_model_parses_tokenized_invocation() -> None: From e1b6f26149d6893984ff1bdd86e4c7c2421e70ba Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Wed, 22 Apr 2026 20:40:34 +0200 Subject: [PATCH 18/26] Format --- framework/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/framework/pyproject.toml b/framework/pyproject.toml index 3babb071f2f4..60512f7c2920 100644 --- a/framework/pyproject.toml +++ b/framework/pyproject.toml @@ -83,6 +83,7 @@ flower-superexec = "flwr.supercore.cli:flower_superexec" flwr-serverapp = "flwr.server.serverapp:flwr_serverapp" flwr-clientapp = "flwr.supernode.cli:flwr_clientapp" flwr-agentapp = "flwr.supercore.cli:flwr_agentapp" +flwr-model = "flwr.supercore.cli:flwr_model" [project.urls] homepage = "https://flower.ai" From 51eefc16b48e1e4d6bf7dd79d4ebbcee0b0318d8 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Thu, 23 Apr 2026 09:18:02 +0200 Subject: [PATCH 19/26] Rename dir --- framework/py/flwr/supercore/{agent => executors}/__init__.py | 2 +- .../py/flwr/supercore/{agent => executors}/run_agentapp.py | 0 .../py/flwr/supercore/{agent => executors}/run_agentapp_test.py | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename framework/py/flwr/supercore/{agent => executors}/__init__.py (95%) rename framework/py/flwr/supercore/{agent => executors}/run_agentapp.py (100%) rename framework/py/flwr/supercore/{agent => executors}/run_agentapp_test.py (100%) diff --git a/framework/py/flwr/supercore/agent/__init__.py b/framework/py/flwr/supercore/executors/__init__.py similarity index 95% rename from framework/py/flwr/supercore/agent/__init__.py rename to framework/py/flwr/supercore/executors/__init__.py index 719c244bbf27..83130220f0b5 100644 --- a/framework/py/flwr/supercore/agent/__init__.py +++ b/framework/py/flwr/supercore/executors/__init__.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== -"""Flower AgentApp components.""" +"""Flower Executors components.""" from .run_agentapp import run_agentapp diff --git a/framework/py/flwr/supercore/agent/run_agentapp.py b/framework/py/flwr/supercore/executors/run_agentapp.py similarity index 100% rename from framework/py/flwr/supercore/agent/run_agentapp.py rename to framework/py/flwr/supercore/executors/run_agentapp.py diff --git a/framework/py/flwr/supercore/agent/run_agentapp_test.py b/framework/py/flwr/supercore/executors/run_agentapp_test.py similarity index 100% rename from framework/py/flwr/supercore/agent/run_agentapp_test.py rename to framework/py/flwr/supercore/executors/run_agentapp_test.py From bc0464431d0ab5139abfff1091b3d0300b4e3b24 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Thu, 23 Apr 2026 09:27:40 +0200 Subject: [PATCH 20/26] Rename --- framework/py/flwr/supercore/cli/flwr_agentapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/py/flwr/supercore/cli/flwr_agentapp.py b/framework/py/flwr/supercore/cli/flwr_agentapp.py index ef6a36f262ce..5d2e07653505 100644 --- a/framework/py/flwr/supercore/cli/flwr_agentapp.py +++ b/framework/py/flwr/supercore/cli/flwr_agentapp.py @@ -23,7 +23,7 @@ from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS from flwr.common.exit import ExitCode, flwr_exit from flwr.common.logger import log, mirror_output_to_queue, restore_output -from flwr.supercore.agent.run_agentapp import run_agentapp +from flwr.supercore.executors.run_agentapp import run_agentapp def flwr_agentapp() -> None: From 17371b77e7a8f595c74ac020c3d2b6e2d9a8bb8a Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Thu, 23 Apr 2026 09:28:12 +0200 Subject: [PATCH 21/26] Rename --- framework/py/flwr/supercore/cli/flwr_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/py/flwr/supercore/cli/flwr_model.py b/framework/py/flwr/supercore/cli/flwr_model.py index 5ef4edf4eec6..d81f1a60a099 100644 --- a/framework/py/flwr/supercore/cli/flwr_model.py +++ b/framework/py/flwr/supercore/cli/flwr_model.py @@ -23,7 +23,7 @@ from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS from flwr.common.exit import ExitCode, flwr_exit from flwr.common.logger import log, mirror_output_to_queue, restore_output -from flwr.supercore.agent.run_model import run_model +from flwr.supercore.executors.run_model import run_model def flwr_model() -> None: From 278d92d98c992a8b93cc66a3be6bc37a062845b1 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Thu, 23 Apr 2026 09:28:43 +0200 Subject: [PATCH 22/26] Rename --- framework/py/flwr/supercore/cli/flwr_connector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/py/flwr/supercore/cli/flwr_connector.py b/framework/py/flwr/supercore/cli/flwr_connector.py index d64f2276a156..cfa70f0aa602 100644 --- a/framework/py/flwr/supercore/cli/flwr_connector.py +++ b/framework/py/flwr/supercore/cli/flwr_connector.py @@ -23,7 +23,7 @@ from flwr.common.constant import SERVERAPPIO_API_DEFAULT_CLIENT_ADDRESS from flwr.common.exit import ExitCode, flwr_exit from flwr.common.logger import log, mirror_output_to_queue, restore_output -from flwr.supercore.agent.run_connector import run_connector +from flwr.supercore.executors.run_connector import run_connector def flwr_connector() -> None: From 5b6d1b11dcf7cb79b0a1eb6c85744be3d0cbd868 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Thu, 23 Apr 2026 09:38:51 +0200 Subject: [PATCH 23/26] Rename --- framework/py/flwr/supercore/executors/run_agentapp_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/py/flwr/supercore/executors/run_agentapp_test.py b/framework/py/flwr/supercore/executors/run_agentapp_test.py index 28394f5c7c0e..092d4cb79082 100644 --- a/framework/py/flwr/supercore/executors/run_agentapp_test.py +++ b/framework/py/flwr/supercore/executors/run_agentapp_test.py @@ -23,7 +23,7 @@ from flwr.common import EventType from flwr.common.exit import ExitCode -run_agentapp_module = importlib.import_module("flwr.supercore.agent.run_agentapp") +run_agentapp_module = importlib.import_module("flwr.supercore.executors.run_agentapp") def test_run_flwr_agentapp_exits_with_stub_message( From 5e210d924bcb6b71a0d3e285519e9814e189cf31 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Thu, 23 Apr 2026 09:39:37 +0200 Subject: [PATCH 24/26] Rename --- framework/py/flwr/supercore/executors/run_model_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/py/flwr/supercore/executors/run_model_test.py b/framework/py/flwr/supercore/executors/run_model_test.py index 957a7d6bc0cc..24b6bc08ec5a 100644 --- a/framework/py/flwr/supercore/executors/run_model_test.py +++ b/framework/py/flwr/supercore/executors/run_model_test.py @@ -23,7 +23,7 @@ from flwr.common import EventType from flwr.common.exit import ExitCode -run_model_module = importlib.import_module("flwr.supercore.agent.run_model") +run_model_module = importlib.import_module("flwr.supercore.executors.run_model") def test_run_flwr_model_exits_with_stub_message( From e205c8e19d583f12550422f0c4ffd5cb18764668 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Thu, 23 Apr 2026 20:03:34 +0200 Subject: [PATCH 25/26] Remove test --- .../supercore/executors/run_agentapp_test.py | 61 ------------------- .../supercore/executors/run_model_test.py | 61 ------------------- 2 files changed, 122 deletions(-) delete mode 100644 framework/py/flwr/supercore/executors/run_agentapp_test.py delete mode 100644 framework/py/flwr/supercore/executors/run_model_test.py diff --git a/framework/py/flwr/supercore/executors/run_agentapp_test.py b/framework/py/flwr/supercore/executors/run_agentapp_test.py deleted file mode 100644 index 092d4cb79082..000000000000 --- a/framework/py/flwr/supercore/executors/run_agentapp_test.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright 2026 Flower Labs GmbH. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ============================================================================== -"""Tests for the Flower AgentApp process.""" - - -import importlib -from queue import Queue - -import pytest - -from flwr.common import EventType -from flwr.common.exit import ExitCode - -run_agentapp_module = importlib.import_module("flwr.supercore.executors.run_agentapp") - - -def test_run_flwr_agentapp_exits_with_stub_message( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The Flower AgentApp process should fail fast with a clear message.""" - captured: dict[str, object] = {} - - def _flwr_exit( - code: int, - message: str | None = None, - event_type: EventType | None = None, - event_details: dict[str, object] | None = None, - ) -> None: - captured["code"] = code - captured["message"] = message - captured["event_type"] = event_type - captured["event_details"] = event_details - raise SystemExit(1) - - monkeypatch.setattr(run_agentapp_module, "flwr_exit", _flwr_exit) - - with pytest.raises(SystemExit): - run_agentapp_module.run_agentapp( - serverappio_api_address="127.0.0.1:9091", - log_queue=Queue(), - token="test-token", - ) - - assert captured == { - "code": ExitCode.SERVERAPP_EXCEPTION, - "message": "`flwr-agentapp` is not implemented yet.", - "event_type": EventType.FLWR_AGENTAPP_RUN_LEAVE, - "event_details": {"success": False}, - } diff --git a/framework/py/flwr/supercore/executors/run_model_test.py b/framework/py/flwr/supercore/executors/run_model_test.py deleted file mode 100644 index 24b6bc08ec5a..000000000000 --- a/framework/py/flwr/supercore/executors/run_model_test.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright 2026 Flower Labs GmbH. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ============================================================================== -"""Tests for the Flower ModelApp process.""" - - -import importlib -from queue import Queue - -import pytest - -from flwr.common import EventType -from flwr.common.exit import ExitCode - -run_model_module = importlib.import_module("flwr.supercore.executors.run_model") - - -def test_run_flwr_model_exits_with_stub_message( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The Flower ModelApp process should fail fast with a clear message.""" - captured: dict[str, object] = {} - - def _flwr_exit( - code: int, - message: str | None = None, - event_type: EventType | None = None, - event_details: dict[str, object] | None = None, - ) -> None: - captured["code"] = code - captured["message"] = message - captured["event_type"] = event_type - captured["event_details"] = event_details - raise SystemExit(1) - - monkeypatch.setattr(run_model_module, "flwr_exit", _flwr_exit) - - with pytest.raises(SystemExit): - run_model_module.run_model( - serverappio_api_address="127.0.0.1:9091", - log_queue=Queue(), - token="test-token", - ) - - assert captured == { - "code": ExitCode.SERVERAPP_EXCEPTION, - "message": "`flwr-model` is not implemented yet.", - "event_type": EventType.FLWR_MODEL_RUN_LEAVE, - "event_details": {"success": False}, - } From 0a4a4d4ca286a73129dae31bf60937fcba5e4170 Mon Sep 17 00:00:00 2001 From: Daniel Nata Nugraha Date: Thu, 23 Apr 2026 20:11:38 +0200 Subject: [PATCH 26/26] Remove test --- .../supercore/executors/run_connector_test.py | 61 ------------------- 1 file changed, 61 deletions(-) delete mode 100644 framework/py/flwr/supercore/executors/run_connector_test.py diff --git a/framework/py/flwr/supercore/executors/run_connector_test.py b/framework/py/flwr/supercore/executors/run_connector_test.py deleted file mode 100644 index 2db614f77faa..000000000000 --- a/framework/py/flwr/supercore/executors/run_connector_test.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright 2026 Flower Labs GmbH. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ============================================================================== -"""Tests for the Flower ConnectorApp process.""" - - -import importlib -from queue import Queue - -import pytest - -from flwr.common import EventType -from flwr.common.exit import ExitCode - -run_connector_module = importlib.import_module("flwr.supercore.agent.run_connector") - - -def test_run_flwr_connector_exits_with_stub_message( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The Flower ConnectorApp process should fail fast with a clear message.""" - captured: dict[str, object] = {} - - def _flwr_exit( - code: int, - message: str | None = None, - event_type: EventType | None = None, - event_details: dict[str, object] | None = None, - ) -> None: - captured["code"] = code - captured["message"] = message - captured["event_type"] = event_type - captured["event_details"] = event_details - raise SystemExit(1) - - monkeypatch.setattr(run_connector_module, "flwr_exit", _flwr_exit) - - with pytest.raises(SystemExit): - run_connector_module.run_connector( - serverappio_api_address="127.0.0.1:9091", - log_queue=Queue(), - token="test-token", - ) - - assert captured == { - "code": ExitCode.SERVERAPP_EXCEPTION, - "message": "`flwr-connector` is not implemented yet.", - "event_type": EventType.FLWR_CONNECTOR_RUN_LEAVE, - "event_details": {"success": False}, - }