flwr 1.14.0__py3-none-any.whl → 1.15.1__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.
- flwr/cli/auth_plugin/__init__.py +31 -0
- flwr/cli/auth_plugin/oidc_cli_plugin.py +150 -0
- flwr/cli/cli_user_auth_interceptor.py +6 -2
- flwr/cli/config_utils.py +24 -147
- flwr/cli/constant.py +27 -0
- flwr/cli/install.py +1 -1
- flwr/cli/log.py +18 -3
- flwr/cli/login/login.py +43 -8
- flwr/cli/ls.py +14 -5
- flwr/cli/new/templates/app/README.md.tpl +3 -2
- flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +4 -4
- flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +2 -2
- flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +4 -4
- flwr/cli/new/templates/app/pyproject.jax.toml.tpl +2 -2
- flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +2 -2
- flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +2 -2
- flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +4 -4
- flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +3 -3
- flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +2 -2
- flwr/cli/run/run.py +21 -11
- flwr/cli/stop.py +13 -4
- flwr/cli/utils.py +54 -40
- flwr/client/app.py +36 -48
- flwr/client/clientapp/app.py +19 -25
- flwr/client/clientapp/utils.py +1 -1
- flwr/client/grpc_client/connection.py +1 -12
- flwr/client/grpc_rere_client/client_interceptor.py +19 -119
- flwr/client/grpc_rere_client/connection.py +46 -36
- flwr/client/grpc_rere_client/grpc_adapter.py +12 -12
- flwr/client/message_handler/task_handler.py +0 -17
- flwr/client/rest_client/connection.py +34 -26
- flwr/client/supernode/app.py +18 -72
- flwr/common/args.py +25 -47
- flwr/common/auth_plugin/auth_plugin.py +34 -23
- flwr/common/config.py +166 -16
- flwr/common/constant.py +24 -9
- flwr/common/differential_privacy.py +2 -1
- flwr/common/exit/__init__.py +24 -0
- flwr/common/exit/exit.py +99 -0
- flwr/common/exit/exit_code.py +93 -0
- flwr/common/exit_handlers.py +32 -30
- flwr/common/grpc.py +167 -4
- flwr/common/logger.py +26 -7
- flwr/common/object_ref.py +0 -14
- flwr/common/record/recordset.py +1 -1
- flwr/common/secure_aggregation/crypto/symmetric_encryption.py +45 -0
- flwr/common/serde.py +6 -4
- flwr/common/typing.py +20 -0
- flwr/proto/clientappio_pb2.py +1 -1
- flwr/proto/error_pb2.py +1 -1
- flwr/proto/exec_pb2.py +13 -25
- flwr/proto/exec_pb2.pyi +27 -54
- flwr/proto/fab_pb2.py +1 -1
- flwr/proto/fleet_pb2.py +31 -31
- flwr/proto/fleet_pb2.pyi +23 -23
- flwr/proto/fleet_pb2_grpc.py +30 -30
- flwr/proto/fleet_pb2_grpc.pyi +20 -20
- flwr/proto/grpcadapter_pb2.py +1 -1
- flwr/proto/log_pb2.py +1 -1
- flwr/proto/message_pb2.py +1 -1
- flwr/proto/node_pb2.py +3 -3
- flwr/proto/node_pb2.pyi +1 -4
- flwr/proto/recordset_pb2.py +1 -1
- flwr/proto/run_pb2.py +1 -1
- flwr/proto/serverappio_pb2.py +24 -25
- flwr/proto/serverappio_pb2.pyi +26 -32
- flwr/proto/serverappio_pb2_grpc.py +28 -28
- flwr/proto/serverappio_pb2_grpc.pyi +16 -16
- flwr/proto/simulationio_pb2.py +1 -1
- flwr/proto/task_pb2.py +1 -1
- flwr/proto/transport_pb2.py +1 -1
- flwr/server/app.py +116 -128
- flwr/server/compat/app_utils.py +0 -1
- flwr/server/compat/driver_client_proxy.py +1 -2
- flwr/server/driver/grpc_driver.py +32 -27
- flwr/server/driver/inmemory_driver.py +2 -1
- flwr/server/serverapp/app.py +12 -10
- flwr/server/superlink/driver/serverappio_grpc.py +1 -1
- flwr/server/superlink/driver/serverappio_servicer.py +74 -48
- flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer.py +20 -88
- flwr/server/superlink/fleet/grpc_bidi/grpc_server.py +2 -165
- flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py +25 -24
- flwr/server/superlink/fleet/grpc_rere/server_interceptor.py +110 -168
- flwr/server/superlink/fleet/message_handler/message_handler.py +37 -24
- flwr/server/superlink/fleet/rest_rere/rest_api.py +16 -18
- flwr/server/superlink/fleet/vce/vce_api.py +2 -2
- flwr/server/superlink/linkstate/in_memory_linkstate.py +45 -75
- flwr/server/superlink/linkstate/linkstate.py +17 -38
- flwr/server/superlink/linkstate/sqlite_linkstate.py +81 -145
- flwr/server/superlink/linkstate/utils.py +18 -8
- flwr/server/superlink/simulation/simulationio_grpc.py +1 -1
- flwr/server/utils/validator.py +9 -34
- flwr/simulation/app.py +4 -6
- flwr/simulation/legacy_app.py +4 -2
- flwr/simulation/run_simulation.py +1 -1
- flwr/simulation/simulationio_connection.py +2 -1
- flwr/superexec/exec_grpc.py +1 -1
- flwr/superexec/exec_servicer.py +23 -2
- {flwr-1.14.0.dist-info → flwr-1.15.1.dist-info}/METADATA +8 -8
- {flwr-1.14.0.dist-info → flwr-1.15.1.dist-info}/RECORD +103 -97
- {flwr-1.14.0.dist-info → flwr-1.15.1.dist-info}/LICENSE +0 -0
- {flwr-1.14.0.dist-info → flwr-1.15.1.dist-info}/WHEEL +0 -0
- {flwr-1.14.0.dist-info → flwr-1.15.1.dist-info}/entry_points.txt +0 -0
flwr/common/config.py
CHANGED
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
import os
|
|
19
19
|
import re
|
|
20
|
+
import zipfile
|
|
21
|
+
from io import BytesIO
|
|
20
22
|
from pathlib import Path
|
|
21
|
-
from typing import Any, Optional, Union, cast, get_args
|
|
23
|
+
from typing import IO, Any, Optional, TypeVar, Union, cast, get_args
|
|
22
24
|
|
|
23
25
|
import tomli
|
|
26
|
+
import typer
|
|
24
27
|
|
|
25
|
-
from flwr.cli.config_utils import get_fab_config, validate_fields
|
|
26
|
-
from flwr.common import ConfigsRecord
|
|
27
28
|
from flwr.common.constant import (
|
|
28
29
|
APP_DIR,
|
|
29
30
|
FAB_CONFIG_FILE,
|
|
@@ -33,6 +34,10 @@ from flwr.common.constant import (
|
|
|
33
34
|
)
|
|
34
35
|
from flwr.common.typing import Run, UserConfig, UserConfigValue
|
|
35
36
|
|
|
37
|
+
from . import ConfigsRecord, object_ref
|
|
38
|
+
|
|
39
|
+
T_dict = TypeVar("T_dict", bound=dict[str, Any]) # pylint: disable=invalid-name
|
|
40
|
+
|
|
36
41
|
|
|
37
42
|
def get_flwr_dir(provided_path: Optional[str] = None) -> Path:
|
|
38
43
|
"""Return the Flower home directory based on env variables."""
|
|
@@ -80,7 +85,7 @@ def get_project_config(project_dir: Union[str, Path]) -> dict[str, Any]:
|
|
|
80
85
|
config = tomli.loads(toml_file.read())
|
|
81
86
|
|
|
82
87
|
# Validate pyproject.toml fields
|
|
83
|
-
is_valid, errors, _ =
|
|
88
|
+
is_valid, errors, _ = validate_fields_in_config(config)
|
|
84
89
|
if not is_valid:
|
|
85
90
|
error_msg = "\n".join([f" - {error}" for error in errors])
|
|
86
91
|
raise ValueError(
|
|
@@ -91,19 +96,28 @@ def get_project_config(project_dir: Union[str, Path]) -> dict[str, Any]:
|
|
|
91
96
|
|
|
92
97
|
|
|
93
98
|
def fuse_dicts(
|
|
94
|
-
main_dict:
|
|
95
|
-
override_dict:
|
|
96
|
-
|
|
99
|
+
main_dict: T_dict,
|
|
100
|
+
override_dict: T_dict,
|
|
101
|
+
check_keys: bool = True,
|
|
102
|
+
) -> T_dict:
|
|
97
103
|
"""Merge a config with the overrides.
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
If `check_keys` is set to True, an error will be raised if the override
|
|
106
|
+
dictionary contains keys that are not present in the main dictionary.
|
|
107
|
+
Otherwise, only the keys present in the main dictionary will be updated.
|
|
101
108
|
"""
|
|
102
|
-
|
|
109
|
+
if not isinstance(main_dict, dict) or not isinstance(override_dict, dict):
|
|
110
|
+
raise ValueError("Both dictionaries must be of type dict")
|
|
111
|
+
|
|
112
|
+
fused_dict = cast(T_dict, main_dict.copy())
|
|
103
113
|
|
|
104
114
|
for key, value in override_dict.items():
|
|
105
115
|
if key in main_dict:
|
|
116
|
+
if isinstance(value, dict):
|
|
117
|
+
fused_dict[key] = fuse_dicts(main_dict[key], value)
|
|
106
118
|
fused_dict[key] = value
|
|
119
|
+
elif check_keys:
|
|
120
|
+
raise ValueError(f"Key '{key}' is not present in the main dictionary")
|
|
107
121
|
|
|
108
122
|
return fused_dict
|
|
109
123
|
|
|
@@ -192,8 +206,8 @@ def unflatten_dict(flat_dict: dict[str, Any]) -> dict[str, Any]:
|
|
|
192
206
|
|
|
193
207
|
|
|
194
208
|
def parse_config_args(
|
|
195
|
-
config: Optional[list[str]],
|
|
196
|
-
) ->
|
|
209
|
+
config: Optional[list[str]], flatten: bool = True
|
|
210
|
+
) -> dict[str, Any]:
|
|
197
211
|
"""Parse separator separated list of key-value pairs separated by '='."""
|
|
198
212
|
overrides: UserConfig = {}
|
|
199
213
|
|
|
@@ -220,17 +234,29 @@ def parse_config_args(
|
|
|
220
234
|
|
|
221
235
|
matches = pattern.findall(config_line)
|
|
222
236
|
toml_str = "\n".join(f"{k} = {v}" for k, v in matches)
|
|
223
|
-
|
|
224
|
-
|
|
237
|
+
try:
|
|
238
|
+
overrides.update(tomli.loads(toml_str))
|
|
239
|
+
flat_overrides = flatten_dict(overrides) if flatten else overrides
|
|
240
|
+
except tomli.TOMLDecodeError as err:
|
|
241
|
+
typer.secho(
|
|
242
|
+
"❌ The provided configuration string is in an invalid format. "
|
|
243
|
+
"The correct format should be, e.g., 'key1=123 key2=false "
|
|
244
|
+
'key3="string"\', where values must be of type bool, int, '
|
|
245
|
+
"string, or float. Ensure proper formatting with "
|
|
246
|
+
"space-separated key-value pairs.",
|
|
247
|
+
fg=typer.colors.RED,
|
|
248
|
+
bold=True,
|
|
249
|
+
)
|
|
250
|
+
raise typer.Exit(code=1) from err
|
|
225
251
|
|
|
226
252
|
return flat_overrides
|
|
227
253
|
|
|
228
254
|
|
|
229
255
|
def get_metadata_from_config(config: dict[str, Any]) -> tuple[str, str]:
|
|
230
|
-
"""Extract `
|
|
256
|
+
"""Extract `fab_id` and `fab_version` from a project config."""
|
|
231
257
|
return (
|
|
232
|
-
config["project"]["version"],
|
|
233
258
|
f"{config['tool']['flwr']['app']['publisher']}/{config['project']['name']}",
|
|
259
|
+
config["project"]["version"],
|
|
234
260
|
)
|
|
235
261
|
|
|
236
262
|
|
|
@@ -241,3 +267,127 @@ def user_config_to_configsrecord(config: UserConfig) -> ConfigsRecord:
|
|
|
241
267
|
c_record[k] = v
|
|
242
268
|
|
|
243
269
|
return c_record
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_fab_config(fab_file: Union[Path, bytes]) -> dict[str, Any]:
|
|
273
|
+
"""Extract the config from a FAB file or path.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
fab_file : Union[Path, bytes]
|
|
278
|
+
The Flower App Bundle file to validate and extract the metadata from.
|
|
279
|
+
It can either be a path to the file or the file itself as bytes.
|
|
280
|
+
|
|
281
|
+
Returns
|
|
282
|
+
-------
|
|
283
|
+
Dict[str, Any]
|
|
284
|
+
The `config` of the given Flower App Bundle.
|
|
285
|
+
"""
|
|
286
|
+
fab_file_archive: Union[Path, IO[bytes]]
|
|
287
|
+
if isinstance(fab_file, bytes):
|
|
288
|
+
fab_file_archive = BytesIO(fab_file)
|
|
289
|
+
elif isinstance(fab_file, Path):
|
|
290
|
+
fab_file_archive = fab_file
|
|
291
|
+
else:
|
|
292
|
+
raise ValueError("fab_file must be either a Path or bytes")
|
|
293
|
+
|
|
294
|
+
with zipfile.ZipFile(fab_file_archive, "r") as zipf:
|
|
295
|
+
with zipf.open("pyproject.toml") as file:
|
|
296
|
+
toml_content = file.read().decode("utf-8")
|
|
297
|
+
try:
|
|
298
|
+
conf = tomli.loads(toml_content)
|
|
299
|
+
except tomli.TOMLDecodeError:
|
|
300
|
+
raise ValueError("Invalid TOML content in pyproject.toml") from None
|
|
301
|
+
|
|
302
|
+
is_valid, errors, _ = validate_config(conf, check_module=False)
|
|
303
|
+
if not is_valid:
|
|
304
|
+
raise ValueError(errors)
|
|
305
|
+
|
|
306
|
+
return conf
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _validate_run_config(config_dict: dict[str, Any], errors: list[str]) -> None:
|
|
310
|
+
for key, value in config_dict.items():
|
|
311
|
+
if isinstance(value, dict):
|
|
312
|
+
_validate_run_config(config_dict[key], errors)
|
|
313
|
+
elif not isinstance(value, get_args(UserConfigValue)):
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"The value for key {key} needs to be of type `int`, `float`, "
|
|
316
|
+
"`bool, `str`, or a `dict` of those.",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# pylint: disable=too-many-branches
|
|
321
|
+
def validate_fields_in_config(
|
|
322
|
+
config: dict[str, Any]
|
|
323
|
+
) -> tuple[bool, list[str], list[str]]:
|
|
324
|
+
"""Validate pyproject.toml fields."""
|
|
325
|
+
errors = []
|
|
326
|
+
warnings = []
|
|
327
|
+
|
|
328
|
+
if "project" not in config:
|
|
329
|
+
errors.append("Missing [project] section")
|
|
330
|
+
else:
|
|
331
|
+
if "name" not in config["project"]:
|
|
332
|
+
errors.append('Property "name" missing in [project]')
|
|
333
|
+
if "version" not in config["project"]:
|
|
334
|
+
errors.append('Property "version" missing in [project]')
|
|
335
|
+
if "description" not in config["project"]:
|
|
336
|
+
warnings.append('Recommended property "description" missing in [project]')
|
|
337
|
+
if "license" not in config["project"]:
|
|
338
|
+
warnings.append('Recommended property "license" missing in [project]')
|
|
339
|
+
if "authors" not in config["project"]:
|
|
340
|
+
warnings.append('Recommended property "authors" missing in [project]')
|
|
341
|
+
|
|
342
|
+
if (
|
|
343
|
+
"tool" not in config
|
|
344
|
+
or "flwr" not in config["tool"]
|
|
345
|
+
or "app" not in config["tool"]["flwr"]
|
|
346
|
+
):
|
|
347
|
+
errors.append("Missing [tool.flwr.app] section")
|
|
348
|
+
else:
|
|
349
|
+
if "publisher" not in config["tool"]["flwr"]["app"]:
|
|
350
|
+
errors.append('Property "publisher" missing in [tool.flwr.app]')
|
|
351
|
+
if "config" in config["tool"]["flwr"]["app"]:
|
|
352
|
+
_validate_run_config(config["tool"]["flwr"]["app"]["config"], errors)
|
|
353
|
+
if "components" not in config["tool"]["flwr"]["app"]:
|
|
354
|
+
errors.append("Missing [tool.flwr.app.components] section")
|
|
355
|
+
else:
|
|
356
|
+
if "serverapp" not in config["tool"]["flwr"]["app"]["components"]:
|
|
357
|
+
errors.append(
|
|
358
|
+
'Property "serverapp" missing in [tool.flwr.app.components]'
|
|
359
|
+
)
|
|
360
|
+
if "clientapp" not in config["tool"]["flwr"]["app"]["components"]:
|
|
361
|
+
errors.append(
|
|
362
|
+
'Property "clientapp" missing in [tool.flwr.app.components]'
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return len(errors) == 0, errors, warnings
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def validate_config(
|
|
369
|
+
config: dict[str, Any],
|
|
370
|
+
check_module: bool = True,
|
|
371
|
+
project_dir: Optional[Union[str, Path]] = None,
|
|
372
|
+
) -> tuple[bool, list[str], list[str]]:
|
|
373
|
+
"""Validate pyproject.toml."""
|
|
374
|
+
is_valid, errors, warnings = validate_fields_in_config(config)
|
|
375
|
+
|
|
376
|
+
if not is_valid:
|
|
377
|
+
return False, errors, warnings
|
|
378
|
+
|
|
379
|
+
# Validate serverapp
|
|
380
|
+
serverapp_ref = config["tool"]["flwr"]["app"]["components"]["serverapp"]
|
|
381
|
+
is_valid, reason = object_ref.validate(serverapp_ref, check_module, project_dir)
|
|
382
|
+
|
|
383
|
+
if not is_valid and isinstance(reason, str):
|
|
384
|
+
return False, [reason], []
|
|
385
|
+
|
|
386
|
+
# Validate clientapp
|
|
387
|
+
clientapp_ref = config["tool"]["flwr"]["app"]["components"]["clientapp"]
|
|
388
|
+
is_valid, reason = object_ref.validate(clientapp_ref, check_module, project_dir)
|
|
389
|
+
|
|
390
|
+
if not is_valid and isinstance(reason, str):
|
|
391
|
+
return False, [reason], []
|
|
392
|
+
|
|
393
|
+
return True, [], []
|
flwr/common/constant.py
CHANGED
|
@@ -17,14 +17,6 @@
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
-
MISSING_EXTRA_REST = """
|
|
21
|
-
Extra dependencies required for using the REST-based Fleet API are missing.
|
|
22
|
-
|
|
23
|
-
To use the REST API, install `flwr` with the `rest` extra:
|
|
24
|
-
|
|
25
|
-
`pip install flwr[rest]`.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
20
|
TRANSPORT_TYPE_GRPC_BIDI = "grpc-bidi"
|
|
29
21
|
TRANSPORT_TYPE_GRPC_RERE = "grpc-rere"
|
|
30
22
|
TRANSPORT_TYPE_GRPC_ADAPTER = "grpc-adapter"
|
|
@@ -83,6 +75,9 @@ FAB_HASH_TRUNCATION = 8
|
|
|
83
75
|
FLWR_DIR = ".flwr" # The default Flower directory: ~/.flwr/
|
|
84
76
|
FLWR_HOME = "FLWR_HOME" # If set, override the default Flower directory
|
|
85
77
|
|
|
78
|
+
# Constant for SuperLink
|
|
79
|
+
SUPERLINK_NODE_ID = 1
|
|
80
|
+
|
|
86
81
|
# Constants entries in Node config for Simulation
|
|
87
82
|
PARTITION_ID_KEY = "partition-id"
|
|
88
83
|
NUM_PARTITIONS_KEY = "num-partitions"
|
|
@@ -113,7 +108,17 @@ MAX_RETRY_DELAY = 20 # Maximum delay duration between two consecutive retries.
|
|
|
113
108
|
|
|
114
109
|
# Constants for user authentication
|
|
115
110
|
CREDENTIALS_DIR = ".credentials"
|
|
116
|
-
|
|
111
|
+
AUTH_TYPE_JSON_KEY = "auth-type" # For key name in JSON file
|
|
112
|
+
AUTH_TYPE_YAML_KEY = "auth_type" # For key name in YAML file
|
|
113
|
+
ACCESS_TOKEN_KEY = "flwr-oidc-access-token"
|
|
114
|
+
REFRESH_TOKEN_KEY = "flwr-oidc-refresh-token"
|
|
115
|
+
|
|
116
|
+
# Constants for node authentication
|
|
117
|
+
PUBLIC_KEY_HEADER = "flwr-public-key-bin" # Must end with "-bin" for binary data
|
|
118
|
+
SIGNATURE_HEADER = "flwr-signature-bin" # Must end with "-bin" for binary data
|
|
119
|
+
TIMESTAMP_HEADER = "flwr-timestamp"
|
|
120
|
+
TIMESTAMP_TOLERANCE = 10 # General tolerance for timestamp verification
|
|
121
|
+
SYSTEM_TIME_TOLERANCE = 5 # Allowance for system time drift
|
|
117
122
|
|
|
118
123
|
|
|
119
124
|
class MessageType:
|
|
@@ -197,3 +202,13 @@ class CliOutputFormat:
|
|
|
197
202
|
def __new__(cls) -> CliOutputFormat:
|
|
198
203
|
"""Prevent instantiation."""
|
|
199
204
|
raise TypeError(f"{cls.__name__} cannot be instantiated.")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class AuthType:
|
|
208
|
+
"""User authentication types."""
|
|
209
|
+
|
|
210
|
+
OIDC = "oidc"
|
|
211
|
+
|
|
212
|
+
def __new__(cls) -> AuthType:
|
|
213
|
+
"""Prevent instantiation."""
|
|
214
|
+
raise TypeError(f"{cls.__name__} cannot be instantiated.")
|
|
@@ -39,7 +39,8 @@ def get_norm(input_arrays: NDArrays) -> float:
|
|
|
39
39
|
def add_gaussian_noise_inplace(input_arrays: NDArrays, std_dev: float) -> None:
|
|
40
40
|
"""Add Gaussian noise to each element of the input arrays."""
|
|
41
41
|
for array in input_arrays:
|
|
42
|
-
|
|
42
|
+
noise = np.random.normal(0, std_dev, array.shape).astype(array.dtype)
|
|
43
|
+
array += noise
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
def clip_inputs_inplace(input_arrays: NDArrays, clipping_norm: float) -> None:
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright 2025 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 exit functionality."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from .exit import flwr_exit
|
|
19
|
+
from .exit_code import ExitCode
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ExitCode",
|
|
23
|
+
"flwr_exit",
|
|
24
|
+
]
|
flwr/common/exit/exit.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Copyright 2025 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
|
+
"""Unified exit function."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from logging import ERROR, INFO
|
|
22
|
+
from typing import Any, NoReturn
|
|
23
|
+
|
|
24
|
+
from flwr.common import EventType, event
|
|
25
|
+
|
|
26
|
+
from ..logger import log
|
|
27
|
+
from .exit_code import EXIT_CODE_HELP
|
|
28
|
+
|
|
29
|
+
HELP_PAGE_URL = "https://flower.ai/docs/framework/ref-exit-codes/"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def flwr_exit(
|
|
33
|
+
code: int,
|
|
34
|
+
message: str | None = None,
|
|
35
|
+
event_type: EventType | None = None,
|
|
36
|
+
event_details: dict[str, Any] | None = None,
|
|
37
|
+
) -> NoReturn:
|
|
38
|
+
"""Handle application exit with an optional message.
|
|
39
|
+
|
|
40
|
+
The exit message logged and displayed will follow this structure:
|
|
41
|
+
|
|
42
|
+
>>> Exit Code: <code>
|
|
43
|
+
>>> <message>
|
|
44
|
+
>>> <short-help-message>
|
|
45
|
+
>>>
|
|
46
|
+
>>> For more information, visit: <help-page-url>
|
|
47
|
+
|
|
48
|
+
- `<code>`: The unique exit code representing the termination reason.
|
|
49
|
+
- `<message>`: Optional context or additional information about the exit.
|
|
50
|
+
- `<short-help-message>`: A brief explanation for the given exit code.
|
|
51
|
+
- `<help-page-url>`: A URL providing detailed documentation and resolution steps.
|
|
52
|
+
"""
|
|
53
|
+
is_error = not 0 <= code < 100 # 0-99 are success exit codes
|
|
54
|
+
|
|
55
|
+
# Construct exit message
|
|
56
|
+
exit_message = f"Exit Code: {code}\n" if is_error else ""
|
|
57
|
+
exit_message += message or ""
|
|
58
|
+
if short_help_message := EXIT_CODE_HELP.get(code, ""):
|
|
59
|
+
exit_message += f"\n{short_help_message}"
|
|
60
|
+
|
|
61
|
+
# Set log level and system exit code
|
|
62
|
+
log_level = ERROR if is_error else INFO
|
|
63
|
+
sys_exit_code = 1 if is_error else 0
|
|
64
|
+
|
|
65
|
+
# Add help URL for non-successful/graceful exits
|
|
66
|
+
if is_error:
|
|
67
|
+
help_url = f"{HELP_PAGE_URL}{code}.html"
|
|
68
|
+
exit_message += f"\n\nFor more information, visit: <{help_url}>"
|
|
69
|
+
|
|
70
|
+
# Telemetry event
|
|
71
|
+
event_type = event_type or _try_obtain_telemetry_event()
|
|
72
|
+
if event_type:
|
|
73
|
+
event_details = event_details or {}
|
|
74
|
+
event_details["exit_code"] = code
|
|
75
|
+
event(event_type, event_details).result()
|
|
76
|
+
|
|
77
|
+
# Log the exit message
|
|
78
|
+
log(log_level, exit_message)
|
|
79
|
+
|
|
80
|
+
# Exit
|
|
81
|
+
sys.exit(sys_exit_code)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# pylint: disable-next=too-many-return-statements
|
|
85
|
+
def _try_obtain_telemetry_event() -> EventType | None:
|
|
86
|
+
"""Try to obtain a telemetry event."""
|
|
87
|
+
if sys.argv[0].endswith("flower-superlink"):
|
|
88
|
+
return EventType.RUN_SUPERLINK_LEAVE
|
|
89
|
+
if sys.argv[0].endswith("flower-supernode"):
|
|
90
|
+
return EventType.RUN_SUPERNODE_LEAVE
|
|
91
|
+
if sys.argv[0].endswith("flwr-serverapp"):
|
|
92
|
+
return EventType.FLWR_SERVERAPP_RUN_LEAVE
|
|
93
|
+
if sys.argv[0].endswith("flwr-clientapp"):
|
|
94
|
+
return None # Not yet implemented
|
|
95
|
+
if sys.argv[0].endswith("flwr-simulation"):
|
|
96
|
+
return EventType.FLWR_SIMULATION_RUN_LEAVE
|
|
97
|
+
if sys.argv[0].endswith("flower-simulation"):
|
|
98
|
+
return EventType.CLI_FLOWER_SIMULATION_LEAVE
|
|
99
|
+
return None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Copyright 2025 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
|
+
"""Exit codes."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExitCode:
|
|
22
|
+
"""Exit codes for Flower components."""
|
|
23
|
+
|
|
24
|
+
# Success exit codes (0-99)
|
|
25
|
+
SUCCESS = 0 # Successful exit without any errors or signals
|
|
26
|
+
GRACEFUL_EXIT_SIGINT = 1 # Graceful exit triggered by SIGINT
|
|
27
|
+
GRACEFUL_EXIT_SIGQUIT = 2 # Graceful exit triggered by SIGQUIT
|
|
28
|
+
GRACEFUL_EXIT_SIGTERM = 3 # Graceful exit triggered by SIGTERM
|
|
29
|
+
|
|
30
|
+
# SuperLink-specific exit codes (100-199)
|
|
31
|
+
SUPERLINK_THREAD_CRASH = 100
|
|
32
|
+
|
|
33
|
+
# ServerApp-specific exit codes (200-299)
|
|
34
|
+
|
|
35
|
+
# SuperNode-specific exit codes (300-399)
|
|
36
|
+
SUPERNODE_REST_ADDRESS_INVALID = 300
|
|
37
|
+
SUPERNODE_NODE_AUTH_KEYS_REQUIRED = 301
|
|
38
|
+
SUPERNODE_NODE_AUTH_KEYS_INVALID = 302
|
|
39
|
+
|
|
40
|
+
# ClientApp-specific exit codes (400-499)
|
|
41
|
+
|
|
42
|
+
# Simulation-specific exit codes (500-599)
|
|
43
|
+
|
|
44
|
+
# Common exit codes (600-)
|
|
45
|
+
COMMON_ADDRESS_INVALID = 600
|
|
46
|
+
COMMON_MISSING_EXTRA_REST = 601
|
|
47
|
+
COMMON_TLS_NOT_SUPPORTED = 602
|
|
48
|
+
|
|
49
|
+
def __new__(cls) -> ExitCode:
|
|
50
|
+
"""Prevent instantiation."""
|
|
51
|
+
raise TypeError(f"{cls.__name__} cannot be instantiated.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# All short help messages for exit codes
|
|
55
|
+
EXIT_CODE_HELP = {
|
|
56
|
+
# Success exit codes (0-99)
|
|
57
|
+
ExitCode.SUCCESS: "",
|
|
58
|
+
ExitCode.GRACEFUL_EXIT_SIGINT: "",
|
|
59
|
+
ExitCode.GRACEFUL_EXIT_SIGQUIT: "",
|
|
60
|
+
ExitCode.GRACEFUL_EXIT_SIGTERM: "",
|
|
61
|
+
# SuperLink-specific exit codes (100-199)
|
|
62
|
+
ExitCode.SUPERLINK_THREAD_CRASH: "An important background thread has crashed.",
|
|
63
|
+
# ServerApp-specific exit codes (200-299)
|
|
64
|
+
# SuperNode-specific exit codes (300-399)
|
|
65
|
+
ExitCode.SUPERNODE_REST_ADDRESS_INVALID: (
|
|
66
|
+
"When using the REST API, please provide `https://` or "
|
|
67
|
+
"`http://` before the server address (e.g. `http://127.0.0.1:8080`)"
|
|
68
|
+
),
|
|
69
|
+
ExitCode.SUPERNODE_NODE_AUTH_KEYS_REQUIRED: (
|
|
70
|
+
"Node authentication requires file paths to both "
|
|
71
|
+
"'--auth-supernode-private-key' and '--auth-supernode-public-key' "
|
|
72
|
+
"to be provided (providing only one of them is not sufficient)."
|
|
73
|
+
),
|
|
74
|
+
ExitCode.SUPERNODE_NODE_AUTH_KEYS_INVALID: (
|
|
75
|
+
"Node uthentication requires elliptic curve private and public key pair. "
|
|
76
|
+
"Please ensure that the file path points to a valid private/public key "
|
|
77
|
+
"file and try again."
|
|
78
|
+
),
|
|
79
|
+
# ClientApp-specific exit codes (400-499)
|
|
80
|
+
# Simulation-specific exit codes (500-599)
|
|
81
|
+
# Common exit codes (600-)
|
|
82
|
+
ExitCode.COMMON_ADDRESS_INVALID: (
|
|
83
|
+
"Please provide a valid URL, IPv4 or IPv6 address."
|
|
84
|
+
),
|
|
85
|
+
ExitCode.COMMON_MISSING_EXTRA_REST: """
|
|
86
|
+
Extra dependencies required for using the REST-based Fleet API are missing.
|
|
87
|
+
|
|
88
|
+
To use the REST API, install `flwr` with the `rest` extra:
|
|
89
|
+
|
|
90
|
+
`pip install "flwr[rest]"`.
|
|
91
|
+
""",
|
|
92
|
+
ExitCode.COMMON_TLS_NOT_SUPPORTED: "Please use the '--insecure' flag.",
|
|
93
|
+
}
|
flwr/common/exit_handlers.py
CHANGED
|
@@ -15,28 +15,41 @@
|
|
|
15
15
|
"""Common function to register exit handlers for server and client."""
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
import
|
|
19
|
-
from signal import SIGINT, SIGTERM, signal
|
|
18
|
+
import signal
|
|
20
19
|
from threading import Thread
|
|
21
20
|
from types import FrameType
|
|
22
|
-
from typing import Optional
|
|
21
|
+
from typing import Callable, Optional
|
|
23
22
|
|
|
24
23
|
from grpc import Server
|
|
25
24
|
|
|
26
|
-
from flwr.common.telemetry import EventType
|
|
25
|
+
from flwr.common.telemetry import EventType
|
|
26
|
+
|
|
27
|
+
from .exit import ExitCode, flwr_exit
|
|
28
|
+
|
|
29
|
+
SIGNAL_TO_EXIT_CODE: dict[int, int] = {
|
|
30
|
+
signal.SIGINT: ExitCode.GRACEFUL_EXIT_SIGINT,
|
|
31
|
+
signal.SIGTERM: ExitCode.GRACEFUL_EXIT_SIGTERM,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# SIGQUIT is not available on Windows
|
|
35
|
+
if hasattr(signal, "SIGQUIT"):
|
|
36
|
+
SIGNAL_TO_EXIT_CODE[signal.SIGQUIT] = ExitCode.GRACEFUL_EXIT_SIGQUIT
|
|
27
37
|
|
|
28
38
|
|
|
29
39
|
def register_exit_handlers(
|
|
30
40
|
event_type: EventType,
|
|
41
|
+
exit_message: Optional[str] = None,
|
|
31
42
|
grpc_servers: Optional[list[Server]] = None,
|
|
32
43
|
bckg_threads: Optional[list[Thread]] = None,
|
|
33
44
|
) -> None:
|
|
34
|
-
"""Register exit handlers for `SIGINT` and `
|
|
45
|
+
"""Register exit handlers for `SIGINT`, `SIGTERM` and `SIGQUIT` signals.
|
|
35
46
|
|
|
36
47
|
Parameters
|
|
37
48
|
----------
|
|
38
49
|
event_type : EventType
|
|
39
50
|
The telemetry event that should be logged before exit.
|
|
51
|
+
exit_message : Optional[str] (default: None)
|
|
52
|
+
The message to be logged before exiting.
|
|
40
53
|
grpc_servers: Optional[List[Server]] (default: None)
|
|
41
54
|
An otpional list of gRPC servers that need to be gracefully
|
|
42
55
|
terminated before exiting.
|
|
@@ -44,24 +57,16 @@ def register_exit_handlers(
|
|
|
44
57
|
An optional list of threads that need to be gracefully
|
|
45
58
|
terminated before exiting.
|
|
46
59
|
"""
|
|
47
|
-
default_handlers = {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
def graceful_exit_handler( # type: ignore
|
|
53
|
-
signalnum,
|
|
54
|
-
frame: FrameType, # pylint: disable=unused-argument
|
|
55
|
-
) -> None:
|
|
60
|
+
default_handlers: dict[int, Callable[[int, FrameType], None]] = {}
|
|
61
|
+
|
|
62
|
+
def graceful_exit_handler(signalnum: int, _frame: FrameType) -> None:
|
|
56
63
|
"""Exit handler to be registered with `signal.signal`.
|
|
57
64
|
|
|
58
65
|
When called will reset signal handler to original signal handler from
|
|
59
66
|
default_handlers.
|
|
60
67
|
"""
|
|
61
68
|
# Reset to default handler
|
|
62
|
-
signal(signalnum, default_handlers[signalnum])
|
|
63
|
-
|
|
64
|
-
event_res = event(event_type=event_type)
|
|
69
|
+
signal.signal(signalnum, default_handlers[signalnum]) # type: ignore
|
|
65
70
|
|
|
66
71
|
if grpc_servers is not None:
|
|
67
72
|
for grpc_server in grpc_servers:
|
|
@@ -71,17 +76,14 @@ def register_exit_handlers(
|
|
|
71
76
|
for bckg_thread in bckg_threads:
|
|
72
77
|
bckg_thread.join()
|
|
73
78
|
|
|
74
|
-
# Ensure event has happend
|
|
75
|
-
event_res.result()
|
|
76
|
-
|
|
77
79
|
# Setup things for graceful exit
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
graceful_exit_handler
|
|
87
|
-
|
|
80
|
+
flwr_exit(
|
|
81
|
+
code=SIGNAL_TO_EXIT_CODE[signalnum],
|
|
82
|
+
message=exit_message,
|
|
83
|
+
event_type=event_type,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Register signal handlers
|
|
87
|
+
for sig in SIGNAL_TO_EXIT_CODE:
|
|
88
|
+
default_handler = signal.signal(sig, graceful_exit_handler) # type: ignore
|
|
89
|
+
default_handlers[sig] = default_handler # type: ignore
|