flwr-nightly 1.9.0.dev20240418__py3-none-any.whl → 1.9.0.dev20240419__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of flwr-nightly might be problematic. Click here for more details.

flwr/cli/config_utils.py CHANGED
@@ -15,13 +15,46 @@
15
15
  """Utility to validate the `pyproject.toml` file."""
16
16
 
17
17
  import os
18
+ from pathlib import Path
18
19
  from typing import Any, Dict, List, Optional, Tuple
19
20
 
20
21
  import tomli
22
+ import typer
21
23
 
22
24
  from flwr.common import object_ref
23
25
 
24
26
 
27
+ def validate_project_dir(project_dir: Path) -> Optional[Dict[str, Any]]:
28
+ """Check if a Flower App directory is valid."""
29
+ config = load(str(project_dir / "pyproject.toml"))
30
+ if config is None:
31
+ typer.secho(
32
+ "❌ Project configuration could not be loaded. "
33
+ "`pyproject.toml` does not exist.",
34
+ fg=typer.colors.RED,
35
+ bold=True,
36
+ )
37
+ return None
38
+
39
+ if not validate(config):
40
+ typer.secho(
41
+ "❌ Project configuration is invalid.",
42
+ fg=typer.colors.RED,
43
+ bold=True,
44
+ )
45
+ return None
46
+
47
+ if "publisher" not in config["flower"]:
48
+ typer.secho(
49
+ "❌ Project configuration is missing required `publisher` field.",
50
+ fg=typer.colors.RED,
51
+ bold=True,
52
+ )
53
+ return None
54
+
55
+ return config
56
+
57
+
25
58
  def load_and_validate_with_defaults(
26
59
  path: Optional[str] = None,
27
60
  ) -> Tuple[Optional[Dict[str, Any]], List[str], List[str]]:
@@ -1 +1,57 @@
1
1
  """$project_name: A Flower / TensorFlow app."""
2
+
3
+ import os
4
+
5
+ import tensorflow as tf
6
+ from flwr.client import ClientApp, NumPyClient
7
+ from flwr_datasets import FederatedDataset
8
+
9
+
10
+ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
11
+
12
+ # Define Flower client
13
+ class FlowerClient(NumPyClient):
14
+ def __init__(self, model, train_data, test_data):
15
+ self.model = model
16
+ self.x_train, self.y_train = train_data
17
+ self.x_test, self.y_test = test_data
18
+
19
+ def get_parameters(self, config):
20
+ return self.model.get_weights()
21
+
22
+ def fit(self, parameters, config):
23
+ self.model.set_weights(parameters)
24
+ self.model.fit(self.x_train, self.y_train, epochs=1, batch_size=32, verbose=0)
25
+ return self.model.get_weights(), len(self.x_train), {}
26
+
27
+ def evaluate(self, parameters, config):
28
+ self.model.set_weights(parameters)
29
+ loss, accuracy = self.model.evaluate(self.x_test, self.y_test, verbose=0)
30
+ return loss, len(self.x_test), {"accuracy": accuracy}
31
+
32
+
33
+ fds = FederatedDataset(dataset="cifar10", partitioners={"train": 2})
34
+
35
+ def client_fn(cid: str):
36
+ """Create and return an instance of Flower `Client`."""
37
+
38
+ # Load model and data (MobileNetV2, CIFAR-10)
39
+ model = tf.keras.applications.MobileNetV2((32, 32, 3), classes=10, weights=None)
40
+ model.compile("adam", "sparse_categorical_crossentropy", metrics=["accuracy"])
41
+
42
+ # Download and partition dataset
43
+ partition = fds.load_partition(int(cid), "train")
44
+ partition.set_format("numpy")
45
+
46
+ # Divide data on each node: 80% train, 20% test
47
+ partition = partition.train_test_split(test_size=0.2, seed=42)
48
+ train_data = partition["train"]["img"] / 255.0, partition["train"]["label"]
49
+ test_data = partition["test"]["img"] / 255.0, partition["test"]["label"]
50
+
51
+ return FlowerClient(model, train_data, test_data).to_client()
52
+
53
+
54
+ # Flower ClientApp
55
+ app = ClientApp(
56
+ client_fn=client_fn,
57
+ )
@@ -1 +1,19 @@
1
1
  """$project_name: A Flower / TensorFlow app."""
2
+
3
+ from flwr.server import ServerApp, ServerConfig
4
+ from flwr.server.strategy import FedAvg
5
+
6
+ # Define config
7
+ config = ServerConfig(num_rounds=3)
8
+
9
+ strategy = FedAvg(
10
+ fraction_fit=1.0,
11
+ fraction_evaluate=1.0,
12
+ min_available_clients=2,
13
+ )
14
+
15
+ # Flower ServerApp
16
+ app = ServerApp(
17
+ config=config,
18
+ strategy=strategy,
19
+ )
flwr/client/__init__.py CHANGED
@@ -21,6 +21,7 @@ from .app import start_numpy_client as start_numpy_client
21
21
  from .client import Client as Client
22
22
  from .client_app import ClientApp as ClientApp
23
23
  from .numpy_client import NumPyClient as NumPyClient
24
+ from .supernode import run_supernode as run_supernode
24
25
  from .typing import ClientFn as ClientFn
25
26
 
26
27
  __all__ = [
@@ -29,6 +30,7 @@ __all__ = [
29
30
  "ClientFn",
30
31
  "NumPyClient",
31
32
  "run_client_app",
33
+ "run_supernode",
32
34
  "start_client",
33
35
  "start_numpy_client",
34
36
  ]
flwr/client/app.py CHANGED
@@ -47,14 +47,15 @@ from .grpc_rere_client.connection import grpc_request_response
47
47
  from .message_handler.message_handler import handle_control_message
48
48
  from .node_state import NodeState
49
49
  from .numpy_client import NumPyClient
50
+ from .supernode.app import parse_args_run_client_app
50
51
 
51
52
 
52
53
  def run_client_app() -> None:
53
54
  """Run Flower client app."""
54
- event(EventType.RUN_CLIENT_APP_ENTER)
55
-
56
55
  log(INFO, "Long-running Flower client starting")
57
56
 
57
+ event(EventType.RUN_CLIENT_APP_ENTER)
58
+
58
59
  args = _parse_args_run_client_app().parse_args()
59
60
 
60
61
  # Obtain certificates
@@ -131,56 +132,7 @@ def _parse_args_run_client_app() -> argparse.ArgumentParser:
131
132
  description="Start a Flower client app",
132
133
  )
133
134
 
134
- parser.add_argument(
135
- "client-app",
136
- help="For example: `client:app` or `project.package.module:wrapper.app`",
137
- )
138
- parser.add_argument(
139
- "--insecure",
140
- action="store_true",
141
- help="Run the client without HTTPS. By default, the client runs with "
142
- "HTTPS enabled. Use this flag only if you understand the risks.",
143
- )
144
- parser.add_argument(
145
- "--rest",
146
- action="store_true",
147
- help="Use REST as a transport layer for the client.",
148
- )
149
- parser.add_argument(
150
- "--root-certificates",
151
- metavar="ROOT_CERT",
152
- type=str,
153
- help="Specifies the path to the PEM-encoded root certificate file for "
154
- "establishing secure HTTPS connections.",
155
- )
156
- parser.add_argument(
157
- "--server",
158
- default="0.0.0.0:9092",
159
- help="Server address",
160
- )
161
- parser.add_argument(
162
- "--max-retries",
163
- type=int,
164
- default=None,
165
- help="The maximum number of times the client will try to connect to the"
166
- "server before giving up in case of a connection error. By default,"
167
- "it is set to None, meaning there is no limit to the number of tries.",
168
- )
169
- parser.add_argument(
170
- "--max-wait-time",
171
- type=float,
172
- default=None,
173
- help="The maximum duration before the client stops trying to"
174
- "connect to the server in case of connection error. By default, it"
175
- "is set to None, meaning there is no limit to the total time.",
176
- )
177
- parser.add_argument(
178
- "--dir",
179
- default="",
180
- help="Add specified directory to the PYTHONPATH and load Flower "
181
- "app from there."
182
- " Default: current working directory.",
183
- )
135
+ parse_args_run_client_app(parser=parser)
184
136
 
185
137
  return parser
186
138
 
@@ -442,7 +394,8 @@ def _start_client_internal(
442
394
  grpc_max_message_length,
443
395
  root_certificates,
444
396
  ) as conn:
445
- receive, send, create_node, delete_node = conn
397
+ # pylint: disable-next=W0612
398
+ receive, send, create_node, delete_node, get_run = conn
446
399
 
447
400
  # Register node
448
401
  if create_node is not None:
@@ -660,6 +613,7 @@ def _init_connection(transport: Optional[str], server_address: str) -> Tuple[
660
613
  Callable[[Message], None],
661
614
  Optional[Callable[[], None]],
662
615
  Optional[Callable[[], None]],
616
+ Optional[Callable[[int], Tuple[str, str]]],
663
617
  ]
664
618
  ],
665
619
  ],
@@ -68,6 +68,7 @@ def grpc_connection( # pylint: disable=R0915
68
68
  Callable[[Message], None],
69
69
  Optional[Callable[[], None]],
70
70
  Optional[Callable[[], None]],
71
+ Optional[Callable[[int], Tuple[str, str]]],
71
72
  ]
72
73
  ]:
73
74
  """Establish a gRPC connection to a gRPC server.
@@ -224,7 +225,7 @@ def grpc_connection( # pylint: disable=R0915
224
225
 
225
226
  try:
226
227
  # Yield methods
227
- yield (receive, send, None, None)
228
+ yield (receive, send, None, None, None)
228
229
  finally:
229
230
  # Make sure to have a final
230
231
  channel.close()
@@ -41,6 +41,8 @@ from flwr.common.serde import message_from_taskins, message_to_taskres
41
41
  from flwr.proto.fleet_pb2 import ( # pylint: disable=E0611
42
42
  CreateNodeRequest,
43
43
  DeleteNodeRequest,
44
+ GetRunRequest,
45
+ GetRunResponse,
44
46
  PingRequest,
45
47
  PingResponse,
46
48
  PullTaskInsRequest,
@@ -69,6 +71,7 @@ def grpc_request_response( # pylint: disable=R0914, R0915
69
71
  Callable[[Message], None],
70
72
  Optional[Callable[[], None]],
71
73
  Optional[Callable[[], None]],
74
+ Optional[Callable[[int], Tuple[str, str]]],
72
75
  ]
73
76
  ]:
74
77
  """Primitives for request/response-based interaction with a server.
@@ -122,7 +125,7 @@ def grpc_request_response( # pylint: disable=R0914, R0915
122
125
  ping_stop_event = threading.Event()
123
126
 
124
127
  ###########################################################################
125
- # ping/create_node/delete_node/receive/send functions
128
+ # ping/create_node/delete_node/receive/send/get_run functions
126
129
  ###########################################################################
127
130
 
128
131
  def ping() -> None:
@@ -241,8 +244,19 @@ def grpc_request_response( # pylint: disable=R0914, R0915
241
244
  # Cleanup
242
245
  metadata = None
243
246
 
247
+ def get_run(run_id: int) -> Tuple[str, str]:
248
+ # Call FleetAPI
249
+ get_run_request = GetRunRequest(run_id=run_id)
250
+ get_run_response: GetRunResponse = retry_invoker.invoke(
251
+ stub.GetRun,
252
+ request=get_run_request,
253
+ )
254
+
255
+ # Return fab_id and fab_version
256
+ return get_run_response.run.fab_id, get_run_response.run.fab_version
257
+
244
258
  try:
245
259
  # Yield methods
246
- yield (receive, send, create_node, delete_node)
260
+ yield (receive, send, create_node, delete_node, get_run)
247
261
  except Exception as exc: # pylint: disable=broad-except
248
262
  log(ERROR, exc)
@@ -21,7 +21,9 @@ import threading
21
21
  from contextlib import contextmanager
22
22
  from copy import copy
23
23
  from logging import ERROR, INFO, WARN
24
- from typing import Callable, Iterator, Optional, Tuple, Union
24
+ from typing import Callable, Iterator, Optional, Tuple, Type, TypeVar, Union
25
+
26
+ from google.protobuf.message import Message as GrpcMessage
25
27
 
26
28
  from flwr.client.heartbeat import start_ping_loop
27
29
  from flwr.client.message_handler.message_handler import validate_out_message
@@ -42,6 +44,9 @@ from flwr.proto.fleet_pb2 import ( # pylint: disable=E0611
42
44
  CreateNodeRequest,
43
45
  CreateNodeResponse,
44
46
  DeleteNodeRequest,
47
+ DeleteNodeResponse,
48
+ GetRunRequest,
49
+ GetRunResponse,
45
50
  PingRequest,
46
51
  PingResponse,
47
52
  PullTaskInsRequest,
@@ -63,6 +68,9 @@ PATH_DELETE_NODE: str = "api/v0/fleet/delete-node"
63
68
  PATH_PULL_TASK_INS: str = "api/v0/fleet/pull-task-ins"
64
69
  PATH_PUSH_TASK_RES: str = "api/v0/fleet/push-task-res"
65
70
  PATH_PING: str = "api/v0/fleet/ping"
71
+ PATH_GET_RUN: str = "/api/v0/fleet/get-run"
72
+
73
+ T = TypeVar("T", bound=GrpcMessage)
66
74
 
67
75
 
68
76
  @contextmanager
@@ -80,6 +88,7 @@ def http_request_response( # pylint: disable=R0914, R0915
80
88
  Callable[[Message], None],
81
89
  Optional[Callable[[], None]],
82
90
  Optional[Callable[[], None]],
91
+ Optional[Callable[[int], Tuple[str, str]]],
83
92
  ]
84
93
  ]:
85
94
  """Primitives for request/response-based interaction with a server.
@@ -141,55 +150,72 @@ def http_request_response( # pylint: disable=R0914, R0915
141
150
  ping_stop_event = threading.Event()
142
151
 
143
152
  ###########################################################################
144
- # ping/create_node/delete_node/receive/send functions
153
+ # ping/create_node/delete_node/receive/send/get_run functions
145
154
  ###########################################################################
146
155
 
147
- def ping() -> None:
148
- # Get Node
149
- if node is None:
150
- log(ERROR, "Node instance missing")
151
- return
152
-
153
- # Construct the ping request
154
- req = PingRequest(node=node, ping_interval=PING_DEFAULT_INTERVAL)
155
- req_bytes: bytes = req.SerializeToString()
156
+ def _request(
157
+ req: GrpcMessage, res_type: Type[T], api_path: str, retry: bool = True
158
+ ) -> Optional[T]:
159
+ # Serialize the request
160
+ req_bytes = req.SerializeToString()
156
161
 
157
162
  # Send the request
158
- res = requests.post(
159
- url=f"{base_url}/{PATH_PING}",
160
- headers={
161
- "Accept": "application/protobuf",
162
- "Content-Type": "application/protobuf",
163
- },
164
- data=req_bytes,
165
- verify=verify,
166
- timeout=PING_CALL_TIMEOUT,
167
- )
163
+ def post() -> requests.Response:
164
+ return requests.post(
165
+ f"{base_url}/{api_path}",
166
+ data=req_bytes,
167
+ headers={
168
+ "Accept": "application/protobuf",
169
+ "Content-Type": "application/protobuf",
170
+ },
171
+ verify=verify,
172
+ timeout=None,
173
+ )
174
+
175
+ if retry:
176
+ res: requests.Response = retry_invoker.invoke(post)
177
+ else:
178
+ res = post()
168
179
 
169
180
  # Check status code and headers
170
181
  if res.status_code != 200:
171
- return
182
+ return None
172
183
  if "content-type" not in res.headers:
173
184
  log(
174
185
  WARN,
175
186
  "[Node] POST /%s: missing header `Content-Type`",
176
- PATH_PING,
187
+ api_path,
177
188
  )
178
- return
189
+ return None
179
190
  if res.headers["content-type"] != "application/protobuf":
180
191
  log(
181
192
  WARN,
182
193
  "[Node] POST /%s: header `Content-Type` has wrong value",
183
- PATH_PING,
194
+ api_path,
184
195
  )
185
- return
196
+ return None
186
197
 
187
198
  # Deserialize ProtoBuf from bytes
188
- ping_res = PingResponse()
189
- ping_res.ParseFromString(res.content)
199
+ grpc_res = res_type()
200
+ grpc_res.ParseFromString(res.content)
201
+ return grpc_res
202
+
203
+ def ping() -> None:
204
+ # Get Node
205
+ if node is None:
206
+ log(ERROR, "Node instance missing")
207
+ return
208
+
209
+ # Construct the ping request
210
+ req = PingRequest(node=node, ping_interval=PING_DEFAULT_INTERVAL)
211
+
212
+ # Send the request
213
+ res = _request(req, PingResponse, PATH_PING, retry=False)
214
+ if res is None:
215
+ return
190
216
 
191
217
  # Check if success
192
- if not ping_res.success:
218
+ if not res.success:
193
219
  raise RuntimeError("Ping failed unexpectedly.")
194
220
 
195
221
  # Wait
@@ -201,46 +227,16 @@ def http_request_response( # pylint: disable=R0914, R0915
201
227
 
202
228
  def create_node() -> None:
203
229
  """Set create_node."""
204
- create_node_req_proto = CreateNodeRequest(ping_interval=PING_DEFAULT_INTERVAL)
205
- create_node_req_bytes: bytes = create_node_req_proto.SerializeToString()
206
-
207
- res = retry_invoker.invoke(
208
- requests.post,
209
- url=f"{base_url}/{PATH_CREATE_NODE}",
210
- headers={
211
- "Accept": "application/protobuf",
212
- "Content-Type": "application/protobuf",
213
- },
214
- data=create_node_req_bytes,
215
- verify=verify,
216
- timeout=None,
217
- )
230
+ req = CreateNodeRequest(ping_interval=PING_DEFAULT_INTERVAL)
218
231
 
219
- # Check status code and headers
220
- if res.status_code != 200:
221
- return
222
- if "content-type" not in res.headers:
223
- log(
224
- WARN,
225
- "[Node] POST /%s: missing header `Content-Type`",
226
- PATH_CREATE_NODE,
227
- )
228
- return
229
- if res.headers["content-type"] != "application/protobuf":
230
- log(
231
- WARN,
232
- "[Node] POST /%s: header `Content-Type` has wrong value",
233
- PATH_CREATE_NODE,
234
- )
232
+ # Send the request
233
+ res = _request(req, CreateNodeResponse, PATH_CREATE_NODE)
234
+ if res is None:
235
235
  return
236
236
 
237
- # Deserialize ProtoBuf from bytes
238
- create_node_response_proto = CreateNodeResponse()
239
- create_node_response_proto.ParseFromString(res.content)
240
-
241
237
  # Remember the node and the ping-loop thread
242
238
  nonlocal node, ping_thread
243
- node = create_node_response_proto.node
239
+ node = res.node
244
240
  ping_thread = start_ping_loop(ping, ping_stop_event)
245
241
 
246
242
  def delete_node() -> None:
@@ -256,36 +252,12 @@ def http_request_response( # pylint: disable=R0914, R0915
256
252
  ping_thread.join()
257
253
 
258
254
  # Send DeleteNode request
259
- delete_node_req_proto = DeleteNodeRequest(node=node)
260
- delete_node_req_req_bytes: bytes = delete_node_req_proto.SerializeToString()
261
- res = retry_invoker.invoke(
262
- requests.post,
263
- url=f"{base_url}/{PATH_DELETE_NODE}",
264
- headers={
265
- "Accept": "application/protobuf",
266
- "Content-Type": "application/protobuf",
267
- },
268
- data=delete_node_req_req_bytes,
269
- verify=verify,
270
- timeout=None,
271
- )
255
+ req = DeleteNodeRequest(node=node)
272
256
 
273
- # Check status code and headers
274
- if res.status_code != 200:
275
- return
276
- if "content-type" not in res.headers:
277
- log(
278
- WARN,
279
- "[Node] POST /%s: missing header `Content-Type`",
280
- PATH_DELETE_NODE,
281
- )
257
+ # Send the request
258
+ res = _request(req, DeleteNodeResponse, PATH_CREATE_NODE)
259
+ if res is None:
282
260
  return
283
- if res.headers["content-type"] != "application/protobuf":
284
- log(
285
- WARN,
286
- "[Node] POST /%s: header `Content-Type` has wrong value",
287
- PATH_DELETE_NODE,
288
- )
289
261
 
290
262
  # Cleanup
291
263
  node = None
@@ -298,46 +270,15 @@ def http_request_response( # pylint: disable=R0914, R0915
298
270
  return None
299
271
 
300
272
  # Request instructions (task) from server
301
- pull_task_ins_req_proto = PullTaskInsRequest(node=node)
302
- pull_task_ins_req_bytes: bytes = pull_task_ins_req_proto.SerializeToString()
273
+ req = PullTaskInsRequest(node=node)
303
274
 
304
- # Request instructions (task) from server
305
- res = retry_invoker.invoke(
306
- requests.post,
307
- url=f"{base_url}/{PATH_PULL_TASK_INS}",
308
- headers={
309
- "Accept": "application/protobuf",
310
- "Content-Type": "application/protobuf",
311
- },
312
- data=pull_task_ins_req_bytes,
313
- verify=verify,
314
- timeout=None,
315
- )
316
-
317
- # Check status code and headers
318
- if res.status_code != 200:
319
- return None
320
- if "content-type" not in res.headers:
321
- log(
322
- WARN,
323
- "[Node] POST /%s: missing header `Content-Type`",
324
- PATH_PULL_TASK_INS,
325
- )
326
- return None
327
- if res.headers["content-type"] != "application/protobuf":
328
- log(
329
- WARN,
330
- "[Node] POST /%s: header `Content-Type` has wrong value",
331
- PATH_PULL_TASK_INS,
332
- )
275
+ # Send the request
276
+ res = _request(req, PullTaskInsResponse, PATH_PULL_TASK_INS)
277
+ if res is None:
333
278
  return None
334
279
 
335
- # Deserialize ProtoBuf from bytes
336
- pull_task_ins_response_proto = PullTaskInsResponse()
337
- pull_task_ins_response_proto.ParseFromString(res.content)
338
-
339
280
  # Get the current TaskIns
340
- task_ins: Optional[TaskIns] = get_task_ins(pull_task_ins_response_proto)
281
+ task_ins: Optional[TaskIns] = get_task_ins(res)
341
282
 
342
283
  # Discard the current TaskIns if not valid
343
284
  if task_ins is not None and not (
@@ -372,61 +313,39 @@ def http_request_response( # pylint: disable=R0914, R0915
372
313
  if not validate_out_message(message, metadata):
373
314
  log(ERROR, "Invalid out message")
374
315
  return
316
+ metadata = None
375
317
 
376
318
  # Construct TaskRes
377
319
  task_res = message_to_taskres(message)
378
320
 
379
321
  # Serialize ProtoBuf to bytes
380
- push_task_res_request_proto = PushTaskResRequest(task_res_list=[task_res])
381
- push_task_res_request_bytes: bytes = (
382
- push_task_res_request_proto.SerializeToString()
383
- )
384
-
385
- # Send ClientMessage to server
386
- res = retry_invoker.invoke(
387
- requests.post,
388
- url=f"{base_url}/{PATH_PUSH_TASK_RES}",
389
- headers={
390
- "Accept": "application/protobuf",
391
- "Content-Type": "application/protobuf",
392
- },
393
- data=push_task_res_request_bytes,
394
- verify=verify,
395
- timeout=None,
396
- )
397
-
398
- metadata = None
322
+ req = PushTaskResRequest(task_res_list=[task_res])
399
323
 
400
- # Check status code and headers
401
- if res.status_code != 200:
402
- return
403
- if "content-type" not in res.headers:
404
- log(
405
- WARN,
406
- "[Node] POST /%s: missing header `Content-Type`",
407
- PATH_PUSH_TASK_RES,
408
- )
409
- return
410
- if res.headers["content-type"] != "application/protobuf":
411
- log(
412
- WARN,
413
- "[Node] POST /%s: header `Content-Type` has wrong value",
414
- PATH_PUSH_TASK_RES,
415
- )
324
+ # Send the request
325
+ res = _request(req, PushTaskResResponse, PATH_PUSH_TASK_RES)
326
+ if res is None:
416
327
  return
417
328
 
418
- # Deserialize ProtoBuf from bytes
419
- push_task_res_response_proto = PushTaskResResponse()
420
- push_task_res_response_proto.ParseFromString(res.content)
421
329
  log(
422
330
  INFO,
423
331
  "[Node] POST /%s: success, created result %s",
424
332
  PATH_PUSH_TASK_RES,
425
- push_task_res_response_proto.results, # pylint: disable=no-member
333
+ res.results, # pylint: disable=no-member
426
334
  )
427
335
 
336
+ def get_run(run_id: int) -> Tuple[str, str]:
337
+ # Construct the request
338
+ req = GetRunRequest(run_id=run_id)
339
+
340
+ # Send the request
341
+ res = _request(req, GetRunResponse, PATH_GET_RUN)
342
+ if res is None:
343
+ return "", ""
344
+
345
+ return res.run.fab_id, res.run.fab_version
346
+
428
347
  try:
429
348
  # Yield methods
430
- yield (receive, send, create_node, delete_node)
349
+ yield (receive, send, create_node, delete_node, get_run)
431
350
  except Exception as exc: # pylint: disable=broad-except
432
351
  log(ERROR, exc)
@@ -0,0 +1,22 @@
1
+ # Copyright 2024 Flower Labs GmbH. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # ==============================================================================
15
+ """Flower SuperNode."""
16
+
17
+
18
+ from .app import run_supernode as run_supernode
19
+
20
+ __all__ = [
21
+ "run_supernode",
22
+ ]
@@ -0,0 +1,107 @@
1
+ # Copyright 2024 Flower Labs GmbH. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # ==============================================================================
15
+ """Flower SuperNode."""
16
+
17
+ import argparse
18
+ from logging import DEBUG, INFO
19
+
20
+ from flwr.common import EventType, event
21
+ from flwr.common.exit_handlers import register_exit_handlers
22
+ from flwr.common.logger import log
23
+
24
+
25
+ def run_supernode() -> None:
26
+ """Run Flower SuperNode."""
27
+ log(INFO, "Starting Flower SuperNode")
28
+
29
+ event(EventType.RUN_SUPERNODE_ENTER)
30
+
31
+ args = _parse_args_run_supernode().parse_args()
32
+
33
+ log(
34
+ DEBUG,
35
+ "Flower will load ClientApp `%s`",
36
+ getattr(args, "client-app"),
37
+ )
38
+
39
+ # Graceful shutdown
40
+ register_exit_handlers(
41
+ event_type=EventType.RUN_SUPERNODE_LEAVE,
42
+ )
43
+
44
+
45
+ def _parse_args_run_supernode() -> argparse.ArgumentParser:
46
+ """Parse flower-supernode command line arguments."""
47
+ parser = argparse.ArgumentParser(
48
+ description="Start a Flower SuperNode",
49
+ )
50
+
51
+ parse_args_run_client_app(parser=parser)
52
+
53
+ return parser
54
+
55
+
56
+ def parse_args_run_client_app(parser: argparse.ArgumentParser) -> None:
57
+ """Parse command line arguments."""
58
+ parser.add_argument(
59
+ "client-app",
60
+ help="For example: `client:app` or `project.package.module:wrapper.app`",
61
+ )
62
+ parser.add_argument(
63
+ "--insecure",
64
+ action="store_true",
65
+ help="Run the client without HTTPS. By default, the client runs with "
66
+ "HTTPS enabled. Use this flag only if you understand the risks.",
67
+ )
68
+ parser.add_argument(
69
+ "--rest",
70
+ action="store_true",
71
+ help="Use REST as a transport layer for the client.",
72
+ )
73
+ parser.add_argument(
74
+ "--root-certificates",
75
+ metavar="ROOT_CERT",
76
+ type=str,
77
+ help="Specifies the path to the PEM-encoded root certificate file for "
78
+ "establishing secure HTTPS connections.",
79
+ )
80
+ parser.add_argument(
81
+ "--server",
82
+ default="0.0.0.0:9092",
83
+ help="Server address",
84
+ )
85
+ parser.add_argument(
86
+ "--max-retries",
87
+ type=int,
88
+ default=None,
89
+ help="The maximum number of times the client will try to connect to the"
90
+ "server before giving up in case of a connection error. By default,"
91
+ "it is set to None, meaning there is no limit to the number of tries.",
92
+ )
93
+ parser.add_argument(
94
+ "--max-wait-time",
95
+ type=float,
96
+ default=None,
97
+ help="The maximum duration before the client stops trying to"
98
+ "connect to the server in case of connection error. By default, it"
99
+ "is set to None, meaning there is no limit to the total time.",
100
+ )
101
+ parser.add_argument(
102
+ "--dir",
103
+ default="",
104
+ help="Add specified directory to the PYTHONPATH and load Flower "
105
+ "app from there."
106
+ " Default: current working directory.",
107
+ )
flwr/common/telemetry.py CHANGED
@@ -160,6 +160,10 @@ class EventType(str, Enum):
160
160
  RUN_SERVER_APP_ENTER = auto()
161
161
  RUN_SERVER_APP_LEAVE = auto()
162
162
 
163
+ # SuperNode
164
+ RUN_SUPERNODE_ENTER = auto()
165
+ RUN_SUPERNODE_LEAVE = auto()
166
+
163
167
 
164
168
  # Use the ThreadPoolExecutor with max_workers=1 to have a queue
165
169
  # and also ensure that telemetry calls are not blocking.
flwr/server/app.py CHANGED
@@ -291,9 +291,11 @@ def run_fleet_api() -> None:
291
291
 
292
292
  # pylint: disable=too-many-branches, too-many-locals, too-many-statements
293
293
  def run_superlink() -> None:
294
- """Run Flower server (Driver API and Fleet API)."""
295
- log(INFO, "Starting Flower server")
294
+ """Run Flower SuperLink (Driver API and Fleet API)."""
295
+ log(INFO, "Starting Flower SuperLink")
296
+
296
297
  event(EventType.RUN_SUPERLINK_ENTER)
298
+
297
299
  args = _parse_args_run_superlink().parse_args()
298
300
 
299
301
  # Parse IP address
@@ -568,9 +570,7 @@ def _parse_args_run_fleet_api() -> argparse.ArgumentParser:
568
570
  def _parse_args_run_superlink() -> argparse.ArgumentParser:
569
571
  """Parse command line arguments for both Driver API and Fleet API."""
570
572
  parser = argparse.ArgumentParser(
571
- description="This will start a Flower server "
572
- "(meaning, a Driver API and a Fleet API), "
573
- "that clients will be able to connect to.",
573
+ description="Start a Flower SuperLink",
574
574
  )
575
575
 
576
576
  _add_args_common(parser=parser)
@@ -33,6 +33,7 @@ from flwr.proto.fleet_pb2 import ( # pylint: disable=E0611
33
33
  PushTaskResRequest,
34
34
  PushTaskResResponse,
35
35
  Reconnect,
36
+ Run,
36
37
  )
37
38
  from flwr.proto.node_pb2 import Node # pylint: disable=E0611
38
39
  from flwr.proto.task_pb2 import TaskIns, TaskRes # pylint: disable=E0611
@@ -109,4 +110,6 @@ def get_run(
109
110
  request: GetRunRequest, state: State # pylint: disable=W0613
110
111
  ) -> GetRunResponse:
111
112
  """Get run information."""
112
- return GetRunResponse()
113
+ run_id, fab_id, fab_version = state.get_run(request.run_id)
114
+ run = Run(run_id=run_id, fab_id=fab_id, fab_version=fab_version)
115
+ return GetRunResponse(run=run)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: flwr-nightly
3
- Version: 1.9.0.dev20240418
3
+ Version: 1.9.0.dev20240419
4
4
  Summary: Flower: A Friendly Federated Learning Framework
5
5
  Home-page: https://flower.ai
6
6
  License: Apache-2.0
@@ -1,7 +1,7 @@
1
1
  flwr/__init__.py,sha256=VmBWedrCxqmt4QvUHBLqyVEH6p7zaFMD_oCHerXHSVw,937
2
2
  flwr/cli/__init__.py,sha256=cZJVgozlkC6Ni2Hd_FAIrqefrkCGOV18fikToq-6iLw,720
3
3
  flwr/cli/app.py,sha256=38thPnMydBmNAxNE9mz4By-KdRUhJfoUgeDuAxMYF_U,1095
4
- flwr/cli/config_utils.py,sha256=fTKBxsXauE8JWdwTb8yLxOm69sAcURefoif-DGUDNII,4696
4
+ flwr/cli/config_utils.py,sha256=1wTPQqOU2fKeU4FP5KyG0xMa0F-qy8x1m2WvztPORb4,5597
5
5
  flwr/cli/example.py,sha256=1bGDYll3BXQY2kRqSN-oICqS5n1b9m0g0RvXTopXHl4,2215
6
6
  flwr/cli/new/__init__.py,sha256=cQzK1WH4JP2awef1t2UQ2xjl1agVEz9rwutV18SWV1k,789
7
7
  flwr/cli/new/new.py,sha256=OHTOpuHRqmafsoV_Hv1V1544mZz54Z0qDRRtMT3dR-M,5380
@@ -13,10 +13,10 @@ flwr/cli/new/templates/app/code/__init__.py,sha256=EM6vfvgAILKPaPn7H1wMV1Wi01WyZ
13
13
  flwr/cli/new/templates/app/code/__init__.py.tpl,sha256=olwrBeJemHNBWvjc6gJURloFRqW40dAy7FRQA5pDqHU,21
14
14
  flwr/cli/new/templates/app/code/client.numpy.py.tpl,sha256=mTh7Y_jOJrPUvDYHVJy4wJCnjXZV_q-jlDkB07U5GSk,521
15
15
  flwr/cli/new/templates/app/code/client.pytorch.py.tpl,sha256=671daPcdZaC4Z5k-dqmCovfb2_FShGmqfjwaR8y6EC8,1173
16
- flwr/cli/new/templates/app/code/client.tensorflow.py.tpl,sha256=d6J5VM681d0j4hj1Duaj1WQyeFoyCiEZh4o4J8zH-_M,48
16
+ flwr/cli/new/templates/app/code/client.tensorflow.py.tpl,sha256=N9SbnI65r2K9FHV_wn4JSpmVeyYpD0qEMehbHcGm4t0,1911
17
17
  flwr/cli/new/templates/app/code/server.numpy.py.tpl,sha256=fRxrDXV7pB1aDhQUXMBmrCsC1zp0uKwsBxZBx1JzbHA,248
18
18
  flwr/cli/new/templates/app/code/server.pytorch.py.tpl,sha256=xtKvUivNMzgOcLSOtnjWouJzIFbXdUQVYMm27uwyJpI,594
19
- flwr/cli/new/templates/app/code/server.tensorflow.py.tpl,sha256=d6J5VM681d0j4hj1Duaj1WQyeFoyCiEZh4o4J8zH-_M,48
19
+ flwr/cli/new/templates/app/code/server.tensorflow.py.tpl,sha256=GUGH8c_6cxgUB9obVJPaA4thxI7OVXsItyfQDsn9E5k,371
20
20
  flwr/cli/new/templates/app/code/task.pytorch.py.tpl,sha256=NvajdZN-eTyfdqKK0v2MrvWITXw9BjJ3Ri5c1haPJDs,3684
21
21
  flwr/cli/new/templates/app/pyproject.numpy.toml.tpl,sha256=0oTH0lY7q-PpRV4HA5woxJ1eWIgZRFcFsHa7-1lULIQ,489
22
22
  flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl,sha256=GYbMAFD90JBRvy8fJbLU7nDITD3sxHv1TncQrg6mjEE,558
@@ -24,15 +24,15 @@ flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl,sha256=7I8BYtE28cnc7ZiO
24
24
  flwr/cli/run/__init__.py,sha256=oCd6HmQDx-sqver1gecgx-uMA38BLTSiiKpl7RGNceg,789
25
25
  flwr/cli/run/run.py,sha256=qxXgShEXHONx-Gjpl515HF60QzRA-Ygpj2sbl0bZUAA,2331
26
26
  flwr/cli/utils.py,sha256=33m5ELefA43VhJwtBHW5ntWkP7X5Tk_5A2s1OcaSBYg,4153
27
- flwr/client/__init__.py,sha256=futk_IdY_N1h8BTve4Iru51bxm7H1gv58ZPIXWi5XUA,1187
28
- flwr/client/app.py,sha256=B48cQa-kb_0WzdtufTXuaoEyrynGWMQCqxdUz_sAKek,26193
27
+ flwr/client/__init__.py,sha256=8LuIrd2GGWJXG2CFWihywicJtntIvCoPLssIUnHqZaA,1262
28
+ flwr/client/app.py,sha256=zs5yeFavIIX-407b25xLapVruprohKSB0Ckk0CjW1Vw,24670
29
29
  flwr/client/client.py,sha256=Vp9UkOkoHdNfn6iMYZsj_5m_GICiFfUlKEVaLad-YhM,8183
30
30
  flwr/client/client_app.py,sha256=-Cs0084tLQUoBCeYZdG2KgU7cjp95_ZJ4MfjoaN4Fzk,8636
31
31
  flwr/client/dpfedavg_numpy_client.py,sha256=9Tnig4iml2J88HBKNahegjXjbfvIQyBtaIQaqjbeqsA,7435
32
32
  flwr/client/grpc_client/__init__.py,sha256=LsnbqXiJhgQcB0XzAlUQgPx011Uf7Y7yabIC1HxivJ8,735
33
- flwr/client/grpc_client/connection.py,sha256=w3Lble9-eCzNOR7fBUsVedVCK4ui9QPhK7i7Ew_a5Vk,8717
33
+ flwr/client/grpc_client/connection.py,sha256=7MfyR6hEq3u46wK3s0vP3eubFq19pKZJCG3EFw_i4T4,8775
34
34
  flwr/client/grpc_rere_client/__init__.py,sha256=avn6W_vHEM_yZEB1S7hCZgnTbXb6ZujqRP_vAzyXu-0,752
35
- flwr/client/grpc_rere_client/connection.py,sha256=JaQIQYUJnmZHfqrGBxYZmEtyC-rUdCCaK1HrMcOXEig,8560
35
+ flwr/client/grpc_rere_client/connection.py,sha256=IEGkM0MymZ1tyL6yAL4ic5ZpGy_zg9bJBVf5KCSL2iY,9052
36
36
  flwr/client/heartbeat.py,sha256=cx37mJBH8LyoIN4Lks85wtqT1mnU5GulQnr4pGCvAq0,2404
37
37
  flwr/client/message_handler/__init__.py,sha256=abHvBRJJiiaAMNgeILQbMOa6h8WqMK2BcnvxwQZFpic,719
38
38
  flwr/client/message_handler/message_handler.py,sha256=ml_FlduAJ5pxO31n1tKRrWfQRSxkMgKLbwXXcRsNSos,6553
@@ -49,7 +49,9 @@ flwr/client/node_state.py,sha256=KTTs_l4I0jBM7IsSsbAGjhfL_yZC3QANbzyvyfZBRDM,177
49
49
  flwr/client/node_state_tests.py,sha256=gPwz0zf2iuDSa11jedkur_u3Xm7lokIDG5ALD2MCvSw,2195
50
50
  flwr/client/numpy_client.py,sha256=u76GWAdHmJM88Agm2EgLQSvO8Jnk225mJTk-_TmPjFE,10283
51
51
  flwr/client/rest_client/__init__.py,sha256=ThwOnkMdzxo_UuyTI47Q7y9oSpuTgNT2OuFvJCfuDiw,735
52
- flwr/client/rest_client/connection.py,sha256=f5jYUC4d3swo39-RJAe56tKjS9P-NBp6hONC9k3QKns,14456
52
+ flwr/client/rest_client/connection.py,sha256=ZxTFVDXlONqKTX6uYgxshoEWqzqVcQ8QQ2hKS93oLM8,11302
53
+ flwr/client/supernode/__init__.py,sha256=D5swXxemuRbA2rB_T9B8LwJW-_PucXwmlFQQerwIUv0,793
54
+ flwr/client/supernode/app.py,sha256=JXRZ76JdyAkhfaEEqsMiONWVQ0bn8YqzZg9oHC4Qfko,3436
53
55
  flwr/client/typing.py,sha256=c9EvjlEjasxn1Wqx6bGl6Xg6vM1gMFfmXht-E2i5J-k,1006
54
56
  flwr/common/__init__.py,sha256=dHOptgKxna78CEQLD5Yu0QIsoSgpIIw5AhIUZCHDWAU,3721
55
57
  flwr/common/address.py,sha256=iTAN9jtmIGMrWFnx9XZQl45ZEtQJVZZLYPRBSNVARGI,1882
@@ -84,7 +86,7 @@ flwr/common/secure_aggregation/quantization.py,sha256=appui7GGrkRPsupF59TkapeV4N
84
86
  flwr/common/secure_aggregation/secaggplus_constants.py,sha256=Fh7-n6pgL4TUnHpNYXo8iW-n5cOGQgQa-c7RcU80tqQ,2183
85
87
  flwr/common/secure_aggregation/secaggplus_utils.py,sha256=87bNZX6CmQekj935R4u3m5hsaEkkfKtGSA-VG2c-O9w,3221
86
88
  flwr/common/serde.py,sha256=Yn83kbSf9vJndTa5ldL4DR_bL_wy_bD4lTlD3ZbB658,22250
87
- flwr/common/telemetry.py,sha256=JkFB6WBOskqAJfzSM-l6tQfRiSi2oiysClfg0-5T7NY,7782
89
+ flwr/common/telemetry.py,sha256=Q84hW6l6MCtD8sgQI4sUcp-N-zqAo607jyApeXC5RpM,7865
88
90
  flwr/common/typing.py,sha256=3Wu6Ol1Ja6Gb0WdlcXVEn1EHYJbc4oRRJA81vEegxBo,4382
89
91
  flwr/common/version.py,sha256=_RDSMGZPEuGKYViZuXPotDtXMvh4iyDH9XOCO4qtPO8,666
90
92
  flwr/proto/__init__.py,sha256=hbY7JYakwZwCkYgCNlmHdc8rtvfoJbAZLalMdc--CGc,683
@@ -118,7 +120,7 @@ flwr/proto/transport_pb2_grpc.py,sha256=vLN3EHtx2aEEMCO4f1Upu-l27BPzd3-5pV-u8wPc
118
120
  flwr/proto/transport_pb2_grpc.pyi,sha256=AGXf8RiIiW2J5IKMlm_3qT3AzcDa4F3P5IqUjve_esA,766
119
121
  flwr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
120
122
  flwr/server/__init__.py,sha256=dNLbXIERZ6X9aA_Bit3R9AARwcaZZzEfDuFmEx8VVOE,1785
121
- flwr/server/app.py,sha256=8rQMxWg3RprQb_ttPK45TumhZPrk6w4EBACxWO4Na8Q,24302
123
+ flwr/server/app.py,sha256=FriloRrkDHTlB5G7EBn6sH4v5GhiYFf_ZhbdROgjKbY,24199
122
124
  flwr/server/client_manager.py,sha256=T8UDSRJBVD3fyIDI7NTAA-NA7GPrMNNgH2OAF54RRxE,6127
123
125
  flwr/server/client_proxy.py,sha256=4G-oTwhb45sfWLx2uZdcXD98IZwdTS6F88xe3akCdUg,2399
124
126
  flwr/server/compat/__init__.py,sha256=VxnJtJyOjNFQXMNi9hIuzNlZM5n0Hj1p3aq_Pm2udw4,892
@@ -173,7 +175,7 @@ flwr/server/superlink/fleet/grpc_bidi/grpc_server.py,sha256=1QyBX5qcFPjMVlv7Trvn
173
175
  flwr/server/superlink/fleet/grpc_rere/__init__.py,sha256=bEJOMWbSlqkw-y5ZHtEXczhoSlAxErcRYffmTMQAV8M,758
174
176
  flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py,sha256=YGn1IPpuX-6NDgaG1UbyREbI9iAyKDimZuNeWxbG6s0,3387
175
177
  flwr/server/superlink/fleet/message_handler/__init__.py,sha256=hEY0l61ojH8Iz30_K1btm1HJ6J49iZJSFUsVYqUTw3A,731
176
- flwr/server/superlink/fleet/message_handler/message_handler.py,sha256=NfhM-xgIhKqcD2pOkZqwv-zbAB999uVYCjYPtWzo9u4,3473
178
+ flwr/server/superlink/fleet/message_handler/message_handler.py,sha256=lG3BkiONcikDVowK0An06V7p2SNkwGbWE5hfN2xlsZw,3622
177
179
  flwr/server/superlink/fleet/rest_rere/__init__.py,sha256=VKDvDq5H8koOUztpmQacVzGJXPLEEkL1Vmolxt3mvnY,735
178
180
  flwr/server/superlink/fleet/rest_rere/rest_api.py,sha256=8gNziOjBA8ygTzfVPYiNkg_qxr-T822Q_Lbo9g2tVyk,7621
179
181
  flwr/server/superlink/fleet/vce/__init__.py,sha256=36MHKiefnJeyjwMQzVUK4m06Ojon3WDcwZGQsAcyVhQ,783
@@ -204,8 +206,8 @@ flwr/simulation/ray_transport/ray_actor.py,sha256=_wv2eP7qxkCZ-6rMyYWnjLrGPBZRxj
204
206
  flwr/simulation/ray_transport/ray_client_proxy.py,sha256=oDu4sEPIOu39vrNi-fqDAe10xtNUXMO49bM2RWfRcyw,6738
205
207
  flwr/simulation/ray_transport/utils.py,sha256=TYdtfg1P9VfTdLMOJlifInGpxWHYs9UfUqIv2wfkRLA,2392
206
208
  flwr/simulation/run_simulation.py,sha256=HiIH6aa_v56NfKQN5ZBd94NyVfaZNyFs43_kItYsQXU,15685
207
- flwr_nightly-1.9.0.dev20240418.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
208
- flwr_nightly-1.9.0.dev20240418.dist-info/METADATA,sha256=wEQknDEOzu-egZOF5Z6423WK0t7uCFULKtCwHOw1gA0,15260
209
- flwr_nightly-1.9.0.dev20240418.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
210
- flwr_nightly-1.9.0.dev20240418.dist-info/entry_points.txt,sha256=utu2wybGyYJSTtsB2ktY_gmy-XtMFo9EFZdishX0zR4,320
211
- flwr_nightly-1.9.0.dev20240418.dist-info/RECORD,,
209
+ flwr_nightly-1.9.0.dev20240419.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
210
+ flwr_nightly-1.9.0.dev20240419.dist-info/METADATA,sha256=W3tyRxj4LXms8QbNvSBIspZOouKU5DIz-UZ-UAiOsYw,15260
211
+ flwr_nightly-1.9.0.dev20240419.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
212
+ flwr_nightly-1.9.0.dev20240419.dist-info/entry_points.txt,sha256=DBrrf685V2W9NbbchQwvuqBEpj5ik8tMZNoZg_W2bZY,363
213
+ flwr_nightly-1.9.0.dev20240419.dist-info/RECORD,,
@@ -5,5 +5,6 @@ flower-fleet-api=flwr.server:run_fleet_api
5
5
  flower-server-app=flwr.server:run_server_app
6
6
  flower-simulation=flwr.simulation:run_simulation_from_cli
7
7
  flower-superlink=flwr.server:run_superlink
8
+ flower-supernode=flwr.client:run_supernode
8
9
  flwr=flwr.cli.app:app
9
10