From 4886c7767b9e33e1b6914d1cce3ae329e1097a29 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 19 Sep 2023 20:25:00 +0200 Subject: [PATCH 01/44] Decouple client and app --- doc/source/how-to-deploy.md | 49 ++++++++++++++++++++++ examples/quickstart-pytorch/client.py | 11 +++-- examples/quickstart-pytorch/driver.py | 25 +++++++++++ examples/quickstart-pytorch/pyproject.toml | 2 +- src/py/flwr/app/__init__.py | 22 ++++++++++ src/py/flwr/app/flower.py | 23 ++++++++++ src/py/flwr/client/run.py | 36 +++++++++++++++- 7 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 doc/source/how-to-deploy.md create mode 100644 examples/quickstart-pytorch/driver.py create mode 100644 src/py/flwr/app/__init__.py create mode 100644 src/py/flwr/app/flower.py diff --git a/doc/source/how-to-deploy.md b/doc/source/how-to-deploy.md new file mode 100644 index 000000000000..e7f3a3fabc61 --- /dev/null +++ b/doc/source/how-to-deploy.md @@ -0,0 +1,49 @@ +# Deploy ๐Ÿงช + +๐Ÿงช = this page covers experimental features that might change in future versions of Flower + +This how-to guide describes the deployment of a long-running Flower server. + +## Preconditions + +Let's assume the following project structure: + +```bash +$ tree . +. +โ””โ”€โ”€ client.py +โ”œโ”€โ”€ driver.py +โ”œโ”€โ”€ requirements.txt +``` + +## Install dependencies + +```bash +pip install -r requirements.txt +``` + +## Start the long-running Flower server + +```bash +flower-server --grpc-rere +``` + +## Start the long-running Flower client + +In a new terminal window, start the first long-running Flower client: + +```bash +flower-client --grpc-rere --app client:app +``` + +In yet another new terminal window, start the second long-running Flower client: + +```bash +flower-client --grpc-rere --app client:app +``` + +## Start the Driver script + +```bash +python driver.py +``` diff --git a/examples/quickstart-pytorch/client.py b/examples/quickstart-pytorch/client.py index 6db7c8a855a0..6e5bbcaec39f 100644 --- a/examples/quickstart-pytorch/client.py +++ b/examples/quickstart-pytorch/client.py @@ -103,8 +103,11 @@ def evaluate(self, parameters, config): return loss, len(testloader.dataset), {"accuracy": accuracy} -# Start Flower client -fl.client.start_numpy_client( - server_address="127.0.0.1:8080", - client=FlowerClient(), +def client_fn(cid: str): + """.""" + return FlowerClient() + + +app = fl.app.Flower( + client_fn=client_fn, ) diff --git a/examples/quickstart-pytorch/driver.py b/examples/quickstart-pytorch/driver.py new file mode 100644 index 000000000000..1248672b6813 --- /dev/null +++ b/examples/quickstart-pytorch/driver.py @@ -0,0 +1,25 @@ +from typing import List, Tuple + +import flwr as fl +from flwr.common import Metrics + + +# Define metric aggregation function +def weighted_average(metrics: List[Tuple[int, Metrics]]) -> Metrics: + # Multiply accuracy of each client by number of examples used + accuracies = [num_examples * m["accuracy"] for num_examples, m in metrics] + examples = [num_examples for num_examples, _ in metrics] + + # Aggregate and return custom metric (weighted average) + return {"accuracy": sum(accuracies) / sum(examples)} + + +# Define strategy +strategy = fl.server.strategy.FedAvg(evaluate_metrics_aggregation_fn=weighted_average) + +# Start Flower driver +fl.driver.start_driver( + server_address="0.0.0.0:9091", + config=fl.server.ServerConfig(num_rounds=3), + strategy=strategy, +) diff --git a/examples/quickstart-pytorch/pyproject.toml b/examples/quickstart-pytorch/pyproject.toml index affdfee26d47..0d1a91836006 100644 --- a/examples/quickstart-pytorch/pyproject.toml +++ b/examples/quickstart-pytorch/pyproject.toml @@ -10,7 +10,7 @@ authors = ["The Flower Authors "] [tool.poetry.dependencies] python = ">=3.8,<3.11" -flwr = ">=1.0,<2.0" +flwr = { path = "../../", develop = true, extras = ["simulation", "rest"] } torch = "1.13.1" torchvision = "0.14.1" tqdm = "4.65.0" diff --git a/src/py/flwr/app/__init__.py b/src/py/flwr/app/__init__.py new file mode 100644 index 000000000000..a7f690463590 --- /dev/null +++ b/src/py/flwr/app/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2020 Adap 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 app package.""" + + +from .flower import Flower as Flower + +__all__ = [ + "Flower", +] diff --git a/src/py/flwr/app/flower.py b/src/py/flwr/app/flower.py new file mode 100644 index 000000000000..8a16458447c4 --- /dev/null +++ b/src/py/flwr/app/flower.py @@ -0,0 +1,23 @@ +# Copyright 2023 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 app.""" + + +class Flower: + """.""" + + def __init__(self, client_fn) -> None: + self.client_fn = client_fn + diff --git a/src/py/flwr/client/run.py b/src/py/flwr/client/run.py index abb8fa94ad56..7e3f7e8e7c7f 100644 --- a/src/py/flwr/client/run.py +++ b/src/py/flwr/client/run.py @@ -16,8 +16,12 @@ import argparse +import sys from logging import INFO +from uvicorn.importer import import_from_string + +from flwr.client import start_client from flwr.common.logger import log @@ -28,6 +32,24 @@ def run_client() -> None: args = _parse_args_client().parse_args() print(args.server) + print(args.app_dir) + print(args.app) + + app_dir = args.app_dir + if app_dir is not None: + sys.path.insert(0, app_dir) + + def app_client_fn(cid: str): + """.""" + app = import_from_string(args.app) + client = app.client_fn(cid=cid) + return client + + return start_client( + server_address=args.server, + client_fn=app_client_fn, + transport="grpc-rere", # Only + ) def _parse_args_client() -> argparse.ArgumentParser: @@ -38,8 +60,20 @@ def _parse_args_client() -> argparse.ArgumentParser: parser.add_argument( "--server", - help="Server address", default="0.0.0.0:9092", + help="Server address", + ) + + parser.add_argument( + "--app-dir", + default="", + help="Look for APP in the specified directory, by adding this to the PYTHONPATH." + " Defaults to the current working directory.", + ) + + parser.add_argument( + "--app", + help="", ) return parser From 1223f90f291999a5ad3e4689c2c97eb1176cc8c4 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 16 Oct 2023 23:39:15 +0200 Subject: [PATCH 02/44] Shorten line --- src/py/flwr/client/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/flwr/client/run.py b/src/py/flwr/client/run.py index 7e3f7e8e7c7f..51eec423a94b 100644 --- a/src/py/flwr/client/run.py +++ b/src/py/flwr/client/run.py @@ -67,7 +67,7 @@ def _parse_args_client() -> argparse.ArgumentParser: parser.add_argument( "--app-dir", default="", - help="Look for APP in the specified directory, by adding this to the PYTHONPATH." + help="Look for APP in specified directory, by adding this to the PYTHONPATH." " Defaults to the current working directory.", ) From a7c57d6b3d9dfb84524089dc87745e969ada1847 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Wed, 25 Oct 2023 12:29:28 +0200 Subject: [PATCH 03/44] Add comments to main client loop --- src/py/flwr/client/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index a74568b8e418..9032f504efd8 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -180,12 +180,16 @@ def single_client_factory( create_node() # pylint: disable=not-callable while True: + # Receive task_ins = receive() if task_ins is None: time.sleep(3) # Wait for 3s before asking again continue + + # Process task_res, sleep_duration, keep_going = handle(client_fn, task_ins) - send(task_res) + + # Send if not keep_going: break From 9a20a0c078bb972c86a2aaed7e037cabbdd4338d Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 30 Oct 2023 15:06:29 +0100 Subject: [PATCH 04/44] Refactor start_client --- src/py/flwr/client/app.py | 69 +++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index a74568b8e418..60d41cf2780e 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -134,37 +134,8 @@ def single_client_factory( client_fn = single_client_factory - # Parse IP address - parsed_address = parse_address(server_address) - if not parsed_address: - sys.exit(f"Server address ({server_address}) cannot be parsed.") - host, port, is_v6 = parsed_address - address = f"[{host}]:{port}" if is_v6 else f"{host}:{port}" - - # Set the default transport layer - if transport is None: - transport = TRANSPORT_TYPE_GRPC_BIDI - - # Use either gRPC bidirectional streaming or REST request/response - if transport == TRANSPORT_TYPE_REST: - try: - from .rest_client.connection import http_request_response - except ModuleNotFoundError: - sys.exit(MISSING_EXTRA_REST) - if server_address[:4] != "http": - sys.exit( - "When using the REST API, please provide `https://` or " - "`http://` before the server address (e.g. `http://127.0.0.1:8080`)" - ) - connection = http_request_response - elif transport == TRANSPORT_TYPE_GRPC_RERE: - connection = grpc_request_response - elif transport == TRANSPORT_TYPE_GRPC_BIDI: - connection = grpc_connection - else: - raise ValueError( - f"Unknown transport type: {transport} (possible: {TRANSPORT_TYPES})" - ) + # Initialize connection context manager + connection, address = _init_connection(transport,server_address) while True: sleep_duration: int = 0 @@ -285,3 +256,39 @@ def start_numpy_client( root_certificates=root_certificates, transport=transport, ) + + +def _init_connection(transport: Optional[str], server_address: str): + # Parse IP address + parsed_address = parse_address(server_address) + if not parsed_address: + sys.exit(f"Server address ({server_address}) cannot be parsed.") + host, port, is_v6 = parsed_address + address = f"[{host}]:{port}" if is_v6 else f"{host}:{port}" + + # Set the default transport layer + if transport is None: + transport = TRANSPORT_TYPE_GRPC_BIDI + + # Use either gRPC bidirectional streaming or REST request/response + if transport == TRANSPORT_TYPE_REST: + try: + from .rest_client.connection import http_request_response + except ModuleNotFoundError: + sys.exit(MISSING_EXTRA_REST) + if server_address[:4] != "http": + sys.exit( + "When using the REST API, please provide `https://` or " + "`http://` before the server address (e.g. `http://127.0.0.1:8080`)" + ) + connection = http_request_response + elif transport == TRANSPORT_TYPE_GRPC_RERE: + connection = grpc_request_response + elif transport == TRANSPORT_TYPE_GRPC_BIDI: + connection = grpc_connection + else: + raise ValueError( + f"Unknown transport type: {transport} (possible: {TRANSPORT_TYPES})" + ) + + return connection, address From 32e4973dd3a9868561de961b176a055d60979bde Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 30 Oct 2023 15:25:11 +0100 Subject: [PATCH 05/44] Format code --- src/py/flwr/client/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 60d41cf2780e..383277decc5a 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -135,7 +135,7 @@ def single_client_factory( client_fn = single_client_factory # Initialize connection context manager - connection, address = _init_connection(transport,server_address) + connection, address = _init_connection(transport, server_address) while True: sleep_duration: int = 0 From 11bf5095b34594cf4d4f3ca90562971e1f2ad32e Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 30 Oct 2023 15:26:33 +0100 Subject: [PATCH 06/44] Add type hint --- src/py/flwr/client/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 383277decc5a..d6b8eab74214 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -19,7 +19,7 @@ import time import warnings from logging import INFO -from typing import Optional, Union +from typing import ContextManager, Optional, Tuple, Union from flwr.client.client import Client from flwr.client.typing import ClientFn @@ -258,7 +258,9 @@ def start_numpy_client( ) -def _init_connection(transport: Optional[str], server_address: str): +def _init_connection( + transport: Optional[str], server_address: str +) -> Tuple[ContextManager, str]: # Parse IP address parsed_address = parse_address(server_address) if not parsed_address: From a1a647a03fd26361c366eaa5d83bc498e6bb1394 Mon Sep 17 00:00:00 2001 From: Charles Beauville Date: Mon, 30 Oct 2023 17:00:33 +0100 Subject: [PATCH 07/44] Test with Iterator --- src/py/flwr/client/app.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index d6b8eab74214..df0dda612fc0 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -19,7 +19,7 @@ import time import warnings from logging import INFO -from typing import ContextManager, Optional, Tuple, Union +from typing import Callable, Iterator, Optional, Tuple, Union from flwr.client.client import Client from flwr.client.typing import ClientFn @@ -33,6 +33,7 @@ TRANSPORT_TYPES, ) from flwr.common.logger import log +from flwr.proto.task_pb2 import TaskIns, TaskRes from .grpc_client.connection import grpc_connection from .grpc_rere_client.connection import grpc_request_response @@ -260,7 +261,17 @@ def start_numpy_client( def _init_connection( transport: Optional[str], server_address: str -) -> Tuple[ContextManager, str]: +) -> tuple[ + Iterator[ + Tuple[ + Callable[[], Optional[TaskIns]], + Callable[[TaskRes], None], + Optional[Callable[[], None]], + Optional[Callable[[], None]], + ] + ], + str, +]: # Parse IP address parsed_address = parse_address(server_address) if not parsed_address: From ce2bbb81540e7b7bcd47a7456f0112ca1c64d83f Mon Sep 17 00:00:00 2001 From: Charles Beauville Date: Mon, 30 Oct 2023 17:05:14 +0100 Subject: [PATCH 08/44] Working solution (I think) --- src/py/flwr/client/app.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index df0dda612fc0..7320649e5586 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -19,7 +19,7 @@ import time import warnings from logging import INFO -from typing import Callable, Iterator, Optional, Tuple, Union +from typing import Callable, Iterator, Optional, Tuple, Union, ContextManager from flwr.client.client import Client from flwr.client.typing import ClientFn @@ -142,8 +142,8 @@ def single_client_factory( sleep_duration: int = 0 with connection( address, - max_message_length=grpc_max_message_length, - root_certificates=root_certificates, + grpc_max_message_length, + root_certificates, ) as conn: receive, send, create_node, delete_node = conn @@ -261,14 +261,17 @@ def start_numpy_client( def _init_connection( transport: Optional[str], server_address: str -) -> tuple[ - Iterator[ - Tuple[ - Callable[[], Optional[TaskIns]], - Callable[[TaskRes], None], - Optional[Callable[[], None]], - Optional[Callable[[], None]], - ] +) -> Tuple[ + Callable[ + [str, int, Union[bytes, str, None]], + ContextManager[ + Tuple[ + Callable[[], Optional[TaskIns]], + Callable[[TaskRes], None], + Optional[Callable[[], None]], + Optional[Callable[[], None]], + ] + ], ], str, ]: From 7617335b050db7a6bfd5127f072e99c4ba005205 Mon Sep 17 00:00:00 2001 From: Charles Beauville Date: Mon, 30 Oct 2023 17:11:04 +0100 Subject: [PATCH 09/44] Fix imports --- src/py/flwr/client/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 7320649e5586..0b7bc19588d5 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -19,7 +19,7 @@ import time import warnings from logging import INFO -from typing import Callable, Iterator, Optional, Tuple, Union, ContextManager +from typing import Callable, ContextManager, Optional, Tuple, Union from flwr.client.client import Client from flwr.client.typing import ClientFn From 156aad092d762b8d173ee0a0bd7943308707dd99 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Sat, 4 Nov 2023 18:29:08 +0100 Subject: [PATCH 10/44] Move task execution into Flower app --- examples/quickstart-pytorch/client.py | 9 ++++ src/py/flwr/app/__init__.py | 4 ++ src/py/flwr/app/flower.py | 48 ++++++++++++++++++- src/py/flwr/client/app.py | 23 +++++++-- .../client/message_handler/message_handler.py | 33 +++++++++++++ 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/examples/quickstart-pytorch/client.py b/examples/quickstart-pytorch/client.py index 6e5bbcaec39f..293bb4ef116d 100644 --- a/examples/quickstart-pytorch/client.py +++ b/examples/quickstart-pytorch/client.py @@ -111,3 +111,12 @@ def client_fn(cid: str): app = fl.app.Flower( client_fn=client_fn, ) + + +if __name__ == "__main__": + # Start Flower client + fl.client.start_client( + server_address="0.0.0.0:9092", + client=FlowerClient().to_client(), + transport="grpc-rere", + ) diff --git a/src/py/flwr/app/__init__.py b/src/py/flwr/app/__init__.py index a7f690463590..423101f81f40 100644 --- a/src/py/flwr/app/__init__.py +++ b/src/py/flwr/app/__init__.py @@ -15,8 +15,12 @@ """Flower app package.""" +from .flower import Bwd as Bwd from .flower import Flower as Flower +from .flower import Fwd as Fwd __all__ = [ "Flower", + "Fwd", + "Bwd", ] diff --git a/src/py/flwr/app/flower.py b/src/py/flwr/app/flower.py index 8a16458447c4..e56fc18e6448 100644 --- a/src/py/flwr/app/flower.py +++ b/src/py/flwr/app/flower.py @@ -15,9 +15,53 @@ """Flower app.""" -class Flower: +from dataclasses import dataclass +from typing import Callable, Dict, Union + +from flwr.client.message_handler.message_handler import handle +from flwr.client.typing import ClientFn +from flwr.proto.task_pb2 import TaskIns, TaskRes + + +@dataclass +class Fwd: + """.""" + + task_ins: TaskIns + state: Dict # TBD + data: Dict # TBD + + +@dataclass +class Bwd: """.""" - def __init__(self, client_fn) -> None: + task_res: Union[TaskRes, Exception] + state: Dict # TBD + data: Dict # TBD + + +App = Callable[[Fwd], Bwd] + + +class Flower: + """Flower app class.""" + + def __init__( + self, + client_fn: ClientFn, # Only for backward compatibility + ) -> None: self.client_fn = client_fn + def __call__(self, fwd: Fwd) -> Bwd: + """.""" + # Execute the task + task_res, _, _ = handle( + client_fn=self.client_fn, + task_ins=fwd.task_ins, + ) + return Bwd( + task_res=task_res, + state={}, + data={}, + ) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 6992eee8e7bb..d3f14bb5e6e8 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -21,7 +21,9 @@ from logging import INFO from typing import Callable, ContextManager, Optional, Tuple, Union +from flwr.app import Bwd, Flower, Fwd from flwr.client.client import Client +from flwr.client.message_handler.message_handler import handle_control_message from flwr.client.typing import ClientFn from flwr.common import GRPC_MAX_MESSAGE_LENGTH, EventType, event from flwr.common.address import parse_address @@ -37,7 +39,6 @@ from .grpc_client.connection import grpc_connection from .grpc_rere_client.connection import grpc_request_response -from .message_handler.message_handler import handle from .numpy_client import NumPyClient @@ -53,7 +54,9 @@ def _check_actionable_client( ) -# pylint: disable=import-outside-toplevel,too-many-locals,too-many-branches +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-branches +# pylint: disable=too-many-locals # pylint: disable=too-many-statements def start_client( *, @@ -158,10 +161,24 @@ def single_client_factory( time.sleep(3) # Wait for 3s before asking again continue + # Check preconditions + sleep_duration, keep_going = handle_control_message(task_ins=task_ins) + if not keep_going: + break + + # Load app + app = Flower(client_fn=client_fn) + # Process - task_res, sleep_duration, keep_going = handle(client_fn, task_ins) + fwd_msg: Fwd = Fwd( + task_ins=task_ins, + state={}, + data={}, + ) + bwd_msg: Bwd = app(fwd=fwd_msg) # Send + send(bwd_msg.task_res) if not keep_going: break diff --git a/src/py/flwr/client/message_handler/message_handler.py b/src/py/flwr/client/message_handler/message_handler.py index d2eecb83d71a..dbb696ded872 100644 --- a/src/py/flwr/client/message_handler/message_handler.py +++ b/src/py/flwr/client/message_handler/message_handler.py @@ -39,6 +39,39 @@ class UnknownServerMessage(Exception): """Exception indicating that the received message is unknown.""" +def handle_control_message(task_ins: TaskIns) -> Tuple[int, bool]: + """Handle control part of the incoming message. + + Parameters + ---------- + task_ins: TaskIns + The task instruction coming from the server, to be processed by the client. + + Returns + ------- + sleep_duration : int + Number of seconds that the client should disconnect from the server. + keep_going : bool + Flag that indicates whether the client should continue to process the + next message from the server (True) or disconnect and optionally + reconnect later (False). + """ + server_msg = get_server_message_from_task_ins(task_ins, exclude_reconnect_ins=False) + + # SecAgg message + if server_msg is None: + return 0, True + + # ReconnectIns message + field = server_msg.WhichOneof("msg") + if field == "reconnect_ins": + disconnect_msg, sleep_duration = _reconnect(server_msg.reconnect_ins) + return disconnect_msg, sleep_duration, False + + # Any other message + return 0, True + + def handle(client_fn: ClientFn, task_ins: TaskIns) -> Tuple[TaskRes, int, bool]: """Handle incoming TaskIns from the server. From a47a760d9eba912d3556bb972f5eb33c25447094 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Sat, 4 Nov 2023 19:50:44 +0100 Subject: [PATCH 11/44] Enable --app in flower-client --- examples/quickstart-pytorch/client.py | 2 +- src/py/flwr/client/app.py | 38 ++++++++++++++++++--------- src/py/flwr/client/run.py | 11 ++++---- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/examples/quickstart-pytorch/client.py b/examples/quickstart-pytorch/client.py index 293bb4ef116d..da73f4fab00d 100644 --- a/examples/quickstart-pytorch/client.py +++ b/examples/quickstart-pytorch/client.py @@ -105,7 +105,7 @@ def evaluate(self, parameters, config): def client_fn(cid: str): """.""" - return FlowerClient() + return FlowerClient().to_client() app = fl.app.Flower( diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index d3f14bb5e6e8..a14f04cb24c9 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -61,6 +61,7 @@ def _check_actionable_client( def start_client( *, server_address: str, + load_app_fn: Optional[Callable[[], Flower]] = None, client_fn: Optional[ClientFn] = None, client: Optional[Client] = None, grpc_max_message_length: int = GRPC_MAX_MESSAGE_LENGTH, @@ -75,6 +76,8 @@ def start_client( The IPv4 or IPv6 address of the server. If the Flower server runs on the same machine on port 8080, then `server_address` would be `"[::]:8080"`. + load_app_fn : ... + ... client_fn : Optional[ClientFn] A callable that instantiates a Client. (default: None) client : Optional[flwr.client.Client] @@ -123,20 +126,29 @@ class `flwr.client.Client` (default: None) """ event(EventType.START_CLIENT_ENTER) - _check_actionable_client(client, client_fn) + if load_app_fn is None: + _check_actionable_client(client, client_fn) - if client_fn is None: - # Wrap `Client` instance in `client_fn` - def single_client_factory( - cid: str, # pylint: disable=unused-argument - ) -> Client: - if client is None: # Added this to keep mypy happy - raise Exception( - "Both `client_fn` and `client` are `None`, but one is required" - ) - return client # Always return the same instance + if client_fn is None: + # Wrap `Client` instance in `client_fn` + def single_client_factory( + cid: str, # pylint: disable=unused-argument + ) -> Client: + if client is None: # Added this to keep mypy happy + raise Exception( + "Both `client_fn` and `client` are `None`, but one is required" + ) + return client # Always return the same instance + + client_fn = single_client_factory + + def _load_app(): + return Flower(client_fn=client_fn) + + load_app_fn = _load_app - client_fn = single_client_factory + # At this point, only `load_app_fn` should be used + # Both `client` and `client_fn` must not be used directly # Initialize connection context manager connection, address = _init_connection(transport, server_address) @@ -167,7 +179,7 @@ def single_client_factory( break # Load app - app = Flower(client_fn=client_fn) + app = load_app_fn() # Process fwd_msg: Fwd = Fwd( diff --git a/src/py/flwr/client/run.py b/src/py/flwr/client/run.py index 51eec423a94b..8bd2d73b863a 100644 --- a/src/py/flwr/client/run.py +++ b/src/py/flwr/client/run.py @@ -21,6 +21,7 @@ from uvicorn.importer import import_from_string +from flwr.app import Flower from flwr.client import start_client from flwr.common.logger import log @@ -39,15 +40,13 @@ def run_client() -> None: if app_dir is not None: sys.path.insert(0, app_dir) - def app_client_fn(cid: str): - """.""" - app = import_from_string(args.app) - client = app.client_fn(cid=cid) - return client + def _load() -> Flower: + app: Flower = import_from_string(args.app) + return app return start_client( server_address=args.server, - client_fn=app_client_fn, + load_app_fn=_load, transport="grpc-rere", # Only ) From 90058f973ff366b93d17603ea3fe8ada9ae7e327 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Sat, 4 Nov 2023 20:34:03 +0100 Subject: [PATCH 12/44] Add comments to start_client --- src/py/flwr/client/app.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 0b7bc19588d5..f17ecbf92095 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -53,7 +53,9 @@ def _check_actionable_client( ) -# pylint: disable=import-outside-toplevel,too-many-locals,too-many-branches +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-branches +# pylint: disable=too-many-locals # pylint: disable=too-many-statements def start_client( *, @@ -152,11 +154,16 @@ def single_client_factory( create_node() # pylint: disable=not-callable while True: + # Receive task_ins = receive() if task_ins is None: time.sleep(3) # Wait for 3s before asking again continue + + # Handle task task_res, sleep_duration, keep_going = handle(client_fn, task_ins) + + # Send send(task_res) if not keep_going: break From 86c147334761f8447880a92f6d9f397cca6f9a7e Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Sat, 4 Nov 2023 20:44:08 +0100 Subject: [PATCH 13/44] Handle control messages in a separate function --- src/py/flwr/client/app.py | 11 ++-- .../client/message_handler/message_handler.py | 53 +++++++++++++++---- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index f17ecbf92095..4e95c16112e4 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -37,7 +37,7 @@ from .grpc_client.connection import grpc_connection from .grpc_rere_client.connection import grpc_request_response -from .message_handler.message_handler import handle +from .message_handler.message_handler import handle, handle_control_message from .numpy_client import NumPyClient @@ -160,9 +160,12 @@ def single_client_factory( time.sleep(3) # Wait for 3s before asking again continue - # Handle task - task_res, sleep_duration, keep_going = handle(client_fn, task_ins) - + # Handle control message + sleep_duration, keep_going = handle_control_message(task_res=task_res) + + # Handle task message + task_res = handle(client_fn, task_ins) + # Send send(task_res) if not keep_going: diff --git a/src/py/flwr/client/message_handler/message_handler.py b/src/py/flwr/client/message_handler/message_handler.py index d2eecb83d71a..85a01c134a80 100644 --- a/src/py/flwr/client/message_handler/message_handler.py +++ b/src/py/flwr/client/message_handler/message_handler.py @@ -39,7 +39,40 @@ class UnknownServerMessage(Exception): """Exception indicating that the received message is unknown.""" -def handle(client_fn: ClientFn, task_ins: TaskIns) -> Tuple[TaskRes, int, bool]: +def handle_control_message(task_ins: TaskIns) -> Tuple[int, bool]: + """Handle control part of the incoming message. + + Parameters + ---------- + task_ins: TaskIns + The task instruction coming from the server, to be processed by the client. + + Returns + ------- + sleep_duration : int + Number of seconds that the client should disconnect from the server. + keep_going : bool + Flag that indicates whether the client should continue to process the + next message from the server (True) or disconnect and optionally + reconnect later (False). + """ + server_msg = get_server_message_from_task_ins(task_ins, exclude_reconnect_ins=False) + + # SecAgg message + if server_msg is None: + return 0, True + + # ReconnectIns message + field = server_msg.WhichOneof("msg") + if field == "reconnect_ins": + disconnect_msg, sleep_duration = _reconnect(server_msg.reconnect_ins) + return disconnect_msg, sleep_duration, False + + # Any other message + return 0, True + + +def handle(client_fn: ClientFn, task_ins: TaskIns) -> TaskRes: """Handle incoming TaskIns from the server. Parameters @@ -80,18 +113,18 @@ def handle(client_fn: ClientFn, task_ins: TaskIns) -> Tuple[TaskRes, int, bool]: sa=SecureAggregation(named_values=serde.named_values_to_proto(res)), ), ) - return task_res, 0, True + return task_res raise NotImplementedError() - client_msg, sleep_duration, keep_going = handle_legacy_message( + client_msg = handle_legacy_message( client_fn, server_msg ) task_res = wrap_client_message_in_task_res(client_msg) - return task_res, sleep_duration, keep_going + return task_res def handle_legacy_message( client_fn: ClientFn, server_msg: ServerMessage -) -> Tuple[ClientMessage, int, bool]: +) -> ClientMessage: """Handle incoming messages from the server. Parameters @@ -115,19 +148,19 @@ def handle_legacy_message( field = server_msg.WhichOneof("msg") if field == "reconnect_ins": disconnect_msg, sleep_duration = _reconnect(server_msg.reconnect_ins) - return disconnect_msg, sleep_duration, False + return disconnect_msg # Instantiate the client client = client_fn("-1") # Execute task if field == "get_properties_ins": - return _get_properties(client, server_msg.get_properties_ins), 0, True + return _get_properties(client, server_msg.get_properties_ins) if field == "get_parameters_ins": - return _get_parameters(client, server_msg.get_parameters_ins), 0, True + return _get_parameters(client, server_msg.get_parameters_ins) if field == "fit_ins": - return _fit(client, server_msg.fit_ins), 0, True + return _fit(client, server_msg.fit_ins) if field == "evaluate_ins": - return _evaluate(client, server_msg.evaluate_ins), 0, True + return _evaluate(client, server_msg.evaluate_ins) raise UnknownServerMessage() From 9bc134996bdea2bc866688b90ce7b5424b9fe63a Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Sat, 4 Nov 2023 20:47:42 +0100 Subject: [PATCH 14/44] Change comment --- src/py/flwr/client/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index f17ecbf92095..a816c2e63d87 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -160,7 +160,7 @@ def single_client_factory( time.sleep(3) # Wait for 3s before asking again continue - # Handle task + # Handle task message task_res, sleep_duration, keep_going = handle(client_fn, task_ins) # Send From 169134090aa79bd4654a16818b320163a5305495 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Sat, 4 Nov 2023 20:57:44 +0100 Subject: [PATCH 15/44] Update comments --- .../flwr/client/message_handler/message_handler.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/py/flwr/client/message_handler/message_handler.py b/src/py/flwr/client/message_handler/message_handler.py index cfcfb87b1a54..045c6d185792 100644 --- a/src/py/flwr/client/message_handler/message_handler.py +++ b/src/py/flwr/client/message_handler/message_handler.py @@ -86,12 +86,6 @@ def handle(client_fn: ClientFn, task_ins: TaskIns) -> TaskRes: ------- task_res: TaskRes The task response that should be returned to the server. - sleep_duration : int - Number of seconds that the client should disconnect from the server. - keep_going : bool - Flag that indicates whether the client should continue to process the - next message from the server (True) or disconnect and optionally - reconnect later (False). """ server_msg = get_server_message_from_task_ins(task_ins, exclude_reconnect_ins=False) if server_msg is None: @@ -136,12 +130,6 @@ def handle_legacy_message( ------- client_msg: ClientMessage The result message that should be returned to the server. - sleep_duration : int - Number of seconds that the client should disconnect from the server. - keep_going : bool - Flag that indicates whether the client should continue to process the - next message from the server (True) or disconnect and optionally - reconnect later (False). """ field = server_msg.WhichOneof("msg") if field == "reconnect_ins": From 7d732e98da5981d28249b1e80391bf8a81b70228 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Sat, 4 Nov 2023 20:59:09 +0100 Subject: [PATCH 16/44] Update comments --- src/py/flwr/client/message_handler/message_handler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/flwr/client/message_handler/message_handler.py b/src/py/flwr/client/message_handler/message_handler.py index 045c6d185792..dfe5ecb7c1e7 100644 --- a/src/py/flwr/client/message_handler/message_handler.py +++ b/src/py/flwr/client/message_handler/message_handler.py @@ -44,7 +44,7 @@ def handle_control_message(task_ins: TaskIns) -> Tuple[int, bool]: Parameters ---------- - task_ins: TaskIns + task_ins : TaskIns The task instruction coming from the server, to be processed by the client. Returns @@ -84,7 +84,7 @@ def handle(client_fn: ClientFn, task_ins: TaskIns) -> TaskRes: Returns ------- - task_res: TaskRes + task_res : TaskRes The task response that should be returned to the server. """ server_msg = get_server_message_from_task_ins(task_ins, exclude_reconnect_ins=False) @@ -128,7 +128,7 @@ def handle_legacy_message( Returns ------- - client_msg: ClientMessage + client_msg : ClientMessage The result message that should be returned to the server. """ field = server_msg.WhichOneof("msg") From 9f6472f16be57f47da204f61cb619f2888df013a Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Sat, 4 Nov 2023 21:07:48 +0100 Subject: [PATCH 17/44] Format --- src/py/flwr/client/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index a816c2e63d87..6c104ea952d8 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -162,7 +162,7 @@ def single_client_factory( # Handle task message task_res, sleep_duration, keep_going = handle(client_fn, task_ins) - + # Send send(task_res) if not keep_going: From c92ea668fdff37fb9f394b3702f2edb31c956bf0 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 13:49:28 +0100 Subject: [PATCH 18/44] Remove exception typing --- examples/quickstart-pytorch/client.py | 1 + src/py/flwr/app/flower.py | 4 ++-- src/py/flwr/client/run.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/quickstart-pytorch/client.py b/examples/quickstart-pytorch/client.py index da73f4fab00d..818e068353a3 100644 --- a/examples/quickstart-pytorch/client.py +++ b/examples/quickstart-pytorch/client.py @@ -108,6 +108,7 @@ def client_fn(cid: str): return FlowerClient().to_client() +# To run this: `flower-client --app client:app` app = fl.app.Flower( client_fn=client_fn, ) diff --git a/src/py/flwr/app/flower.py b/src/py/flwr/app/flower.py index 882f0425d13e..577b495b1f51 100644 --- a/src/py/flwr/app/flower.py +++ b/src/py/flwr/app/flower.py @@ -16,7 +16,7 @@ from dataclasses import dataclass -from typing import Callable, Dict, Union +from typing import Callable, Dict from flwr.client.message_handler.message_handler import handle from flwr.client.typing import ClientFn @@ -36,7 +36,7 @@ class Fwd: class Bwd: """.""" - task_res: Union[TaskRes, Exception] + task_res: TaskRes state: Dict # TBD data: Dict # TBD diff --git a/src/py/flwr/client/run.py b/src/py/flwr/client/run.py index 8bd2d73b863a..d90853a31f99 100644 --- a/src/py/flwr/client/run.py +++ b/src/py/flwr/client/run.py @@ -72,7 +72,7 @@ def _parse_args_client() -> argparse.ArgumentParser: parser.add_argument( "--app", - help="", + help="For example: `client:app` or `project.package.module:wrapper.app", ) return parser From e37d2bf46e9ba51e39abc34474dfaf835867cfda Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 14:25:12 +0100 Subject: [PATCH 19/44] Update message handler --- src/py/flwr/client/message_handler/message_handler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/flwr/client/message_handler/message_handler.py b/src/py/flwr/client/message_handler/message_handler.py index dfe5ecb7c1e7..caaa2d994d02 100644 --- a/src/py/flwr/client/message_handler/message_handler.py +++ b/src/py/flwr/client/message_handler/message_handler.py @@ -65,8 +65,8 @@ def handle_control_message(task_ins: TaskIns) -> Tuple[int, bool]: # ReconnectIns message field = server_msg.WhichOneof("msg") if field == "reconnect_ins": - disconnect_msg, sleep_duration = _reconnect(server_msg.reconnect_ins) - return disconnect_msg, sleep_duration, False + _, sleep_duration = _reconnect(server_msg.reconnect_ins) + return sleep_duration, False # Any other message return 0, True @@ -133,7 +133,7 @@ def handle_legacy_message( """ field = server_msg.WhichOneof("msg") if field == "reconnect_ins": - disconnect_msg, sleep_duration = _reconnect(server_msg.reconnect_ins) + disconnect_msg, _ = _reconnect(server_msg.reconnect_ins) return disconnect_msg # Instantiate the client From 3d841594e54f1f33619142546b891aa83af9ccdf Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 14:32:35 +0100 Subject: [PATCH 20/44] Update tests --- .../client/message_handler/message_handler_test.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/py/flwr/client/message_handler/message_handler_test.py b/src/py/flwr/client/message_handler/message_handler_test.py index d9603101864f..c8462c73cabd 100644 --- a/src/py/flwr/client/message_handler/message_handler_test.py +++ b/src/py/flwr/client/message_handler/message_handler_test.py @@ -36,7 +36,7 @@ from flwr.proto.task_pb2 import Task, TaskIns, TaskRes from flwr.proto.transport_pb2 import ClientMessage, Code, ServerMessage, Status -from .message_handler import handle +from .message_handler import handle, handle_control_message class ClientWithoutProps(Client): @@ -130,9 +130,8 @@ def test_client_without_get_properties() -> None: ) # Execute - task_res, actual_sleep_duration, actual_keep_going = handle( - client_fn=_get_client_fn(client), task_ins=task_ins - ) + actual_sleep_duration, actual_keep_going = handle_control_message(task_ins=task_ins) + task_res = handle(client_fn=_get_client_fn(client), task_ins=task_ins) if not task_res.HasField("task"): raise ValueError("Task value not found") @@ -193,9 +192,8 @@ def test_client_with_get_properties() -> None: ) # Execute - task_res, actual_sleep_duration, actual_keep_going = handle( - client_fn=_get_client_fn(client), task_ins=task_ins - ) + actual_sleep_duration, actual_keep_going = handle_control_message(task_ins=task_ins) + task_res = handle(client_fn=_get_client_fn(client), task_ins=task_ins) if not task_res.HasField("task"): raise ValueError("Task value not found") From 78356d0ce82690e86a24df62743d011c94124a66 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 14:59:32 +0100 Subject: [PATCH 21/44] Remove early exit --- src/py/flwr/client/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index ca6725f7d81f..e12065fdea4e 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -174,8 +174,6 @@ def _load_app(): # Handle control message sleep_duration, keep_going = handle_control_message(task_ins=task_ins) - if not keep_going: - break # Load app app = load_app_fn() From 7812c4287553040d8a4bdafaeb370dcb78df052f Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 15:28:26 +0100 Subject: [PATCH 22/44] Return DisconnectRes to the server --- src/py/flwr/client/app.py | 7 +++--- .../client/message_handler/message_handler.py | 22 ++++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index f25cb5ec78a3..afc5ff423868 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -160,15 +160,16 @@ def single_client_factory( continue # Handle control message - sleep_duration, keep_going = handle_control_message(task_ins=task_ins) + task_res, sleep_duration = handle_control_message(task_ins=task_ins) + if task_res: + send(task_res) + break # Handle task message task_res = handle(client_fn, task_ins) # Send send(task_res) - if not keep_going: - break # Unregister node if delete_node is not None: diff --git a/src/py/flwr/client/message_handler/message_handler.py b/src/py/flwr/client/message_handler/message_handler.py index caaa2d994d02..971f2fe56e31 100644 --- a/src/py/flwr/client/message_handler/message_handler.py +++ b/src/py/flwr/client/message_handler/message_handler.py @@ -15,7 +15,7 @@ """Client-side message handler.""" -from typing import Tuple +from typing import Optional, Tuple from flwr.client.client import ( Client, @@ -35,11 +35,15 @@ from flwr.proto.transport_pb2 import ClientMessage, Reason, ServerMessage +class UnexpectedServerMessage(Exception): + """Exception indicating that the received message is unexpected.""" + + class UnknownServerMessage(Exception): """Exception indicating that the received message is unknown.""" -def handle_control_message(task_ins: TaskIns) -> Tuple[int, bool]: +def handle_control_message(task_ins: TaskIns) -> Tuple[Optional[TaskRes], int]: """Handle control part of the incoming message. Parameters @@ -60,16 +64,17 @@ def handle_control_message(task_ins: TaskIns) -> Tuple[int, bool]: # SecAgg message if server_msg is None: - return 0, True + return None, 0 # ReconnectIns message field = server_msg.WhichOneof("msg") if field == "reconnect_ins": - _, sleep_duration = _reconnect(server_msg.reconnect_ins) - return sleep_duration, False + disconnect_msg, sleep_duration = _reconnect(server_msg.reconnect_ins) + task_res = wrap_client_message_in_task_res(disconnect_msg) + return task_res, sleep_duration # Any other message - return 0, True + return None, 0 def handle(client_fn: ClientFn, task_ins: TaskIns) -> TaskRes: @@ -132,9 +137,10 @@ def handle_legacy_message( The result message that should be returned to the server. """ field = server_msg.WhichOneof("msg") + + # Must be handled elsewhere if field == "reconnect_ins": - disconnect_msg, _ = _reconnect(server_msg.reconnect_ins) - return disconnect_msg + raise UnexpectedServerMessage() # Instantiate the client client = client_fn("-1") From f30b014616e78a461ccd7dc20962f84eabf8461e Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 15:32:44 +0100 Subject: [PATCH 23/44] Update tests --- .../client/message_handler/message_handler_test.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/py/flwr/client/message_handler/message_handler_test.py b/src/py/flwr/client/message_handler/message_handler_test.py index c8462c73cabd..0183f161f873 100644 --- a/src/py/flwr/client/message_handler/message_handler_test.py +++ b/src/py/flwr/client/message_handler/message_handler_test.py @@ -130,7 +130,9 @@ def test_client_without_get_properties() -> None: ) # Execute - actual_sleep_duration, actual_keep_going = handle_control_message(task_ins=task_ins) + disconnect_task_res, actual_sleep_duration = handle_control_message( + task_ins=task_ins + ) task_res = handle(client_fn=_get_client_fn(client), task_ins=task_ins) if not task_res.HasField("task"): @@ -170,8 +172,8 @@ def test_client_without_get_properties() -> None: expected_msg = ClientMessage(get_properties_res=expected_get_properties_res) assert actual_msg == expected_msg + assert not disconnect_task_res assert actual_sleep_duration == 0 - assert actual_keep_going is True def test_client_with_get_properties() -> None: @@ -192,7 +194,9 @@ def test_client_with_get_properties() -> None: ) # Execute - actual_sleep_duration, actual_keep_going = handle_control_message(task_ins=task_ins) + disconnect_task_res, actual_sleep_duration = handle_control_message( + task_ins=task_ins + ) task_res = handle(client_fn=_get_client_fn(client), task_ins=task_ins) if not task_res.HasField("task"): @@ -235,5 +239,5 @@ def test_client_with_get_properties() -> None: expected_msg = ClientMessage(get_properties_res=expected_get_properties_res) assert actual_msg == expected_msg + assert not disconnect_task_res assert actual_sleep_duration == 0 - assert actual_keep_going is True From 27f7099342c81517e8f030b6c3f03eab23620a8f Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 15:55:59 +0100 Subject: [PATCH 24/44] Remove data field from Fwd/Bwd --- src/py/flwr/app/flower.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/py/flwr/app/flower.py b/src/py/flwr/app/flower.py index 577b495b1f51..2b279a56a370 100644 --- a/src/py/flwr/app/flower.py +++ b/src/py/flwr/app/flower.py @@ -29,7 +29,6 @@ class Fwd: task_ins: TaskIns state: Dict # TBD - data: Dict # TBD @dataclass @@ -38,7 +37,6 @@ class Bwd: task_res: TaskRes state: Dict # TBD - data: Dict # TBD App = Callable[[Fwd], Bwd] From ebbb8d5647303497e9efa549da68937cdb3f96e0 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 16:04:59 +0100 Subject: [PATCH 25/44] Remove Fwd/Bwd --- src/py/flwr/app/flower.py | 1 - src/py/flwr/client/app.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/py/flwr/app/flower.py b/src/py/flwr/app/flower.py index 2b279a56a370..e4a0a58048a4 100644 --- a/src/py/flwr/app/flower.py +++ b/src/py/flwr/app/flower.py @@ -61,5 +61,4 @@ def __call__(self, fwd: Fwd) -> Bwd: return Bwd( task_res=task_res, state={}, - data={}, ) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 3d854c14f0ca..3142e43728bb 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -185,7 +185,6 @@ def _load_app(): fwd_msg: Fwd = Fwd( task_ins=task_ins, state={}, - data={}, ) bwd_msg: Bwd = app(fwd=fwd_msg) From 4a67ba0ccc9eab82a5199270796a4c15acb4240a Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 17:08:50 +0100 Subject: [PATCH 26/44] Add type annotations --- src/py/flwr/app/flower.py | 4 ++-- src/py/flwr/client/app.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/flwr/app/flower.py b/src/py/flwr/app/flower.py index e4a0a58048a4..3d19ef1b540a 100644 --- a/src/py/flwr/app/flower.py +++ b/src/py/flwr/app/flower.py @@ -28,7 +28,7 @@ class Fwd: """.""" task_ins: TaskIns - state: Dict # TBD + state: Dict[str, str] # TBD @dataclass @@ -36,7 +36,7 @@ class Bwd: """.""" task_res: TaskRes - state: Dict # TBD + state: Dict[str, str] # TBD App = Callable[[Fwd], Bwd] diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 3142e43728bb..c93a9a655528 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -141,7 +141,7 @@ def single_client_factory( client_fn = single_client_factory - def _load_app(): + def _load_app() -> Flower: return Flower(client_fn=client_fn) load_app_fn = _load_app From daca6bc06ef18a728aad077cb79ebf684d3bd3ed Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 17:34:30 +0100 Subject: [PATCH 27/44] Replace dict with WorkloadState --- src/py/flwr/app/flower.py | 8 ++++---- src/py/flwr/client/app.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/py/flwr/app/flower.py b/src/py/flwr/app/flower.py index 3d19ef1b540a..7567ac6811af 100644 --- a/src/py/flwr/app/flower.py +++ b/src/py/flwr/app/flower.py @@ -21,14 +21,14 @@ from flwr.client.message_handler.message_handler import handle from flwr.client.typing import ClientFn from flwr.proto.task_pb2 import TaskIns, TaskRes - +from flwr.client.workload_state import WorkloadState @dataclass class Fwd: """.""" task_ins: TaskIns - state: Dict[str, str] # TBD + state: WorkloadState @dataclass @@ -36,7 +36,7 @@ class Bwd: """.""" task_res: TaskRes - state: Dict[str, str] # TBD + state: WorkloadState App = Callable[[Fwd], Bwd] @@ -60,5 +60,5 @@ def __call__(self, fwd: Fwd) -> Bwd: ) return Bwd( task_res=task_res, - state={}, + state=WorkloadState(state={}), ) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index c93a9a655528..c74f898f21b5 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -39,7 +39,7 @@ from .grpc_rere_client.connection import grpc_request_response from .message_handler.message_handler import handle_control_message from .numpy_client import NumPyClient - +from .workload_state import WorkloadState def _check_actionable_client( client: Optional[Client], client_fn: Optional[ClientFn] @@ -184,7 +184,7 @@ def _load_app() -> Flower: # Handle task message fwd_msg: Fwd = Fwd( task_ins=task_ins, - state={}, + state=WorkloadState(state={}), ) bwd_msg: Bwd = app(fwd=fwd_msg) From 37c74acceff8cf1574e3cfa21c6b067081c31198 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 17:46:06 +0100 Subject: [PATCH 28/44] Format --- src/py/flwr/app/flower.py | 5 +++-- src/py/flwr/client/app.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/py/flwr/app/flower.py b/src/py/flwr/app/flower.py index 7567ac6811af..b3323a7ac7e8 100644 --- a/src/py/flwr/app/flower.py +++ b/src/py/flwr/app/flower.py @@ -16,12 +16,13 @@ from dataclasses import dataclass -from typing import Callable, Dict +from typing import Callable from flwr.client.message_handler.message_handler import handle from flwr.client.typing import ClientFn -from flwr.proto.task_pb2 import TaskIns, TaskRes from flwr.client.workload_state import WorkloadState +from flwr.proto.task_pb2 import TaskIns, TaskRes + @dataclass class Fwd: diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index c74f898f21b5..f39d27e6f34f 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -41,6 +41,7 @@ from .numpy_client import NumPyClient from .workload_state import WorkloadState + def _check_actionable_client( client: Optional[Client], client_fn: Optional[ClientFn] ) -> None: From dc5e7ce4355fb5dc0523665eb055eda371b9cc5e Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Mon, 6 Nov 2023 21:04:25 +0100 Subject: [PATCH 29/44] Move Flower callable and run_client --- src/py/flwr/app/__init__.py | 6 +-- src/py/flwr/client/__init__.py | 2 +- src/py/flwr/client/app.py | 55 +++++++++++++++++++ src/py/flwr/{app => client}/flower.py | 0 src/py/flwr/client/run.py | 78 --------------------------- 5 files changed, 59 insertions(+), 82 deletions(-) rename src/py/flwr/{app => client}/flower.py (100%) delete mode 100644 src/py/flwr/client/run.py diff --git a/src/py/flwr/app/__init__.py b/src/py/flwr/app/__init__.py index 423101f81f40..582d58d3f652 100644 --- a/src/py/flwr/app/__init__.py +++ b/src/py/flwr/app/__init__.py @@ -15,9 +15,9 @@ """Flower app package.""" -from .flower import Bwd as Bwd -from .flower import Flower as Flower -from .flower import Fwd as Fwd +from flwr.client.flower import Bwd as Bwd +from flwr.client.flower import Flower as Flower +from flwr.client.flower import Fwd as Fwd __all__ = [ "Flower", diff --git a/src/py/flwr/client/__init__.py b/src/py/flwr/client/__init__.py index 56bfadc558c3..13540a76cc25 100644 --- a/src/py/flwr/client/__init__.py +++ b/src/py/flwr/client/__init__.py @@ -15,11 +15,11 @@ """Flower client.""" +from .app import run_client as run_client from .app import start_client as start_client from .app import start_numpy_client as start_numpy_client from .client import Client as Client from .numpy_client import NumPyClient as NumPyClient -from .run import run_client as run_client from .typing import ClientFn as ClientFn __all__ = [ diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index f39d27e6f34f..db4141c64379 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -15,11 +15,14 @@ """Flower client app.""" +import argparse import sys import time from logging import INFO from typing import Callable, ContextManager, Optional, Tuple, Union +from uvicorn.importer import import_from_string + from flwr.app import Bwd, Flower, Fwd from flwr.client.client import Client from flwr.client.typing import ClientFn @@ -339,3 +342,55 @@ def _init_connection( ) return connection, address + + +def run_client() -> None: + """Run Flower client.""" + log(INFO, "Long-running Flower client starting") + + args = _parse_args_client().parse_args() + + print(args.server) + print(args.app_dir) + print(args.app) + + app_dir = args.app_dir + if app_dir is not None: + sys.path.insert(0, app_dir) + + def _load() -> Flower: + app: Flower = import_from_string(args.app) + return app + + return start_client( + server_address=args.server, + load_app_fn=_load, + transport="grpc-rere", # Only + ) + + +def _parse_args_client() -> argparse.ArgumentParser: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Start a long-running Flower client", + ) + + parser.add_argument( + "--server", + default="0.0.0.0:9092", + help="Server address", + ) + + parser.add_argument( + "--app-dir", + default="", + help="Look for APP in specified directory, by adding this to the PYTHONPATH." + " Defaults to the current working directory.", + ) + + parser.add_argument( + "--app", + help="For example: `client:app` or `project.package.module:wrapper.app", + ) + + return parser diff --git a/src/py/flwr/app/flower.py b/src/py/flwr/client/flower.py similarity index 100% rename from src/py/flwr/app/flower.py rename to src/py/flwr/client/flower.py diff --git a/src/py/flwr/client/run.py b/src/py/flwr/client/run.py deleted file mode 100644 index d90853a31f99..000000000000 --- a/src/py/flwr/client/run.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright 2023 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. -# ============================================================================== -"""Long-running Flower client.""" - - -import argparse -import sys -from logging import INFO - -from uvicorn.importer import import_from_string - -from flwr.app import Flower -from flwr.client import start_client -from flwr.common.logger import log - - -def run_client() -> None: - """Run Flower client.""" - log(INFO, "Long-running Flower client starting") - - args = _parse_args_client().parse_args() - - print(args.server) - print(args.app_dir) - print(args.app) - - app_dir = args.app_dir - if app_dir is not None: - sys.path.insert(0, app_dir) - - def _load() -> Flower: - app: Flower = import_from_string(args.app) - return app - - return start_client( - server_address=args.server, - load_app_fn=_load, - transport="grpc-rere", # Only - ) - - -def _parse_args_client() -> argparse.ArgumentParser: - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="Start a long-running Flower client", - ) - - parser.add_argument( - "--server", - default="0.0.0.0:9092", - help="Server address", - ) - - parser.add_argument( - "--app-dir", - default="", - help="Look for APP in specified directory, by adding this to the PYTHONPATH." - " Defaults to the current working directory.", - ) - - parser.add_argument( - "--app", - help="For example: `client:app` or `project.package.module:wrapper.app", - ) - - return parser From 26a2245d2a0018d3a78102ec8a5da4282ef112be Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 7 Nov 2023 12:10:04 +0100 Subject: [PATCH 30/44] Resolve merge conflict --- src/py/flwr/client/app.py | 82 ++++++++++++++------------------------- 1 file changed, 29 insertions(+), 53 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index a7d17de5d701..d76476735566 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -52,6 +52,22 @@ def run_client() -> None: args = _parse_args_client().parse_args() print(args.server) + print(args.app_dir) + print(args.app) + + app_dir = args.app_dir + if app_dir is not None: + sys.path.insert(0, app_dir) + + def _load() -> Flower: + app: Flower = import_from_string(args.app) + return app + + return start_client( + server_address=args.server, + load_app_fn=_load, + transport="grpc-rere", # Only + ) def _parse_args_client() -> argparse.ArgumentParser: @@ -62,8 +78,20 @@ def _parse_args_client() -> argparse.ArgumentParser: parser.add_argument( "--server", - help="Server address", default="0.0.0.0:9092", + help="Server address", + ) + + parser.add_argument( + "--app-dir", + default="", + help="Look for APP in specified directory, by adding this to the PYTHONPATH." + " Defaults to the current working directory.", + ) + + parser.add_argument( + "--app", + help="For example: `client:app` or `project.package.module:wrapper.app", ) return parser @@ -366,55 +394,3 @@ def _init_connection( ) return connection, address - - -def run_client() -> None: - """Run Flower client.""" - log(INFO, "Long-running Flower client starting") - - args = _parse_args_client().parse_args() - - print(args.server) - print(args.app_dir) - print(args.app) - - app_dir = args.app_dir - if app_dir is not None: - sys.path.insert(0, app_dir) - - def _load() -> Flower: - app: Flower = import_from_string(args.app) - return app - - return start_client( - server_address=args.server, - load_app_fn=_load, - transport="grpc-rere", # Only - ) - - -def _parse_args_client() -> argparse.ArgumentParser: - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="Start a long-running Flower client", - ) - - parser.add_argument( - "--server", - default="0.0.0.0:9092", - help="Server address", - ) - - parser.add_argument( - "--app-dir", - default="", - help="Look for APP in specified directory, by adding this to the PYTHONPATH." - " Defaults to the current working directory.", - ) - - parser.add_argument( - "--app", - help="For example: `client:app` or `project.package.module:wrapper.app", - ) - - return parser From 383f6aee28cf3a0e16944fde8fa48e8afa7f7b7b Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Fri, 10 Nov 2023 19:26:39 +0100 Subject: [PATCH 31/44] Add custom function to load Flower callable --- src/py/flwr/client/app.py | 5 ++-- src/py/flwr/client/flower.py | 48 ++++++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index d76476735566..ec6599ff5946 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -21,8 +21,6 @@ from logging import INFO from typing import Callable, ContextManager, Optional, Tuple, Union -from uvicorn.importer import import_from_string - from flwr.app import Bwd, Flower, Fwd from flwr.client.client import Client from flwr.client.typing import ClientFn @@ -38,6 +36,7 @@ from flwr.common.logger import log from flwr.proto.task_pb2 import TaskIns, TaskRes +from .flower import load_callable from .grpc_client.connection import grpc_connection from .grpc_rere_client.connection import grpc_request_response from .message_handler.message_handler import handle_control_message @@ -60,7 +59,7 @@ def run_client() -> None: sys.path.insert(0, app_dir) def _load() -> Flower: - app: Flower = import_from_string(args.app) + app: Flower = load_callable(args.app) return app return start_client( diff --git a/src/py/flwr/client/flower.py b/src/py/flwr/client/flower.py index b3323a7ac7e8..1e3fd3a695df 100644 --- a/src/py/flwr/client/flower.py +++ b/src/py/flwr/client/flower.py @@ -15,8 +15,9 @@ """Flower app.""" +import importlib from dataclasses import dataclass -from typing import Callable +from typing import Callable, cast from flwr.client.message_handler.message_handler import handle from flwr.client.typing import ClientFn @@ -40,7 +41,7 @@ class Bwd: state: WorkloadState -App = Callable[[Fwd], Bwd] +FlowerCallable = Callable[[Fwd], Bwd] class Flower: @@ -63,3 +64,46 @@ def __call__(self, fwd: Fwd) -> Bwd: task_res=task_res, state=WorkloadState(state={}), ) + + +class LoadCallableError(Exception): + """.""" + + +def load_callable(module_attribute_str: str) -> Flower: + """.""" + module_str, _, attributes_str = module_attribute_str.partition(":") + if not module_str: + raise LoadCallableError( + f"Missing module in {module_attribute_str}", + ) from None + if not attributes_str: + raise LoadCallableError( + f"Missing attribute in {module_attribute_str}", + ) from None + + # Load module + try: + module = importlib.import_module(module_str) + except ModuleNotFoundError: + raise LoadCallableError( + f"Unable to load module {module_str}", + ) from None + + # Recursively load attribute + attribute = module + try: + for attribute_str in attributes_str.split("."): + attribute = getattr(attribute, attribute_str) + except AttributeError: + raise LoadCallableError( + f"Unable to load attribute {attributes_str} from module {module_str}", + ) from None + + # Check type + if not isinstance(attribute, Flower): + raise LoadCallableError( + f"Attribute {attributes_str} is not of type {Flower}", + ) from None + + return cast(Flower, attribute) From 7976dbca5a317b928b0f13459d84234c5dedc3be Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Wed, 15 Nov 2023 17:19:47 +0100 Subject: [PATCH 32/44] Add type annotation --- src/py/flwr/client/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index ec6599ff5946..2e24fbd539f3 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -234,7 +234,7 @@ def _load_app() -> Flower: break # Load app - app = load_app_fn() + app: Flower = load_app_fn() # Handle task message fwd_msg: Fwd = Fwd( From 65b4369ba3f472de8b08fc4dbe02c6b956ca4b09 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 09:53:46 +0100 Subject: [PATCH 33/44] Remove --grpc-rere --- doc/source/how-to-deploy.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/source/how-to-deploy.md b/doc/source/how-to-deploy.md index e7f3a3fabc61..d00d454dd093 100644 --- a/doc/source/how-to-deploy.md +++ b/doc/source/how-to-deploy.md @@ -25,7 +25,7 @@ pip install -r requirements.txt ## Start the long-running Flower server ```bash -flower-server --grpc-rere +flower-server ``` ## Start the long-running Flower client @@ -33,13 +33,13 @@ flower-server --grpc-rere In a new terminal window, start the first long-running Flower client: ```bash -flower-client --grpc-rere --app client:app +flower-client --app client:app ``` In yet another new terminal window, start the second long-running Flower client: ```bash -flower-client --grpc-rere --app client:app +flower-client --app client:app ``` ## Start the Driver script From 1107cadb07040586173b1b595ed55cc161a0d246 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 10:14:39 +0100 Subject: [PATCH 34/44] Create mt-pytorch-app example --- examples/mt-pytorch-app/client.py | 123 +++++++++++++++++++++++ examples/mt-pytorch-app/driver.py | 25 +++++ examples/mt-pytorch-app/pyproject.toml | 16 +++ examples/mt-pytorch-app/requirements.txt | 4 + examples/mt-pytorch-app/run.sh | 20 ++++ examples/mt-pytorch-app/server.py | 25 +++++ 6 files changed, 213 insertions(+) create mode 100644 examples/mt-pytorch-app/client.py create mode 100644 examples/mt-pytorch-app/driver.py create mode 100644 examples/mt-pytorch-app/pyproject.toml create mode 100644 examples/mt-pytorch-app/requirements.txt create mode 100755 examples/mt-pytorch-app/run.sh create mode 100644 examples/mt-pytorch-app/server.py diff --git a/examples/mt-pytorch-app/client.py b/examples/mt-pytorch-app/client.py new file mode 100644 index 000000000000..818e068353a3 --- /dev/null +++ b/examples/mt-pytorch-app/client.py @@ -0,0 +1,123 @@ +import warnings +from collections import OrderedDict + +import flwr as fl +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.utils.data import DataLoader +from torchvision.datasets import CIFAR10 +from torchvision.transforms import Compose, Normalize, ToTensor +from tqdm import tqdm + + +# ############################################################################# +# 1. Regular PyTorch pipeline: nn.Module, train, test, and DataLoader +# ############################################################################# + +warnings.filterwarnings("ignore", category=UserWarning) +DEVICE = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + + +class Net(nn.Module): + """Model (simple CNN adapted from 'PyTorch: A 60 Minute Blitz')""" + + def __init__(self) -> None: + super(Net, self).__init__() + self.conv1 = nn.Conv2d(3, 6, 5) + self.pool = nn.MaxPool2d(2, 2) + self.conv2 = nn.Conv2d(6, 16, 5) + self.fc1 = nn.Linear(16 * 5 * 5, 120) + self.fc2 = nn.Linear(120, 84) + self.fc3 = nn.Linear(84, 10) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.pool(F.relu(self.conv1(x))) + x = self.pool(F.relu(self.conv2(x))) + x = x.view(-1, 16 * 5 * 5) + x = F.relu(self.fc1(x)) + x = F.relu(self.fc2(x)) + return self.fc3(x) + + +def train(net, trainloader, epochs): + """Train the model on the training set.""" + criterion = torch.nn.CrossEntropyLoss() + optimizer = torch.optim.SGD(net.parameters(), lr=0.001, momentum=0.9) + for _ in range(epochs): + for images, labels in tqdm(trainloader): + optimizer.zero_grad() + criterion(net(images.to(DEVICE)), labels.to(DEVICE)).backward() + optimizer.step() + + +def test(net, testloader): + """Validate the model on the test set.""" + criterion = torch.nn.CrossEntropyLoss() + correct, loss = 0, 0.0 + with torch.no_grad(): + for images, labels in tqdm(testloader): + outputs = net(images.to(DEVICE)) + labels = labels.to(DEVICE) + loss += criterion(outputs, labels).item() + correct += (torch.max(outputs.data, 1)[1] == labels).sum().item() + accuracy = correct / len(testloader.dataset) + return loss, accuracy + + +def load_data(): + """Load CIFAR-10 (training and test set).""" + trf = Compose([ToTensor(), Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]) + trainset = CIFAR10("./data", train=True, download=True, transform=trf) + testset = CIFAR10("./data", train=False, download=True, transform=trf) + return DataLoader(trainset, batch_size=32, shuffle=True), DataLoader(testset) + + +# ############################################################################# +# 2. Federation of the pipeline with Flower +# ############################################################################# + +# Load model and data (simple CNN, CIFAR-10) +net = Net().to(DEVICE) +trainloader, testloader = load_data() + + +# Define Flower client +class FlowerClient(fl.client.NumPyClient): + def get_parameters(self, config): + return [val.cpu().numpy() for _, val in net.state_dict().items()] + + def set_parameters(self, parameters): + params_dict = zip(net.state_dict().keys(), parameters) + state_dict = OrderedDict({k: torch.tensor(v) for k, v in params_dict}) + net.load_state_dict(state_dict, strict=True) + + def fit(self, parameters, config): + self.set_parameters(parameters) + train(net, trainloader, epochs=1) + return self.get_parameters(config={}), len(trainloader.dataset), {} + + def evaluate(self, parameters, config): + self.set_parameters(parameters) + loss, accuracy = test(net, testloader) + return loss, len(testloader.dataset), {"accuracy": accuracy} + + +def client_fn(cid: str): + """.""" + return FlowerClient().to_client() + + +# To run this: `flower-client --app client:app` +app = fl.app.Flower( + client_fn=client_fn, +) + + +if __name__ == "__main__": + # Start Flower client + fl.client.start_client( + server_address="0.0.0.0:9092", + client=FlowerClient().to_client(), + transport="grpc-rere", + ) diff --git a/examples/mt-pytorch-app/driver.py b/examples/mt-pytorch-app/driver.py new file mode 100644 index 000000000000..1248672b6813 --- /dev/null +++ b/examples/mt-pytorch-app/driver.py @@ -0,0 +1,25 @@ +from typing import List, Tuple + +import flwr as fl +from flwr.common import Metrics + + +# Define metric aggregation function +def weighted_average(metrics: List[Tuple[int, Metrics]]) -> Metrics: + # Multiply accuracy of each client by number of examples used + accuracies = [num_examples * m["accuracy"] for num_examples, m in metrics] + examples = [num_examples for num_examples, _ in metrics] + + # Aggregate and return custom metric (weighted average) + return {"accuracy": sum(accuracies) / sum(examples)} + + +# Define strategy +strategy = fl.server.strategy.FedAvg(evaluate_metrics_aggregation_fn=weighted_average) + +# Start Flower driver +fl.driver.start_driver( + server_address="0.0.0.0:9091", + config=fl.server.ServerConfig(num_rounds=3), + strategy=strategy, +) diff --git a/examples/mt-pytorch-app/pyproject.toml b/examples/mt-pytorch-app/pyproject.toml new file mode 100644 index 000000000000..0d1a91836006 --- /dev/null +++ b/examples/mt-pytorch-app/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["poetry-core>=1.4.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "quickstart-pytorch" +version = "0.1.0" +description = "PyTorch Federated Learning Quickstart with Flower" +authors = ["The Flower Authors "] + +[tool.poetry.dependencies] +python = ">=3.8,<3.11" +flwr = { path = "../../", develop = true, extras = ["simulation", "rest"] } +torch = "1.13.1" +torchvision = "0.14.1" +tqdm = "4.65.0" diff --git a/examples/mt-pytorch-app/requirements.txt b/examples/mt-pytorch-app/requirements.txt new file mode 100644 index 000000000000..797ca6db6244 --- /dev/null +++ b/examples/mt-pytorch-app/requirements.txt @@ -0,0 +1,4 @@ +flwr>=1.0, <2.0 +torch==1.13.1 +torchvision==0.14.1 +tqdm==4.65.0 diff --git a/examples/mt-pytorch-app/run.sh b/examples/mt-pytorch-app/run.sh new file mode 100755 index 000000000000..d2bf34f834b1 --- /dev/null +++ b/examples/mt-pytorch-app/run.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e +cd "$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"/ + +# Download the CIFAR-10 dataset +python -c "from torchvision.datasets import CIFAR10; CIFAR10('./data', download=True)" + +echo "Starting server" +python server.py & +sleep 3 # Sleep for 3s to give the server enough time to start + +for i in `seq 0 1`; do + echo "Starting client $i" + python client.py & +done + +# Enable CTRL+C to stop all background processes +trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM +# Wait for all background processes to complete +wait diff --git a/examples/mt-pytorch-app/server.py b/examples/mt-pytorch-app/server.py new file mode 100644 index 000000000000..fe691a88aba0 --- /dev/null +++ b/examples/mt-pytorch-app/server.py @@ -0,0 +1,25 @@ +from typing import List, Tuple + +import flwr as fl +from flwr.common import Metrics + + +# Define metric aggregation function +def weighted_average(metrics: List[Tuple[int, Metrics]]) -> Metrics: + # Multiply accuracy of each client by number of examples used + accuracies = [num_examples * m["accuracy"] for num_examples, m in metrics] + examples = [num_examples for num_examples, _ in metrics] + + # Aggregate and return custom metric (weighted average) + return {"accuracy": sum(accuracies) / sum(examples)} + + +# Define strategy +strategy = fl.server.strategy.FedAvg(evaluate_metrics_aggregation_fn=weighted_average) + +# Start Flower server +fl.server.start_server( + server_address="0.0.0.0:8080", + config=fl.server.ServerConfig(num_rounds=3), + strategy=strategy, +) From 22b515980fb9bfe64da2f59e394d434e9c90bf21 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 10:17:01 +0100 Subject: [PATCH 35/44] Reset quickstart-pytorch example --- examples/quickstart-pytorch/client.py | 21 ++++-------------- examples/quickstart-pytorch/driver.py | 25 ---------------------- examples/quickstart-pytorch/pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 43 deletions(-) delete mode 100644 examples/quickstart-pytorch/driver.py diff --git a/examples/quickstart-pytorch/client.py b/examples/quickstart-pytorch/client.py index 818e068353a3..6db7c8a855a0 100644 --- a/examples/quickstart-pytorch/client.py +++ b/examples/quickstart-pytorch/client.py @@ -103,21 +103,8 @@ def evaluate(self, parameters, config): return loss, len(testloader.dataset), {"accuracy": accuracy} -def client_fn(cid: str): - """.""" - return FlowerClient().to_client() - - -# To run this: `flower-client --app client:app` -app = fl.app.Flower( - client_fn=client_fn, +# Start Flower client +fl.client.start_numpy_client( + server_address="127.0.0.1:8080", + client=FlowerClient(), ) - - -if __name__ == "__main__": - # Start Flower client - fl.client.start_client( - server_address="0.0.0.0:9092", - client=FlowerClient().to_client(), - transport="grpc-rere", - ) diff --git a/examples/quickstart-pytorch/driver.py b/examples/quickstart-pytorch/driver.py deleted file mode 100644 index 1248672b6813..000000000000 --- a/examples/quickstart-pytorch/driver.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import List, Tuple - -import flwr as fl -from flwr.common import Metrics - - -# Define metric aggregation function -def weighted_average(metrics: List[Tuple[int, Metrics]]) -> Metrics: - # Multiply accuracy of each client by number of examples used - accuracies = [num_examples * m["accuracy"] for num_examples, m in metrics] - examples = [num_examples for num_examples, _ in metrics] - - # Aggregate and return custom metric (weighted average) - return {"accuracy": sum(accuracies) / sum(examples)} - - -# Define strategy -strategy = fl.server.strategy.FedAvg(evaluate_metrics_aggregation_fn=weighted_average) - -# Start Flower driver -fl.driver.start_driver( - server_address="0.0.0.0:9091", - config=fl.server.ServerConfig(num_rounds=3), - strategy=strategy, -) diff --git a/examples/quickstart-pytorch/pyproject.toml b/examples/quickstart-pytorch/pyproject.toml index 0d1a91836006..affdfee26d47 100644 --- a/examples/quickstart-pytorch/pyproject.toml +++ b/examples/quickstart-pytorch/pyproject.toml @@ -10,7 +10,7 @@ authors = ["The Flower Authors "] [tool.poetry.dependencies] python = ">=3.8,<3.11" -flwr = { path = "../../", develop = true, extras = ["simulation", "rest"] } +flwr = ">=1.0,<2.0" torch = "1.13.1" torchvision = "0.14.1" tqdm = "4.65.0" From ec6fcfcfcb3cb0fc8bb11498bac2602d02d7a5a9 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 10:22:51 +0100 Subject: [PATCH 36/44] Remove how-to-deploy doc --- doc/source/how-to-deploy.md | 49 ------------------------------------- 1 file changed, 49 deletions(-) delete mode 100644 doc/source/how-to-deploy.md diff --git a/doc/source/how-to-deploy.md b/doc/source/how-to-deploy.md deleted file mode 100644 index d00d454dd093..000000000000 --- a/doc/source/how-to-deploy.md +++ /dev/null @@ -1,49 +0,0 @@ -# Deploy ๐Ÿงช - -๐Ÿงช = this page covers experimental features that might change in future versions of Flower - -This how-to guide describes the deployment of a long-running Flower server. - -## Preconditions - -Let's assume the following project structure: - -```bash -$ tree . -. -โ””โ”€โ”€ client.py -โ”œโ”€โ”€ driver.py -โ”œโ”€โ”€ requirements.txt -``` - -## Install dependencies - -```bash -pip install -r requirements.txt -``` - -## Start the long-running Flower server - -```bash -flower-server -``` - -## Start the long-running Flower client - -In a new terminal window, start the first long-running Flower client: - -```bash -flower-client --app client:app -``` - -In yet another new terminal window, start the second long-running Flower client: - -```bash -flower-client --app client:app -``` - -## Start the Driver script - -```bash -python driver.py -``` From c3dae4ab335ef93db8ecb7957b16eaeb0d5ff51c Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 10:53:53 +0100 Subject: [PATCH 37/44] Rename --app to --callable --- examples/mt-pytorch-app/client.py | 4 +-- src/py/flwr/client/app.py | 54 ++++++++++++++++++------------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/examples/mt-pytorch-app/client.py b/examples/mt-pytorch-app/client.py index 818e068353a3..5211733b021e 100644 --- a/examples/mt-pytorch-app/client.py +++ b/examples/mt-pytorch-app/client.py @@ -108,8 +108,8 @@ def client_fn(cid: str): return FlowerClient().to_client() -# To run this: `flower-client --app client:app` -app = fl.app.Flower( +# To run this: `flower-client --callable client:flower` +flower = fl.app.Flower( client_fn=client_fn, ) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 2e24fbd539f3..342a15dd9929 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -18,7 +18,7 @@ import argparse import sys import time -from logging import INFO +from logging import INFO, WARN from typing import Callable, ContextManager, Optional, Tuple, Union from flwr.app import Bwd, Flower, Fwd @@ -51,20 +51,20 @@ def run_client() -> None: args = _parse_args_client().parse_args() print(args.server) - print(args.app_dir) - print(args.app) + print(args.callable_dir) + print(args.callable) - app_dir = args.app_dir - if app_dir is not None: - sys.path.insert(0, app_dir) + callable_dir = args.callable_dir + if callable_dir is not None: + sys.path.insert(0, callable_dir) def _load() -> Flower: - app: Flower = load_callable(args.app) - return app + callable: Flower = load_callable(args.callable) + return callable return start_client( server_address=args.server, - load_app_fn=_load, + load_callable_fn=_load, transport="grpc-rere", # Only ) @@ -80,17 +80,15 @@ def _parse_args_client() -> argparse.ArgumentParser: default="0.0.0.0:9092", help="Server address", ) - parser.add_argument( - "--app-dir", - default="", - help="Look for APP in specified directory, by adding this to the PYTHONPATH." - " Defaults to the current working directory.", + "--callable", + help="For example: `client:flower` or `project.package.module:wrapper.flower", ) - parser.add_argument( - "--app", - help="For example: `client:app` or `project.package.module:wrapper.app", + "--callable-dir", + default="", + help="Add specified directory to the PYTHONPATH and load callable from there." + " Default: current working directory.", ) return parser @@ -115,7 +113,7 @@ def _check_actionable_client( def start_client( *, server_address: str, - load_app_fn: Optional[Callable[[], Flower]] = None, + load_callable_fn: Optional[Callable[[], Flower]] = None, client_fn: Optional[ClientFn] = None, client: Optional[Client] = None, grpc_max_message_length: int = GRPC_MAX_MESSAGE_LENGTH, @@ -130,7 +128,7 @@ def start_client( The IPv4 or IPv6 address of the server. If the Flower server runs on the same machine on port 8080, then `server_address` would be `"[::]:8080"`. - load_app_fn : ... + load_callable_fn : Optional[Callable[[], Flower]] (default: None) ... client_fn : Optional[ClientFn] A callable that instantiates a Client. (default: None) @@ -180,7 +178,7 @@ class `flwr.client.Client` (default: None) """ event(EventType.START_CLIENT_ENTER) - if load_app_fn is None: + if load_callable_fn is None: _check_actionable_client(client, client_fn) if client_fn is None: @@ -199,9 +197,19 @@ def single_client_factory( def _load_app() -> Flower: return Flower(client_fn=client_fn) - load_app_fn = _load_app + load_callable_fn = _load_app + else: + log( + WARN, + """ + EXPERIMENTAL FEATURE: `load_callable_fn` + + This is an experimental feature. It could change significantly or be removed + entirely in future versions of Flower. + """, + ) - # At this point, only `load_app_fn` should be used + # At this point, only `load_callable_fn` should be used # Both `client` and `client_fn` must not be used directly # Initialize connection context manager @@ -234,7 +242,7 @@ def _load_app() -> Flower: break # Load app - app: Flower = load_app_fn() + app: Flower = load_callable_fn() # Handle task message fwd_msg: Fwd = Fwd( From 77b23135a156f28d781706c63beda99a3ba8eff7 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 10:54:43 +0100 Subject: [PATCH 38/44] Rename mt-pytorch-app to mt-pytorch-callable --- examples/{mt-pytorch-app => mt-pytorch-callable}/client.py | 0 examples/{mt-pytorch-app => mt-pytorch-callable}/driver.py | 0 examples/{mt-pytorch-app => mt-pytorch-callable}/pyproject.toml | 0 examples/{mt-pytorch-app => mt-pytorch-callable}/requirements.txt | 0 examples/{mt-pytorch-app => mt-pytorch-callable}/run.sh | 0 examples/{mt-pytorch-app => mt-pytorch-callable}/server.py | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename examples/{mt-pytorch-app => mt-pytorch-callable}/client.py (100%) rename examples/{mt-pytorch-app => mt-pytorch-callable}/driver.py (100%) rename examples/{mt-pytorch-app => mt-pytorch-callable}/pyproject.toml (100%) rename examples/{mt-pytorch-app => mt-pytorch-callable}/requirements.txt (100%) rename examples/{mt-pytorch-app => mt-pytorch-callable}/run.sh (100%) rename examples/{mt-pytorch-app => mt-pytorch-callable}/server.py (100%) diff --git a/examples/mt-pytorch-app/client.py b/examples/mt-pytorch-callable/client.py similarity index 100% rename from examples/mt-pytorch-app/client.py rename to examples/mt-pytorch-callable/client.py diff --git a/examples/mt-pytorch-app/driver.py b/examples/mt-pytorch-callable/driver.py similarity index 100% rename from examples/mt-pytorch-app/driver.py rename to examples/mt-pytorch-callable/driver.py diff --git a/examples/mt-pytorch-app/pyproject.toml b/examples/mt-pytorch-callable/pyproject.toml similarity index 100% rename from examples/mt-pytorch-app/pyproject.toml rename to examples/mt-pytorch-callable/pyproject.toml diff --git a/examples/mt-pytorch-app/requirements.txt b/examples/mt-pytorch-callable/requirements.txt similarity index 100% rename from examples/mt-pytorch-app/requirements.txt rename to examples/mt-pytorch-callable/requirements.txt diff --git a/examples/mt-pytorch-app/run.sh b/examples/mt-pytorch-callable/run.sh similarity index 100% rename from examples/mt-pytorch-app/run.sh rename to examples/mt-pytorch-callable/run.sh diff --git a/examples/mt-pytorch-app/server.py b/examples/mt-pytorch-callable/server.py similarity index 100% rename from examples/mt-pytorch-app/server.py rename to examples/mt-pytorch-callable/server.py From edbe3cc6582460eaeee3f5cd3f8e3bc351689596 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 11:18:42 +0100 Subject: [PATCH 39/44] Finish renaming to callable --- examples/mt-pytorch-callable/client.py | 2 +- src/py/flwr/__init__.py | 3 ++- src/py/flwr/{app => callable}/__init__.py | 2 +- src/py/flwr/client/app.py | 2 +- src/py/flwr/client/flower.py | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) rename src/py/flwr/{app => callable}/__init__.py (96%) diff --git a/examples/mt-pytorch-callable/client.py b/examples/mt-pytorch-callable/client.py index 5211733b021e..a95d5cbd95a7 100644 --- a/examples/mt-pytorch-callable/client.py +++ b/examples/mt-pytorch-callable/client.py @@ -109,7 +109,7 @@ def client_fn(cid: str): # To run this: `flower-client --callable client:flower` -flower = fl.app.Flower( +flower = fl.callable.Flower( client_fn=client_fn, ) diff --git a/src/py/flwr/__init__.py b/src/py/flwr/__init__.py index d3cbf00747a4..58cc9eb12dcb 100644 --- a/src/py/flwr/__init__.py +++ b/src/py/flwr/__init__.py @@ -17,9 +17,10 @@ from flwr.common.version import package_version as _package_version -from . import client, common, driver, server, simulation +from . import callable, client, common, driver, server, simulation __all__ = [ + "callable", "client", "common", "driver", diff --git a/src/py/flwr/app/__init__.py b/src/py/flwr/callable/__init__.py similarity index 96% rename from src/py/flwr/app/__init__.py rename to src/py/flwr/callable/__init__.py index 582d58d3f652..090c78062d02 100644 --- a/src/py/flwr/app/__init__.py +++ b/src/py/flwr/callable/__init__.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== -"""Flower app package.""" +"""Flower callable package.""" from flwr.client.flower import Bwd as Bwd diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 342a15dd9929..e5578fc2ac69 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -21,8 +21,8 @@ from logging import INFO, WARN from typing import Callable, ContextManager, Optional, Tuple, Union -from flwr.app import Bwd, Flower, Fwd from flwr.client.client import Client +from flwr.client.flower import Bwd, Flower, Fwd from flwr.client.typing import ClientFn from flwr.common import GRPC_MAX_MESSAGE_LENGTH, EventType, event from flwr.common.address import parse_address diff --git a/src/py/flwr/client/flower.py b/src/py/flwr/client/flower.py index 1e3fd3a695df..694c5806a538 100644 --- a/src/py/flwr/client/flower.py +++ b/src/py/flwr/client/flower.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== -"""Flower app.""" +"""Flower callable.""" import importlib From 81c13e3e99ee68f2901d32b5769e54cbd6a1d4b8 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 11:26:39 +0100 Subject: [PATCH 40/44] Update docstring --- src/py/flwr/client/flower.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/flwr/client/flower.py b/src/py/flwr/client/flower.py index 694c5806a538..eb508fceb04e 100644 --- a/src/py/flwr/client/flower.py +++ b/src/py/flwr/client/flower.py @@ -45,7 +45,7 @@ class Bwd: class Flower: - """Flower app class.""" + """Flower callable.""" def __init__( self, From 20f6f59f53a8cec17ad006ebeb4a74e15dc3e387 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 12:07:15 +0100 Subject: [PATCH 41/44] Rename callable to flower --- examples/mt-pytorch-callable/client.py | 2 +- src/py/flwr/__init__.py | 4 ++-- src/py/flwr/client/app.py | 4 ++-- src/py/flwr/{callable => flower}/__init__.py | 0 4 files changed, 5 insertions(+), 5 deletions(-) rename src/py/flwr/{callable => flower}/__init__.py (100%) diff --git a/examples/mt-pytorch-callable/client.py b/examples/mt-pytorch-callable/client.py index a95d5cbd95a7..6f9747784ae0 100644 --- a/examples/mt-pytorch-callable/client.py +++ b/examples/mt-pytorch-callable/client.py @@ -109,7 +109,7 @@ def client_fn(cid: str): # To run this: `flower-client --callable client:flower` -flower = fl.callable.Flower( +flower = fl.flower.Flower( client_fn=client_fn, ) diff --git a/src/py/flwr/__init__.py b/src/py/flwr/__init__.py index 58cc9eb12dcb..e05799280339 100644 --- a/src/py/flwr/__init__.py +++ b/src/py/flwr/__init__.py @@ -17,13 +17,13 @@ from flwr.common.version import package_version as _package_version -from . import callable, client, common, driver, server, simulation +from . import client, common, driver, flower, server, simulation __all__ = [ - "callable", "client", "common", "driver", + "flower", "server", "simulation", ] diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index e5578fc2ac69..47d5fbbd4c23 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -59,8 +59,8 @@ def run_client() -> None: sys.path.insert(0, callable_dir) def _load() -> Flower: - callable: Flower = load_callable(args.callable) - return callable + flower: Flower = load_callable(args.callable) + return flower return start_client( server_address=args.server, diff --git a/src/py/flwr/callable/__init__.py b/src/py/flwr/flower/__init__.py similarity index 100% rename from src/py/flwr/callable/__init__.py rename to src/py/flwr/flower/__init__.py From a1c6f645873830308d828fe4ca00eb628859f935 Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 12:29:42 +0100 Subject: [PATCH 42/44] Use experimental feature warning --- src/py/flwr/client/app.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 47d5fbbd4c23..098eab1c5411 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -18,7 +18,7 @@ import argparse import sys import time -from logging import INFO, WARN +from logging import INFO from typing import Callable, ContextManager, Optional, Tuple, Union from flwr.client.client import Client @@ -33,7 +33,7 @@ TRANSPORT_TYPE_REST, TRANSPORT_TYPES, ) -from flwr.common.logger import log +from flwr.common.logger import log, warn_experimental_feature from flwr.proto.task_pb2 import TaskIns, TaskRes from .flower import load_callable @@ -199,15 +199,7 @@ def _load_app() -> Flower: load_callable_fn = _load_app else: - log( - WARN, - """ - EXPERIMENTAL FEATURE: `load_callable_fn` - - This is an experimental feature. It could change significantly or be removed - entirely in future versions of Flower. - """, - ) + warn_experimental_feature("`load_callable_fn`") # At this point, only `load_callable_fn` should be used # Both `client` and `client_fn` must not be used directly From 44c01297dc0e841ec740702aea371620ab6dbe2e Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Tue, 21 Nov 2023 12:33:52 +0100 Subject: [PATCH 43/44] Add README to mt-pytorch-callable --- examples/mt-pytorch-callable/README.md | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 examples/mt-pytorch-callable/README.md diff --git a/examples/mt-pytorch-callable/README.md b/examples/mt-pytorch-callable/README.md new file mode 100644 index 000000000000..65ef000c26f2 --- /dev/null +++ b/examples/mt-pytorch-callable/README.md @@ -0,0 +1,49 @@ +# Deploy ๐Ÿงช + +๐Ÿงช = this page covers experimental features that might change in future versions of Flower + +This how-to guide describes the deployment of a long-running Flower server. + +## Preconditions + +Let's assume the following project structure: + +```bash +$ tree . +. +โ””โ”€โ”€ client.py +โ”œโ”€โ”€ driver.py +โ”œโ”€โ”€ requirements.txt +``` + +## Install dependencies + +```bash +pip install -r requirements.txt +``` + +## Start the long-running Flower server + +```bash +flower-server --insecure +``` + +## Start the long-running Flower client + +In a new terminal window, start the first long-running Flower client: + +```bash +flower-client --callable client:flower +``` + +In yet another new terminal window, start the second long-running Flower client: + +```bash +flower-client --callable client:flower +``` + +## Start the Driver script + +```bash +python driver.py +``` From 0c9f6eac7816d547b2b504b5bcfa50813284ffcb Mon Sep 17 00:00:00 2001 From: "Daniel J. Beutel" Date: Wed, 22 Nov 2023 19:15:34 +0100 Subject: [PATCH 44/44] Add docstrings --- src/py/flwr/client/app.py | 2 +- src/py/flwr/client/flower.py | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/py/flwr/client/app.py b/src/py/flwr/client/app.py index 098eab1c5411..b39dbbfc33c0 100644 --- a/src/py/flwr/client/app.py +++ b/src/py/flwr/client/app.py @@ -82,7 +82,7 @@ def _parse_args_client() -> argparse.ArgumentParser: ) parser.add_argument( "--callable", - help="For example: `client:flower` or `project.package.module:wrapper.flower", + help="For example: `client:flower` or `project.package.module:wrapper.flower`", ) parser.add_argument( "--callable-dir", diff --git a/src/py/flwr/client/flower.py b/src/py/flwr/client/flower.py index eb508fceb04e..9eeb41887e24 100644 --- a/src/py/flwr/client/flower.py +++ b/src/py/flwr/client/flower.py @@ -45,7 +45,30 @@ class Bwd: class Flower: - """Flower callable.""" + """Flower callable. + + Examples + -------- + Assuming a typical client implementation in `FlowerClient`, you can wrap it in a + Flower callable as follows: + + >>> class FlowerClient(NumPyClient): + >>> # ... + >>> + >>> def client_fn(cid): + >>> return FlowerClient().to_client() + >>> + >>> flower = Flower(client_fn) + + If the above code is in a Python module called `client`, it can be started as + follows: + + >>> flower-client --callable client:flower + + In this `client:flower` example, `client` refers to the Python module in which the + previous code lives in. `flower` refers to the global attribute `flower` that points + to an object of type `Flower` (a Flower callable). + """ def __init__( self, @@ -71,7 +94,13 @@ class LoadCallableError(Exception): def load_callable(module_attribute_str: str) -> Flower: - """.""" + """Load the `Flower` object specified in a module attribute string. + + The module/attribute string should have the form :. Valid + examples include `client:flower` and `project.package.module:wrapper.flower`. It + must refer to a module on the PYTHONPATH, the module needs to have the specified + attribute, and the attribute must be of type `Flower`. + """ module_str, _, attributes_str = module_attribute_str.partition(":") if not module_str: raise LoadCallableError(