flwr 1.22.0__py3-none-any.whl → 1.24.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 (301) hide show
  1. flwr/__init__.py +16 -5
  2. flwr/app/error.py +2 -2
  3. flwr/app/exception.py +3 -3
  4. flwr/cli/app.py +34 -1
  5. flwr/cli/app_cmd/__init__.py +23 -0
  6. flwr/cli/app_cmd/publish.py +285 -0
  7. flwr/cli/app_cmd/review.py +252 -0
  8. flwr/cli/auth_plugin/__init__.py +15 -6
  9. flwr/cli/auth_plugin/auth_plugin.py +94 -0
  10. flwr/cli/auth_plugin/noop_auth_plugin.py +101 -0
  11. flwr/cli/auth_plugin/oidc_cli_plugin.py +46 -32
  12. flwr/cli/build.py +166 -53
  13. flwr/cli/{cli_user_auth_interceptor.py → cli_account_auth_interceptor.py} +29 -11
  14. flwr/cli/config_utils.py +101 -13
  15. flwr/cli/federation/__init__.py +24 -0
  16. flwr/cli/federation/ls.py +140 -0
  17. flwr/cli/federation/show.py +317 -0
  18. flwr/cli/install.py +91 -13
  19. flwr/cli/log.py +54 -11
  20. flwr/cli/login/login.py +41 -27
  21. flwr/cli/ls.py +177 -133
  22. flwr/cli/new/new.py +175 -40
  23. flwr/cli/new/templates/app/code/task.pytorch.py.tpl +1 -0
  24. flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +1 -1
  25. flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +1 -1
  26. flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +1 -1
  27. flwr/cli/new/templates/app/pyproject.jax.toml.tpl +1 -1
  28. flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +1 -1
  29. flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +1 -1
  30. flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +3 -3
  31. flwr/cli/new/templates/app/pyproject.pytorch_legacy_api.toml.tpl +1 -1
  32. flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +1 -1
  33. flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +2 -2
  34. flwr/cli/new/templates/app/pyproject.xgboost.toml.tpl +1 -1
  35. flwr/cli/pull.py +12 -7
  36. flwr/cli/run/run.py +82 -31
  37. flwr/cli/run_utils.py +130 -0
  38. flwr/cli/stop.py +27 -9
  39. flwr/cli/supernode/__init__.py +25 -0
  40. flwr/cli/supernode/ls.py +268 -0
  41. flwr/cli/supernode/register.py +190 -0
  42. flwr/cli/supernode/unregister.py +140 -0
  43. flwr/cli/utils.py +464 -81
  44. flwr/client/__init__.py +2 -1
  45. flwr/client/dpfedavg_numpy_client.py +4 -1
  46. flwr/client/grpc_adapter_client/connection.py +12 -15
  47. flwr/client/grpc_rere_client/connection.py +68 -41
  48. flwr/client/grpc_rere_client/grpc_adapter.py +34 -14
  49. flwr/client/grpc_rere_client/{client_interceptor.py → node_auth_client_interceptor.py} +5 -7
  50. flwr/client/message_handler/message_handler.py +2 -2
  51. flwr/client/mod/secure_aggregation/secaggplus_mod.py +10 -8
  52. flwr/client/numpy_client.py +1 -1
  53. flwr/client/rest_client/connection.py +94 -51
  54. flwr/client/run_info_store.py +4 -5
  55. flwr/client/typing.py +1 -1
  56. flwr/clientapp/__init__.py +1 -2
  57. flwr/{client → clientapp}/client_app.py +9 -10
  58. flwr/clientapp/mod/centraldp_mods.py +16 -17
  59. flwr/clientapp/mod/localdp_mod.py +8 -9
  60. flwr/clientapp/typing.py +1 -1
  61. flwr/{client/clientapp → clientapp}/utils.py +4 -4
  62. flwr/common/address.py +1 -2
  63. flwr/common/args.py +3 -4
  64. flwr/common/config.py +13 -16
  65. flwr/common/constant.py +56 -13
  66. flwr/common/differential_privacy.py +3 -4
  67. flwr/common/event_log_plugin/event_log_plugin.py +3 -4
  68. flwr/common/exit/exit.py +15 -2
  69. flwr/common/exit/exit_code.py +39 -10
  70. flwr/common/exit/exit_handler.py +6 -2
  71. flwr/common/exit/signal_handler.py +5 -5
  72. flwr/common/grpc.py +6 -6
  73. flwr/common/inflatable_protobuf_utils.py +1 -1
  74. flwr/common/inflatable_utils.py +48 -31
  75. flwr/common/logger.py +19 -19
  76. flwr/common/message.py +4 -4
  77. flwr/common/object_ref.py +7 -7
  78. flwr/common/record/array.py +6 -6
  79. flwr/common/record/arrayrecord.py +18 -21
  80. flwr/common/record/configrecord.py +3 -3
  81. flwr/common/record/recorddict.py +5 -5
  82. flwr/common/record/typeddict.py +9 -2
  83. flwr/common/recorddict_compat.py +7 -10
  84. flwr/common/retry_invoker.py +20 -20
  85. flwr/common/secure_aggregation/crypto/symmetric_encryption.py +1 -89
  86. flwr/common/secure_aggregation/ndarrays_arithmetic.py +3 -3
  87. flwr/common/serde.py +9 -6
  88. flwr/common/serde_utils.py +2 -2
  89. flwr/common/telemetry.py +9 -5
  90. flwr/common/typing.py +59 -43
  91. flwr/compat/client/app.py +39 -38
  92. flwr/compat/client/grpc_client/connection.py +13 -13
  93. flwr/compat/server/app.py +5 -6
  94. flwr/proto/appio_pb2.py +13 -3
  95. flwr/proto/appio_pb2.pyi +134 -65
  96. flwr/proto/appio_pb2_grpc.py +20 -0
  97. flwr/proto/appio_pb2_grpc.pyi +27 -0
  98. flwr/proto/clientappio_pb2.py +17 -7
  99. flwr/proto/clientappio_pb2.pyi +15 -0
  100. flwr/proto/clientappio_pb2_grpc.py +206 -40
  101. flwr/proto/clientappio_pb2_grpc.pyi +168 -53
  102. flwr/proto/control_pb2.py +72 -40
  103. flwr/proto/control_pb2.pyi +319 -87
  104. flwr/proto/control_pb2_grpc.py +339 -28
  105. flwr/proto/control_pb2_grpc.pyi +209 -37
  106. flwr/proto/error_pb2.py +13 -3
  107. flwr/proto/error_pb2.pyi +24 -6
  108. flwr/proto/error_pb2_grpc.py +20 -0
  109. flwr/proto/error_pb2_grpc.pyi +27 -0
  110. flwr/proto/fab_pb2.py +24 -10
  111. flwr/proto/fab_pb2.pyi +68 -20
  112. flwr/proto/fab_pb2_grpc.py +20 -0
  113. flwr/proto/fab_pb2_grpc.pyi +27 -0
  114. flwr/proto/federation_pb2.py +38 -0
  115. flwr/proto/federation_pb2.pyi +56 -0
  116. flwr/proto/federation_pb2_grpc.py +24 -0
  117. flwr/proto/federation_pb2_grpc.pyi +31 -0
  118. flwr/proto/fleet_pb2.py +45 -27
  119. flwr/proto/fleet_pb2.pyi +186 -70
  120. flwr/proto/fleet_pb2_grpc.py +277 -66
  121. flwr/proto/fleet_pb2_grpc.pyi +201 -55
  122. flwr/proto/grpcadapter_pb2.py +14 -4
  123. flwr/proto/grpcadapter_pb2.pyi +38 -16
  124. flwr/proto/grpcadapter_pb2_grpc.py +35 -4
  125. flwr/proto/grpcadapter_pb2_grpc.pyi +38 -7
  126. flwr/proto/heartbeat_pb2.py +17 -7
  127. flwr/proto/heartbeat_pb2.pyi +51 -22
  128. flwr/proto/heartbeat_pb2_grpc.py +20 -0
  129. flwr/proto/heartbeat_pb2_grpc.pyi +27 -0
  130. flwr/proto/log_pb2.py +13 -3
  131. flwr/proto/log_pb2.pyi +34 -11
  132. flwr/proto/log_pb2_grpc.py +20 -0
  133. flwr/proto/log_pb2_grpc.pyi +27 -0
  134. flwr/proto/message_pb2.py +15 -5
  135. flwr/proto/message_pb2.pyi +154 -86
  136. flwr/proto/message_pb2_grpc.py +20 -0
  137. flwr/proto/message_pb2_grpc.pyi +27 -0
  138. flwr/proto/node_pb2.py +16 -4
  139. flwr/proto/node_pb2.pyi +77 -4
  140. flwr/proto/node_pb2_grpc.py +20 -0
  141. flwr/proto/node_pb2_grpc.pyi +27 -0
  142. flwr/proto/recorddict_pb2.py +13 -3
  143. flwr/proto/recorddict_pb2.pyi +184 -107
  144. flwr/proto/recorddict_pb2_grpc.py +20 -0
  145. flwr/proto/recorddict_pb2_grpc.pyi +27 -0
  146. flwr/proto/run_pb2.py +40 -31
  147. flwr/proto/run_pb2.pyi +149 -84
  148. flwr/proto/run_pb2_grpc.py +20 -0
  149. flwr/proto/run_pb2_grpc.pyi +27 -0
  150. flwr/proto/serverappio_pb2.py +13 -3
  151. flwr/proto/serverappio_pb2.pyi +32 -8
  152. flwr/proto/serverappio_pb2_grpc.py +246 -65
  153. flwr/proto/serverappio_pb2_grpc.pyi +221 -85
  154. flwr/proto/simulationio_pb2.py +16 -8
  155. flwr/proto/simulationio_pb2.pyi +15 -0
  156. flwr/proto/simulationio_pb2_grpc.py +162 -41
  157. flwr/proto/simulationio_pb2_grpc.pyi +149 -55
  158. flwr/proto/transport_pb2.py +20 -10
  159. flwr/proto/transport_pb2.pyi +249 -160
  160. flwr/proto/transport_pb2_grpc.py +35 -4
  161. flwr/proto/transport_pb2_grpc.pyi +38 -8
  162. flwr/server/app.py +173 -127
  163. flwr/server/client_manager.py +4 -5
  164. flwr/server/client_proxy.py +10 -11
  165. flwr/server/compat/app.py +4 -5
  166. flwr/server/compat/app_utils.py +2 -1
  167. flwr/server/compat/grid_client_proxy.py +10 -12
  168. flwr/server/compat/legacy_context.py +3 -4
  169. flwr/server/fleet_event_log_interceptor.py +2 -1
  170. flwr/server/grid/grid.py +2 -3
  171. flwr/server/grid/grpc_grid.py +10 -8
  172. flwr/server/grid/inmemory_grid.py +4 -4
  173. flwr/server/run_serverapp.py +2 -3
  174. flwr/server/server.py +34 -39
  175. flwr/server/server_app.py +7 -8
  176. flwr/server/server_config.py +1 -2
  177. flwr/server/serverapp/app.py +34 -28
  178. flwr/server/serverapp_components.py +4 -5
  179. flwr/server/strategy/aggregate.py +9 -8
  180. flwr/server/strategy/bulyan.py +13 -11
  181. flwr/server/strategy/dp_adaptive_clipping.py +16 -20
  182. flwr/server/strategy/dp_fixed_clipping.py +12 -17
  183. flwr/server/strategy/dpfedavg_adaptive.py +3 -4
  184. flwr/server/strategy/dpfedavg_fixed.py +6 -10
  185. flwr/server/strategy/fault_tolerant_fedavg.py +14 -13
  186. flwr/server/strategy/fedadagrad.py +18 -14
  187. flwr/server/strategy/fedadam.py +16 -14
  188. flwr/server/strategy/fedavg.py +16 -17
  189. flwr/server/strategy/fedavg_android.py +15 -15
  190. flwr/server/strategy/fedavgm.py +21 -18
  191. flwr/server/strategy/fedmedian.py +2 -3
  192. flwr/server/strategy/fedopt.py +11 -10
  193. flwr/server/strategy/fedprox.py +10 -9
  194. flwr/server/strategy/fedtrimmedavg.py +12 -11
  195. flwr/server/strategy/fedxgb_bagging.py +13 -11
  196. flwr/server/strategy/fedxgb_cyclic.py +6 -6
  197. flwr/server/strategy/fedxgb_nn_avg.py +4 -4
  198. flwr/server/strategy/fedyogi.py +16 -14
  199. flwr/server/strategy/krum.py +12 -11
  200. flwr/server/strategy/qfedavg.py +16 -15
  201. flwr/server/strategy/strategy.py +6 -9
  202. flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer.py +19 -8
  203. flwr/server/superlink/fleet/grpc_bidi/flower_service_servicer.py +1 -2
  204. flwr/server/superlink/fleet/grpc_bidi/grpc_bridge.py +3 -4
  205. flwr/server/superlink/fleet/grpc_bidi/grpc_client_proxy.py +10 -12
  206. flwr/server/superlink/fleet/grpc_bidi/grpc_server.py +1 -3
  207. flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py +136 -42
  208. flwr/server/superlink/fleet/grpc_rere/{server_interceptor.py → node_auth_server_interceptor.py} +28 -51
  209. flwr/server/superlink/fleet/message_handler/message_handler.py +100 -49
  210. flwr/server/superlink/fleet/rest_rere/rest_api.py +54 -33
  211. flwr/server/superlink/fleet/vce/backend/backend.py +2 -2
  212. flwr/server/superlink/fleet/vce/backend/raybackend.py +6 -6
  213. flwr/server/superlink/fleet/vce/vce_api.py +32 -13
  214. flwr/server/superlink/linkstate/in_memory_linkstate.py +266 -207
  215. flwr/server/superlink/linkstate/linkstate.py +161 -62
  216. flwr/server/superlink/linkstate/linkstate_factory.py +24 -6
  217. flwr/server/superlink/linkstate/sqlite_linkstate.py +698 -638
  218. flwr/server/superlink/linkstate/utils.py +9 -60
  219. flwr/server/superlink/serverappio/serverappio_grpc.py +1 -2
  220. flwr/server/superlink/serverappio/serverappio_servicer.py +28 -23
  221. flwr/server/superlink/simulation/simulationio_grpc.py +1 -2
  222. flwr/server/superlink/simulation/simulationio_servicer.py +19 -14
  223. flwr/server/superlink/utils.py +4 -6
  224. flwr/server/typing.py +1 -1
  225. flwr/server/utils/tensorboard.py +15 -8
  226. flwr/server/utils/validator.py +2 -3
  227. flwr/server/workflow/default_workflows.py +5 -5
  228. flwr/server/workflow/secure_aggregation/secagg_workflow.py +2 -4
  229. flwr/server/workflow/secure_aggregation/secaggplus_workflow.py +12 -10
  230. flwr/serverapp/strategy/bulyan.py +16 -15
  231. flwr/serverapp/strategy/dp_adaptive_clipping.py +12 -11
  232. flwr/serverapp/strategy/dp_fixed_clipping.py +11 -14
  233. flwr/serverapp/strategy/fedadagrad.py +10 -11
  234. flwr/serverapp/strategy/fedadam.py +10 -11
  235. flwr/serverapp/strategy/fedavg.py +9 -10
  236. flwr/serverapp/strategy/fedavgm.py +17 -16
  237. flwr/serverapp/strategy/fedmedian.py +2 -2
  238. flwr/serverapp/strategy/fedopt.py +10 -11
  239. flwr/serverapp/strategy/fedprox.py +7 -8
  240. flwr/serverapp/strategy/fedtrimmedavg.py +9 -9
  241. flwr/serverapp/strategy/fedxgb_bagging.py +3 -3
  242. flwr/serverapp/strategy/fedxgb_cyclic.py +9 -9
  243. flwr/serverapp/strategy/fedyogi.py +9 -11
  244. flwr/serverapp/strategy/krum.py +7 -7
  245. flwr/serverapp/strategy/multikrum.py +9 -9
  246. flwr/serverapp/strategy/qfedavg.py +17 -16
  247. flwr/serverapp/strategy/strategy.py +6 -9
  248. flwr/serverapp/strategy/strategy_utils.py +7 -8
  249. flwr/simulation/app.py +46 -42
  250. flwr/simulation/legacy_app.py +12 -12
  251. flwr/simulation/ray_transport/ray_actor.py +11 -12
  252. flwr/simulation/ray_transport/ray_client_proxy.py +12 -13
  253. flwr/simulation/run_simulation.py +44 -43
  254. flwr/simulation/simulationio_connection.py +4 -4
  255. flwr/supercore/cli/flower_superexec.py +3 -4
  256. flwr/supercore/constant.py +52 -0
  257. flwr/supercore/corestate/corestate.py +24 -3
  258. flwr/supercore/corestate/in_memory_corestate.py +138 -0
  259. flwr/supercore/corestate/sqlite_corestate.py +157 -0
  260. flwr/supercore/ffs/disk_ffs.py +1 -2
  261. flwr/supercore/ffs/ffs.py +1 -2
  262. flwr/supercore/ffs/ffs_factory.py +1 -2
  263. flwr/{common → supercore}/heartbeat.py +20 -25
  264. flwr/supercore/object_store/in_memory_object_store.py +1 -6
  265. flwr/supercore/object_store/object_store.py +1 -2
  266. flwr/supercore/object_store/object_store_factory.py +27 -8
  267. flwr/supercore/object_store/sqlite_object_store.py +253 -0
  268. flwr/{client/clientapp → supercore/primitives}/__init__.py +1 -1
  269. flwr/supercore/primitives/asymmetric.py +117 -0
  270. flwr/supercore/primitives/asymmetric_ed25519.py +175 -0
  271. flwr/supercore/sqlite_mixin.py +159 -0
  272. flwr/supercore/superexec/plugin/base_exec_plugin.py +1 -2
  273. flwr/supercore/superexec/plugin/exec_plugin.py +3 -3
  274. flwr/supercore/superexec/run_superexec.py +9 -13
  275. flwr/supercore/utils.py +20 -0
  276. flwr/superlink/artifact_provider/artifact_provider.py +1 -2
  277. flwr/{common → superlink}/auth_plugin/__init__.py +6 -6
  278. flwr/superlink/auth_plugin/auth_plugin.py +88 -0
  279. flwr/superlink/auth_plugin/noop_auth_plugin.py +84 -0
  280. flwr/superlink/federation/__init__.py +24 -0
  281. flwr/superlink/federation/federation_manager.py +64 -0
  282. flwr/superlink/federation/noop_federation_manager.py +71 -0
  283. flwr/superlink/servicer/control/{control_user_auth_interceptor.py → control_account_auth_interceptor.py} +41 -32
  284. flwr/superlink/servicer/control/control_event_log_interceptor.py +7 -7
  285. flwr/superlink/servicer/control/control_grpc.py +18 -17
  286. flwr/superlink/servicer/control/control_license_interceptor.py +3 -3
  287. flwr/superlink/servicer/control/control_servicer.py +239 -63
  288. flwr/supernode/cli/flower_supernode.py +74 -26
  289. flwr/supernode/nodestate/in_memory_nodestate.py +60 -49
  290. flwr/supernode/nodestate/nodestate.py +7 -8
  291. flwr/supernode/nodestate/nodestate_factory.py +7 -4
  292. flwr/supernode/runtime/run_clientapp.py +43 -24
  293. flwr/supernode/servicer/clientappio/clientappio_servicer.py +40 -10
  294. flwr/supernode/start_client_internal.py +175 -51
  295. {flwr-1.22.0.dist-info → flwr-1.24.0.dist-info}/METADATA +8 -8
  296. flwr-1.24.0.dist-info/RECORD +454 -0
  297. flwr/common/auth_plugin/auth_plugin.py +0 -149
  298. flwr/supercore/object_store/utils.py +0 -43
  299. flwr-1.22.0.dist-info/RECORD +0 -428
  300. {flwr-1.22.0.dist-info → flwr-1.24.0.dist-info}/WHEEL +0 -0
  301. {flwr-1.22.0.dist-info → flwr-1.24.0.dist-info}/entry_points.txt +0 -0
flwr/cli/utils.py CHANGED
@@ -18,41 +18,65 @@
18
18
  import hashlib
19
19
  import json
20
20
  import re
21
- from collections.abc import Iterator
21
+ from collections.abc import Callable, Iterable, Iterator
22
22
  from contextlib import contextmanager
23
23
  from pathlib import Path
24
- from typing import Any, Callable, Optional, Union, cast
24
+ from typing import Any, cast
25
25
 
26
26
  import grpc
27
+ import pathspec
28
+ import requests
27
29
  import typer
28
30
 
29
- from flwr.cli.cli_user_auth_interceptor import CliUserAuthInterceptor
30
- from flwr.common.auth_plugin import CliAuthPlugin
31
31
  from flwr.common.constant import (
32
- AUTH_TYPE_JSON_KEY,
32
+ ACCESS_TOKEN_KEY,
33
+ AUTHN_TYPE_JSON_KEY,
33
34
  CREDENTIALS_DIR,
34
35
  FLWR_DIR,
36
+ NO_ACCOUNT_AUTH_MESSAGE,
35
37
  NO_ARTIFACT_PROVIDER_MESSAGE,
36
- NO_USER_AUTH_MESSAGE,
38
+ NODE_NOT_FOUND_MESSAGE,
39
+ PUBLIC_KEY_ALREADY_IN_USE_MESSAGE,
40
+ PUBLIC_KEY_NOT_VALID,
37
41
  PULL_UNFINISHED_RUN_MESSAGE,
42
+ REFRESH_TOKEN_KEY,
38
43
  RUN_ID_NOT_FOUND_MESSAGE,
44
+ AuthnType,
39
45
  )
40
46
  from flwr.common.grpc import (
41
47
  GRPC_MAX_MESSAGE_LENGTH,
42
48
  create_channel,
43
49
  on_channel_state_change,
44
50
  )
51
+ from flwr.common.version import package_version as flwr_version
52
+ from flwr.supercore.constant import APP_ID_PATTERN, APP_VERSION_PATTERN
45
53
 
46
- from .auth_plugin import get_cli_auth_plugins
54
+ from .auth_plugin import CliAuthPlugin, get_cli_plugin_class
55
+ from .cli_account_auth_interceptor import CliAccountAuthInterceptor
47
56
  from .config_utils import validate_certificate_in_federation_config
48
57
 
49
58
 
50
59
  def prompt_text(
51
60
  text: str,
52
61
  predicate: Callable[[str], bool] = lambda _: True,
53
- default: Optional[str] = None,
62
+ default: str | None = None,
54
63
  ) -> str:
55
- """Ask user to enter text input."""
64
+ """Ask user to enter text input.
65
+
66
+ Parameters
67
+ ----------
68
+ text : str
69
+ The prompt text to display to the user.
70
+ predicate : Callable[[str], bool] (default: lambda _: True)
71
+ A function to validate the user input. Default accepts all non-empty strings.
72
+ default : str | None (default: None)
73
+ Default value to use if user presses enter without input.
74
+
75
+ Returns
76
+ -------
77
+ str
78
+ The validated user input.
79
+ """
56
80
  while True:
57
81
  result = typer.prompt(
58
82
  typer.style(f"\n💬 {text}", fg=typer.colors.MAGENTA, bold=True),
@@ -66,7 +90,20 @@ def prompt_text(
66
90
 
67
91
 
68
92
  def prompt_options(text: str, options: list[str]) -> str:
69
- """Ask user to select one of the given options and return the selected item."""
93
+ """Ask user to select one of the given options and return the selected item.
94
+
95
+ Parameters
96
+ ----------
97
+ text : str
98
+ The prompt text to display to the user.
99
+ options : list[str]
100
+ List of options to present to the user.
101
+
102
+ Returns
103
+ -------
104
+ str
105
+ The selected option from the list.
106
+ """
70
107
  # Turn options into a list with index as in " [ 0] quickstart-pytorch"
71
108
  options_formatted = [
72
109
  " [ "
@@ -124,9 +161,19 @@ def is_valid_project_name(name: str) -> bool:
124
161
  def sanitize_project_name(name: str) -> str:
125
162
  """Sanitize the given string to make it a valid Python project name.
126
163
 
127
- This version replaces spaces, dots, slashes, and underscores with dashes, removes
164
+ This function replaces spaces, dots, slashes, and underscores with dashes, removes
128
165
  any characters not allowed in Python project names, makes the string lowercase, and
129
166
  ensures it starts with a valid character.
167
+
168
+ Parameters
169
+ ----------
170
+ name : str
171
+ The project name to sanitize.
172
+
173
+ Returns
174
+ -------
175
+ str
176
+ The sanitized project name that is valid for Python projects.
130
177
  """
131
178
  # Replace whitespace with '_'
132
179
  name_with_hyphens = re.sub(r"[ ./_]", "-", name)
@@ -151,8 +198,19 @@ def sanitize_project_name(name: str) -> str:
151
198
  return sanitized_name
152
199
 
153
200
 
154
- def get_sha256_hash(file_path_or_int: Union[Path, int]) -> str:
155
- """Calculate the SHA-256 hash of a file."""
201
+ def get_sha256_hash(file_path_or_int: Path | int) -> str:
202
+ """Calculate the SHA-256 hash of a file or integer.
203
+
204
+ Parameters
205
+ ----------
206
+ file_path_or_int : Path | int
207
+ Either a path to a file to hash, or an integer to convert to string and hash.
208
+
209
+ Returns
210
+ -------
211
+ str
212
+ The SHA-256 hash as a hexadecimal string.
213
+ """
156
214
  sha256 = hashlib.sha256()
157
215
  if isinstance(file_path_or_int, Path):
158
216
  with open(file_path_or_int, "rb") as f:
@@ -166,8 +224,8 @@ def get_sha256_hash(file_path_or_int: Union[Path, int]) -> str:
166
224
  return sha256.hexdigest()
167
225
 
168
226
 
169
- def get_user_auth_config_path(root_dir: Path, federation: str) -> Path:
170
- """Return the path to the user auth config file.
227
+ def get_account_auth_config_path(root_dir: Path, federation: str) -> Path:
228
+ """Return the path to the account auth config file.
171
229
 
172
230
  Additionally, a `.gitignore` file will be created in the Flower directory to
173
231
  include the `.credentials` folder to be excluded from git. If the `.gitignore`
@@ -211,77 +269,134 @@ def get_user_auth_config_path(root_dir: Path, federation: str) -> Path:
211
269
  f"Please check the permissions of `{gitignore_path}` and try again.",
212
270
  fg=typer.colors.RED,
213
271
  bold=True,
272
+ err=True,
214
273
  )
215
274
  raise typer.Exit(code=1) from err
216
275
 
217
276
  return credentials_dir / f"{federation}.json"
218
277
 
219
278
 
220
- def try_obtain_cli_auth_plugin(
279
+ def account_auth_enabled(federation_config: dict[str, Any]) -> bool:
280
+ """Check if account authentication is enabled in the federation config.
281
+
282
+ Parameters
283
+ ----------
284
+ federation_config : dict[str, Any]
285
+ The federation configuration dictionary.
286
+
287
+ Returns
288
+ -------
289
+ bool
290
+ True if account authentication is enabled, False otherwise.
291
+ """
292
+ enabled: bool = federation_config.get("enable-user-auth", False)
293
+ enabled |= federation_config.get("enable-account-auth", False)
294
+ if "enable-user-auth" in federation_config:
295
+ typer.secho(
296
+ "`enable-user-auth` is deprecated and will be removed in a future "
297
+ "release. Please use `enable-account-auth` instead.",
298
+ fg=typer.colors.YELLOW,
299
+ bold=True,
300
+ )
301
+ return enabled
302
+
303
+
304
+ def retrieve_authn_type(config_path: Path) -> str:
305
+ """Retrieve the auth type from the config file or return NOOP if not found.
306
+
307
+ Parameters
308
+ ----------
309
+ config_path : Path
310
+ Path to the authentication configuration file.
311
+
312
+ Returns
313
+ -------
314
+ str
315
+ The authentication type string, or AuthnType.NOOP if not found.
316
+ """
317
+ try:
318
+ with config_path.open("r", encoding="utf-8") as file:
319
+ json_file = json.load(file)
320
+ authn_type: str = json_file[AUTHN_TYPE_JSON_KEY]
321
+ return authn_type
322
+ except (FileNotFoundError, KeyError):
323
+ return AuthnType.NOOP
324
+
325
+
326
+ def load_cli_auth_plugin(
221
327
  root_dir: Path,
222
328
  federation: str,
223
329
  federation_config: dict[str, Any],
224
- auth_type: Optional[str] = None,
225
- ) -> Optional[CliAuthPlugin]:
226
- """Load the CLI-side user auth plugin for the given auth type."""
227
- # Check if user auth is enabled
228
- if not federation_config.get("enable-user-auth", False):
229
- return None
230
-
231
- config_path = get_user_auth_config_path(root_dir, federation)
232
-
233
- # Get the auth type from the config if not provided
234
- # auth_type will be None for all CLI commands except login
235
- if auth_type is None:
236
- try:
237
- with config_path.open("r", encoding="utf-8") as file:
238
- json_file = json.load(file)
239
- auth_type = json_file[AUTH_TYPE_JSON_KEY]
240
- except (FileNotFoundError, KeyError):
241
- typer.secho(
242
- "❌ Missing or invalid credentials for user authentication. "
243
- "Please run `flwr login` to authenticate.",
244
- fg=typer.colors.RED,
245
- bold=True,
246
- )
247
- raise typer.Exit(code=1) from None
330
+ authn_type: str | None = None,
331
+ ) -> CliAuthPlugin:
332
+ """Load the CLI-side account auth plugin for the given authn type.
333
+
334
+ Parameters
335
+ ----------
336
+ root_dir : Path
337
+ Root directory of the Flower project.
338
+ federation : str
339
+ Name of the federation.
340
+ federation_config : dict[str, Any]
341
+ Federation configuration dictionary.
342
+ authn_type : str | None
343
+ Authentication type. If None, will be determined from config.
344
+
345
+ Returns
346
+ -------
347
+ CliAuthPlugin
348
+ The loaded authentication plugin instance.
349
+
350
+ Raises
351
+ ------
352
+ typer.Exit
353
+ If the authentication type is unknown.
354
+ """
355
+ # Find the path to the account auth config file
356
+ config_path = get_account_auth_config_path(root_dir, federation)
357
+
358
+ # Determine the auth type if not provided
359
+ # Only `flwr login` command can provide `authn_type` explicitly, as it can query the
360
+ # SuperLink for the auth type.
361
+ if authn_type is None:
362
+ authn_type = AuthnType.NOOP
363
+ if account_auth_enabled(federation_config):
364
+ authn_type = retrieve_authn_type(config_path)
248
365
 
249
366
  # Retrieve auth plugin class and instantiate it
250
367
  try:
251
- all_plugins: dict[str, type[CliAuthPlugin]] = get_cli_auth_plugins()
252
- auth_plugin_class = all_plugins[auth_type]
368
+ auth_plugin_class = get_cli_plugin_class(authn_type)
253
369
  return auth_plugin_class(config_path)
254
- except KeyError:
255
- typer.echo(f"❌ Unknown user authentication type: {auth_type}")
256
- raise typer.Exit(code=1) from None
257
- except ImportError:
258
- typer.echo("❌ No authentication plugins are currently supported.")
370
+ except ValueError:
371
+ typer.echo(f"❌ Unknown account authentication type: {authn_type}")
259
372
  raise typer.Exit(code=1) from None
260
373
 
261
374
 
262
375
  def init_channel(
263
- app: Path, federation_config: dict[str, Any], auth_plugin: Optional[CliAuthPlugin]
376
+ app: Path, federation_config: dict[str, Any], auth_plugin: CliAuthPlugin
264
377
  ) -> grpc.Channel:
265
- """Initialize gRPC channel to the Control API."""
378
+ """Initialize gRPC channel to the Control API.
379
+
380
+ Parameters
381
+ ----------
382
+ app : Path
383
+ Path to the Flower app directory.
384
+ federation_config : dict[str, Any]
385
+ Federation configuration dictionary containing address and TLS settings.
386
+ auth_plugin : CliAuthPlugin
387
+ Authentication plugin instance for handling credentials.
388
+
389
+ Returns
390
+ -------
391
+ grpc.Channel
392
+ Configured gRPC channel with authentication interceptors.
393
+ """
266
394
  insecure, root_certificates_bytes = validate_certificate_in_federation_config(
267
395
  app, federation_config
268
396
  )
269
397
 
270
- # Initialize the CLI-side user auth interceptor
271
- interceptors: list[grpc.UnaryUnaryClientInterceptor] = []
272
- if auth_plugin is not None:
273
- # Check if TLS is enabled. If not, raise an error
274
- if insecure:
275
- typer.secho(
276
- "❌ User authentication requires TLS to be enabled. "
277
- "Remove `insecure = true` from the federation configuration.",
278
- fg=typer.colors.RED,
279
- bold=True,
280
- )
281
- raise typer.Exit(code=1)
282
-
283
- auth_plugin.load_tokens()
284
- interceptors.append(CliUserAuthInterceptor(auth_plugin))
398
+ # Load tokens
399
+ auth_plugin.load_tokens()
285
400
 
286
401
  # Create the gRPC channel
287
402
  channel = create_channel(
@@ -289,19 +404,32 @@ def init_channel(
289
404
  insecure=insecure,
290
405
  root_certificates=root_certificates_bytes,
291
406
  max_message_length=GRPC_MAX_MESSAGE_LENGTH,
292
- interceptors=interceptors or None,
407
+ interceptors=[CliAccountAuthInterceptor(auth_plugin)],
293
408
  )
294
409
  channel.subscribe(on_channel_state_change)
295
410
  return channel
296
411
 
297
412
 
298
413
  @contextmanager
299
- def flwr_cli_grpc_exc_handler() -> Iterator[None]:
414
+ def flwr_cli_grpc_exc_handler() -> Iterator[None]: # pylint: disable=too-many-branches
300
415
  """Context manager to handle specific gRPC errors.
301
416
 
302
- It catches grpc.RpcError exceptions with UNAUTHENTICATED, UNIMPLEMENTED,
303
- UNAVAILABLE, and PERMISSION_DENIED statuses, informs the user, and exits the
304
- application. All other exceptions will be allowed to escape.
417
+ Catches grpc.RpcError exceptions with UNAUTHENTICATED, UNIMPLEMENTED,
418
+ UNAVAILABLE, PERMISSION_DENIED, NOT_FOUND, and FAILED_PRECONDITION statuses,
419
+ informs the user, and exits the application. All other exceptions will be
420
+ allowed to escape.
421
+
422
+ Yields
423
+ ------
424
+ None
425
+ Context manager yields nothing.
426
+
427
+ Raises
428
+ ------
429
+ typer.Exit
430
+ On handled gRPC error statuses with appropriate exit code.
431
+ grpc.RpcError
432
+ For unhandled gRPC error statuses.
305
433
  """
306
434
  try:
307
435
  yield
@@ -312,20 +440,23 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
312
440
  " to authenticate and try again.",
313
441
  fg=typer.colors.RED,
314
442
  bold=True,
443
+ err=True,
315
444
  )
316
445
  raise typer.Exit(code=1) from None
317
446
  if e.code() == grpc.StatusCode.UNIMPLEMENTED:
318
- if e.details() == NO_USER_AUTH_MESSAGE: # pylint: disable=E1101
447
+ if e.details() == NO_ACCOUNT_AUTH_MESSAGE: # pylint: disable=E1101
319
448
  typer.secho(
320
- "❌ User authentication is not enabled on this SuperLink.",
449
+ "❌ Account authentication is not enabled on this SuperLink.",
321
450
  fg=typer.colors.RED,
322
451
  bold=True,
452
+ err=True,
323
453
  )
324
454
  elif e.details() == NO_ARTIFACT_PROVIDER_MESSAGE: # pylint: disable=E1101
325
455
  typer.secho(
326
456
  "❌ The SuperLink does not support `flwr pull` command.",
327
457
  fg=typer.colors.RED,
328
458
  bold=True,
459
+ err=True,
329
460
  )
330
461
  else:
331
462
  typer.secho(
@@ -335,6 +466,7 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
335
466
  "the CLI and SuperLink are compatible.",
336
467
  fg=typer.colors.RED,
337
468
  bold=True,
469
+ err=True,
338
470
  )
339
471
  raise typer.Exit(code=1) from None
340
472
  if e.code() == grpc.StatusCode.PERMISSION_DENIED:
@@ -342,6 +474,7 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
342
474
  "❌ Permission denied.",
343
475
  fg=typer.colors.RED,
344
476
  bold=True,
477
+ err=True,
345
478
  )
346
479
  # pylint: disable-next=E1101
347
480
  typer.secho(e.details(), fg=typer.colors.RED, bold=True)
@@ -352,18 +485,26 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
352
485
  "connection and 'address' in the federation configuration.",
353
486
  fg=typer.colors.RED,
354
487
  bold=True,
488
+ err=True,
355
489
  )
356
490
  raise typer.Exit(code=1) from None
357
- if (
358
- e.code() == grpc.StatusCode.NOT_FOUND
359
- and e.details() == RUN_ID_NOT_FOUND_MESSAGE # pylint: disable=E1101
360
- ):
361
- typer.secho(
362
- "❌ Run ID not found.",
363
- fg=typer.colors.RED,
364
- bold=True,
365
- )
366
- raise typer.Exit(code=1) from None
491
+ if e.code() == grpc.StatusCode.NOT_FOUND:
492
+ if e.details() == RUN_ID_NOT_FOUND_MESSAGE: # pylint: disable=E1101
493
+ typer.secho(
494
+ "❌ Run ID not found.",
495
+ fg=typer.colors.RED,
496
+ bold=True,
497
+ err=True,
498
+ )
499
+ raise typer.Exit(code=1) from None
500
+ if e.details() == NODE_NOT_FOUND_MESSAGE: # pylint: disable=E1101
501
+ typer.secho(
502
+ "❌ Node ID not found for this account.",
503
+ fg=typer.colors.RED,
504
+ bold=True,
505
+ err=True,
506
+ )
507
+ raise typer.Exit(code=1) from None
367
508
  if e.code() == grpc.StatusCode.FAILED_PRECONDITION:
368
509
  if e.details() == PULL_UNFINISHED_RUN_MESSAGE: # pylint: disable=E1101
369
510
  typer.secho(
@@ -371,6 +512,248 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
371
512
  "the run is finished. You can check the run status with `flwr ls`.",
372
513
  fg=typer.colors.RED,
373
514
  bold=True,
515
+ err=True,
516
+ )
517
+ raise typer.Exit(code=1) from None
518
+ if (
519
+ e.details() == PUBLIC_KEY_ALREADY_IN_USE_MESSAGE
520
+ ): # pylint: disable=E1101
521
+ typer.secho(
522
+ "❌ The provided public key is already in use by another "
523
+ "SuperNode.",
524
+ fg=typer.colors.RED,
525
+ bold=True,
526
+ err=True,
527
+ )
528
+ raise typer.Exit(code=1) from None
529
+ if e.details() == PUBLIC_KEY_NOT_VALID: # pylint: disable=E1101
530
+ typer.secho(
531
+ "❌ The provided public key is invalid. Please provide a valid "
532
+ "NIST EC public key.",
533
+ fg=typer.colors.RED,
534
+ bold=True,
535
+ err=True,
374
536
  )
375
537
  raise typer.Exit(code=1) from None
538
+
539
+ # Log details from grpc error directly
540
+ typer.secho(
541
+ f"❌ {e.details()}",
542
+ fg=typer.colors.RED,
543
+ bold=True,
544
+ err=True,
545
+ )
546
+ raise typer.Exit(code=1) from None
376
547
  raise
548
+
549
+
550
+ def request_download_link(
551
+ app_id: str, app_version: str | None, in_url: str, out_url: str
552
+ ) -> str:
553
+ """Request a download link for the given app from the Flower platform API.
554
+
555
+ Parameters
556
+ ----------
557
+ app_id : str
558
+ The application identifier.
559
+ app_version : str | None
560
+ The application version, or None for latest.
561
+ in_url : str
562
+ The API endpoint URL.
563
+ out_url : str
564
+ The key name for the download URL in the response.
565
+
566
+ Returns
567
+ -------
568
+ str
569
+ The download URL for the application.
570
+
571
+ Raises
572
+ ------
573
+ typer.Exit
574
+ If connection fails, app not found, or API request fails.
575
+ """
576
+ headers = {
577
+ "Content-Type": "application/json",
578
+ "Accept": "application/json",
579
+ }
580
+ body = {
581
+ "app_id": app_id, # send raw string of app_id
582
+ "app_version": app_version,
583
+ "flwr_version": flwr_version,
584
+ }
585
+ try:
586
+ resp = requests.post(in_url, headers=headers, data=json.dumps(body), timeout=20)
587
+ except requests.RequestException as e:
588
+ typer.secho(
589
+ f"Unable to connect to Platform API: {e}",
590
+ fg=typer.colors.RED,
591
+ err=True,
592
+ )
593
+ raise typer.Exit(code=1) from e
594
+
595
+ if resp.status_code == 404:
596
+ error_message = resp.json()["detail"]
597
+ if isinstance(error_message, dict):
598
+ available_app_versions = error_message["available_app_versions"]
599
+ available_versions_str = (
600
+ ", ".join(map(str, available_app_versions))
601
+ if available_app_versions
602
+ else "None"
603
+ )
604
+ typer.secho(
605
+ f"{app_id}=={app_version} not found in Platform API. "
606
+ f"Available app versions for {app_id}: {available_versions_str}",
607
+ fg=typer.colors.RED,
608
+ err=True,
609
+ )
610
+ else:
611
+ typer.secho(
612
+ f"{app_id} not found in Platform API.",
613
+ fg=typer.colors.RED,
614
+ err=True,
615
+ )
616
+ raise typer.Exit(code=1)
617
+
618
+ if not resp.ok:
619
+ typer.secho(
620
+ f"Platform API request failed with "
621
+ f"status {resp.status_code}. Details: {resp.text}",
622
+ fg=typer.colors.RED,
623
+ err=True,
624
+ )
625
+ raise typer.Exit(code=1)
626
+
627
+ data = resp.json()
628
+ if out_url not in data:
629
+ typer.secho(
630
+ "Invalid response from Platform API",
631
+ fg=typer.colors.RED,
632
+ err=True,
633
+ )
634
+ raise typer.Exit(code=1)
635
+ return str(data[out_url])
636
+
637
+
638
+ def build_pathspec(patterns: Iterable[str]) -> pathspec.PathSpec:
639
+ """Build a PathSpec from a list of GitIgnore-style patterns.
640
+
641
+ Parameters
642
+ ----------
643
+ patterns : Iterable[str]
644
+ Iterable of GitIgnore-style pattern strings.
645
+
646
+ Returns
647
+ -------
648
+ pathspec.PathSpec
649
+ Compiled PathSpec object for pattern matching.
650
+ """
651
+ return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
652
+
653
+
654
+ def load_gitignore_patterns(file: Path | bytes) -> list[str]:
655
+ """Load gitignore patterns from .gitignore file bytes.
656
+
657
+ Parameters
658
+ ----------
659
+ file : Path | bytes
660
+ The path to a .gitignore file or its bytes content.
661
+
662
+ Returns
663
+ -------
664
+ list[str]
665
+ List of gitignore patterns.
666
+ Returns empty list if content can't be decoded or the file does not exist.
667
+ """
668
+ try:
669
+ if isinstance(file, Path):
670
+ content = file.read_text(encoding="utf-8")
671
+ else:
672
+ content = file.decode("utf-8")
673
+ patterns = [
674
+ line.strip()
675
+ for line in content.splitlines()
676
+ if line.strip() and not line.strip().startswith("#")
677
+ ]
678
+ return patterns
679
+ except (UnicodeDecodeError, OSError):
680
+ return []
681
+
682
+
683
+ def validate_credentials_content(creds_path: Path) -> str:
684
+ """Load and validate the credentials file content.
685
+
686
+ Ensures required keys exist:
687
+ - AUTHN_TYPE_JSON_KEY
688
+ - ACCESS_TOKEN_KEY
689
+ - REFRESH_TOKEN_KEY
690
+ """
691
+ try:
692
+ creds: dict[str, str] = json.loads(creds_path.read_text(encoding="utf-8"))
693
+ except (OSError, json.JSONDecodeError) as err:
694
+ typer.secho(
695
+ f"Invalid credentials file at '{creds_path}': {err}",
696
+ fg=typer.colors.RED,
697
+ err=True,
698
+ )
699
+ raise typer.Exit(code=1) from err
700
+
701
+ required_keys = [AUTHN_TYPE_JSON_KEY, ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY]
702
+ missing = [key for key in required_keys if key not in creds]
703
+
704
+ if missing:
705
+ typer.secho(
706
+ f"Credentials file '{creds_path}' is missing "
707
+ f"required key(s): {', '.join(missing)}. Please log in again.",
708
+ fg=typer.colors.RED,
709
+ err=True,
710
+ )
711
+ raise typer.Exit(code=1)
712
+
713
+ return creds[ACCESS_TOKEN_KEY]
714
+
715
+
716
+ def parse_app_spec(app_spec: str) -> tuple[str, str | None]:
717
+ """Parse app specification string into app ID and version.
718
+
719
+ Parameters
720
+ ----------
721
+ app_spec : str
722
+ The app specification string in the format '@account/app' or
723
+ '@account/app==x.y.z' (digits only).
724
+
725
+ Returns
726
+ -------
727
+ tuple[str, str | None]
728
+ A tuple containing the app ID and optional version.
729
+
730
+ Raises
731
+ ------
732
+ typer.Exit
733
+ If the app specification format is invalid.
734
+ """
735
+ if "==" in app_spec:
736
+ app_id, app_version = app_spec.split("==")
737
+
738
+ # Validate app version format
739
+ if not re.match(APP_VERSION_PATTERN, app_version):
740
+ typer.secho(
741
+ "❌ Invalid app version. Expected format: x.y.z (digits only).",
742
+ fg=typer.colors.RED,
743
+ err=True,
744
+ )
745
+ raise typer.Exit(code=1)
746
+ else:
747
+ app_id = app_spec
748
+ app_version = None
749
+
750
+ # Validate app_id format
751
+ if not re.match(APP_ID_PATTERN, app_id):
752
+ typer.secho(
753
+ "❌ Invalid remote app ID. Expected format: '@account/app'.",
754
+ fg=typer.colors.RED,
755
+ err=True,
756
+ )
757
+ raise typer.Exit(code=1)
758
+
759
+ return app_id, app_version