flwr 1.13.1__py3-none-any.whl → 1.15.0__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.
Files changed (158) hide show
  1. flwr/cli/app.py +5 -0
  2. flwr/cli/auth_plugin/__init__.py +31 -0
  3. flwr/cli/auth_plugin/oidc_cli_plugin.py +150 -0
  4. flwr/cli/build.py +1 -0
  5. flwr/cli/cli_user_auth_interceptor.py +90 -0
  6. flwr/cli/config_utils.py +43 -149
  7. flwr/cli/constant.py +27 -0
  8. flwr/cli/example.py +1 -0
  9. flwr/cli/install.py +2 -1
  10. flwr/cli/log.py +34 -37
  11. flwr/cli/login/__init__.py +22 -0
  12. flwr/cli/login/login.py +116 -0
  13. flwr/cli/ls.py +214 -106
  14. flwr/cli/new/__init__.py +1 -0
  15. flwr/cli/new/new.py +2 -1
  16. flwr/cli/new/templates/app/.gitignore.tpl +3 -0
  17. flwr/cli/new/templates/app/README.md.tpl +3 -2
  18. flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +4 -4
  19. flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +4 -4
  20. flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +4 -4
  21. flwr/cli/new/templates/app/pyproject.jax.toml.tpl +2 -2
  22. flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +3 -4
  23. flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +2 -2
  24. flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +4 -4
  25. flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +3 -3
  26. flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +2 -2
  27. flwr/cli/run/__init__.py +1 -0
  28. flwr/cli/run/run.py +103 -43
  29. flwr/cli/stop.py +139 -0
  30. flwr/cli/utils.py +186 -8
  31. flwr/client/app.py +49 -50
  32. flwr/client/client.py +1 -32
  33. flwr/client/clientapp/app.py +23 -26
  34. flwr/client/clientapp/utils.py +2 -1
  35. flwr/client/grpc_adapter_client/connection.py +1 -1
  36. flwr/client/grpc_client/connection.py +2 -13
  37. flwr/client/grpc_rere_client/client_interceptor.py +19 -119
  38. flwr/client/grpc_rere_client/connection.py +59 -43
  39. flwr/client/grpc_rere_client/grpc_adapter.py +12 -12
  40. flwr/client/message_handler/message_handler.py +1 -2
  41. flwr/client/message_handler/task_handler.py +0 -17
  42. flwr/client/mod/comms_mods.py +1 -0
  43. flwr/client/mod/localdp_mod.py +1 -1
  44. flwr/client/nodestate/__init__.py +1 -0
  45. flwr/client/nodestate/nodestate.py +1 -0
  46. flwr/client/nodestate/nodestate_factory.py +1 -0
  47. flwr/client/numpy_client.py +0 -44
  48. flwr/client/rest_client/connection.py +37 -29
  49. flwr/client/supernode/app.py +20 -74
  50. flwr/common/address.py +1 -0
  51. flwr/common/args.py +26 -47
  52. flwr/common/auth_plugin/__init__.py +24 -0
  53. flwr/common/auth_plugin/auth_plugin.py +122 -0
  54. flwr/common/config.py +169 -17
  55. flwr/common/constant.py +38 -9
  56. flwr/common/differential_privacy.py +2 -1
  57. flwr/common/exit/__init__.py +24 -0
  58. flwr/common/exit/exit.py +99 -0
  59. flwr/common/exit/exit_code.py +93 -0
  60. flwr/common/exit_handlers.py +24 -10
  61. flwr/common/grpc.py +167 -4
  62. flwr/common/logger.py +66 -7
  63. flwr/common/message.py +1 -0
  64. flwr/common/object_ref.py +57 -54
  65. flwr/common/pyproject.py +1 -0
  66. flwr/common/record/__init__.py +1 -0
  67. flwr/common/record/parametersrecord.py +1 -0
  68. flwr/common/record/recordset.py +1 -1
  69. flwr/common/retry_invoker.py +77 -0
  70. flwr/common/secure_aggregation/crypto/symmetric_encryption.py +45 -0
  71. flwr/common/secure_aggregation/secaggplus_utils.py +2 -2
  72. flwr/common/serde.py +6 -4
  73. flwr/common/telemetry.py +15 -4
  74. flwr/common/typing.py +32 -0
  75. flwr/common/version.py +1 -0
  76. flwr/proto/clientappio_pb2.py +1 -1
  77. flwr/proto/error_pb2.py +1 -1
  78. flwr/proto/exec_pb2.py +27 -15
  79. flwr/proto/exec_pb2.pyi +80 -2
  80. flwr/proto/exec_pb2_grpc.py +102 -0
  81. flwr/proto/exec_pb2_grpc.pyi +39 -0
  82. flwr/proto/fab_pb2.py +5 -5
  83. flwr/proto/fab_pb2.pyi +4 -1
  84. flwr/proto/fleet_pb2.py +31 -31
  85. flwr/proto/fleet_pb2.pyi +23 -23
  86. flwr/proto/fleet_pb2_grpc.py +30 -30
  87. flwr/proto/fleet_pb2_grpc.pyi +20 -20
  88. flwr/proto/grpcadapter_pb2.py +1 -1
  89. flwr/proto/log_pb2.py +1 -1
  90. flwr/proto/message_pb2.py +1 -1
  91. flwr/proto/node_pb2.py +3 -3
  92. flwr/proto/node_pb2.pyi +1 -4
  93. flwr/proto/recordset_pb2.py +1 -1
  94. flwr/proto/run_pb2.py +1 -1
  95. flwr/proto/serverappio_pb2.py +24 -25
  96. flwr/proto/serverappio_pb2.pyi +32 -32
  97. flwr/proto/serverappio_pb2_grpc.py +62 -28
  98. flwr/proto/serverappio_pb2_grpc.pyi +29 -16
  99. flwr/proto/simulationio_pb2.py +3 -3
  100. flwr/proto/simulationio_pb2_grpc.py +34 -0
  101. flwr/proto/simulationio_pb2_grpc.pyi +13 -0
  102. flwr/proto/task_pb2.py +1 -1
  103. flwr/proto/transport_pb2.py +1 -1
  104. flwr/server/app.py +152 -112
  105. flwr/server/compat/app_utils.py +7 -2
  106. flwr/server/compat/driver_client_proxy.py +1 -2
  107. flwr/server/driver/grpc_driver.py +38 -85
  108. flwr/server/driver/inmemory_driver.py +7 -2
  109. flwr/server/run_serverapp.py +8 -9
  110. flwr/server/serverapp/app.py +37 -13
  111. flwr/server/strategy/dpfedavg_fixed.py +1 -0
  112. flwr/server/superlink/driver/serverappio_grpc.py +2 -1
  113. flwr/server/superlink/driver/serverappio_servicer.py +148 -63
  114. flwr/server/superlink/ffs/disk_ffs.py +1 -0
  115. flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer.py +20 -87
  116. flwr/server/superlink/fleet/grpc_bidi/flower_service_servicer.py +1 -0
  117. flwr/server/superlink/fleet/grpc_bidi/grpc_server.py +2 -165
  118. flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py +56 -35
  119. flwr/server/superlink/fleet/grpc_rere/server_interceptor.py +99 -169
  120. flwr/server/superlink/fleet/message_handler/message_handler.py +69 -29
  121. flwr/server/superlink/fleet/rest_rere/rest_api.py +20 -19
  122. flwr/server/superlink/fleet/vce/__init__.py +1 -0
  123. flwr/server/superlink/fleet/vce/backend/__init__.py +1 -0
  124. flwr/server/superlink/fleet/vce/backend/raybackend.py +1 -0
  125. flwr/server/superlink/fleet/vce/vce_api.py +2 -2
  126. flwr/server/superlink/linkstate/in_memory_linkstate.py +60 -99
  127. flwr/server/superlink/linkstate/linkstate.py +30 -36
  128. flwr/server/superlink/linkstate/sqlite_linkstate.py +105 -188
  129. flwr/server/superlink/linkstate/utils.py +18 -8
  130. flwr/server/superlink/simulation/simulationio_grpc.py +1 -1
  131. flwr/server/superlink/simulation/simulationio_servicer.py +33 -0
  132. flwr/server/superlink/utils.py +65 -0
  133. flwr/server/utils/validator.py +9 -34
  134. flwr/simulation/app.py +20 -10
  135. flwr/simulation/legacy_app.py +4 -2
  136. flwr/simulation/ray_transport/ray_actor.py +1 -0
  137. flwr/simulation/ray_transport/utils.py +1 -0
  138. flwr/simulation/run_simulation.py +36 -22
  139. flwr/simulation/simulationio_connection.py +5 -1
  140. flwr/superexec/app.py +1 -0
  141. flwr/superexec/deployment.py +1 -0
  142. flwr/superexec/exec_grpc.py +20 -2
  143. flwr/superexec/exec_servicer.py +97 -2
  144. flwr/superexec/exec_user_auth_interceptor.py +101 -0
  145. flwr/superexec/executor.py +1 -0
  146. {flwr-1.13.1.dist-info → flwr-1.15.0.dist-info}/METADATA +14 -13
  147. {flwr-1.13.1.dist-info → flwr-1.15.0.dist-info}/RECORD +150 -144
  148. flwr/proto/common_pb2.py +0 -36
  149. flwr/proto/common_pb2.pyi +0 -121
  150. flwr/proto/common_pb2_grpc.py +0 -4
  151. flwr/proto/common_pb2_grpc.pyi +0 -4
  152. flwr/proto/control_pb2.py +0 -27
  153. flwr/proto/control_pb2.pyi +0 -7
  154. flwr/proto/control_pb2_grpc.py +0 -135
  155. flwr/proto/control_pb2_grpc.pyi +0 -53
  156. {flwr-1.13.1.dist-info → flwr-1.15.0.dist-info}/LICENSE +0 -0
  157. {flwr-1.13.1.dist-info → flwr-1.15.0.dist-info}/WHEEL +0 -0
  158. {flwr-1.13.1.dist-info → flwr-1.15.0.dist-info}/entry_points.txt +0 -0
flwr/common/config.py CHANGED
@@ -14,23 +14,30 @@
14
14
  # ==============================================================================
15
15
  """Provide functions for managing global Flower config."""
16
16
 
17
+
17
18
  import os
18
19
  import re
20
+ import zipfile
21
+ from io import BytesIO
19
22
  from pathlib import Path
20
- from typing import Any, Optional, Union, cast, get_args
23
+ from typing import IO, Any, Optional, TypeVar, Union, cast, get_args
21
24
 
22
25
  import tomli
26
+ import typer
23
27
 
24
- from flwr.cli.config_utils import get_fab_config, validate_fields
25
- from flwr.common import ConfigsRecord
26
28
  from flwr.common.constant import (
27
29
  APP_DIR,
28
30
  FAB_CONFIG_FILE,
29
31
  FAB_HASH_TRUNCATION,
32
+ FLWR_DIR,
30
33
  FLWR_HOME,
31
34
  )
32
35
  from flwr.common.typing import Run, UserConfig, UserConfigValue
33
36
 
37
+ from . import ConfigsRecord, object_ref
38
+
39
+ T_dict = TypeVar("T_dict", bound=dict[str, Any]) # pylint: disable=invalid-name
40
+
34
41
 
35
42
  def get_flwr_dir(provided_path: Optional[str] = None) -> Path:
36
43
  """Return the Flower home directory based on env variables."""
@@ -38,7 +45,7 @@ def get_flwr_dir(provided_path: Optional[str] = None) -> Path:
38
45
  return Path(
39
46
  os.getenv(
40
47
  FLWR_HOME,
41
- Path(f"{os.getenv('XDG_DATA_HOME', os.getenv('HOME'))}") / ".flwr",
48
+ Path(f"{os.getenv('XDG_DATA_HOME', os.getenv('HOME'))}") / FLWR_DIR,
42
49
  )
43
50
  )
44
51
  return Path(provided_path).absolute()
@@ -78,7 +85,7 @@ def get_project_config(project_dir: Union[str, Path]) -> dict[str, Any]:
78
85
  config = tomli.loads(toml_file.read())
79
86
 
80
87
  # Validate pyproject.toml fields
81
- is_valid, errors, _ = validate_fields(config)
88
+ is_valid, errors, _ = validate_fields_in_config(config)
82
89
  if not is_valid:
83
90
  error_msg = "\n".join([f" - {error}" for error in errors])
84
91
  raise ValueError(
@@ -89,19 +96,28 @@ def get_project_config(project_dir: Union[str, Path]) -> dict[str, Any]:
89
96
 
90
97
 
91
98
  def fuse_dicts(
92
- main_dict: UserConfig,
93
- override_dict: UserConfig,
94
- ) -> UserConfig:
99
+ main_dict: T_dict,
100
+ override_dict: T_dict,
101
+ check_keys: bool = True,
102
+ ) -> T_dict:
95
103
  """Merge a config with the overrides.
96
104
 
97
- Remove the nesting by adding the nested keys as prefixes separated by dots, and fuse
98
- it with the override dict.
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.
99
108
  """
100
- fused_dict = main_dict.copy()
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())
101
113
 
102
114
  for key, value in override_dict.items():
103
115
  if key in main_dict:
116
+ if isinstance(value, dict):
117
+ fused_dict[key] = fuse_dicts(main_dict[key], value)
104
118
  fused_dict[key] = value
119
+ elif check_keys:
120
+ raise ValueError(f"Key '{key}' is not present in the main dictionary")
105
121
 
106
122
  return fused_dict
107
123
 
@@ -190,8 +206,8 @@ def unflatten_dict(flat_dict: dict[str, Any]) -> dict[str, Any]:
190
206
 
191
207
 
192
208
  def parse_config_args(
193
- config: Optional[list[str]],
194
- ) -> UserConfig:
209
+ config: Optional[list[str]], flatten: bool = True
210
+ ) -> dict[str, Any]:
195
211
  """Parse separator separated list of key-value pairs separated by '='."""
196
212
  overrides: UserConfig = {}
197
213
 
@@ -218,17 +234,29 @@ def parse_config_args(
218
234
 
219
235
  matches = pattern.findall(config_line)
220
236
  toml_str = "\n".join(f"{k} = {v}" for k, v in matches)
221
- overrides.update(tomli.loads(toml_str))
222
- flat_overrides = flatten_dict(overrides)
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
223
251
 
224
252
  return flat_overrides
225
253
 
226
254
 
227
255
  def get_metadata_from_config(config: dict[str, Any]) -> tuple[str, str]:
228
- """Extract `fab_version` and `fab_id` from a project config."""
256
+ """Extract `fab_id` and `fab_version` from a project config."""
229
257
  return (
230
- config["project"]["version"],
231
258
  f"{config['tool']['flwr']['app']['publisher']}/{config['project']['name']}",
259
+ config["project"]["version"],
232
260
  )
233
261
 
234
262
 
@@ -239,3 +267,127 @@ def user_config_to_configsrecord(config: UserConfig) -> ConfigsRecord:
239
267
  c_record[k] = v
240
268
 
241
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"
@@ -80,7 +72,11 @@ FAB_ALLOWED_EXTENSIONS = {".py", ".toml", ".md"}
80
72
  FAB_CONFIG_FILE = "pyproject.toml"
81
73
  FAB_DATE = (2024, 10, 1, 0, 0, 0)
82
74
  FAB_HASH_TRUNCATION = 8
83
- FLWR_HOME = "FLWR_HOME"
75
+ FLWR_DIR = ".flwr" # The default Flower directory: ~/.flwr/
76
+ FLWR_HOME = "FLWR_HOME" # If set, override the default Flower directory
77
+
78
+ # Constant for SuperLink
79
+ SUPERLINK_NODE_ID = 1
84
80
 
85
81
  # Constants entries in Node config for Simulation
86
82
  PARTITION_ID_KEY = "partition-id"
@@ -110,6 +106,18 @@ LOG_UPLOAD_INTERVAL = 0.2 # Minimum interval between two log uploads
110
106
  # Retry configurations
111
107
  MAX_RETRY_DELAY = 20 # Maximum delay duration between two consecutive retries.
112
108
 
109
+ # Constants for user authentication
110
+ CREDENTIALS_DIR = ".credentials"
111
+ AUTH_TYPE_KEY = "auth_type"
112
+ ACCESS_TOKEN_KEY = "access_token"
113
+ REFRESH_TOKEN_KEY = "refresh_token"
114
+
115
+ # Constants for node authentication
116
+ PUBLIC_KEY_HEADER = "public-key-bin" # Must end with "-bin" for binary data
117
+ SIGNATURE_HEADER = "signature-bin" # Must end with "-bin" for binary data
118
+ TIMESTAMP_HEADER = "timestamp"
119
+ TIMESTAMP_TOLERANCE = 10 # Tolerance for timestamp verification
120
+
113
121
 
114
122
  class MessageType:
115
123
  """Message type."""
@@ -181,3 +189,24 @@ class SubStatus:
181
189
  def __new__(cls) -> SubStatus:
182
190
  """Prevent instantiation."""
183
191
  raise TypeError(f"{cls.__name__} cannot be instantiated.")
192
+
193
+
194
+ class CliOutputFormat:
195
+ """Define output format for `flwr` CLI commands."""
196
+
197
+ DEFAULT = "default"
198
+ JSON = "json"
199
+
200
+ def __new__(cls) -> CliOutputFormat:
201
+ """Prevent instantiation."""
202
+ raise TypeError(f"{cls.__name__} cannot be instantiated.")
203
+
204
+
205
+ class AuthType:
206
+ """User authentication types."""
207
+
208
+ OIDC = "oidc"
209
+
210
+ def __new__(cls) -> AuthType:
211
+ """Prevent instantiation."""
212
+ 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
- array += np.random.normal(0, std_dev, array.shape)
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
+ ]
@@ -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
+ }
@@ -15,28 +15,38 @@
15
15
  """Common function to register exit handlers for server and client."""
16
16
 
17
17
 
18
- import sys
19
- from signal import SIGINT, SIGTERM, signal
18
+ from signal import SIGINT, SIGQUIT, SIGTERM, signal
20
19
  from threading import Thread
21
20
  from types import FrameType
22
21
  from typing import Optional
23
22
 
24
23
  from grpc import Server
25
24
 
26
- from flwr.common.telemetry import EventType, event
25
+ from flwr.common.telemetry import EventType
26
+
27
+ from .exit import ExitCode, flwr_exit
28
+
29
+ SIGNAL_TO_EXIT_CODE = {
30
+ SIGINT: ExitCode.GRACEFUL_EXIT_SIGINT,
31
+ SIGQUIT: ExitCode.GRACEFUL_EXIT_SIGQUIT,
32
+ SIGTERM: ExitCode.GRACEFUL_EXIT_SIGTERM,
33
+ }
27
34
 
28
35
 
29
36
  def register_exit_handlers(
30
37
  event_type: EventType,
38
+ exit_message: Optional[str] = None,
31
39
  grpc_servers: Optional[list[Server]] = None,
32
40
  bckg_threads: Optional[list[Thread]] = None,
33
41
  ) -> None:
34
- """Register exit handlers for `SIGINT` and `SIGTERM` signals.
42
+ """Register exit handlers for `SIGINT`, `SIGTERM` and `SIGQUIT` signals.
35
43
 
36
44
  Parameters
37
45
  ----------
38
46
  event_type : EventType
39
47
  The telemetry event that should be logged before exit.
48
+ exit_message : Optional[str] (default: None)
49
+ The message to be logged before exiting.
40
50
  grpc_servers: Optional[List[Server]] (default: None)
41
51
  An otpional list of gRPC servers that need to be gracefully
42
52
  terminated before exiting.
@@ -46,6 +56,7 @@ def register_exit_handlers(
46
56
  """
47
57
  default_handlers = {
48
58
  SIGINT: None,
59
+ SIGQUIT: None,
49
60
  SIGTERM: None,
50
61
  }
51
62
 
@@ -61,8 +72,6 @@ def register_exit_handlers(
61
72
  # Reset to default handler
62
73
  signal(signalnum, default_handlers[signalnum])
63
74
 
64
- event_res = event(event_type=event_type)
65
-
66
75
  if grpc_servers is not None:
67
76
  for grpc_server in grpc_servers:
68
77
  grpc_server.stop(grace=1)
@@ -71,16 +80,21 @@ def register_exit_handlers(
71
80
  for bckg_thread in bckg_threads:
72
81
  bckg_thread.join()
73
82
 
74
- # Ensure event has happend
75
- event_res.result()
76
-
77
83
  # Setup things for graceful exit
78
- sys.exit(0)
84
+ flwr_exit(
85
+ code=SIGNAL_TO_EXIT_CODE[signalnum],
86
+ message=exit_message,
87
+ event_type=event_type,
88
+ )
79
89
 
80
90
  default_handlers[SIGINT] = signal( # type: ignore
81
91
  SIGINT,
82
92
  graceful_exit_handler, # type: ignore
83
93
  )
94
+ default_handlers[SIGQUIT] = signal( # type: ignore
95
+ SIGQUIT,
96
+ graceful_exit_handler, # type: ignore
97
+ )
84
98
  default_handlers[SIGTERM] = signal( # type: ignore
85
99
  SIGTERM,
86
100
  graceful_exit_handler, # type: ignore