flwr 1.23.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 (292) 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 +19 -0
  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/auth_plugin.py +4 -5
  9. flwr/cli/auth_plugin/noop_auth_plugin.py +54 -11
  10. flwr/cli/auth_plugin/oidc_cli_plugin.py +32 -9
  11. flwr/cli/build.py +60 -18
  12. flwr/cli/cli_account_auth_interceptor.py +24 -7
  13. flwr/cli/config_utils.py +101 -13
  14. flwr/cli/federation/__init__.py +24 -0
  15. flwr/cli/federation/ls.py +140 -0
  16. flwr/cli/federation/show.py +317 -0
  17. flwr/cli/install.py +91 -13
  18. flwr/cli/log.py +52 -9
  19. flwr/cli/login/login.py +7 -4
  20. flwr/cli/ls.py +170 -130
  21. flwr/cli/new/new.py +33 -50
  22. flwr/cli/new/templates/app/code/task.pytorch.py.tpl +1 -0
  23. flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +1 -1
  24. flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +1 -1
  25. flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +1 -1
  26. flwr/cli/new/templates/app/pyproject.jax.toml.tpl +1 -1
  27. flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +1 -1
  28. flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +1 -1
  29. flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +3 -3
  30. flwr/cli/new/templates/app/pyproject.pytorch_legacy_api.toml.tpl +1 -1
  31. flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +1 -1
  32. flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +2 -2
  33. flwr/cli/new/templates/app/pyproject.xgboost.toml.tpl +1 -1
  34. flwr/cli/pull.py +10 -5
  35. flwr/cli/run/run.py +77 -30
  36. flwr/cli/run_utils.py +130 -0
  37. flwr/cli/stop.py +25 -7
  38. flwr/cli/supernode/ls.py +16 -8
  39. flwr/cli/supernode/register.py +9 -4
  40. flwr/cli/supernode/unregister.py +5 -3
  41. flwr/cli/utils.py +376 -16
  42. flwr/client/__init__.py +1 -1
  43. flwr/client/dpfedavg_numpy_client.py +4 -1
  44. flwr/client/grpc_adapter_client/connection.py +6 -7
  45. flwr/client/grpc_rere_client/connection.py +10 -11
  46. flwr/client/grpc_rere_client/grpc_adapter.py +6 -2
  47. flwr/client/grpc_rere_client/node_auth_client_interceptor.py +2 -1
  48. flwr/client/message_handler/message_handler.py +2 -2
  49. flwr/client/mod/secure_aggregation/secaggplus_mod.py +3 -3
  50. flwr/client/numpy_client.py +1 -1
  51. flwr/client/rest_client/connection.py +12 -14
  52. flwr/client/run_info_store.py +4 -5
  53. flwr/client/typing.py +1 -1
  54. flwr/clientapp/client_app.py +9 -10
  55. flwr/clientapp/mod/centraldp_mods.py +16 -17
  56. flwr/clientapp/mod/localdp_mod.py +8 -9
  57. flwr/clientapp/typing.py +1 -1
  58. flwr/clientapp/utils.py +3 -3
  59. flwr/common/address.py +1 -2
  60. flwr/common/args.py +3 -4
  61. flwr/common/config.py +13 -16
  62. flwr/common/constant.py +5 -2
  63. flwr/common/differential_privacy.py +3 -4
  64. flwr/common/event_log_plugin/event_log_plugin.py +3 -4
  65. flwr/common/exit/exit.py +15 -2
  66. flwr/common/exit/exit_code.py +19 -0
  67. flwr/common/exit/exit_handler.py +6 -2
  68. flwr/common/exit/signal_handler.py +5 -5
  69. flwr/common/grpc.py +6 -6
  70. flwr/common/inflatable_protobuf_utils.py +1 -1
  71. flwr/common/inflatable_utils.py +38 -21
  72. flwr/common/logger.py +19 -19
  73. flwr/common/message.py +4 -4
  74. flwr/common/object_ref.py +7 -7
  75. flwr/common/record/array.py +3 -3
  76. flwr/common/record/arrayrecord.py +18 -30
  77. flwr/common/record/configrecord.py +3 -3
  78. flwr/common/record/recorddict.py +5 -5
  79. flwr/common/record/typeddict.py +9 -2
  80. flwr/common/recorddict_compat.py +7 -10
  81. flwr/common/retry_invoker.py +20 -20
  82. flwr/common/secure_aggregation/ndarrays_arithmetic.py +3 -3
  83. flwr/common/serde.py +5 -4
  84. flwr/common/serde_utils.py +2 -2
  85. flwr/common/telemetry.py +9 -5
  86. flwr/common/typing.py +52 -37
  87. flwr/compat/client/app.py +38 -37
  88. flwr/compat/client/grpc_client/connection.py +11 -11
  89. flwr/compat/server/app.py +5 -6
  90. flwr/proto/appio_pb2.py +13 -3
  91. flwr/proto/appio_pb2.pyi +134 -65
  92. flwr/proto/appio_pb2_grpc.py +20 -0
  93. flwr/proto/appio_pb2_grpc.pyi +27 -0
  94. flwr/proto/clientappio_pb2.py +17 -7
  95. flwr/proto/clientappio_pb2.pyi +15 -0
  96. flwr/proto/clientappio_pb2_grpc.py +206 -40
  97. flwr/proto/clientappio_pb2_grpc.pyi +168 -53
  98. flwr/proto/control_pb2.py +71 -52
  99. flwr/proto/control_pb2.pyi +277 -111
  100. flwr/proto/control_pb2_grpc.py +249 -40
  101. flwr/proto/control_pb2_grpc.pyi +185 -52
  102. flwr/proto/error_pb2.py +13 -3
  103. flwr/proto/error_pb2.pyi +24 -6
  104. flwr/proto/error_pb2_grpc.py +20 -0
  105. flwr/proto/error_pb2_grpc.pyi +27 -0
  106. flwr/proto/fab_pb2.py +14 -4
  107. flwr/proto/fab_pb2.pyi +59 -31
  108. flwr/proto/fab_pb2_grpc.py +20 -0
  109. flwr/proto/fab_pb2_grpc.pyi +27 -0
  110. flwr/proto/federation_pb2.py +38 -0
  111. flwr/proto/federation_pb2.pyi +56 -0
  112. flwr/proto/federation_pb2_grpc.py +24 -0
  113. flwr/proto/federation_pb2_grpc.pyi +31 -0
  114. flwr/proto/fleet_pb2.py +14 -4
  115. flwr/proto/fleet_pb2.pyi +137 -61
  116. flwr/proto/fleet_pb2_grpc.py +189 -48
  117. flwr/proto/fleet_pb2_grpc.pyi +175 -61
  118. flwr/proto/grpcadapter_pb2.py +14 -4
  119. flwr/proto/grpcadapter_pb2.pyi +38 -16
  120. flwr/proto/grpcadapter_pb2_grpc.py +35 -4
  121. flwr/proto/grpcadapter_pb2_grpc.pyi +38 -7
  122. flwr/proto/heartbeat_pb2.py +17 -7
  123. flwr/proto/heartbeat_pb2.pyi +51 -22
  124. flwr/proto/heartbeat_pb2_grpc.py +20 -0
  125. flwr/proto/heartbeat_pb2_grpc.pyi +27 -0
  126. flwr/proto/log_pb2.py +13 -3
  127. flwr/proto/log_pb2.pyi +34 -11
  128. flwr/proto/log_pb2_grpc.py +20 -0
  129. flwr/proto/log_pb2_grpc.pyi +27 -0
  130. flwr/proto/message_pb2.py +15 -5
  131. flwr/proto/message_pb2.pyi +154 -86
  132. flwr/proto/message_pb2_grpc.py +20 -0
  133. flwr/proto/message_pb2_grpc.pyi +27 -0
  134. flwr/proto/node_pb2.py +15 -5
  135. flwr/proto/node_pb2.pyi +50 -25
  136. flwr/proto/node_pb2_grpc.py +20 -0
  137. flwr/proto/node_pb2_grpc.pyi +27 -0
  138. flwr/proto/recorddict_pb2.py +13 -3
  139. flwr/proto/recorddict_pb2.pyi +184 -107
  140. flwr/proto/recorddict_pb2_grpc.py +20 -0
  141. flwr/proto/recorddict_pb2_grpc.pyi +27 -0
  142. flwr/proto/run_pb2.py +40 -31
  143. flwr/proto/run_pb2.pyi +149 -84
  144. flwr/proto/run_pb2_grpc.py +20 -0
  145. flwr/proto/run_pb2_grpc.pyi +27 -0
  146. flwr/proto/serverappio_pb2.py +13 -3
  147. flwr/proto/serverappio_pb2.pyi +32 -8
  148. flwr/proto/serverappio_pb2_grpc.py +246 -65
  149. flwr/proto/serverappio_pb2_grpc.pyi +221 -85
  150. flwr/proto/simulationio_pb2.py +16 -8
  151. flwr/proto/simulationio_pb2.pyi +15 -0
  152. flwr/proto/simulationio_pb2_grpc.py +162 -41
  153. flwr/proto/simulationio_pb2_grpc.pyi +149 -55
  154. flwr/proto/transport_pb2.py +20 -10
  155. flwr/proto/transport_pb2.pyi +249 -160
  156. flwr/proto/transport_pb2_grpc.py +35 -4
  157. flwr/proto/transport_pb2_grpc.pyi +38 -8
  158. flwr/server/app.py +38 -17
  159. flwr/server/client_manager.py +4 -5
  160. flwr/server/client_proxy.py +10 -11
  161. flwr/server/compat/app.py +4 -5
  162. flwr/server/compat/app_utils.py +2 -1
  163. flwr/server/compat/grid_client_proxy.py +10 -12
  164. flwr/server/compat/legacy_context.py +3 -4
  165. flwr/server/fleet_event_log_interceptor.py +2 -1
  166. flwr/server/grid/grid.py +2 -3
  167. flwr/server/grid/grpc_grid.py +10 -8
  168. flwr/server/grid/inmemory_grid.py +4 -4
  169. flwr/server/run_serverapp.py +2 -3
  170. flwr/server/server.py +34 -39
  171. flwr/server/server_app.py +7 -8
  172. flwr/server/server_config.py +1 -2
  173. flwr/server/serverapp/app.py +34 -28
  174. flwr/server/serverapp_components.py +4 -5
  175. flwr/server/strategy/aggregate.py +9 -8
  176. flwr/server/strategy/bulyan.py +13 -11
  177. flwr/server/strategy/dp_adaptive_clipping.py +16 -20
  178. flwr/server/strategy/dp_fixed_clipping.py +12 -17
  179. flwr/server/strategy/dpfedavg_adaptive.py +3 -4
  180. flwr/server/strategy/dpfedavg_fixed.py +6 -10
  181. flwr/server/strategy/fault_tolerant_fedavg.py +14 -13
  182. flwr/server/strategy/fedadagrad.py +18 -14
  183. flwr/server/strategy/fedadam.py +16 -14
  184. flwr/server/strategy/fedavg.py +16 -17
  185. flwr/server/strategy/fedavg_android.py +15 -15
  186. flwr/server/strategy/fedavgm.py +21 -18
  187. flwr/server/strategy/fedmedian.py +2 -3
  188. flwr/server/strategy/fedopt.py +11 -10
  189. flwr/server/strategy/fedprox.py +10 -9
  190. flwr/server/strategy/fedtrimmedavg.py +12 -11
  191. flwr/server/strategy/fedxgb_bagging.py +13 -11
  192. flwr/server/strategy/fedxgb_cyclic.py +6 -6
  193. flwr/server/strategy/fedxgb_nn_avg.py +4 -4
  194. flwr/server/strategy/fedyogi.py +16 -14
  195. flwr/server/strategy/krum.py +12 -11
  196. flwr/server/strategy/qfedavg.py +16 -15
  197. flwr/server/strategy/strategy.py +6 -9
  198. flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer.py +2 -1
  199. flwr/server/superlink/fleet/grpc_bidi/flower_service_servicer.py +1 -2
  200. flwr/server/superlink/fleet/grpc_bidi/grpc_bridge.py +3 -4
  201. flwr/server/superlink/fleet/grpc_bidi/grpc_client_proxy.py +10 -12
  202. flwr/server/superlink/fleet/grpc_bidi/grpc_server.py +1 -3
  203. flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py +4 -4
  204. flwr/server/superlink/fleet/grpc_rere/node_auth_server_interceptor.py +3 -2
  205. flwr/server/superlink/fleet/message_handler/message_handler.py +34 -28
  206. flwr/server/superlink/fleet/rest_rere/rest_api.py +2 -2
  207. flwr/server/superlink/fleet/vce/backend/backend.py +1 -1
  208. flwr/server/superlink/fleet/vce/backend/raybackend.py +5 -5
  209. flwr/server/superlink/fleet/vce/vce_api.py +15 -9
  210. flwr/server/superlink/linkstate/in_memory_linkstate.py +115 -150
  211. flwr/server/superlink/linkstate/linkstate.py +59 -43
  212. flwr/server/superlink/linkstate/linkstate_factory.py +22 -5
  213. flwr/server/superlink/linkstate/sqlite_linkstate.py +447 -438
  214. flwr/server/superlink/linkstate/utils.py +6 -6
  215. flwr/server/superlink/serverappio/serverappio_grpc.py +1 -2
  216. flwr/server/superlink/serverappio/serverappio_servicer.py +26 -21
  217. flwr/server/superlink/simulation/simulationio_grpc.py +1 -2
  218. flwr/server/superlink/simulation/simulationio_servicer.py +18 -13
  219. flwr/server/superlink/utils.py +4 -6
  220. flwr/server/typing.py +1 -1
  221. flwr/server/utils/tensorboard.py +15 -8
  222. flwr/server/workflow/default_workflows.py +5 -5
  223. flwr/server/workflow/secure_aggregation/secagg_workflow.py +2 -4
  224. flwr/server/workflow/secure_aggregation/secaggplus_workflow.py +8 -8
  225. flwr/serverapp/strategy/bulyan.py +16 -15
  226. flwr/serverapp/strategy/dp_adaptive_clipping.py +12 -11
  227. flwr/serverapp/strategy/dp_fixed_clipping.py +11 -14
  228. flwr/serverapp/strategy/fedadagrad.py +10 -11
  229. flwr/serverapp/strategy/fedadam.py +10 -11
  230. flwr/serverapp/strategy/fedavg.py +9 -10
  231. flwr/serverapp/strategy/fedavgm.py +17 -16
  232. flwr/serverapp/strategy/fedmedian.py +2 -2
  233. flwr/serverapp/strategy/fedopt.py +10 -11
  234. flwr/serverapp/strategy/fedprox.py +7 -8
  235. flwr/serverapp/strategy/fedtrimmedavg.py +9 -9
  236. flwr/serverapp/strategy/fedxgb_bagging.py +3 -3
  237. flwr/serverapp/strategy/fedxgb_cyclic.py +9 -9
  238. flwr/serverapp/strategy/fedyogi.py +9 -11
  239. flwr/serverapp/strategy/krum.py +7 -7
  240. flwr/serverapp/strategy/multikrum.py +9 -9
  241. flwr/serverapp/strategy/qfedavg.py +17 -16
  242. flwr/serverapp/strategy/strategy.py +6 -9
  243. flwr/serverapp/strategy/strategy_utils.py +7 -8
  244. flwr/simulation/app.py +46 -42
  245. flwr/simulation/legacy_app.py +12 -12
  246. flwr/simulation/ray_transport/ray_actor.py +10 -11
  247. flwr/simulation/ray_transport/ray_client_proxy.py +11 -12
  248. flwr/simulation/run_simulation.py +43 -43
  249. flwr/simulation/simulationio_connection.py +4 -4
  250. flwr/supercore/cli/flower_superexec.py +3 -4
  251. flwr/supercore/constant.py +31 -1
  252. flwr/supercore/corestate/corestate.py +24 -3
  253. flwr/supercore/corestate/in_memory_corestate.py +138 -0
  254. flwr/supercore/corestate/sqlite_corestate.py +157 -0
  255. flwr/supercore/ffs/disk_ffs.py +1 -2
  256. flwr/supercore/ffs/ffs.py +1 -2
  257. flwr/supercore/ffs/ffs_factory.py +1 -2
  258. flwr/{common → supercore}/heartbeat.py +20 -25
  259. flwr/supercore/object_store/in_memory_object_store.py +1 -2
  260. flwr/supercore/object_store/object_store.py +1 -2
  261. flwr/supercore/object_store/object_store_factory.py +1 -2
  262. flwr/supercore/object_store/sqlite_object_store.py +8 -7
  263. flwr/supercore/primitives/asymmetric.py +1 -1
  264. flwr/supercore/primitives/asymmetric_ed25519.py +11 -1
  265. flwr/supercore/sqlite_mixin.py +37 -34
  266. flwr/supercore/superexec/plugin/base_exec_plugin.py +1 -2
  267. flwr/supercore/superexec/plugin/exec_plugin.py +3 -3
  268. flwr/supercore/superexec/run_superexec.py +9 -13
  269. flwr/superlink/artifact_provider/artifact_provider.py +1 -2
  270. flwr/superlink/auth_plugin/auth_plugin.py +6 -9
  271. flwr/superlink/auth_plugin/noop_auth_plugin.py +6 -9
  272. flwr/superlink/federation/__init__.py +24 -0
  273. flwr/superlink/federation/federation_manager.py +64 -0
  274. flwr/superlink/federation/noop_federation_manager.py +71 -0
  275. flwr/superlink/servicer/control/control_account_auth_interceptor.py +22 -13
  276. flwr/superlink/servicer/control/control_event_log_interceptor.py +7 -7
  277. flwr/superlink/servicer/control/control_grpc.py +5 -6
  278. flwr/superlink/servicer/control/control_license_interceptor.py +3 -3
  279. flwr/superlink/servicer/control/control_servicer.py +102 -18
  280. flwr/supernode/cli/flower_supernode.py +58 -3
  281. flwr/supernode/nodestate/in_memory_nodestate.py +60 -49
  282. flwr/supernode/nodestate/nodestate.py +7 -8
  283. flwr/supernode/nodestate/nodestate_factory.py +7 -4
  284. flwr/supernode/runtime/run_clientapp.py +41 -22
  285. flwr/supernode/servicer/clientappio/clientappio_servicer.py +40 -10
  286. flwr/supernode/start_client_internal.py +158 -42
  287. {flwr-1.23.0.dist-info → flwr-1.24.0.dist-info}/METADATA +8 -8
  288. flwr-1.24.0.dist-info/RECORD +454 -0
  289. flwr/supercore/object_store/utils.py +0 -43
  290. flwr-1.23.0.dist-info/RECORD +0 -439
  291. {flwr-1.23.0.dist-info → flwr-1.24.0.dist-info}/WHEEL +0 -0
  292. {flwr-1.23.0.dist-info → flwr-1.24.0.dist-info}/entry_points.txt +0 -0
flwr/__init__.py CHANGED
@@ -15,18 +15,29 @@
15
15
  """Flower main package."""
16
16
 
17
17
 
18
+ import importlib
19
+
18
20
  from flwr.common.version import package_version as _package_version
19
21
 
20
- from . import app, client, clientapp, common, server, serverapp, simulation
22
+ from . import app, clientapp, serverapp
21
23
 
22
24
  __all__ = [
23
25
  "app",
24
- "client",
25
26
  "clientapp",
26
- "common",
27
- "server",
28
27
  "serverapp",
29
- "simulation",
30
28
  ]
31
29
 
32
30
  __version__ = _package_version
31
+
32
+
33
+ # Lazy imports for legacy support
34
+ _lazy_imports = {"simulation", "server", "client", "common"}
35
+
36
+
37
+ def __getattr__(name: str) -> object:
38
+ """Lazy import for legacy support."""
39
+ if name in _lazy_imports:
40
+ module = importlib.import_module(f"{__name__}.{name}")
41
+ globals()[name] = module
42
+ return module
43
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
flwr/app/error.py CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- from typing import Optional, cast
20
+ from typing import cast
21
21
 
22
22
  DEFAULT_TTL = 43200 # This is 12 hours
23
23
  MESSAGE_INIT_ERROR_MESSAGE = (
@@ -54,7 +54,7 @@ class Error:
54
54
  @property
55
55
  def reason(self) -> str | None:
56
56
  """Reason reported about the error."""
57
- return cast(Optional[str], self.__dict__["_reason"])
57
+ return cast(str | None, self.__dict__["_reason"])
58
58
 
59
59
  def __repr__(self) -> str:
60
60
  """Return a string representation of this instance."""
flwr/app/exception.py CHANGED
@@ -15,11 +15,11 @@
15
15
  """Flower application exceptions."""
16
16
 
17
17
 
18
- class AppExitException(BaseException):
18
+ class AppExitException(Exception):
19
19
  """Base exception for all application-level errors in ServerApp and ClientApp.
20
20
 
21
- When raised, the process will exit and report a telemetry event with the associated
22
- exit code. This is not intended to be caught by user code.
21
+ When raised (and not suppressed), the process will exit and report a telemetry event
22
+ with the associated exit code.
23
23
  """
24
24
 
25
25
  # Default exit code — subclasses must override
flwr/cli/app.py CHANGED
@@ -19,7 +19,11 @@ from typer.main import get_command
19
19
 
20
20
  from flwr.common.version import package_version
21
21
 
22
+ from .app_cmd import publish as app_publish
23
+ from .app_cmd import review as app_review
22
24
  from .build import build
25
+ from .federation import ls as federation_list
26
+ from .federation import show as federation_show
23
27
  from .install import install
24
28
  from .log import log
25
29
  from .login import login
@@ -63,6 +67,21 @@ supernode_app.command("list")(supernode_list)
63
67
  supernode_app.command(hidden=True)(supernode_list)
64
68
  app.add_typer(supernode_app, name="supernode")
65
69
 
70
+ # Create app command group
71
+ app_app = typer.Typer(help="Manage Apps")
72
+ app_app.command()(app_review)
73
+ app_app.command()(app_publish)
74
+ app.add_typer(app_app, name="app")
75
+
76
+ # Create federation command group
77
+ federation_app = typer.Typer(help="Manage Federations")
78
+ # Make it appear as "list"
79
+ federation_app.command("list")(federation_list)
80
+ # Hide "ls" command (left as alias)
81
+ federation_app.command(hidden=True)(federation_list)
82
+ app.add_typer(federation_app, name="federation")
83
+ federation_app.command()(federation_show)
84
+
66
85
  typer_click_object = get_command(app)
67
86
 
68
87
 
@@ -0,0 +1,23 @@
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 command line interface `app` command."""
16
+
17
+ from .publish import publish as publish
18
+ from .review import review as review
19
+
20
+ __all__ = [
21
+ "publish",
22
+ "review",
23
+ ]
@@ -0,0 +1,285 @@
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 command line interface `app publish` command."""
16
+
17
+
18
+ from contextlib import ExitStack
19
+ from pathlib import Path
20
+ from typing import IO, Annotated
21
+
22
+ import requests
23
+ import typer
24
+ from requests import Response
25
+
26
+ from flwr.common.constant import FAB_CONFIG_FILE
27
+ from flwr.common.version import package_version as flwr_version
28
+ from flwr.supercore.constant import (
29
+ APP_PUBLISH_EXCLUDE_PATTERNS,
30
+ APP_PUBLISH_INCLUDE_PATTERNS,
31
+ MAX_DIR_DEPTH,
32
+ MAX_FILE_BYTES,
33
+ MAX_FILE_COUNT,
34
+ MAX_TOTAL_BYTES,
35
+ MIME_MAP,
36
+ PLATFORM_API_URL,
37
+ UTF8,
38
+ )
39
+
40
+ from ..auth_plugin.oidc_cli_plugin import OidcCliPlugin
41
+ from ..config_utils import (
42
+ load_and_validate,
43
+ process_loaded_project_config,
44
+ validate_federation_in_project_config,
45
+ )
46
+ from ..constant import FEDERATION_CONFIG_HELP_MESSAGE
47
+ from ..utils import build_pathspec, load_cli_auth_plugin, load_gitignore_patterns
48
+
49
+
50
+ # pylint: disable=too-many-locals
51
+ def publish(
52
+ app: Annotated[
53
+ Path,
54
+ typer.Argument(
55
+ help="Project directory to upload (defaults to current directory)."
56
+ ),
57
+ ] = Path("."),
58
+ federation: Annotated[
59
+ str | None,
60
+ typer.Argument(
61
+ help="Name of the federation used for login before publishing app."
62
+ ),
63
+ ] = None,
64
+ federation_config_overrides: Annotated[
65
+ list[str] | None,
66
+ typer.Option(
67
+ "--federation-config",
68
+ help=FEDERATION_CONFIG_HELP_MESSAGE,
69
+ ),
70
+ ] = None,
71
+ ) -> None:
72
+ """Publish a Flower App to the Flower Platform.
73
+
74
+ This command uploads your app project to the Flower Platform. Files are filtered
75
+ based on .gitignore patterns and allowed file extensions.
76
+ """
77
+ # Load configs
78
+ pyproject_path = app / FAB_CONFIG_FILE if app else None
79
+ config, errors, warnings = load_and_validate(pyproject_path, check_module=False)
80
+ config = process_loaded_project_config(config, errors, warnings)
81
+ federation, federation_config = validate_federation_in_project_config(
82
+ federation, config, federation_config_overrides
83
+ )
84
+
85
+ # Load the authentication plugin
86
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config)
87
+ auth_plugin.load_tokens()
88
+ if not isinstance(auth_plugin, OidcCliPlugin) or not auth_plugin.access_token:
89
+ typer.secho(
90
+ "❌ Please log in before publishing app.",
91
+ fg=typer.colors.RED,
92
+ err=True,
93
+ )
94
+ raise typer.Exit(code=1)
95
+
96
+ # Load token from the plugin
97
+ token = auth_plugin.access_token
98
+
99
+ # Collect & validate app files
100
+ file_paths = _collect_file_paths(app)
101
+ _validate_files(file_paths)
102
+
103
+ # Build and POST multipart
104
+ with ExitStack() as stack:
105
+ files_param = _build_multipart_files_param(app, file_paths, stack)
106
+ try:
107
+ resp = _post_files(files_param, token)
108
+ except requests.RequestException as err:
109
+ typer.secho(f"❌ Network error: {err}", fg=typer.colors.RED, err=True)
110
+ raise typer.Exit(code=1) from err
111
+
112
+ if resp.ok:
113
+ typer.secho("🎊 Upload successful", fg=typer.colors.GREEN, bold=True)
114
+ return # success
115
+
116
+ # Error path:
117
+ msg = f"❌ Upload failed with status {resp.status_code}"
118
+ if resp.text:
119
+ msg += f": {resp.text}"
120
+ typer.secho(msg, fg=typer.colors.RED, err=True)
121
+ raise typer.Exit(code=1)
122
+
123
+
124
+ def _depth_of(relative_path_to_root: Path) -> int:
125
+ """Return depth that is number of parts (directories) in the relative path
126
+ (excluding filename).
127
+
128
+ Example: "a/b/c.py" -> depth 2
129
+ Interpret "directory depth" as number of directories: len(parts) - 1
130
+ """
131
+ return max(0, len(relative_path_to_root.parts) - 1)
132
+
133
+
134
+ def _detect_mime(path: Path) -> str:
135
+ """Detect files' MIME."""
136
+ return MIME_MAP.get(path.suffix.lower(), "text/plain; charset=utf-8")
137
+
138
+
139
+ def _collect_file_paths(root: Path) -> list[Path]:
140
+ """Return list of file paths that match include/exclude patterns."""
141
+ # Build include/exclude pathspecs
142
+ # Note: This should be a temporary solution until we have a complete mechanism
143
+ # for configurable inclusion and exclusion rules.
144
+ # Note: Unlike Git, we do not support nested .gitignore files in subdirectories.
145
+ gitignore_patterns = tuple(load_gitignore_patterns(root / ".gitignore"))
146
+ exclude_pathspec = build_pathspec(gitignore_patterns + APP_PUBLISH_EXCLUDE_PATTERNS)
147
+ include_pathspec = build_pathspec(APP_PUBLISH_INCLUDE_PATTERNS)
148
+
149
+ # Walk the directory tree
150
+ file_paths: list[Path] = []
151
+ for path in root.rglob("*"):
152
+ if not path.is_file():
153
+ continue
154
+
155
+ # Skip excluded or not included files
156
+ # Note: pathspec requires POSIX style relative paths
157
+ relative_path = path.relative_to(root)
158
+ posix = relative_path.as_posix()
159
+ if exclude_pathspec.match_file(posix) or not include_pathspec.match_file(posix):
160
+ typer.echo(typer.style(f"Skip: {path}", fg=typer.colors.YELLOW))
161
+ continue
162
+
163
+ # Check max depth
164
+ if _depth_of(relative_path) > MAX_DIR_DEPTH:
165
+ typer.secho(
166
+ f"Error: '{path}' "
167
+ f"exceeds the maximum directory depth "
168
+ f"of {MAX_DIR_DEPTH}.",
169
+ fg=typer.colors.RED,
170
+ err=True,
171
+ )
172
+ raise typer.Exit(code=2)
173
+
174
+ file_paths.append(path)
175
+
176
+ # Sort for deterministic ordering
177
+ file_paths.sort(key=lambda path: path.as_posix())
178
+ return file_paths
179
+
180
+
181
+ def _validate_files(file_paths: list[Path]) -> None:
182
+ """Validate files against upload constraints.
183
+
184
+ Checks file count, individual file size, total size, and UTF-8 encoding.
185
+ """
186
+ if len(file_paths) == 0:
187
+ typer.secho(
188
+ "Nothing to upload: no files matched after applying .gitignore and "
189
+ "allowed extensions.",
190
+ fg=typer.colors.RED,
191
+ err=True,
192
+ )
193
+ raise typer.Exit(code=2)
194
+
195
+ if len(file_paths) > MAX_FILE_COUNT:
196
+ typer.secho(
197
+ f"Too many files: {len(file_paths)} > allowed maximum of {MAX_FILE_COUNT}.",
198
+ fg=typer.colors.RED,
199
+ err=True,
200
+ )
201
+ raise typer.Exit(code=2)
202
+
203
+ # Calculate files size
204
+ total_size = 0
205
+ for path in file_paths:
206
+ file_size = path.stat().st_size
207
+ total_size += file_size
208
+
209
+ # Check single file size
210
+ if file_size > MAX_FILE_BYTES:
211
+ typer.secho(
212
+ f"File too large: '{path.as_posix()}' is {file_size:,} bytes, "
213
+ f"exceeding the per-file limit of {MAX_FILE_BYTES:,} bytes.",
214
+ fg=typer.colors.RED,
215
+ err=True,
216
+ )
217
+ raise typer.Exit(code=2)
218
+
219
+ # Ensure we can decode as UTF-8.
220
+ try:
221
+ path.read_text(encoding=UTF8)
222
+ except UnicodeDecodeError as err:
223
+ typer.secho(
224
+ f"Encoding error: '{path}' is not UTF-8 encoded.",
225
+ fg=typer.colors.RED,
226
+ err=True,
227
+ )
228
+ raise typer.Exit(code=2) from err
229
+
230
+ # Check total files size
231
+ if total_size > MAX_TOTAL_BYTES:
232
+ typer.secho(
233
+ "Total size of all files is too large: "
234
+ f"{total_size:,} bytes > {MAX_TOTAL_BYTES:,} bytes.",
235
+ fg=typer.colors.RED,
236
+ err=True,
237
+ )
238
+ raise typer.Exit(code=2)
239
+
240
+ # Print validation passed prompt
241
+ typer.echo(typer.style("✅ Validation passed", fg=typer.colors.GREEN, bold=True))
242
+ typer.echo(f"{len(file_paths)} files, {total_size:,} bytes in total")
243
+
244
+
245
+ def _build_multipart_files_param(
246
+ root: Path,
247
+ file_paths: list[Path],
248
+ stack: ExitStack,
249
+ ) -> list[tuple[str, tuple[str, IO[bytes], str]]]:
250
+ """Build multipart/form-data files parameter for HTTP upload.
251
+
252
+ Returns list of tuples: (field_name, (filename, file_object, content_type)).
253
+ File handles are registered with ExitStack for proper cleanup.
254
+ """
255
+ form: list[tuple[str, tuple[str, IO[bytes], str]]] = []
256
+ for path in file_paths:
257
+ # Detect MIME (content type)
258
+ mime = _detect_mime(path)
259
+
260
+ # Open file and register with ExitStack
261
+ # pylint: disable-next=consider-using-with
262
+ fobj = stack.enter_context(open(path.resolve(), "rb"))
263
+ typer.echo(f"Attach {path} ({mime}, {path.stat().st_size:,} bytes)")
264
+
265
+ # Get relative POSIX path
266
+ relative_posix = path.relative_to(root).as_posix()
267
+
268
+ # Append to form data (key, (filename, fileobj, mime))
269
+ form.append(("files", (relative_posix, fobj, mime)))
270
+ return form
271
+
272
+
273
+ def _post_files(
274
+ files_param: list[tuple[str, tuple[str, IO[bytes], str]]],
275
+ token: str,
276
+ ) -> Response:
277
+ """POST multipart with one part per file."""
278
+ url = f"{PLATFORM_API_URL}/hub/apps/publish"
279
+ headers = {"Authorization": f"Bearer {token}"}
280
+ body = {"flwr_version": flwr_version}
281
+
282
+ resp = requests.post(
283
+ url, files=files_param, headers=headers, json=body, timeout=120
284
+ )
285
+ return resp
@@ -0,0 +1,252 @@
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 command line interface `app review` command."""
16
+
17
+
18
+ import base64
19
+ import hashlib
20
+ import re
21
+ from pathlib import Path
22
+ from typing import Annotated
23
+
24
+ import requests
25
+ import typer
26
+ from cryptography.exceptions import UnsupportedAlgorithm
27
+ from cryptography.hazmat.primitives.asymmetric import ed25519
28
+
29
+ from flwr.common import now
30
+ from flwr.common.config import get_flwr_dir
31
+ from flwr.common.constant import FAB_CONFIG_FILE
32
+ from flwr.common.version import package_version as flwr_version
33
+ from flwr.supercore.constant import PLATFORM_API_URL
34
+ from flwr.supercore.primitives.asymmetric_ed25519 import (
35
+ create_message_to_sign,
36
+ load_private_key,
37
+ sign_message,
38
+ )
39
+
40
+ from ..auth_plugin.oidc_cli_plugin import OidcCliPlugin
41
+ from ..config_utils import (
42
+ load_and_validate,
43
+ process_loaded_project_config,
44
+ validate_federation_in_project_config,
45
+ )
46
+ from ..constant import FEDERATION_CONFIG_HELP_MESSAGE
47
+ from ..install import install_from_fab
48
+ from ..utils import load_cli_auth_plugin, parse_app_spec, request_download_link
49
+
50
+ TRY_AGAIN_MESSAGE = "Please try again or press CTRL+C to abort.\n"
51
+
52
+
53
+ # pylint: disable-next=too-many-locals, too-many-statements
54
+ def review(
55
+ app_spec: Annotated[
56
+ str,
57
+ typer.Argument(
58
+ help="App specifier (e.g., '@account/app' or '@account/app==1.0.0'). "
59
+ "Version is optional; defaults to the latest."
60
+ ),
61
+ ],
62
+ app_dir_login: Annotated[
63
+ Path,
64
+ typer.Argument(
65
+ help="Project directory to used for login before reviewing app."
66
+ ),
67
+ ] = Path("."),
68
+ federation: Annotated[
69
+ str | None,
70
+ typer.Argument(
71
+ help="Name of the federation used for login before reviewing app."
72
+ ),
73
+ ] = None,
74
+ federation_config_overrides: Annotated[
75
+ list[str] | None,
76
+ typer.Option(
77
+ "--federation-config",
78
+ help=FEDERATION_CONFIG_HELP_MESSAGE,
79
+ ),
80
+ ] = None,
81
+ ) -> None:
82
+ """Download a FAB for <APP-ID>, unpack it for manual review, and upon confirmation
83
+ sign & submit the review to the Platform."""
84
+ # Load configs
85
+ pyproject_path = app_dir_login / FAB_CONFIG_FILE if app_dir_login else None
86
+ config, errors, warnings = load_and_validate(pyproject_path, check_module=False)
87
+ config = process_loaded_project_config(config, errors, warnings)
88
+ federation, federation_config = validate_federation_in_project_config(
89
+ federation, config, federation_config_overrides
90
+ )
91
+
92
+ # Load the authentication plugin
93
+ auth_plugin = load_cli_auth_plugin(app_dir_login, federation, federation_config)
94
+ auth_plugin.load_tokens()
95
+ if not isinstance(auth_plugin, OidcCliPlugin) or not auth_plugin.access_token:
96
+ typer.secho(
97
+ "❌ Please log in before reviewing app.",
98
+ fg=typer.colors.RED,
99
+ err=True,
100
+ )
101
+ raise typer.Exit(code=1)
102
+
103
+ # Load token from the plugin
104
+ token = auth_plugin.access_token
105
+
106
+ # Validate app version and ID format
107
+ app_id, app_version = parse_app_spec(app_spec)
108
+
109
+ # Download FAB
110
+ typer.secho("Downloading FAB... ", fg=typer.colors.BLUE)
111
+ url = f"{PLATFORM_API_URL}/hub/fetch-fab"
112
+ presigned_url = request_download_link(app_id, app_version, url, "fab_url")
113
+ fab_bytes = _download_fab(presigned_url)
114
+
115
+ # Unpack FAB
116
+ typer.secho("Unpacking FAB... ", fg=typer.colors.BLUE)
117
+ review_dir = _create_review_dir()
118
+ review_app_path = install_from_fab(fab_bytes, review_dir)
119
+
120
+ # Extract app version
121
+ version_pattern = re.compile(r"\b(\d+\.\d+\.\d+)\b")
122
+ match = version_pattern.search(str(review_app_path))
123
+ assert match is not None
124
+ app_version = match.group(1)
125
+
126
+ # Prompt to ask for sign
127
+ typer.secho(
128
+ f"""
129
+ Review the unpacked app in the following directory:
130
+
131
+ {typer.style(review_app_path, fg=typer.colors.GREEN, bold=True)}
132
+
133
+ If you have reviewed the app and want to continue to sign it,
134
+ type {typer.style("SIGN", fg=typer.colors.GREEN, bold=True)} or abort with CTRL+C.
135
+ """,
136
+ fg=typer.colors.BLUE,
137
+ )
138
+
139
+ confirmation = typer.prompt("Type SIGN to continue").strip()
140
+ if confirmation.upper() != "SIGN":
141
+ typer.secho("Aborted (user did not type SIGN).", fg=typer.colors.YELLOW)
142
+ raise typer.Exit(code=130)
143
+
144
+ # Ask for private key path (retry until valid)
145
+ while True:
146
+ try:
147
+ key_path_str = typer.prompt(
148
+ "Please specify the path of Ed25519 OpenSSH private key for signing"
149
+ )
150
+ except typer.Abort as e:
151
+ typer.secho("Aborted by user.", fg=typer.colors.YELLOW, err=True)
152
+ raise typer.Exit(code=130) from e
153
+
154
+ key_path = Path(key_path_str).expanduser().resolve()
155
+
156
+ if not key_path.is_file():
157
+ typer.secho(
158
+ f"❌ Private key not found: {key_path}",
159
+ fg=typer.colors.RED,
160
+ err=True,
161
+ )
162
+ typer.secho(TRY_AGAIN_MESSAGE, fg=typer.colors.YELLOW)
163
+ continue
164
+
165
+ # Load private key
166
+ try:
167
+ private_key = load_private_key(key_path)
168
+ except (OSError, ValueError, UnsupportedAlgorithm) as e:
169
+ typer.secho(
170
+ f"❌ Failed to load the private key: {e}", fg=typer.colors.RED, err=True
171
+ )
172
+ typer.secho(TRY_AGAIN_MESSAGE, fg=typer.colors.YELLOW)
173
+ continue
174
+ break # valid
175
+
176
+ # Sign FAB
177
+ signature, signed_at = _sign_fab(fab_bytes, private_key)
178
+
179
+ # Submit review
180
+ _submit_review(app_id, app_version, signature, signed_at, token)
181
+
182
+
183
+ def _create_review_dir() -> Path:
184
+ """Create a directory for reviewing code."""
185
+ home = get_flwr_dir()
186
+ review_dir = home / "reviews"
187
+ review_dir.mkdir(parents=True, exist_ok=True)
188
+ return review_dir
189
+
190
+
191
+ def _download_fab(url: str) -> bytes:
192
+ """Download FAB file from given URL."""
193
+ try:
194
+ r = requests.get(url, timeout=60)
195
+ r.raise_for_status()
196
+ except requests.RequestException as e:
197
+ typer.secho(
198
+ f"❌ FAB download failed: {e}",
199
+ fg=typer.colors.RED,
200
+ err=True,
201
+ )
202
+ raise typer.Exit(code=1) from e
203
+ return r.content
204
+
205
+
206
+ def _sign_fab(
207
+ fab_bytes: bytes, private_key: ed25519.Ed25519PrivateKey
208
+ ) -> tuple[bytes, int]:
209
+ """Sign the given FAB hash bytes."""
210
+ # Get current timestamp
211
+ timestamp = int(now().timestamp())
212
+ message_to_sign = create_message_to_sign(
213
+ hashlib.sha256(fab_bytes).digest(),
214
+ timestamp,
215
+ )
216
+ return sign_message(private_key, message_to_sign), timestamp
217
+
218
+
219
+ def _submit_review(
220
+ app_id: str, app_version: str, signature: bytes, signed_at: int, token: str
221
+ ) -> None:
222
+ """Submit review to Flower Platform API."""
223
+ signature_b64 = base64.urlsafe_b64encode(signature).rstrip(b"=").decode("ascii")
224
+ url = f"{PLATFORM_API_URL}/hub/apps/signature"
225
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
226
+ payload = {
227
+ "app_id": app_id,
228
+ "app_version": app_version,
229
+ "signature_b64": signature_b64,
230
+ "signed_at": signed_at,
231
+ "flwr_version": flwr_version,
232
+ }
233
+ try:
234
+ resp = requests.post(url, headers=headers, json=payload, timeout=120)
235
+ except requests.RequestException as e:
236
+ typer.secho(
237
+ f"❌ Network error while submitting review: {e}",
238
+ fg=typer.colors.RED,
239
+ err=True,
240
+ )
241
+ raise typer.Exit(code=1) from e
242
+
243
+ if resp.ok:
244
+ typer.secho("🎊 Review submitted", fg=typer.colors.GREEN, bold=True)
245
+ return
246
+
247
+ # Error path:
248
+ msg = f"❌ Review submission failed (HTTP {resp.status_code})"
249
+ if resp.text:
250
+ msg += f": {resp.text}"
251
+ typer.secho(msg, fg=typer.colors.RED, err=True)
252
+ raise typer.Exit(code=1)
@@ -18,7 +18,6 @@
18
18
  from abc import ABC, abstractmethod
19
19
  from collections.abc import Sequence
20
20
  from pathlib import Path
21
- from typing import Optional, Union
22
21
 
23
22
  from flwr.common.typing import AccountAuthCredentials, AccountAuthLoginDetails
24
23
  from flwr.proto.control_pb2_grpc import ControlStub
@@ -84,12 +83,12 @@ class CliAuthPlugin(ABC):
84
83
 
85
84
  @abstractmethod
86
85
  def write_tokens_to_metadata(
87
- self, metadata: Sequence[tuple[str, Union[str, bytes]]]
88
- ) -> Sequence[tuple[str, Union[str, bytes]]]:
86
+ self, metadata: Sequence[tuple[str, str | bytes]]
87
+ ) -> Sequence[tuple[str, str | bytes]]:
89
88
  """Write authentication tokens to the provided metadata."""
90
89
 
91
90
  @abstractmethod
92
91
  def read_tokens_from_metadata(
93
- self, metadata: Sequence[tuple[str, Union[str, bytes]]]
94
- ) -> Optional[AccountAuthCredentials]:
92
+ self, metadata: Sequence[tuple[str, str | bytes]]
93
+ ) -> AccountAuthCredentials | None:
95
94
  """Read authentication tokens from the provided metadata."""