flwr 1.23.0__py3-none-any.whl → 1.25.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 (339) 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/{new/templates → app_cmd}/__init__.py +9 -1
  6. flwr/cli/app_cmd/publish.py +285 -0
  7. flwr/cli/app_cmd/review.py +262 -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/{new/templates/app/code/flwr_tune → federation}/__init__.py +10 -1
  15. flwr/cli/federation/ls.py +140 -0
  16. flwr/cli/federation/show.py +318 -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 +211 -130
  21. flwr/cli/new/new.py +123 -331
  22. flwr/cli/pull.py +10 -5
  23. flwr/cli/run/run.py +71 -29
  24. flwr/cli/run_utils.py +148 -0
  25. flwr/cli/stop.py +26 -8
  26. flwr/cli/supernode/ls.py +25 -12
  27. flwr/cli/supernode/register.py +9 -4
  28. flwr/cli/supernode/unregister.py +5 -3
  29. flwr/cli/utils.py +239 -16
  30. flwr/client/__init__.py +1 -1
  31. flwr/client/dpfedavg_numpy_client.py +4 -1
  32. flwr/client/grpc_adapter_client/connection.py +8 -9
  33. flwr/client/grpc_rere_client/connection.py +16 -14
  34. flwr/client/grpc_rere_client/grpc_adapter.py +6 -2
  35. flwr/client/grpc_rere_client/node_auth_client_interceptor.py +2 -1
  36. flwr/client/message_handler/message_handler.py +2 -2
  37. flwr/client/mod/secure_aggregation/secaggplus_mod.py +3 -3
  38. flwr/client/numpy_client.py +1 -1
  39. flwr/client/rest_client/connection.py +18 -18
  40. flwr/client/run_info_store.py +4 -5
  41. flwr/client/typing.py +1 -1
  42. flwr/clientapp/client_app.py +9 -10
  43. flwr/clientapp/mod/centraldp_mods.py +16 -17
  44. flwr/clientapp/mod/localdp_mod.py +8 -9
  45. flwr/clientapp/typing.py +1 -1
  46. flwr/clientapp/utils.py +3 -3
  47. flwr/common/address.py +1 -2
  48. flwr/common/args.py +3 -4
  49. flwr/common/config.py +13 -16
  50. flwr/common/constant.py +5 -2
  51. flwr/common/differential_privacy.py +3 -4
  52. flwr/common/event_log_plugin/event_log_plugin.py +3 -4
  53. flwr/common/exit/exit.py +15 -2
  54. flwr/common/exit/exit_code.py +19 -0
  55. flwr/common/exit/exit_handler.py +6 -2
  56. flwr/common/exit/signal_handler.py +5 -5
  57. flwr/common/grpc.py +6 -6
  58. flwr/common/inflatable_protobuf_utils.py +1 -1
  59. flwr/common/inflatable_utils.py +38 -21
  60. flwr/common/logger.py +19 -19
  61. flwr/common/message.py +4 -4
  62. flwr/common/object_ref.py +7 -7
  63. flwr/common/record/array.py +3 -3
  64. flwr/common/record/arrayrecord.py +18 -30
  65. flwr/common/record/configrecord.py +3 -3
  66. flwr/common/record/recorddict.py +5 -5
  67. flwr/common/record/typeddict.py +9 -2
  68. flwr/common/recorddict_compat.py +7 -10
  69. flwr/common/retry_invoker.py +20 -20
  70. flwr/common/secure_aggregation/ndarrays_arithmetic.py +3 -3
  71. flwr/common/serde.py +11 -4
  72. flwr/common/serde_utils.py +2 -2
  73. flwr/common/telemetry.py +9 -5
  74. flwr/common/typing.py +58 -37
  75. flwr/compat/client/app.py +38 -37
  76. flwr/compat/client/grpc_client/connection.py +11 -11
  77. flwr/compat/server/app.py +5 -6
  78. flwr/proto/appio_pb2.py +13 -3
  79. flwr/proto/appio_pb2.pyi +134 -65
  80. flwr/proto/appio_pb2_grpc.py +20 -0
  81. flwr/proto/appio_pb2_grpc.pyi +27 -0
  82. flwr/proto/clientappio_pb2.py +17 -7
  83. flwr/proto/clientappio_pb2.pyi +15 -0
  84. flwr/proto/clientappio_pb2_grpc.py +206 -40
  85. flwr/proto/clientappio_pb2_grpc.pyi +168 -53
  86. flwr/proto/control_pb2.py +71 -52
  87. flwr/proto/control_pb2.pyi +277 -111
  88. flwr/proto/control_pb2_grpc.py +249 -40
  89. flwr/proto/control_pb2_grpc.pyi +185 -52
  90. flwr/proto/error_pb2.py +13 -3
  91. flwr/proto/error_pb2.pyi +24 -6
  92. flwr/proto/error_pb2_grpc.py +20 -0
  93. flwr/proto/error_pb2_grpc.pyi +27 -0
  94. flwr/proto/fab_pb2.py +14 -4
  95. flwr/proto/fab_pb2.pyi +59 -31
  96. flwr/proto/fab_pb2_grpc.py +20 -0
  97. flwr/proto/fab_pb2_grpc.pyi +27 -0
  98. flwr/proto/federation_pb2.py +38 -0
  99. flwr/proto/federation_pb2.pyi +56 -0
  100. flwr/proto/federation_pb2_grpc.py +24 -0
  101. flwr/proto/federation_pb2_grpc.pyi +31 -0
  102. flwr/proto/fleet_pb2.py +24 -14
  103. flwr/proto/fleet_pb2.pyi +141 -61
  104. flwr/proto/fleet_pb2_grpc.py +189 -48
  105. flwr/proto/fleet_pb2_grpc.pyi +175 -61
  106. flwr/proto/grpcadapter_pb2.py +14 -4
  107. flwr/proto/grpcadapter_pb2.pyi +38 -16
  108. flwr/proto/grpcadapter_pb2_grpc.py +35 -4
  109. flwr/proto/grpcadapter_pb2_grpc.pyi +38 -7
  110. flwr/proto/heartbeat_pb2.py +17 -7
  111. flwr/proto/heartbeat_pb2.pyi +51 -22
  112. flwr/proto/heartbeat_pb2_grpc.py +20 -0
  113. flwr/proto/heartbeat_pb2_grpc.pyi +27 -0
  114. flwr/proto/log_pb2.py +13 -3
  115. flwr/proto/log_pb2.pyi +34 -11
  116. flwr/proto/log_pb2_grpc.py +20 -0
  117. flwr/proto/log_pb2_grpc.pyi +27 -0
  118. flwr/proto/message_pb2.py +15 -5
  119. flwr/proto/message_pb2.pyi +154 -86
  120. flwr/proto/message_pb2_grpc.py +20 -0
  121. flwr/proto/message_pb2_grpc.pyi +27 -0
  122. flwr/proto/node_pb2.py +15 -5
  123. flwr/proto/node_pb2.pyi +50 -25
  124. flwr/proto/node_pb2_grpc.py +20 -0
  125. flwr/proto/node_pb2_grpc.pyi +27 -0
  126. flwr/proto/recorddict_pb2.py +13 -3
  127. flwr/proto/recorddict_pb2.pyi +184 -107
  128. flwr/proto/recorddict_pb2_grpc.py +20 -0
  129. flwr/proto/recorddict_pb2_grpc.pyi +27 -0
  130. flwr/proto/run_pb2.py +40 -31
  131. flwr/proto/run_pb2.pyi +158 -84
  132. flwr/proto/run_pb2_grpc.py +20 -0
  133. flwr/proto/run_pb2_grpc.pyi +27 -0
  134. flwr/proto/serverappio_pb2.py +13 -3
  135. flwr/proto/serverappio_pb2.pyi +32 -8
  136. flwr/proto/serverappio_pb2_grpc.py +246 -65
  137. flwr/proto/serverappio_pb2_grpc.pyi +221 -85
  138. flwr/proto/simulationio_pb2.py +16 -8
  139. flwr/proto/simulationio_pb2.pyi +15 -0
  140. flwr/proto/simulationio_pb2_grpc.py +162 -41
  141. flwr/proto/simulationio_pb2_grpc.pyi +149 -55
  142. flwr/proto/transport_pb2.py +20 -10
  143. flwr/proto/transport_pb2.pyi +249 -160
  144. flwr/proto/transport_pb2_grpc.py +35 -4
  145. flwr/proto/transport_pb2_grpc.pyi +38 -8
  146. flwr/server/app.py +39 -17
  147. flwr/server/client_manager.py +4 -5
  148. flwr/server/client_proxy.py +10 -11
  149. flwr/server/compat/app.py +4 -5
  150. flwr/server/compat/app_utils.py +2 -1
  151. flwr/server/compat/grid_client_proxy.py +10 -12
  152. flwr/server/compat/legacy_context.py +3 -4
  153. flwr/server/fleet_event_log_interceptor.py +2 -1
  154. flwr/server/grid/grid.py +2 -3
  155. flwr/server/grid/grpc_grid.py +10 -8
  156. flwr/server/grid/inmemory_grid.py +4 -4
  157. flwr/server/run_serverapp.py +2 -3
  158. flwr/server/server.py +34 -39
  159. flwr/server/server_app.py +7 -8
  160. flwr/server/server_config.py +1 -2
  161. flwr/server/serverapp/app.py +34 -28
  162. flwr/server/serverapp_components.py +4 -5
  163. flwr/server/strategy/aggregate.py +9 -8
  164. flwr/server/strategy/bulyan.py +13 -11
  165. flwr/server/strategy/dp_adaptive_clipping.py +16 -20
  166. flwr/server/strategy/dp_fixed_clipping.py +12 -17
  167. flwr/server/strategy/dpfedavg_adaptive.py +3 -4
  168. flwr/server/strategy/dpfedavg_fixed.py +6 -10
  169. flwr/server/strategy/fault_tolerant_fedavg.py +14 -13
  170. flwr/server/strategy/fedadagrad.py +18 -14
  171. flwr/server/strategy/fedadam.py +16 -14
  172. flwr/server/strategy/fedavg.py +16 -17
  173. flwr/server/strategy/fedavg_android.py +15 -15
  174. flwr/server/strategy/fedavgm.py +21 -18
  175. flwr/server/strategy/fedmedian.py +2 -3
  176. flwr/server/strategy/fedopt.py +11 -10
  177. flwr/server/strategy/fedprox.py +10 -9
  178. flwr/server/strategy/fedtrimmedavg.py +12 -11
  179. flwr/server/strategy/fedxgb_bagging.py +13 -11
  180. flwr/server/strategy/fedxgb_cyclic.py +6 -6
  181. flwr/server/strategy/fedxgb_nn_avg.py +4 -4
  182. flwr/server/strategy/fedyogi.py +16 -14
  183. flwr/server/strategy/krum.py +12 -11
  184. flwr/server/strategy/qfedavg.py +16 -15
  185. flwr/server/strategy/strategy.py +6 -9
  186. flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer.py +2 -1
  187. flwr/server/superlink/fleet/grpc_bidi/flower_service_servicer.py +1 -2
  188. flwr/server/superlink/fleet/grpc_bidi/grpc_bridge.py +3 -4
  189. flwr/server/superlink/fleet/grpc_bidi/grpc_client_proxy.py +10 -12
  190. flwr/server/superlink/fleet/grpc_bidi/grpc_server.py +1 -3
  191. flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py +4 -4
  192. flwr/server/superlink/fleet/grpc_rere/node_auth_server_interceptor.py +3 -2
  193. flwr/server/superlink/fleet/message_handler/message_handler.py +75 -30
  194. flwr/server/superlink/fleet/rest_rere/rest_api.py +2 -2
  195. flwr/server/superlink/fleet/vce/backend/backend.py +1 -1
  196. flwr/server/superlink/fleet/vce/backend/raybackend.py +5 -5
  197. flwr/server/superlink/fleet/vce/vce_api.py +15 -9
  198. flwr/server/superlink/linkstate/in_memory_linkstate.py +148 -149
  199. flwr/server/superlink/linkstate/linkstate.py +91 -43
  200. flwr/server/superlink/linkstate/linkstate_factory.py +22 -5
  201. flwr/server/superlink/linkstate/sqlite_linkstate.py +502 -436
  202. flwr/server/superlink/linkstate/utils.py +6 -6
  203. flwr/server/superlink/serverappio/serverappio_grpc.py +1 -2
  204. flwr/server/superlink/serverappio/serverappio_servicer.py +26 -21
  205. flwr/server/superlink/simulation/simulationio_grpc.py +1 -2
  206. flwr/server/superlink/simulation/simulationio_servicer.py +18 -13
  207. flwr/server/superlink/utils.py +4 -6
  208. flwr/server/typing.py +1 -1
  209. flwr/server/utils/tensorboard.py +15 -8
  210. flwr/server/workflow/default_workflows.py +5 -5
  211. flwr/server/workflow/secure_aggregation/secagg_workflow.py +2 -4
  212. flwr/server/workflow/secure_aggregation/secaggplus_workflow.py +8 -8
  213. flwr/serverapp/strategy/bulyan.py +16 -15
  214. flwr/serverapp/strategy/dp_adaptive_clipping.py +12 -11
  215. flwr/serverapp/strategy/dp_fixed_clipping.py +11 -14
  216. flwr/serverapp/strategy/fedadagrad.py +10 -11
  217. flwr/serverapp/strategy/fedadam.py +10 -11
  218. flwr/serverapp/strategy/fedavg.py +9 -10
  219. flwr/serverapp/strategy/fedavgm.py +17 -16
  220. flwr/serverapp/strategy/fedmedian.py +2 -2
  221. flwr/serverapp/strategy/fedopt.py +10 -11
  222. flwr/serverapp/strategy/fedprox.py +7 -8
  223. flwr/serverapp/strategy/fedtrimmedavg.py +9 -9
  224. flwr/serverapp/strategy/fedxgb_bagging.py +3 -3
  225. flwr/serverapp/strategy/fedxgb_cyclic.py +9 -9
  226. flwr/serverapp/strategy/fedyogi.py +9 -11
  227. flwr/serverapp/strategy/krum.py +7 -7
  228. flwr/serverapp/strategy/multikrum.py +9 -9
  229. flwr/serverapp/strategy/qfedavg.py +17 -16
  230. flwr/serverapp/strategy/strategy.py +6 -9
  231. flwr/serverapp/strategy/strategy_utils.py +7 -8
  232. flwr/simulation/app.py +46 -42
  233. flwr/simulation/legacy_app.py +12 -12
  234. flwr/simulation/ray_transport/ray_actor.py +10 -11
  235. flwr/simulation/ray_transport/ray_client_proxy.py +11 -12
  236. flwr/simulation/run_simulation.py +43 -43
  237. flwr/simulation/simulationio_connection.py +4 -4
  238. flwr/supercore/cli/flower_superexec.py +3 -4
  239. flwr/supercore/constant.py +34 -1
  240. flwr/supercore/corestate/corestate.py +24 -3
  241. flwr/supercore/corestate/in_memory_corestate.py +138 -0
  242. flwr/supercore/corestate/sqlite_corestate.py +157 -0
  243. flwr/supercore/ffs/disk_ffs.py +1 -2
  244. flwr/supercore/ffs/ffs.py +1 -2
  245. flwr/supercore/ffs/ffs_factory.py +1 -2
  246. flwr/{common → supercore}/heartbeat.py +20 -25
  247. flwr/supercore/object_store/in_memory_object_store.py +1 -2
  248. flwr/supercore/object_store/object_store.py +1 -2
  249. flwr/supercore/object_store/object_store_factory.py +1 -2
  250. flwr/supercore/object_store/sqlite_object_store.py +8 -7
  251. flwr/supercore/primitives/asymmetric.py +1 -1
  252. flwr/supercore/primitives/asymmetric_ed25519.py +11 -1
  253. flwr/supercore/sqlite_mixin.py +37 -34
  254. flwr/supercore/superexec/plugin/base_exec_plugin.py +1 -2
  255. flwr/supercore/superexec/plugin/exec_plugin.py +3 -3
  256. flwr/supercore/superexec/run_superexec.py +9 -13
  257. flwr/supercore/utils.py +190 -0
  258. flwr/superlink/artifact_provider/artifact_provider.py +1 -2
  259. flwr/superlink/auth_plugin/auth_plugin.py +6 -9
  260. flwr/superlink/auth_plugin/noop_auth_plugin.py +6 -9
  261. flwr/{cli/new/templates/app → superlink/federation}/__init__.py +10 -1
  262. flwr/superlink/federation/federation_manager.py +64 -0
  263. flwr/superlink/federation/noop_federation_manager.py +71 -0
  264. flwr/superlink/servicer/control/control_account_auth_interceptor.py +22 -13
  265. flwr/superlink/servicer/control/control_event_log_interceptor.py +7 -7
  266. flwr/superlink/servicer/control/control_grpc.py +7 -6
  267. flwr/superlink/servicer/control/control_license_interceptor.py +3 -3
  268. flwr/superlink/servicer/control/control_servicer.py +190 -23
  269. flwr/supernode/cli/flower_supernode.py +58 -3
  270. flwr/supernode/nodestate/in_memory_nodestate.py +121 -49
  271. flwr/supernode/nodestate/nodestate.py +52 -8
  272. flwr/supernode/nodestate/nodestate_factory.py +7 -4
  273. flwr/supernode/runtime/run_clientapp.py +41 -22
  274. flwr/supernode/servicer/clientappio/clientappio_servicer.py +46 -10
  275. flwr/supernode/start_client_internal.py +165 -46
  276. {flwr-1.23.0.dist-info → flwr-1.25.0.dist-info}/METADATA +9 -11
  277. flwr-1.25.0.dist-info/RECORD +393 -0
  278. flwr/cli/new/templates/app/.gitignore.tpl +0 -163
  279. flwr/cli/new/templates/app/LICENSE.tpl +0 -202
  280. flwr/cli/new/templates/app/README.baseline.md.tpl +0 -127
  281. flwr/cli/new/templates/app/README.flowertune.md.tpl +0 -68
  282. flwr/cli/new/templates/app/README.md.tpl +0 -37
  283. flwr/cli/new/templates/app/code/__init__.baseline.py.tpl +0 -1
  284. flwr/cli/new/templates/app/code/__init__.py +0 -15
  285. flwr/cli/new/templates/app/code/__init__.py.tpl +0 -1
  286. flwr/cli/new/templates/app/code/__init__.pytorch_legacy_api.py.tpl +0 -1
  287. flwr/cli/new/templates/app/code/client.baseline.py.tpl +0 -75
  288. flwr/cli/new/templates/app/code/client.huggingface.py.tpl +0 -93
  289. flwr/cli/new/templates/app/code/client.jax.py.tpl +0 -71
  290. flwr/cli/new/templates/app/code/client.mlx.py.tpl +0 -102
  291. flwr/cli/new/templates/app/code/client.numpy.py.tpl +0 -46
  292. flwr/cli/new/templates/app/code/client.pytorch.py.tpl +0 -80
  293. flwr/cli/new/templates/app/code/client.pytorch_legacy_api.py.tpl +0 -55
  294. flwr/cli/new/templates/app/code/client.sklearn.py.tpl +0 -108
  295. flwr/cli/new/templates/app/code/client.tensorflow.py.tpl +0 -82
  296. flwr/cli/new/templates/app/code/client.xgboost.py.tpl +0 -110
  297. flwr/cli/new/templates/app/code/dataset.baseline.py.tpl +0 -36
  298. flwr/cli/new/templates/app/code/flwr_tune/client_app.py.tpl +0 -92
  299. flwr/cli/new/templates/app/code/flwr_tune/dataset.py.tpl +0 -87
  300. flwr/cli/new/templates/app/code/flwr_tune/models.py.tpl +0 -56
  301. flwr/cli/new/templates/app/code/flwr_tune/server_app.py.tpl +0 -73
  302. flwr/cli/new/templates/app/code/flwr_tune/strategy.py.tpl +0 -78
  303. flwr/cli/new/templates/app/code/model.baseline.py.tpl +0 -66
  304. flwr/cli/new/templates/app/code/server.baseline.py.tpl +0 -43
  305. flwr/cli/new/templates/app/code/server.huggingface.py.tpl +0 -42
  306. flwr/cli/new/templates/app/code/server.jax.py.tpl +0 -39
  307. flwr/cli/new/templates/app/code/server.mlx.py.tpl +0 -41
  308. flwr/cli/new/templates/app/code/server.numpy.py.tpl +0 -38
  309. flwr/cli/new/templates/app/code/server.pytorch.py.tpl +0 -41
  310. flwr/cli/new/templates/app/code/server.pytorch_legacy_api.py.tpl +0 -31
  311. flwr/cli/new/templates/app/code/server.sklearn.py.tpl +0 -44
  312. flwr/cli/new/templates/app/code/server.tensorflow.py.tpl +0 -38
  313. flwr/cli/new/templates/app/code/server.xgboost.py.tpl +0 -56
  314. flwr/cli/new/templates/app/code/strategy.baseline.py.tpl +0 -1
  315. flwr/cli/new/templates/app/code/task.huggingface.py.tpl +0 -98
  316. flwr/cli/new/templates/app/code/task.jax.py.tpl +0 -57
  317. flwr/cli/new/templates/app/code/task.mlx.py.tpl +0 -102
  318. flwr/cli/new/templates/app/code/task.numpy.py.tpl +0 -7
  319. flwr/cli/new/templates/app/code/task.pytorch.py.tpl +0 -98
  320. flwr/cli/new/templates/app/code/task.pytorch_legacy_api.py.tpl +0 -111
  321. flwr/cli/new/templates/app/code/task.sklearn.py.tpl +0 -67
  322. flwr/cli/new/templates/app/code/task.tensorflow.py.tpl +0 -52
  323. flwr/cli/new/templates/app/code/task.xgboost.py.tpl +0 -67
  324. flwr/cli/new/templates/app/code/utils.baseline.py.tpl +0 -1
  325. flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +0 -146
  326. flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +0 -80
  327. flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +0 -65
  328. flwr/cli/new/templates/app/pyproject.jax.toml.tpl +0 -52
  329. flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +0 -56
  330. flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +0 -49
  331. flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +0 -53
  332. flwr/cli/new/templates/app/pyproject.pytorch_legacy_api.toml.tpl +0 -53
  333. flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +0 -52
  334. flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +0 -53
  335. flwr/cli/new/templates/app/pyproject.xgboost.toml.tpl +0 -61
  336. flwr/supercore/object_store/utils.py +0 -43
  337. flwr-1.23.0.dist-info/RECORD +0 -439
  338. {flwr-1.23.0.dist-info → flwr-1.25.0.dist-info}/WHEEL +0 -0
  339. {flwr-1.23.0.dist-info → flwr-1.25.0.dist-info}/entry_points.txt +0 -0
@@ -16,7 +16,6 @@
16
16
 
17
17
 
18
18
  from logging import DEBUG
19
- from typing import Optional
20
19
 
21
20
  from flwr.common.logger import log
22
21
 
@@ -35,7 +34,7 @@ class FfsFactory:
35
34
 
36
35
  def __init__(self, base_dir: str) -> None:
37
36
  self.base_dir = base_dir
38
- self.ffs_instance: Optional[Ffs] = None
37
+ self.ffs_instance: Ffs | None = None
39
38
 
40
39
  def ffs(self) -> Ffs:
41
40
  """Return a Ffs instance and create it, if necessary."""
@@ -16,24 +16,27 @@
16
16
 
17
17
 
18
18
  import random
19
+ import signal
19
20
  import threading
20
- from typing import Callable, Union
21
+ from collections.abc import Callable
21
22
 
22
23
  import grpc
23
24
 
25
+ from flwr.common.constant import (
26
+ HEARTBEAT_BASE_MULTIPLIER,
27
+ HEARTBEAT_CALL_TIMEOUT,
28
+ HEARTBEAT_DEFAULT_INTERVAL,
29
+ HEARTBEAT_RANDOM_RANGE,
30
+ )
31
+ from flwr.common.retry_invoker import RetryInvoker, exponential
32
+ from flwr.proto.clientappio_pb2_grpc import ClientAppIoStub
33
+
24
34
  # pylint: disable=E0611
25
35
  from flwr.proto.heartbeat_pb2 import SendAppHeartbeatRequest
26
36
  from flwr.proto.serverappio_pb2_grpc import ServerAppIoStub
27
37
  from flwr.proto.simulationio_pb2_grpc import SimulationIoStub
28
38
 
29
39
  # pylint: enable=E0611
30
- from .constant import (
31
- HEARTBEAT_BASE_MULTIPLIER,
32
- HEARTBEAT_CALL_TIMEOUT,
33
- HEARTBEAT_DEFAULT_INTERVAL,
34
- HEARTBEAT_RANDOM_RANGE,
35
- )
36
- from .retry_invoker import RetryInvoker, exponential
37
40
 
38
41
 
39
42
  class HeartbeatFailure(Exception):
@@ -116,24 +119,18 @@ class HeartbeatSender:
116
119
  raise HeartbeatFailure
117
120
 
118
121
 
119
- def get_grpc_app_heartbeat_fn(
120
- stub: Union[ServerAppIoStub, SimulationIoStub],
121
- run_id: int,
122
- *,
123
- failure_message: str,
122
+ def make_app_heartbeat_fn_grpc(
123
+ stub: ServerAppIoStub | SimulationIoStub | ClientAppIoStub,
124
+ token: str,
124
125
  ) -> Callable[[], bool]:
125
- """Get the function to send a heartbeat to gRPC endpoint.
126
-
127
- This function is for app heartbeats only. It is not used for node heartbeats.
126
+ """Get the function to send a heartbeat to gRPC endpoint from an app process.
128
127
 
129
128
  Parameters
130
129
  ----------
131
130
  stub : Union[ServerAppIoStub, SimulationIoStub]
132
131
  gRPC stub to send the heartbeat.
133
- run_id : int
134
- The run ID to use in the heartbeat request.
135
- failure_message : str
136
- Error message to raise if the heartbeat fails.
132
+ token : str
133
+ The token to use in the heartbeat request.
137
134
 
138
135
  Returns
139
136
  -------
@@ -141,9 +138,7 @@ def get_grpc_app_heartbeat_fn(
141
138
  Function that sends a heartbeat to the gRPC endpoint.
142
139
  """
143
140
  # Construct the heartbeat request
144
- req = SendAppHeartbeatRequest(
145
- run_id=run_id, heartbeat_interval=HEARTBEAT_DEFAULT_INTERVAL
146
- )
141
+ req = SendAppHeartbeatRequest(token=token)
147
142
 
148
143
  def fn() -> bool:
149
144
  # Call ServerAppIo API
@@ -157,9 +152,9 @@ def get_grpc_app_heartbeat_fn(
157
152
  return False
158
153
  raise
159
154
 
160
- # Check if not successful
155
+ # Raise SIGINT to trigger graceful shutdown if heartbeat failed
161
156
  if not res.success:
162
- raise RuntimeError(failure_message)
157
+ signal.raise_signal(signal.SIGINT)
163
158
  return True
164
159
 
165
160
  return fn
@@ -17,7 +17,6 @@
17
17
 
18
18
  import threading
19
19
  from dataclasses import dataclass
20
- from typing import Optional
21
20
 
22
21
  from flwr.common.inflatable import (
23
22
  get_object_id,
@@ -154,7 +153,7 @@ class InMemoryObjectStore(ObjectStore):
154
153
  self.store[object_id].content = object_content
155
154
  self.store[object_id].is_available = True
156
155
 
157
- def get(self, object_id: str) -> Optional[bytes]:
156
+ def get(self, object_id: str) -> bytes | None:
158
157
  """Get an object from the store."""
159
158
  with self.lock_store:
160
159
  # Check if the object ID is pre-registered
@@ -16,7 +16,6 @@
16
16
 
17
17
 
18
18
  import abc
19
- from typing import Optional
20
19
 
21
20
  from flwr.proto.message_pb2 import ObjectTree # pylint: disable=E0611
22
21
 
@@ -89,7 +88,7 @@ class ObjectStore(abc.ABC):
89
88
  """
90
89
 
91
90
  @abc.abstractmethod
92
- def get(self, object_id: str) -> Optional[bytes]:
91
+ def get(self, object_id: str) -> bytes | None:
93
92
  """Get an object from the store.
94
93
 
95
94
  Parameters
@@ -16,7 +16,6 @@
16
16
 
17
17
 
18
18
  from logging import DEBUG
19
- from typing import Optional
20
19
 
21
20
  from flwr.common.logger import log
22
21
  from flwr.supercore.constant import FLWR_IN_MEMORY_DB_NAME
@@ -40,7 +39,7 @@ class ObjectStoreFactory:
40
39
 
41
40
  def __init__(self, database: str = FLWR_IN_MEMORY_DB_NAME) -> None:
42
41
  self.database = database
43
- self.store_instance: Optional[ObjectStore] = None
42
+ self.store_instance: ObjectStore | None = None
44
43
 
45
44
  def store(self) -> ObjectStore:
46
45
  """Return an ObjectStore instance and create it, if necessary.
@@ -15,7 +15,7 @@
15
15
  """Flower SQLite ObjectStore implementation."""
16
16
 
17
17
 
18
- from typing import Optional, cast
18
+ from typing import cast
19
19
 
20
20
  from flwr.common.inflatable import (
21
21
  get_object_id,
@@ -63,13 +63,12 @@ class SqliteObjectStore(ObjectStore, SqliteMixin):
63
63
  super().__init__(database_path)
64
64
  self.verify = verify
65
65
 
66
- def initialize(self, log_queries: bool = False) -> list[tuple[str]]:
67
- """Connect to the DB, enable FK support, and create tables if needed."""
68
- return self._ensure_initialized(
66
+ def get_sql_statements(self) -> tuple[str, ...]:
67
+ """Return SQL statements for ObjectStore tables."""
68
+ return (
69
69
  SQL_CREATE_OBJECTS,
70
70
  SQL_CREATE_OBJECT_CHILDREN,
71
71
  SQL_CREATE_RUN_OBJECTS,
72
- log_queries=log_queries,
73
72
  )
74
73
 
75
74
  def preregister(self, run_id: int, object_tree: ObjectTree) -> list[str]:
@@ -126,7 +125,9 @@ class SqliteObjectStore(ObjectStore, SqliteMixin):
126
125
  "SELECT object_id FROM objects WHERE object_id=?", (object_id,)
127
126
  ).fetchone()
128
127
  if not row:
129
- raise NoObjectInStoreError(f"Object {object_id} not found.")
128
+ raise NoObjectInStoreError(
129
+ f"Object {object_id} was not pre-registered."
130
+ )
130
131
  children = self.query(
131
132
  "SELECT child_id FROM object_children WHERE parent_id=?", (object_id,)
132
133
  )
@@ -176,7 +177,7 @@ class SqliteObjectStore(ObjectStore, SqliteMixin):
176
177
  (object_content, object_id),
177
178
  )
178
179
 
179
- def get(self, object_id: str) -> Optional[bytes]:
180
+ def get(self, object_id: str) -> bytes | None:
180
181
  """Get an object from the store."""
181
182
  rows = self.query("SELECT content FROM objects WHERE object_id=?", (object_id,))
182
183
  return rows[0]["content"] if rows else None
@@ -113,5 +113,5 @@ def uses_nist_ec_curve(public_key: ec.EllipticCurvePublicKey) -> bool:
113
113
  """Return True if the provided key uses a NIST EC curve."""
114
114
  return isinstance(
115
115
  public_key.curve,
116
- (ec.SECP192R1, ec.SECP224R1, ec.SECP256R1, ec.SECP384R1, ec.SECP521R1),
116
+ (ec.SECP192R1 | ec.SECP224R1 | ec.SECP256R1 | ec.SECP384R1 | ec.SECP521R1),
117
117
  )
@@ -15,6 +15,7 @@
15
15
  """Ed25519-only asymmetric cryptography utilities."""
16
16
 
17
17
  import base64
18
+ from pathlib import Path
18
19
 
19
20
  from cryptography.exceptions import InvalidSignature
20
21
  from cryptography.hazmat.primitives import serialization
@@ -150,7 +151,7 @@ def verify_signature(
150
151
  return False
151
152
 
152
153
 
153
- def create_signed_message(fab_digest: bytes, timestamp: int) -> bytes:
154
+ def create_message_to_sign(fab_digest: bytes, timestamp: int) -> bytes:
154
155
  """Create a canonical message:
155
156
  timestamp (8 bytes big-endian) + fab_digest.
156
157
  """
@@ -163,3 +164,12 @@ def decode_base64url(sig: str) -> bytes:
163
164
  # add missing padding (=) to a multiple of 4
164
165
  pad = (-len(sig)) % 4
165
166
  return base64.urlsafe_b64decode(sig + ("=" * pad))
167
+
168
+
169
+ def load_private_key(path: Path) -> ed25519.Ed25519PrivateKey:
170
+ """Load an SSH-format private key (Ed25519) using cryptography."""
171
+ key_bytes = path.read_bytes()
172
+ private_key = serialization.load_ssh_private_key(key_bytes, password=None)
173
+ if not isinstance(private_key, ed25519.Ed25519PrivateKey):
174
+ raise ValueError("Private key is not Ed25519")
175
+ return private_key
@@ -17,14 +17,14 @@
17
17
 
18
18
  import re
19
19
  import sqlite3
20
- from abc import ABC, abstractmethod
20
+ from abc import ABC
21
21
  from collections.abc import Sequence
22
22
  from logging import DEBUG, ERROR
23
- from typing import Any, Optional, Union
23
+ from typing import Any
24
24
 
25
25
  from flwr.common.logger import log
26
26
 
27
- DictOrTuple = Union[tuple[Any, ...], dict[str, Any]]
27
+ DictOrTuple = tuple[Any, ...] | dict[str, Any]
28
28
 
29
29
 
30
30
  class SqliteMixin(ABC):
@@ -32,7 +32,7 @@ class SqliteMixin(ABC):
32
32
 
33
33
  def __init__(self, database_path: str) -> None:
34
34
  self.database_path = database_path
35
- self._conn: Optional[sqlite3.Connection] = None
35
+ self._conn: sqlite3.Connection | None = None
36
36
 
37
37
  @property
38
38
  def conn(self) -> sqlite3.Connection:
@@ -41,10 +41,24 @@ class SqliteMixin(ABC):
41
41
  raise AttributeError("Database not initialized. Call initialize() first.")
42
42
  return self._conn
43
43
 
44
- @abstractmethod
44
+ def get_sql_statements(self) -> tuple[str, ...]:
45
+ """Return SQL statements for this class.
46
+
47
+ Subclasses can override this to provide their SQL CREATE statements.
48
+ The base implementation returns an empty tuple.
49
+
50
+ Returns
51
+ -------
52
+ tuple[str, ...]
53
+ SQL CREATE TABLE/INDEX statements for this class.
54
+ """
55
+ return ()
56
+
45
57
  def initialize(self, log_queries: bool = False) -> list[tuple[str]]:
46
58
  """Connect to the DB, enable FK support, and create tables if needed.
47
59
 
60
+ This method executes SQL statements returned by `get_sql_statements()`.
61
+
48
62
  Parameters
49
63
  ----------
50
64
  log_queries : bool
@@ -57,45 +71,34 @@ class SqliteMixin(ABC):
57
71
 
58
72
  Examples
59
73
  --------
60
- Implement in subclass:
74
+ Override `get_sql_statements()` in your subclass:
61
75
 
62
76
  .. code:: python
63
77
 
64
- def initialize(self, log_queries: bool = False) -> list[tuple[str]]:
65
- return self._ensure_initialized(
78
+ def get_sql_statements(self) -> tuple[str, ...]:
79
+ return (
66
80
  SQL_CREATE_TABLE_FOO,
67
81
  SQL_CREATE_TABLE_BAR,
68
- log_queries=log_queries
69
82
  )
70
- """
71
-
72
- def _ensure_initialized(
73
- self,
74
- *create_statements: str,
75
- log_queries: bool = False,
76
- ) -> list[tuple[str]]:
77
- """Connect to the DB, enable FK support, and create tables if needed.
78
83
 
79
- Subclasses should call this with their own CREATE TABLE/INDEX statements in
80
- their `.initialize()` methods.
84
+ To include parent SQL statements, call super():
81
85
 
82
- Parameters
83
- ----------
84
- create_statements : str
85
- SQL statements to create tables and indexes.
86
- log_queries : bool
87
- Log each query which is executed.
86
+ .. code:: python
88
87
 
89
- Returns
90
- -------
91
- list[tuple[str]]
92
- The list of all tables in the DB.
88
+ def get_sql_statements(self) -> tuple[str, ...]:
89
+ return super().get_sql_statements() + (
90
+ SQL_CREATE_TABLE_FOO,
91
+ SQL_CREATE_TABLE_BAR,
92
+ )
93
93
  """
94
94
  self._conn = sqlite3.connect(self.database_path)
95
95
  # Enable Write-Ahead Logging (WAL) for better concurrency
96
96
  self._conn.execute("PRAGMA journal_mode = WAL;")
97
97
  self._conn.execute("PRAGMA synchronous = NORMAL;")
98
98
  self._conn.execute("PRAGMA foreign_keys = ON;")
99
+ self._conn.execute("PRAGMA cache_size = -64000;") # 64MB cache
100
+ self._conn.execute("PRAGMA temp_store = MEMORY;") # In-memory temp tables
101
+ self._conn.execute("PRAGMA mmap_size = 268435456;") # 256MB memory-mapped I/O
99
102
  self._conn.row_factory = dict_factory
100
103
 
101
104
  if log_queries:
@@ -103,7 +106,7 @@ class SqliteMixin(ABC):
103
106
 
104
107
  # Create tables and indexes
105
108
  cur = self._conn.cursor()
106
- for sql in create_statements:
109
+ for sql in self.get_sql_statements():
107
110
  cur.execute(sql)
108
111
  res = cur.execute("SELECT name FROM sqlite_schema;")
109
112
  return res.fetchall()
@@ -111,7 +114,7 @@ class SqliteMixin(ABC):
111
114
  def query(
112
115
  self,
113
116
  query: str,
114
- data: Optional[Union[Sequence[DictOrTuple], DictOrTuple]] = None,
117
+ data: Sequence[DictOrTuple] | DictOrTuple | None = None,
115
118
  ) -> list[dict[str, Any]]:
116
119
  """Execute a SQL query and return the results as list of dicts."""
117
120
  if self._conn is None:
@@ -127,8 +130,8 @@ class SqliteMixin(ABC):
127
130
  with self._conn:
128
131
  if (
129
132
  len(data) > 0
130
- and isinstance(data, (tuple, list))
131
- and isinstance(data[0], (tuple, dict))
133
+ and isinstance(data, (tuple | list))
134
+ and isinstance(data[0], (tuple | dict))
132
135
  ):
133
136
  rows = self._conn.executemany(query, data)
134
137
  else:
@@ -153,4 +156,4 @@ def dict_factory(
153
156
  Less efficent for retrival of large amounts of data but easier to use.
154
157
  """
155
158
  fields = [column[0] for column in cursor.description]
156
- return dict(zip(fields, row))
159
+ return dict(zip(fields, row, strict=True))
@@ -18,7 +18,6 @@
18
18
  import os
19
19
  import subprocess
20
20
  from collections.abc import Sequence
21
- from typing import Optional
22
21
 
23
22
  from .exec_plugin import ExecPlugin
24
23
 
@@ -33,7 +32,7 @@ class BaseExecPlugin(ExecPlugin):
33
32
  command = ""
34
33
  appio_api_address_arg = ""
35
34
 
36
- def select_run_id(self, candidate_run_ids: Sequence[int]) -> Optional[int]:
35
+ def select_run_id(self, candidate_run_ids: Sequence[int]) -> int | None:
37
36
  """Select a run ID to execute from a sequence of candidates."""
38
37
  if not candidate_run_ids:
39
38
  return None
@@ -16,8 +16,8 @@
16
16
 
17
17
 
18
18
  from abc import ABC, abstractmethod
19
- from collections.abc import Sequence
20
- from typing import Any, Callable, Optional
19
+ from collections.abc import Callable, Sequence
20
+ from typing import Any
21
21
 
22
22
  from flwr.common.typing import Run
23
23
 
@@ -36,7 +36,7 @@ class ExecPlugin(ABC):
36
36
  self.get_run = get_run
37
37
 
38
38
  @abstractmethod
39
- def select_run_id(self, candidate_run_ids: Sequence[int]) -> Optional[int]:
39
+ def select_run_id(self, candidate_run_ids: Sequence[int]) -> int | None:
40
40
  """Select a run ID to execute from a sequence of candidates.
41
41
 
42
42
  A candidate run ID is one that has at least one pending message and is
@@ -17,7 +17,7 @@
17
17
 
18
18
  import time
19
19
  from logging import WARN
20
- from typing import Any, Optional, Union
20
+ from typing import Any
21
21
 
22
22
  from flwr.common.config import get_flwr_dir
23
23
  from flwr.common.exit import ExitCode, flwr_exit, register_signal_handlers
@@ -43,14 +43,12 @@ from .plugin import ExecPlugin
43
43
 
44
44
  def run_superexec( # pylint: disable=R0913,R0914,R0917
45
45
  plugin_class: type[ExecPlugin],
46
- stub_class: Union[
47
- type[ClientAppIoStub], type[ServerAppIoStub], type[SimulationIoStub]
48
- ],
46
+ stub_class: type[ClientAppIoStub] | type[ServerAppIoStub] | type[SimulationIoStub],
49
47
  appio_api_address: str,
50
- plugin_config: Optional[dict[str, Any]] = None,
51
- flwr_dir: Optional[str] = None,
52
- parent_pid: Optional[int] = None,
53
- health_server_address: Optional[str] = None,
48
+ plugin_config: dict[str, Any] | None = None,
49
+ flwr_dir: str | None = None,
50
+ parent_pid: int | None = None,
51
+ health_server_address: str | None = None,
54
52
  ) -> None:
55
53
  """Run Flower SuperExec.
56
54
 
@@ -158,12 +156,10 @@ def run_with_deprecation_warning( # pylint: disable=R0913, R0917
158
156
  cmd: str,
159
157
  plugin_type: str,
160
158
  plugin_class: type[ExecPlugin],
161
- stub_class: Union[
162
- type[ClientAppIoStub], type[ServerAppIoStub], type[SimulationIoStub]
163
- ],
159
+ stub_class: type[ClientAppIoStub] | type[ServerAppIoStub] | type[SimulationIoStub],
164
160
  appio_api_address: str,
165
- flwr_dir: Optional[str],
166
- parent_pid: Optional[int],
161
+ flwr_dir: str | None,
162
+ parent_pid: int | None,
167
163
  warn_run_once: bool,
168
164
  ) -> None:
169
165
  """Log a deprecation warning and run the equivalent `flower-superexec` command.
flwr/supercore/utils.py CHANGED
@@ -15,6 +15,16 @@
15
15
  """Utility functions for the infrastructure."""
16
16
 
17
17
 
18
+ import json
19
+ import re
20
+
21
+ import requests
22
+
23
+ from flwr.common.version import package_version as flwr_version
24
+
25
+ from .constant import APP_ID_PATTERN, APP_VERSION_PATTERN
26
+
27
+
18
28
  def mask_string(value: str, head: int = 4, tail: int = 4) -> str:
19
29
  """Mask a string by preserving only the head and tail characters.
20
30
 
@@ -50,3 +60,183 @@ def int64_to_uint64(signed: int) -> int:
50
60
  if signed < 0:
51
61
  return signed + (1 << 64)
52
62
  return signed
63
+
64
+
65
+ def parse_app_spec(app_spec: str) -> tuple[str, str | None]:
66
+ """Parse app specification string into app ID and version.
67
+
68
+ Parameters
69
+ ----------
70
+ app_spec : str
71
+ The app specification string in the format '@account/app' or
72
+ '@account/app==x.y.z' (digits only).
73
+
74
+ Returns
75
+ -------
76
+ tuple[str, str | None]
77
+ A tuple containing the app ID and optional version.
78
+
79
+ Raises
80
+ ------
81
+ ValueError
82
+ If the app specification format is invalid.
83
+ """
84
+ if "==" in app_spec:
85
+ app_id, app_version = app_spec.split("==", 1)
86
+
87
+ if not re.match(APP_VERSION_PATTERN, app_version):
88
+ raise ValueError(
89
+ "Invalid app version. Expected format: x.y.z (digits only)."
90
+ )
91
+ else:
92
+ app_id = app_spec
93
+ app_version = None
94
+
95
+ if not re.match(APP_ID_PATTERN, app_id):
96
+ raise ValueError(
97
+ "Invalid remote app ID. Expected format: '@account_name/app_name'."
98
+ )
99
+
100
+ return app_id, app_version
101
+
102
+
103
+ def request_download_link(
104
+ app_id: str, app_version: str | None, in_url: str, out_url: str
105
+ ) -> tuple[str, list[dict[str, str]] | None]:
106
+ """Request a download link for the given app from the Flower Platform API.
107
+
108
+ Parameters
109
+ ----------
110
+ app_id : str
111
+ The application identifier in the format '@account/app'.
112
+ app_version : str | None
113
+ The application version (e.g., '1.2.3'), or None to request the latest version.
114
+ in_url : str
115
+ The Platform API endpoint URL to query.
116
+ out_url : str
117
+ The key name in the response that contains the download URL.
118
+
119
+ Returns
120
+ -------
121
+ tuple[str, list[dict[str, str]] | None]
122
+ A tuple containing:
123
+ - The download URL for the application.
124
+ - A list of verification dictionaries if provided by the API, otherwise None.
125
+
126
+ Raises
127
+ ------
128
+ ValueError
129
+ If the API connection fails, the application or version is not found,
130
+ the API returns a non-200 response, or the response format is invalid.
131
+ """
132
+ headers = {
133
+ "Content-Type": "application/json",
134
+ "Accept": "application/json",
135
+ }
136
+ body = {
137
+ "app_id": app_id, # send raw string of app_id
138
+ "app_version": app_version,
139
+ "flwr_version": flwr_version,
140
+ }
141
+
142
+ try:
143
+ resp = requests.post(in_url, headers=headers, data=json.dumps(body), timeout=20)
144
+ except requests.RequestException as e:
145
+ raise ValueError(f"Unable to connect to Platform API: {e}") from e
146
+
147
+ if resp.status_code == 404:
148
+ # Expecting a JSON body with a "detail" field
149
+ try:
150
+ error_message = resp.json().get("detail")
151
+ except ValueError:
152
+ # JSON parsing failed
153
+ raise ValueError(f"{app_id} not found in Platform API.") from None
154
+
155
+ if isinstance(error_message, dict):
156
+ available_app_versions = error_message.get("available_app_versions", [])
157
+ available_versions_str = (
158
+ ", ".join(map(str, available_app_versions))
159
+ if available_app_versions
160
+ else "None"
161
+ )
162
+ raise ValueError(
163
+ f"{app_id}=={app_version} not found in Platform API. "
164
+ f"Available app versions for {app_id}: {available_versions_str}"
165
+ )
166
+
167
+ raise ValueError(f"{app_id} not found in Platform API.")
168
+
169
+ if not resp.ok:
170
+ raise ValueError(
171
+ f"Platform API request failed with status {resp.status_code}. "
172
+ f"Details: {resp.text}"
173
+ )
174
+
175
+ data = resp.json()
176
+ if out_url not in data:
177
+ raise ValueError("Invalid response from Platform API")
178
+
179
+ verifications = data["verifications"] if "verifications" in data else None
180
+
181
+ return str(data[out_url]), verifications
182
+
183
+
184
+ def humanize_duration(seconds: float) -> str:
185
+ """Convert a duration in seconds to a human-friendly string.
186
+
187
+ Rules:
188
+ - < 90 seconds: show seconds
189
+ - < 1 hour: show minutes + seconds
190
+ - < 1 day: show hours + minutes
191
+ - >= 1 day: show days + hours
192
+ """
193
+ seconds = int(seconds)
194
+
195
+ # Under 90 seconds → Seconds only
196
+ if seconds < 90:
197
+ return f"{seconds}s"
198
+
199
+ # Under 1 hour → Minutes and seconds
200
+ minutes, sec = divmod(seconds, 60)
201
+ if minutes < 60:
202
+ return f"{minutes}m {sec}s"
203
+
204
+ # Under 1 day → Hours and minutes
205
+ hours, minutes = divmod(minutes, 60)
206
+ if hours < 24:
207
+ return f"{hours}h {minutes}m"
208
+
209
+ # 1+ days → Days and hours
210
+ days, hours = divmod(hours, 24)
211
+ return f"{days}d {hours}h"
212
+
213
+
214
+ def humanize_bytes(num_bytes: int) -> str:
215
+ """Convert a number of bytes to a human-friendly string.
216
+
217
+ Uses 1024-based units and 0-1 decimal precision.
218
+ Rules:
219
+ - < 1 KB: bytes
220
+ - < 1 MB: KB
221
+ - < 1 GB: MB
222
+ - < 1 TB: GB
223
+ """
224
+ value = float(num_bytes)
225
+
226
+ for suffix in ["B", "KB", "MB", "GB", "TB"]:
227
+ if value < 1024 or suffix == "TB":
228
+ # Bytes → no decimals
229
+ if suffix == "B":
230
+ return f"{int(value)} B"
231
+
232
+ # Decide precision: 1 decimal for <10, otherwise no decimal
233
+ if value < 10:
234
+ formatted = f"{value:.1f}"
235
+ else:
236
+ formatted = f"{int(value)}"
237
+
238
+ return f"{formatted} {suffix}"
239
+
240
+ value /= 1024
241
+
242
+ raise RuntimeError("Unreachable code") # Make mypy happy