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/ls.py CHANGED
@@ -17,9 +17,8 @@
17
17
 
18
18
  import io
19
19
  import json
20
- from datetime import datetime, timedelta
21
20
  from pathlib import Path
22
- from typing import Annotated, Optional
21
+ from typing import Annotated, cast
23
22
 
24
23
  import typer
25
24
  from rich.console import Console
@@ -33,33 +32,31 @@ from flwr.cli.config_utils import (
33
32
  validate_federation_in_project_config,
34
33
  )
35
34
  from flwr.cli.constant import FEDERATION_CONFIG_HELP_MESSAGE
36
- from flwr.common.constant import FAB_CONFIG_FILE, CliOutputFormat, SubStatus
37
- from flwr.common.date import format_timedelta, isoformat8601_utc
35
+ from flwr.common.constant import FAB_CONFIG_FILE, CliOutputFormat, Status, SubStatus
38
36
  from flwr.common.logger import print_json_error, redirect_output, restore_output
39
37
  from flwr.common.serde import run_from_proto
40
- from flwr.common.typing import Run
41
38
  from flwr.proto.control_pb2 import ( # pylint: disable=E0611
42
39
  ListRunsRequest,
43
40
  ListRunsResponse,
44
41
  )
45
42
  from flwr.proto.control_pb2_grpc import ControlStub
46
43
 
47
- from .utils import flwr_cli_grpc_exc_handler, init_channel, try_obtain_cli_auth_plugin
48
-
49
- _RunListType = tuple[int, str, str, str, str, str, str, str, str]
44
+ from .run_utils import RunRow, format_runs
45
+ from .utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
50
46
 
51
47
 
52
48
  def ls( # pylint: disable=too-many-locals, too-many-branches, R0913, R0917
49
+ ctx: typer.Context,
53
50
  app: Annotated[
54
51
  Path,
55
52
  typer.Argument(help="Path of the Flower project"),
56
53
  ] = Path("."),
57
54
  federation: Annotated[
58
- Optional[str],
55
+ str | None,
59
56
  typer.Argument(help="Name of the federation"),
60
57
  ] = None,
61
58
  federation_config_overrides: Annotated[
62
- Optional[list[str]],
59
+ list[str] | None,
63
60
  typer.Option(
64
61
  "--federation-config",
65
62
  help=FEDERATION_CONFIG_HELP_MESSAGE,
@@ -73,7 +70,7 @@ def ls( # pylint: disable=too-many-locals, too-many-branches, R0913, R0917
73
70
  ),
74
71
  ] = False,
75
72
  run_id: Annotated[
76
- Optional[int],
73
+ int | None,
77
74
  typer.Option(
78
75
  "--run-id",
79
76
  help="Specific run ID to display",
@@ -93,15 +90,17 @@ def ls( # pylint: disable=too-many-locals, too-many-branches, R0913, R0917
93
90
  The following details are displayed:
94
91
 
95
92
  - **Run ID:** Unique identifier for the run.
96
- - **FAB:** Name of the FAB associated with the run (``{FAB_ID} (v{FAB_VERSION})``).
93
+ - **Federation:** The federation to which the run belongs.
94
+ - **App:** The App associated with the run (``<APP_ID>==<APP_VERSION>``).
97
95
  - **Status:** Current status of the run (pending, starting, running, finished).
98
96
  - **Elapsed:** Time elapsed since the run started (``HH:MM:SS``).
99
- - **Created At:** Timestamp when the run was created.
100
- - **Running At:** Timestamp when the run started running.
101
- - **Finished At:** Timestamp when the run finished.
97
+ - **Status Changed @:** Timestamp of the most recent status change.
102
98
 
103
99
  All timestamps follow ISO 8601, UTC and are formatted as ``YYYY-MM-DD HH:MM:SSZ``.
104
100
  """
101
+ # Resolve command used (list or ls)
102
+ command_name = cast(str, ctx.command.name) if ctx.command else "list"
103
+
105
104
  suppress_output = output_format == CliOutputFormat.JSON
106
105
  captured_output = io.StringIO()
107
106
  try:
@@ -111,19 +110,19 @@ def ls( # pylint: disable=too-many-locals, too-many-branches, R0913, R0917
111
110
  typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)
112
111
 
113
112
  pyproject_path = app / FAB_CONFIG_FILE if app else None
114
- config, errors, warnings = load_and_validate(path=pyproject_path)
113
+ config, errors, warnings = load_and_validate(pyproject_path, check_module=False)
115
114
  config = process_loaded_project_config(config, errors, warnings)
116
115
  federation, federation_config = validate_federation_in_project_config(
117
116
  federation, config, federation_config_overrides
118
117
  )
119
- exit_if_no_address(federation_config, "ls")
118
+ exit_if_no_address(federation_config, command_name)
120
119
  channel = None
121
120
  try:
122
121
  if runs and run_id is not None:
123
122
  raise ValueError(
124
123
  "The options '--runs' and '--run-id' are mutually exclusive."
125
124
  )
126
- auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
125
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config)
127
126
  channel = init_channel(app, federation_config, auth_plugin)
128
127
  stub = ControlStub(channel)
129
128
 
@@ -139,7 +138,10 @@ def ls( # pylint: disable=too-many-locals, too-many-branches, R0913, R0917
139
138
  if output_format == CliOutputFormat.JSON:
140
139
  Console().print_json(_to_json(formatted_runs))
141
140
  else:
142
- Console().print(_to_table(formatted_runs))
141
+ if run_id is not None:
142
+ Console().print(_to_detail_table(formatted_runs[0]))
143
+ else:
144
+ Console().print(_to_table(formatted_runs))
143
145
  finally:
144
146
  if channel:
145
147
  channel.close()
@@ -153,6 +155,7 @@ def ls( # pylint: disable=too-many-locals, too-many-branches, R0913, R0917
153
155
  f"{err}",
154
156
  fg=typer.colors.RED,
155
157
  bold=True,
158
+ err=True,
156
159
  )
157
160
  finally:
158
161
  if suppress_output:
@@ -160,156 +163,197 @@ def ls( # pylint: disable=too-many-locals, too-many-branches, R0913, R0917
160
163
  captured_output.close()
161
164
 
162
165
 
163
- def _format_runs(run_dict: dict[int, Run], now_isoformat: str) -> list[_RunListType]:
164
- """Format runs to a list."""
165
-
166
- def _format_datetime(dt: Optional[datetime]) -> str:
167
- return isoformat8601_utc(dt).replace("T", " ") if dt else "N/A"
168
-
169
- run_list: list[_RunListType] = []
170
-
171
- # Add rows
172
- for run in sorted(
173
- run_dict.values(), key=lambda x: datetime.fromisoformat(x.pending_at)
174
- ):
175
- # Combine status and sub-status into a single string
176
- if run.status.sub_status == "":
177
- status_text = run.status.status
178
- else:
179
- status_text = f"{run.status.status}:{run.status.sub_status}"
180
-
181
- # Convert isoformat to datetime
182
- pending_at = datetime.fromisoformat(run.pending_at) if run.pending_at else None
183
- running_at = datetime.fromisoformat(run.running_at) if run.running_at else None
184
- finished_at = (
185
- datetime.fromisoformat(run.finished_at) if run.finished_at else None
186
- )
187
-
188
- # Calculate elapsed time
189
- elapsed_time = timedelta()
190
- if running_at:
191
- if finished_at:
192
- end_time = finished_at
193
- else:
194
- end_time = datetime.fromisoformat(now_isoformat)
195
- elapsed_time = end_time - running_at
196
-
197
- run_list.append(
198
- (
199
- run.run_id,
200
- run.fab_id,
201
- run.fab_version,
202
- run.fab_hash,
203
- status_text,
204
- format_timedelta(elapsed_time),
205
- _format_datetime(pending_at),
206
- _format_datetime(running_at),
207
- _format_datetime(finished_at),
208
- )
209
- )
210
- return run_list
166
+ def _get_status_style(status_text: str) -> str:
167
+ """Determine the display style/color for a status.
211
168
 
169
+ Parameters
170
+ ----------
171
+ status_text : str
172
+ The status text to determine color for.
212
173
 
213
- def _to_table(run_list: list[_RunListType]) -> Table:
214
- """Format the provided run list to a rich Table."""
174
+ Returns
175
+ -------
176
+ str
177
+ Color name for rich console styling (e.g., 'green', 'red', 'blue').
178
+ """
179
+ status = status_text.lower()
180
+ sub_status = status_text.rsplit(":", maxsplit=1)[-1]
181
+
182
+ if sub_status == SubStatus.COMPLETED: # finished:completed
183
+ return "green"
184
+ if sub_status == SubStatus.FAILED: # finished:failed
185
+ return "red"
186
+ if sub_status == SubStatus.STOPPED: # finished:stopped
187
+ return "yellow"
188
+ if status in (Status.STARTING, Status.RUNNING): # starting, running
189
+ return "blue"
190
+ return "bright_black" # pending
191
+
192
+
193
+ def _to_table(run_list: list[RunRow]) -> Table:
194
+ """Format the provided run list to a rich Table.
195
+
196
+ Parameters
197
+ ----------
198
+ run_list : list[RunRow]
199
+ List of run information to display.
200
+
201
+ Returns
202
+ -------
203
+ Table
204
+ Rich Table object with formatted run information.
205
+ """
215
206
  table = Table(header_style="bold cyan", show_lines=True)
216
207
 
217
208
  # Add columns
218
- table.add_column(
219
- Text("Run ID", justify="center"), style="bright_white", overflow="fold"
220
- )
221
- table.add_column(Text("FAB", justify="center"), style="dim white")
209
+ table.add_column(Text("Run ID", justify="center"), no_wrap=True)
210
+ table.add_column(Text("Federation", justify="center"))
211
+ table.add_column(Text("App", justify="center"))
222
212
  table.add_column(Text("Status", justify="center"))
223
213
  table.add_column(Text("Elapsed", justify="center"), style="blue")
224
- table.add_column(Text("Created At", justify="center"), style="dim white")
225
- table.add_column(Text("Running At", justify="center"), style="dim white")
226
- table.add_column(Text("Finished At", justify="center"), style="dim white")
214
+ table.add_column(Text("Status Changed @", justify="center"))
227
215
 
228
216
  for row in run_list:
229
- (
230
- run_id,
231
- fab_id,
232
- fab_version,
233
- _,
234
- status_text,
235
- elapsed,
236
- created_at,
237
- running_at,
238
- finished_at,
239
- ) = row
240
- # Style the status based on its value
241
- sub_status = status_text.rsplit(":", maxsplit=1)[-1]
242
- if sub_status == SubStatus.COMPLETED:
243
- status_style = "green"
244
- elif sub_status == SubStatus.FAILED:
245
- status_style = "red"
217
+ status_style = _get_status_style(row.status_text)
218
+
219
+ # Use the most recent timestamp
220
+ if row.finished_at != "N/A":
221
+ status_changed_at = row.finished_at
222
+ elif row.running_at != "N/A":
223
+ status_changed_at = row.running_at
224
+ elif row.starting_at != "N/A":
225
+ status_changed_at = row.starting_at
246
226
  else:
247
- status_style = "yellow"
227
+ status_changed_at = row.pending_at
248
228
 
249
229
  formatted_row = (
250
- f"[bold]{run_id}[/bold]",
251
- f"{fab_id} (v{fab_version})",
252
- f"[{status_style}]{status_text}[/{status_style}]",
253
- elapsed,
254
- created_at,
255
- running_at,
256
- finished_at,
230
+ f"[bold]{row.run_id}[/bold]",
231
+ row.federation,
232
+ f"@{row.fab_id}=={row.fab_version}",
233
+ f"[{status_style}]{row.status_text}[/{status_style}]",
234
+ row.elapsed,
235
+ status_changed_at,
257
236
  )
258
237
  table.add_row(*formatted_row)
259
238
 
260
239
  return table
261
240
 
262
241
 
263
- def _to_json(run_list: list[_RunListType]) -> str:
264
- """Format run status list to a JSON formatted string."""
242
+ def _to_detail_table(run: RunRow) -> Table:
243
+ """Format a single run's details in a vertical table layout.
244
+
245
+ Parameters
246
+ ----------
247
+ run : RunRow
248
+ The run information to display.
249
+
250
+ Returns
251
+ -------
252
+ Table
253
+ Rich Table object with detailed run information in vertical format.
254
+ """
255
+ status_style = _get_status_style(run.status_text)
256
+
257
+ # Create vertical table with field names on the left
258
+ table = Table(show_header=False, show_lines=False)
259
+ table.add_column("Field", style="bold cyan", no_wrap=True)
260
+ table.add_column("Value")
261
+
262
+ # Add rows with all details
263
+ table.add_row("Run ID", f"[bold]{run.run_id}[/bold]")
264
+ table.add_row("Federation", run.federation)
265
+ table.add_row("App", f"@{run.fab_id}=={run.fab_version}")
266
+ table.add_row("FAB Hash", f"{run.fab_hash[:8]}...{run.fab_hash[-8:]}")
267
+ table.add_row("Status", f"[{status_style}]{run.status_text}[/{status_style}]")
268
+ table.add_row("Elapsed", f"[blue]{run.elapsed}[/blue]")
269
+ table.add_row("Pending At", run.pending_at)
270
+ table.add_row("Starting At", run.starting_at)
271
+ table.add_row("Running At", run.running_at)
272
+ table.add_row("Finished At", run.finished_at)
273
+
274
+ return table
275
+
276
+
277
+ def _to_json(run_list: list[RunRow]) -> str:
278
+ """Format run status list to a JSON formatted string.
279
+
280
+ Parameters
281
+ ----------
282
+ run_list : list[RunRow]
283
+ List of run information to serialize.
284
+
285
+ Returns
286
+ -------
287
+ str
288
+ JSON string containing formatted run information.
289
+ """
265
290
  runs_list = []
266
291
  for row in run_list:
267
- (
268
- run_id,
269
- fab_id,
270
- fab_version,
271
- fab_hash,
272
- status_text,
273
- elapsed,
274
- created_at,
275
- running_at,
276
- finished_at,
277
- ) = row
278
292
  runs_list.append(
279
293
  {
280
- "run-id": run_id,
281
- "fab-id": fab_id,
282
- "fab-name": fab_id.split("/")[-1],
283
- "fab-version": fab_version,
284
- "fab-hash": fab_hash[:8],
285
- "status": status_text,
286
- "elapsed": elapsed,
287
- "created-at": created_at,
288
- "running-at": running_at,
289
- "finished-at": finished_at,
294
+ "run-id": row.run_id,
295
+ "federation": row.federation,
296
+ "fab-id": row.fab_id,
297
+ "fab-name": row.fab_id.split("/")[-1],
298
+ "fab-version": row.fab_version,
299
+ "fab-hash": row.fab_hash[:8],
300
+ "status": row.status_text,
301
+ "elapsed": row.elapsed,
302
+ "pending-at": row.pending_at,
303
+ "starting-at": row.starting_at,
304
+ "running-at": row.running_at,
305
+ "finished-at": row.finished_at,
290
306
  }
291
307
  )
292
308
 
293
309
  return json.dumps({"success": True, "runs": runs_list})
294
310
 
295
311
 
296
- def _list_runs(stub: ControlStub) -> list[_RunListType]:
297
- """List all runs."""
312
+ def _list_runs(stub: ControlStub) -> list[RunRow]:
313
+ """List all runs.
314
+
315
+ Parameters
316
+ ----------
317
+ stub : ControlStub
318
+ The gRPC stub for Control API communication.
319
+
320
+ Returns
321
+ -------
322
+ list[RunRow]
323
+ List of formatted run information for all runs.
324
+ """
298
325
  with flwr_cli_grpc_exc_handler():
299
326
  res: ListRunsResponse = stub.ListRuns(ListRunsRequest())
300
- run_dict = {run_id: run_from_proto(proto) for run_id, proto in res.run_dict.items()}
327
+ runs = [run_from_proto(proto) for proto in res.run_dict.values()]
328
+
329
+ return format_runs(runs, res.now)
301
330
 
302
- return _format_runs(run_dict, res.now)
303
331
 
332
+ def _display_one_run(stub: ControlStub, run_id: int) -> list[RunRow]:
333
+ """Display information about a specific run.
304
334
 
305
- def _display_one_run(stub: ControlStub, run_id: int) -> list[_RunListType]:
306
- """Display information about a specific run."""
335
+ Parameters
336
+ ----------
337
+ stub : ControlStub
338
+ The gRPC stub for Control API communication.
339
+ run_id : int
340
+ The unique identifier of the run to display.
341
+
342
+ Returns
343
+ -------
344
+ list[RunRow]
345
+ List containing the formatted run information (single item).
346
+
347
+ Raises
348
+ ------
349
+ ValueError
350
+ If the run_id is not found.
351
+ """
307
352
  with flwr_cli_grpc_exc_handler():
308
353
  res: ListRunsResponse = stub.ListRuns(ListRunsRequest(run_id=run_id))
309
354
  if not res.run_dict:
310
355
  # This won't be reached as an gRPC error is raised if run_id is invalid
311
356
  raise ValueError(f"Run ID {run_id} not found")
312
357
 
313
- run_dict = {run_id: run_from_proto(proto) for run_id, proto in res.run_dict.items()}
314
-
315
- return _format_runs(run_dict, res.now)
358
+ runs = [run_from_proto(proto) for proto in res.run_dict.values()]
359
+ return format_runs(runs, res.now)
flwr/cli/new/new.py CHANGED
@@ -15,18 +15,25 @@
15
15
  """Flower command line interface `new` command."""
16
16
 
17
17
 
18
+ import io
18
19
  import re
20
+ import zipfile
19
21
  from enum import Enum
20
22
  from pathlib import Path
21
23
  from string import Template
22
- from typing import Annotated, Optional
24
+ from typing import Annotated
23
25
 
26
+ import requests
24
27
  import typer
25
28
 
29
+ from flwr.supercore.constant import PLATFORM_API_URL
30
+
26
31
  from ..utils import (
27
32
  is_valid_project_name,
33
+ parse_app_spec,
28
34
  prompt_options,
29
35
  prompt_text,
36
+ request_download_link,
30
37
  sanitize_project_name,
31
38
  )
32
39
 
@@ -93,24 +100,186 @@ def render_and_create(file_path: Path, template: str, context: dict[str, str]) -
93
100
  create_file(file_path, content)
94
101
 
95
102
 
103
+ def print_success_prompt(
104
+ package_name: str, llm_challenge_str: str | None = None
105
+ ) -> None:
106
+ """Print styled setup instructions for running a new Flower App after creation."""
107
+ prompt = typer.style(
108
+ "🎊 Flower App creation successful.\n\n"
109
+ "To run your Flower App, first install its dependencies:\n\n",
110
+ fg=typer.colors.GREEN,
111
+ bold=True,
112
+ )
113
+
114
+ _add = " huggingface-cli login\n" if llm_challenge_str else ""
115
+
116
+ prompt += typer.style(
117
+ f" cd {package_name} && pip install -e .\n" + _add + "\n",
118
+ fg=typer.colors.BRIGHT_CYAN,
119
+ bold=True,
120
+ )
121
+
122
+ prompt += typer.style(
123
+ "then, run the app:\n\n ",
124
+ fg=typer.colors.GREEN,
125
+ bold=True,
126
+ )
127
+
128
+ prompt += typer.style(
129
+ "\tflwr run .\n\n",
130
+ fg=typer.colors.BRIGHT_CYAN,
131
+ bold=True,
132
+ )
133
+
134
+ prompt += typer.style(
135
+ "💡 Check the README in your app directory to learn how to\n"
136
+ "customize it and how to run it using the Deployment Runtime.\n",
137
+ fg=typer.colors.GREEN,
138
+ bold=True,
139
+ )
140
+
141
+ print(prompt)
142
+
143
+
144
+ # Security: prevent zip-slip
145
+ def _safe_extract_zip(zf: zipfile.ZipFile, dest_dir: Path) -> None:
146
+ """Extract ZIP file into destination directory."""
147
+ dest_dir = dest_dir.resolve()
148
+
149
+ def _is_within_directory(base: Path, target: Path) -> bool:
150
+ try:
151
+ target.relative_to(base)
152
+ return True
153
+ except ValueError:
154
+ return False
155
+
156
+ for member in zf.infolist():
157
+ # Skip directory placeholders;
158
+ # ZipInfo can represent them as names ending with '/'.
159
+ if member.is_dir():
160
+ target_path = (dest_dir / member.filename).resolve()
161
+ if not _is_within_directory(dest_dir, target_path):
162
+ raise ValueError(f"Unsafe path in zip: {member.filename}")
163
+ target_path.mkdir(parents=True, exist_ok=True)
164
+ continue
165
+
166
+ # Files
167
+ target_path = (dest_dir / member.filename).resolve()
168
+ if not _is_within_directory(dest_dir, target_path):
169
+ raise ValueError(f"Unsafe path in zip: {member.filename}")
170
+
171
+ # Ensure parent exists
172
+ target_path.parent.mkdir(parents=True, exist_ok=True)
173
+
174
+ # Extract
175
+ with zf.open(member, "r") as src, open(target_path, "wb") as dst:
176
+ dst.write(src.read())
177
+
178
+
179
+ def _download_zip_to_memory(presigned_url: str) -> io.BytesIO:
180
+ """Download ZIP file from Platform API to memory."""
181
+ try:
182
+ r = requests.get(presigned_url, timeout=60)
183
+ r.raise_for_status()
184
+ except requests.RequestException as e:
185
+ typer.secho(
186
+ f"ZIP download failed: {e}",
187
+ fg=typer.colors.RED,
188
+ err=True,
189
+ )
190
+ raise typer.Exit(code=1) from e
191
+
192
+ buf = io.BytesIO(r.content)
193
+ # Validate it's a zip
194
+ if not zipfile.is_zipfile(buf):
195
+ typer.secho(
196
+ "Downloaded file is not a valid ZIP",
197
+ fg=typer.colors.RED,
198
+ err=True,
199
+ )
200
+ raise typer.Exit(code=1)
201
+ buf.seek(0)
202
+ return buf
203
+
204
+
205
+ def download_remote_app_via_api(app_spec: str) -> None:
206
+ """Download App from Platform API."""
207
+ # Validate app version and ID format
208
+ app_id, app_version = parse_app_spec(app_spec)
209
+ app_name = app_id.split("/")[1]
210
+
211
+ project_dir = Path.cwd() / app_name
212
+ if project_dir.exists():
213
+ if not typer.confirm(
214
+ typer.style(
215
+ f"\n💬 {app_name} already exists, do you want to override it?",
216
+ fg=typer.colors.MAGENTA,
217
+ bold=True,
218
+ )
219
+ ):
220
+ return
221
+
222
+ print(
223
+ typer.style(
224
+ f"\n🔗 Requesting download link for {app_id}...",
225
+ fg=typer.colors.GREEN,
226
+ bold=True,
227
+ )
228
+ )
229
+ # Fetch ZIP downloading URL
230
+ url = f"{PLATFORM_API_URL}/hub/fetch-zip"
231
+ presigned_url = request_download_link(app_id, app_version, url, "zip_url")
232
+
233
+ print(
234
+ typer.style(
235
+ "⬇️ Downloading ZIP into memory...",
236
+ fg=typer.colors.GREEN,
237
+ bold=True,
238
+ )
239
+ )
240
+ zip_buf = _download_zip_to_memory(presigned_url)
241
+
242
+ print(
243
+ typer.style(
244
+ f"📦 Unpacking into {project_dir}...",
245
+ fg=typer.colors.GREEN,
246
+ bold=True,
247
+ )
248
+ )
249
+ with zipfile.ZipFile(zip_buf) as zf:
250
+ _safe_extract_zip(zf, Path.cwd())
251
+
252
+ print_success_prompt(app_name)
253
+
254
+
96
255
  # pylint: disable=too-many-locals,too-many-branches,too-many-statements
97
256
  def new(
98
257
  app_name: Annotated[
99
- Optional[str],
100
- typer.Argument(help="The name of the Flower App"),
258
+ str | None,
259
+ typer.Argument(
260
+ help="Flower app name. For remote apps, use the format "
261
+ "'@account_name/app_name' or '@account_name/app_name==x.y.z'. "
262
+ "Version is optional (defaults to latest)."
263
+ ),
101
264
  ] = None,
102
265
  framework: Annotated[
103
- Optional[MlFramework],
266
+ MlFramework | None,
104
267
  typer.Option(case_sensitive=False, help="The ML framework to use"),
105
268
  ] = None,
106
269
  username: Annotated[
107
- Optional[str],
270
+ str | None,
108
271
  typer.Option(case_sensitive=False, help="The Flower username of the author"),
109
272
  ] = None,
110
273
  ) -> None:
111
274
  """Create new Flower App."""
112
275
  if app_name is None:
113
276
  app_name = prompt_text("Please provide the app name")
277
+
278
+ # Download remote app
279
+ if app_name and app_name.startswith("@"):
280
+ download_remote_app_via_api(app_name)
281
+ return
282
+
114
283
  if not is_valid_project_name(app_name):
115
284
  app_name = prompt_text(
116
285
  "Please provide a name that only contains "
@@ -282,38 +451,4 @@ def new(
282
451
  context=context,
283
452
  )
284
453
 
285
- prompt = typer.style(
286
- "🎊 Flower App creation successful.\n\n"
287
- "To run your Flower App, first install its dependencies:\n\n",
288
- fg=typer.colors.GREEN,
289
- bold=True,
290
- )
291
-
292
- _add = " huggingface-cli login\n" if llm_challenge_str else ""
293
-
294
- prompt += typer.style(
295
- f" cd {package_name} && pip install -e .\n" + _add + "\n",
296
- fg=typer.colors.BRIGHT_CYAN,
297
- bold=True,
298
- )
299
-
300
- prompt += typer.style(
301
- "then, run the app:\n\n ",
302
- fg=typer.colors.GREEN,
303
- bold=True,
304
- )
305
-
306
- prompt += typer.style(
307
- "\tflwr run .\n\n",
308
- fg=typer.colors.BRIGHT_CYAN,
309
- bold=True,
310
- )
311
-
312
- prompt += typer.style(
313
- "💡 Check the README in your app directory to learn how to\n"
314
- "customize it and how to run it using the Deployment Runtime.\n",
315
- fg=typer.colors.GREEN,
316
- bold=True,
317
- )
318
-
319
- print(prompt)
454
+ print_success_prompt(package_name, llm_challenge_str)