flwr 1.24.0__py3-none-any.whl → 1.26.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 (204) hide show
  1. flwr/__init__.py +1 -1
  2. flwr/app/__init__.py +4 -1
  3. flwr/app/message_type.py +29 -0
  4. flwr/app/metadata.py +5 -2
  5. flwr/app/user_config.py +19 -0
  6. flwr/cli/app.py +37 -19
  7. flwr/cli/app_cmd/publish.py +25 -75
  8. flwr/cli/app_cmd/review.py +25 -66
  9. flwr/cli/auth_plugin/auth_plugin.py +5 -10
  10. flwr/cli/auth_plugin/noop_auth_plugin.py +1 -2
  11. flwr/cli/auth_plugin/oidc_cli_plugin.py +38 -38
  12. flwr/cli/build.py +15 -28
  13. flwr/cli/config/__init__.py +21 -0
  14. flwr/cli/config/ls.py +71 -0
  15. flwr/cli/config_migration.py +297 -0
  16. flwr/cli/config_utils.py +63 -156
  17. flwr/cli/constant.py +71 -0
  18. flwr/cli/federation/__init__.py +0 -2
  19. flwr/cli/federation/ls.py +256 -64
  20. flwr/cli/flower_config.py +429 -0
  21. flwr/cli/install.py +23 -62
  22. flwr/cli/log.py +23 -37
  23. flwr/cli/login/login.py +29 -63
  24. flwr/cli/ls.py +72 -61
  25. flwr/cli/new/new.py +98 -309
  26. flwr/cli/pull.py +19 -37
  27. flwr/cli/run/run.py +87 -100
  28. flwr/cli/run_utils.py +23 -5
  29. flwr/cli/stop.py +33 -74
  30. flwr/cli/supernode/ls.py +35 -62
  31. flwr/cli/supernode/register.py +31 -80
  32. flwr/cli/supernode/unregister.py +24 -70
  33. flwr/cli/typing.py +200 -0
  34. flwr/cli/utils.py +160 -412
  35. flwr/client/grpc_adapter_client/connection.py +2 -2
  36. flwr/client/grpc_rere_client/connection.py +9 -6
  37. flwr/client/grpc_rere_client/grpc_adapter.py +1 -1
  38. flwr/client/message_handler/message_handler.py +2 -1
  39. flwr/client/mod/centraldp_mods.py +1 -1
  40. flwr/client/mod/localdp_mod.py +1 -1
  41. flwr/client/mod/secure_aggregation/secaggplus_mod.py +1 -1
  42. flwr/client/rest_client/connection.py +6 -4
  43. flwr/client/run_info_store.py +2 -1
  44. flwr/clientapp/client_app.py +2 -1
  45. flwr/common/__init__.py +3 -2
  46. flwr/common/args.py +5 -5
  47. flwr/common/config.py +12 -17
  48. flwr/common/constant.py +3 -16
  49. flwr/common/context.py +2 -1
  50. flwr/common/exit/exit.py +4 -4
  51. flwr/common/exit/exit_code.py +6 -0
  52. flwr/common/grpc.py +2 -1
  53. flwr/common/logger.py +1 -1
  54. flwr/common/message.py +1 -1
  55. flwr/common/retry_invoker.py +13 -5
  56. flwr/common/secure_aggregation/ndarrays_arithmetic.py +5 -2
  57. flwr/common/serde.py +13 -5
  58. flwr/common/telemetry.py +1 -1
  59. flwr/common/typing.py +10 -3
  60. flwr/compat/client/app.py +6 -9
  61. flwr/compat/client/grpc_client/connection.py +2 -1
  62. flwr/compat/common/constant.py +29 -0
  63. flwr/compat/server/app.py +1 -1
  64. flwr/proto/clientappio_pb2.py +2 -2
  65. flwr/proto/clientappio_pb2_grpc.py +104 -88
  66. flwr/proto/clientappio_pb2_grpc.pyi +140 -80
  67. flwr/proto/federation_pb2.py +5 -3
  68. flwr/proto/federation_pb2.pyi +32 -2
  69. flwr/proto/fleet_pb2.py +10 -10
  70. flwr/proto/fleet_pb2.pyi +5 -1
  71. flwr/proto/run_pb2.py +18 -26
  72. flwr/proto/run_pb2.pyi +10 -58
  73. flwr/proto/serverappio_pb2.py +2 -2
  74. flwr/proto/serverappio_pb2_grpc.py +138 -207
  75. flwr/proto/serverappio_pb2_grpc.pyi +189 -155
  76. flwr/proto/simulationio_pb2.py +2 -2
  77. flwr/proto/simulationio_pb2_grpc.py +62 -90
  78. flwr/proto/simulationio_pb2_grpc.pyi +95 -55
  79. flwr/server/app.py +7 -13
  80. flwr/server/compat/grid_client_proxy.py +2 -1
  81. flwr/server/grid/grpc_grid.py +5 -5
  82. flwr/server/serverapp/app.py +11 -4
  83. flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer.py +1 -1
  84. flwr/server/superlink/fleet/grpc_rere/node_auth_server_interceptor.py +13 -12
  85. flwr/server/superlink/fleet/message_handler/message_handler.py +42 -2
  86. flwr/server/superlink/linkstate/__init__.py +2 -2
  87. flwr/server/superlink/linkstate/in_memory_linkstate.py +36 -10
  88. flwr/server/superlink/linkstate/linkstate.py +34 -21
  89. flwr/server/superlink/linkstate/linkstate_factory.py +16 -8
  90. flwr/server/superlink/linkstate/{sqlite_linkstate.py → sql_linkstate.py} +471 -516
  91. flwr/server/superlink/linkstate/utils.py +49 -2
  92. flwr/server/superlink/serverappio/serverappio_servicer.py +1 -33
  93. flwr/server/superlink/simulation/simulationio_servicer.py +0 -19
  94. flwr/server/utils/validator.py +1 -1
  95. flwr/server/workflow/default_workflows.py +2 -1
  96. flwr/server/workflow/secure_aggregation/secaggplus_workflow.py +1 -1
  97. flwr/serverapp/strategy/bulyan.py +7 -1
  98. flwr/serverapp/strategy/dp_fixed_clipping.py +9 -1
  99. flwr/serverapp/strategy/fedavg.py +1 -1
  100. flwr/serverapp/strategy/fedxgb_cyclic.py +1 -1
  101. flwr/simulation/ray_transport/ray_client_proxy.py +2 -6
  102. flwr/simulation/run_simulation.py +3 -12
  103. flwr/simulation/simulationio_connection.py +3 -3
  104. flwr/{common → supercore}/address.py +7 -33
  105. flwr/supercore/app_utils.py +2 -1
  106. flwr/supercore/constant.py +27 -2
  107. flwr/supercore/corestate/{sqlite_corestate.py → sql_corestate.py} +19 -23
  108. flwr/supercore/credential_store/__init__.py +33 -0
  109. flwr/supercore/credential_store/credential_store.py +34 -0
  110. flwr/supercore/credential_store/file_credential_store.py +76 -0
  111. flwr/{common → supercore}/date.py +0 -11
  112. flwr/supercore/ffs/disk_ffs.py +1 -1
  113. flwr/supercore/object_store/object_store_factory.py +14 -6
  114. flwr/supercore/object_store/{sqlite_object_store.py → sql_object_store.py} +115 -117
  115. flwr/supercore/sql_mixin.py +315 -0
  116. flwr/{cli/new/templates → supercore/state}/__init__.py +2 -2
  117. flwr/{cli/new/templates/app/code/flwr_tune → supercore/state/alembic}/__init__.py +2 -2
  118. flwr/supercore/state/alembic/env.py +103 -0
  119. flwr/supercore/state/alembic/script.py.mako +43 -0
  120. flwr/supercore/state/alembic/utils.py +239 -0
  121. flwr/{cli/new/templates/app → supercore/state/alembic/versions}/__init__.py +2 -2
  122. flwr/supercore/state/alembic/versions/rev_2026_01_28_initialize_migration_of_state_tables.py +200 -0
  123. flwr/supercore/state/schema/README.md +121 -0
  124. flwr/{cli/new/templates/app/code → supercore/state/schema}/__init__.py +2 -2
  125. flwr/supercore/state/schema/corestate_tables.py +36 -0
  126. flwr/supercore/state/schema/linkstate_tables.py +152 -0
  127. flwr/supercore/state/schema/objectstore_tables.py +90 -0
  128. flwr/supercore/superexec/run_superexec.py +2 -2
  129. flwr/supercore/utils.py +225 -0
  130. flwr/superlink/federation/federation_manager.py +2 -2
  131. flwr/superlink/federation/noop_federation_manager.py +8 -6
  132. flwr/superlink/servicer/control/control_grpc.py +2 -0
  133. flwr/superlink/servicer/control/control_servicer.py +106 -21
  134. flwr/supernode/cli/flower_supernode.py +2 -1
  135. flwr/supernode/nodestate/in_memory_nodestate.py +62 -1
  136. flwr/supernode/nodestate/nodestate.py +45 -0
  137. flwr/supernode/runtime/run_clientapp.py +14 -14
  138. flwr/supernode/servicer/clientappio/clientappio_servicer.py +13 -5
  139. flwr/supernode/start_client_internal.py +17 -10
  140. {flwr-1.24.0.dist-info → flwr-1.26.0.dist-info}/METADATA +8 -8
  141. {flwr-1.24.0.dist-info → flwr-1.26.0.dist-info}/RECORD +144 -184
  142. flwr/cli/federation/show.py +0 -317
  143. flwr/cli/new/templates/app/.gitignore.tpl +0 -163
  144. flwr/cli/new/templates/app/LICENSE.tpl +0 -202
  145. flwr/cli/new/templates/app/README.baseline.md.tpl +0 -127
  146. flwr/cli/new/templates/app/README.flowertune.md.tpl +0 -68
  147. flwr/cli/new/templates/app/README.md.tpl +0 -37
  148. flwr/cli/new/templates/app/code/__init__.baseline.py.tpl +0 -1
  149. flwr/cli/new/templates/app/code/__init__.py.tpl +0 -1
  150. flwr/cli/new/templates/app/code/__init__.pytorch_legacy_api.py.tpl +0 -1
  151. flwr/cli/new/templates/app/code/client.baseline.py.tpl +0 -75
  152. flwr/cli/new/templates/app/code/client.huggingface.py.tpl +0 -93
  153. flwr/cli/new/templates/app/code/client.jax.py.tpl +0 -71
  154. flwr/cli/new/templates/app/code/client.mlx.py.tpl +0 -102
  155. flwr/cli/new/templates/app/code/client.numpy.py.tpl +0 -46
  156. flwr/cli/new/templates/app/code/client.pytorch.py.tpl +0 -80
  157. flwr/cli/new/templates/app/code/client.pytorch_legacy_api.py.tpl +0 -55
  158. flwr/cli/new/templates/app/code/client.sklearn.py.tpl +0 -108
  159. flwr/cli/new/templates/app/code/client.tensorflow.py.tpl +0 -82
  160. flwr/cli/new/templates/app/code/client.xgboost.py.tpl +0 -110
  161. flwr/cli/new/templates/app/code/dataset.baseline.py.tpl +0 -36
  162. flwr/cli/new/templates/app/code/flwr_tune/client_app.py.tpl +0 -92
  163. flwr/cli/new/templates/app/code/flwr_tune/dataset.py.tpl +0 -87
  164. flwr/cli/new/templates/app/code/flwr_tune/models.py.tpl +0 -56
  165. flwr/cli/new/templates/app/code/flwr_tune/server_app.py.tpl +0 -73
  166. flwr/cli/new/templates/app/code/flwr_tune/strategy.py.tpl +0 -78
  167. flwr/cli/new/templates/app/code/model.baseline.py.tpl +0 -66
  168. flwr/cli/new/templates/app/code/server.baseline.py.tpl +0 -43
  169. flwr/cli/new/templates/app/code/server.huggingface.py.tpl +0 -42
  170. flwr/cli/new/templates/app/code/server.jax.py.tpl +0 -39
  171. flwr/cli/new/templates/app/code/server.mlx.py.tpl +0 -41
  172. flwr/cli/new/templates/app/code/server.numpy.py.tpl +0 -38
  173. flwr/cli/new/templates/app/code/server.pytorch.py.tpl +0 -41
  174. flwr/cli/new/templates/app/code/server.pytorch_legacy_api.py.tpl +0 -31
  175. flwr/cli/new/templates/app/code/server.sklearn.py.tpl +0 -44
  176. flwr/cli/new/templates/app/code/server.tensorflow.py.tpl +0 -38
  177. flwr/cli/new/templates/app/code/server.xgboost.py.tpl +0 -56
  178. flwr/cli/new/templates/app/code/strategy.baseline.py.tpl +0 -1
  179. flwr/cli/new/templates/app/code/task.huggingface.py.tpl +0 -98
  180. flwr/cli/new/templates/app/code/task.jax.py.tpl +0 -57
  181. flwr/cli/new/templates/app/code/task.mlx.py.tpl +0 -102
  182. flwr/cli/new/templates/app/code/task.numpy.py.tpl +0 -7
  183. flwr/cli/new/templates/app/code/task.pytorch.py.tpl +0 -99
  184. flwr/cli/new/templates/app/code/task.pytorch_legacy_api.py.tpl +0 -111
  185. flwr/cli/new/templates/app/code/task.sklearn.py.tpl +0 -67
  186. flwr/cli/new/templates/app/code/task.tensorflow.py.tpl +0 -52
  187. flwr/cli/new/templates/app/code/task.xgboost.py.tpl +0 -67
  188. flwr/cli/new/templates/app/code/utils.baseline.py.tpl +0 -1
  189. flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +0 -146
  190. flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +0 -80
  191. flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +0 -65
  192. flwr/cli/new/templates/app/pyproject.jax.toml.tpl +0 -52
  193. flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +0 -56
  194. flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +0 -49
  195. flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +0 -53
  196. flwr/cli/new/templates/app/pyproject.pytorch_legacy_api.toml.tpl +0 -53
  197. flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +0 -52
  198. flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +0 -53
  199. flwr/cli/new/templates/app/pyproject.xgboost.toml.tpl +0 -61
  200. flwr/common/pyproject.py +0 -42
  201. flwr/supercore/sqlite_mixin.py +0 -159
  202. /flwr/{common → supercore}/version.py +0 -0
  203. {flwr-1.24.0.dist-info → flwr-1.26.0.dist-info}/WHEEL +0 -0
  204. {flwr-1.24.0.dist-info → flwr-1.26.0.dist-info}/entry_points.txt +0 -0
flwr/cli/utils.py CHANGED
@@ -18,21 +18,25 @@
18
18
  import hashlib
19
19
  import json
20
20
  import re
21
+ import sys
21
22
  from collections.abc import Callable, Iterable, Iterator
22
23
  from contextlib import contextmanager
24
+ from io import StringIO
23
25
  from pathlib import Path
24
26
  from typing import Any, cast
25
27
 
28
+ import click
26
29
  import grpc
27
30
  import pathspec
28
- import requests
29
31
  import typer
32
+ from rich.console import Console
30
33
 
34
+ from flwr.cli.typing import SuperLinkConnection
31
35
  from flwr.common.constant import (
32
36
  ACCESS_TOKEN_KEY,
33
37
  AUTHN_TYPE_JSON_KEY,
34
- CREDENTIALS_DIR,
35
- FLWR_DIR,
38
+ FEDERATION_NOT_FOUND_MESSAGE,
39
+ FEDERATION_NOT_SPECIFIED_MESSAGE,
36
40
  NO_ACCOUNT_AUTH_MESSAGE,
37
41
  NO_ARTIFACT_PROVIDER_MESSAGE,
38
42
  NODE_NOT_FOUND_MESSAGE,
@@ -42,18 +46,67 @@ from flwr.common.constant import (
42
46
  REFRESH_TOKEN_KEY,
43
47
  RUN_ID_NOT_FOUND_MESSAGE,
44
48
  AuthnType,
49
+ CliOutputFormat,
45
50
  )
46
51
  from flwr.common.grpc import (
47
52
  GRPC_MAX_MESSAGE_LENGTH,
48
53
  create_channel,
49
54
  on_channel_state_change,
50
55
  )
51
- from flwr.common.version import package_version as flwr_version
52
- from flwr.supercore.constant import APP_ID_PATTERN, APP_VERSION_PATTERN
56
+ from flwr.common.logger import print_json_error, redirect_output, restore_output
57
+ from flwr.supercore.credential_store import get_credential_store
53
58
 
54
59
  from .auth_plugin import CliAuthPlugin, get_cli_plugin_class
55
60
  from .cli_account_auth_interceptor import CliAccountAuthInterceptor
56
- from .config_utils import validate_certificate_in_federation_config
61
+ from .config_utils import load_certificate_in_connection
62
+ from .constant import AUTHN_TYPE_STORE_KEY
63
+
64
+
65
+ def print_json_to_stdout(data: str | Any) -> None:
66
+ """Print JSON data to stdout, bypassing any output redirection.
67
+
68
+ Use this function within the `cli_output_handler` context manager to print JSON
69
+ output directly to the terminal, even when stdout is being captured.
70
+ """
71
+ if isinstance(data, str):
72
+ Console(file=sys.__stdout__).print_json(data)
73
+ else:
74
+ Console(file=sys.__stdout__).print_json(data=data)
75
+
76
+
77
+ @contextmanager # docsig: ignore=SIG503
78
+ def cli_output_handler(
79
+ output_format: str = CliOutputFormat.DEFAULT,
80
+ ) -> Iterator[bool]:
81
+ """Context manager for handling CLI output in different formats.
82
+
83
+ This context manager provides consistent output handling for CLI commands by:
84
+ - Redirecting stdout/stderr when JSON format is requested
85
+ - Catching and handling exceptions appropriately based on the output format
86
+
87
+ Use the `print_json_to_stdout()` utility function to print JSON output that bypasses
88
+ output redirection.
89
+ """
90
+ is_json = output_format == CliOutputFormat.JSON
91
+ captured_output = StringIO()
92
+
93
+ if is_json:
94
+ redirect_output(captured_output)
95
+
96
+ try:
97
+ yield is_json
98
+ except Exception as err: # pylint: disable=broad-except
99
+ if is_json:
100
+ restore_output()
101
+ print_json_error(captured_output.getvalue(), err)
102
+ else:
103
+ if isinstance(err, typer.Exit):
104
+ raise # Allow typer.Exit to escape normally
105
+ raise click.ClickException(str(err)) from None
106
+ finally:
107
+ if is_json:
108
+ restore_output()
109
+ captured_output.close()
57
110
 
58
111
 
59
112
  def prompt_text(
@@ -158,46 +211,6 @@ def is_valid_project_name(name: str) -> bool:
158
211
  return True
159
212
 
160
213
 
161
- def sanitize_project_name(name: str) -> str:
162
- """Sanitize the given string to make it a valid Python project name.
163
-
164
- This function replaces spaces, dots, slashes, and underscores with dashes, removes
165
- any characters not allowed in Python project names, makes the string lowercase, and
166
- ensures it starts with a valid character.
167
-
168
- Parameters
169
- ----------
170
- name : str
171
- The project name to sanitize.
172
-
173
- Returns
174
- -------
175
- str
176
- The sanitized project name that is valid for Python projects.
177
- """
178
- # Replace whitespace with '_'
179
- name_with_hyphens = re.sub(r"[ ./_]", "-", name)
180
-
181
- # Allowed characters in a module name: letters, digits, underscore
182
- allowed_chars = set(
183
- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
184
- )
185
-
186
- # Make the string lowercase
187
- sanitized_name = name_with_hyphens.lower()
188
-
189
- # Remove any characters not allowed in Python module names
190
- sanitized_name = "".join(c for c in sanitized_name if c in allowed_chars)
191
-
192
- # Ensure the first character is a letter or underscore
193
- while sanitized_name and (
194
- sanitized_name[0].isdigit() or sanitized_name[0] not in allowed_chars
195
- ):
196
- sanitized_name = sanitized_name[1:]
197
-
198
- return sanitized_name
199
-
200
-
201
214
  def get_sha256_hash(file_path_or_int: Path | int) -> str:
202
215
  """Calculate the SHA-256 hash of a file or integer.
203
216
 
@@ -224,121 +237,27 @@ def get_sha256_hash(file_path_or_int: Path | int) -> str:
224
237
  return sha256.hexdigest()
225
238
 
226
239
 
227
- def get_account_auth_config_path(root_dir: Path, federation: str) -> Path:
228
- """Return the path to the account auth config file.
240
+ def get_authn_type(host: str) -> str:
241
+ """Retrieve the authentication type for the given host from the credential store.
229
242
 
230
- Additionally, a `.gitignore` file will be created in the Flower directory to
231
- include the `.credentials` folder to be excluded from git. If the `.gitignore`
232
- file already exists, a warning will be displayed if the `.credentials` entry is
233
- not found.
243
+ `AuthnType.NOOP` is returned if no authentication type is found.
234
244
  """
235
- # Locate the credentials directory
236
- abs_flwr_dir = root_dir.absolute() / FLWR_DIR
237
- credentials_dir = abs_flwr_dir / CREDENTIALS_DIR
238
- credentials_dir.mkdir(parents=True, exist_ok=True)
239
-
240
- # Determine the absolute path of the Flower directory for .gitignore
241
- gitignore_path = abs_flwr_dir / ".gitignore"
242
- credential_entry = CREDENTIALS_DIR
243
-
244
- try:
245
- if gitignore_path.exists():
246
- with open(gitignore_path, encoding="utf-8") as gitignore_file:
247
- lines = gitignore_file.read().splitlines()
248
-
249
- # Warn if .credentials is not already in .gitignore
250
- if credential_entry not in lines:
251
- typer.secho(
252
- f"`.gitignore` exists, but `{credential_entry}` entry not found. "
253
- "Consider adding it to your `.gitignore` to exclude Flower "
254
- "credentials from git.",
255
- fg=typer.colors.YELLOW,
256
- bold=True,
257
- )
258
- else:
259
- typer.secho(
260
- f"Creating a new `.gitignore` with `{credential_entry}` entry...",
261
- fg=typer.colors.BLUE,
262
- )
263
- # Create a new .gitignore with .credentials
264
- with open(gitignore_path, "w", encoding="utf-8") as gitignore_file:
265
- gitignore_file.write(f"{credential_entry}\n")
266
- except Exception as err:
267
- typer.secho(
268
- "❌ An error occurred while handling `.gitignore.` "
269
- f"Please check the permissions of `{gitignore_path}` and try again.",
270
- fg=typer.colors.RED,
271
- bold=True,
272
- err=True,
273
- )
274
- raise typer.Exit(code=1) from err
275
-
276
- return credentials_dir / f"{federation}.json"
277
-
278
-
279
- def account_auth_enabled(federation_config: dict[str, Any]) -> bool:
280
- """Check if account authentication is enabled in the federation config.
281
-
282
- Parameters
283
- ----------
284
- federation_config : dict[str, Any]
285
- The federation configuration dictionary.
286
-
287
- Returns
288
- -------
289
- bool
290
- True if account authentication is enabled, False otherwise.
291
- """
292
- enabled: bool = federation_config.get("enable-user-auth", False)
293
- enabled |= federation_config.get("enable-account-auth", False)
294
- if "enable-user-auth" in federation_config:
295
- typer.secho(
296
- "`enable-user-auth` is deprecated and will be removed in a future "
297
- "release. Please use `enable-account-auth` instead.",
298
- fg=typer.colors.YELLOW,
299
- bold=True,
300
- )
301
- return enabled
302
-
303
-
304
- def retrieve_authn_type(config_path: Path) -> str:
305
- """Retrieve the auth type from the config file or return NOOP if not found.
306
-
307
- Parameters
308
- ----------
309
- config_path : Path
310
- Path to the authentication configuration file.
311
-
312
- Returns
313
- -------
314
- str
315
- The authentication type string, or AuthnType.NOOP if not found.
316
- """
317
- try:
318
- with config_path.open("r", encoding="utf-8") as file:
319
- json_file = json.load(file)
320
- authn_type: str = json_file[AUTHN_TYPE_JSON_KEY]
321
- return authn_type
322
- except (FileNotFoundError, KeyError):
245
+ store = get_credential_store()
246
+ authn_type = store.get(AUTHN_TYPE_STORE_KEY % host)
247
+ if authn_type is None:
323
248
  return AuthnType.NOOP
249
+ return authn_type.decode("utf-8")
324
250
 
325
251
 
326
- def load_cli_auth_plugin(
327
- root_dir: Path,
328
- federation: str,
329
- federation_config: dict[str, Any],
330
- authn_type: str | None = None,
252
+ def load_cli_auth_plugin_from_connection(
253
+ host: str, authn_type: str | None = None
331
254
  ) -> CliAuthPlugin:
332
- """Load the CLI-side account auth plugin for the given authn type.
255
+ """Load the CLI-side account auth plugin for the given connection.
333
256
 
334
257
  Parameters
335
258
  ----------
336
- root_dir : Path
337
- Root directory of the Flower project.
338
- federation : str
339
- Name of the federation.
340
- federation_config : dict[str, Any]
341
- Federation configuration dictionary.
259
+ host : str
260
+ The SuperLink Control API address.
342
261
  authn_type : str | None
343
262
  Authentication type. If None, will be determined from config.
344
263
 
@@ -349,41 +268,48 @@ def load_cli_auth_plugin(
349
268
 
350
269
  Raises
351
270
  ------
352
- typer.Exit
271
+ click.ClickException
353
272
  If the authentication type is unknown.
354
273
  """
355
- # Find the path to the account auth config file
356
- config_path = get_account_auth_config_path(root_dir, federation)
357
-
358
274
  # Determine the auth type if not provided
359
275
  # Only `flwr login` command can provide `authn_type` explicitly, as it can query the
360
276
  # SuperLink for the auth type.
361
277
  if authn_type is None:
362
- authn_type = AuthnType.NOOP
363
- if account_auth_enabled(federation_config):
364
- authn_type = retrieve_authn_type(config_path)
278
+ authn_type = get_authn_type(host)
365
279
 
366
280
  # Retrieve auth plugin class and instantiate it
367
281
  try:
368
282
  auth_plugin_class = get_cli_plugin_class(authn_type)
369
- return auth_plugin_class(config_path)
283
+ return auth_plugin_class(host)
370
284
  except ValueError:
371
- typer.echo(f"❌ Unknown account authentication type: {authn_type}")
372
- raise typer.Exit(code=1) from None
285
+ raise click.ClickException(
286
+ f"Unknown account authentication type: {authn_type}"
287
+ ) from None
288
+
289
+
290
+ def require_superlink_address(connection: SuperLinkConnection) -> str:
291
+ """Return the SuperLink address or exit if it is not configured."""
292
+ if connection.address is None:
293
+ cmd = click.get_current_context().command.name
294
+ raise click.ClickException(
295
+ f"`flwr {cmd}` currently works with a SuperLink. Ensure that the "
296
+ "correct SuperLink (Control API) address is provided SuperLink connection "
297
+ "you are using. Check your Flower configuration file. You may use `flwr "
298
+ "config list` to see its location in the file system."
299
+ )
300
+ return connection.address
373
301
 
374
302
 
375
- def init_channel(
376
- app: Path, federation_config: dict[str, Any], auth_plugin: CliAuthPlugin
303
+ def init_channel_from_connection(
304
+ connection: SuperLinkConnection, auth_plugin: CliAuthPlugin | None = None
377
305
  ) -> grpc.Channel:
378
306
  """Initialize gRPC channel to the Control API.
379
307
 
380
308
  Parameters
381
309
  ----------
382
- app : Path
383
- Path to the Flower app directory.
384
- federation_config : dict[str, Any]
385
- Federation configuration dictionary containing address and TLS settings.
386
- auth_plugin : CliAuthPlugin
310
+ connection : SuperLinkConnection
311
+ SuperLink connection configuration.
312
+ auth_plugin : CliAuthPlugin | None (default: None)
387
313
  Authentication plugin instance for handling credentials.
388
314
 
389
315
  Returns
@@ -391,17 +317,20 @@ def init_channel(
391
317
  grpc.Channel
392
318
  Configured gRPC channel with authentication interceptors.
393
319
  """
394
- insecure, root_certificates_bytes = validate_certificate_in_federation_config(
395
- app, federation_config
396
- )
320
+ address = require_superlink_address(connection)
397
321
 
322
+ root_certificates_bytes = load_certificate_in_connection(connection)
323
+
324
+ # Load authentication plugin
325
+ if auth_plugin is None:
326
+ auth_plugin = load_cli_auth_plugin_from_connection(address)
398
327
  # Load tokens
399
328
  auth_plugin.load_tokens()
400
329
 
401
330
  # Create the gRPC channel
402
331
  channel = create_channel(
403
- server_address=federation_config["address"],
404
- insecure=insecure,
332
+ server_address=address,
333
+ insecure=connection.insecure,
405
334
  root_certificates=root_certificates_bytes,
406
335
  max_message_length=GRPC_MAX_MESSAGE_LENGTH,
407
336
  interceptors=[CliAccountAuthInterceptor(auth_plugin)],
@@ -426,7 +355,7 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]: # pylint: disable=too-many-b
426
355
 
427
356
  Raises
428
357
  ------
429
- typer.Exit
358
+ click.ClickException
430
359
  On handled gRPC error statuses with appropriate exit code.
431
360
  grpc.RpcError
432
361
  For unhandled gRPC error statuses.
@@ -435,206 +364,77 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]: # pylint: disable=too-many-b
435
364
  yield
436
365
  except grpc.RpcError as e:
437
366
  if e.code() == grpc.StatusCode.UNAUTHENTICATED:
438
- typer.secho(
439
- "Authentication failed. Please run `flwr login`"
440
- " to authenticate and try again.",
441
- fg=typer.colors.RED,
442
- bold=True,
443
- err=True,
444
- )
445
- raise typer.Exit(code=1) from None
367
+ raise click.ClickException(
368
+ "Authentication failed. Please run `flwr login`"
369
+ " to authenticate and try again."
370
+ ) from None
446
371
  if e.code() == grpc.StatusCode.UNIMPLEMENTED:
447
372
  if e.details() == NO_ACCOUNT_AUTH_MESSAGE: # pylint: disable=E1101
448
- typer.secho(
449
- "Account authentication is not enabled on this SuperLink.",
450
- fg=typer.colors.RED,
451
- bold=True,
452
- err=True,
453
- )
454
- elif e.details() == NO_ARTIFACT_PROVIDER_MESSAGE: # pylint: disable=E1101
455
- typer.secho(
456
- "The SuperLink does not support `flwr pull` command.",
457
- fg=typer.colors.RED,
458
- bold=True,
459
- err=True,
460
- )
461
- else:
462
- typer.secho(
463
- "❌ The SuperLink cannot process this request. Please verify that "
464
- "you set the address to its Control API endpoint correctly in your "
465
- "`pyproject.toml`, and ensure that the Flower versions used by "
466
- "the CLI and SuperLink are compatible.",
467
- fg=typer.colors.RED,
468
- bold=True,
469
- err=True,
470
- )
471
- raise typer.Exit(code=1) from None
373
+ raise click.ClickException(
374
+ "Account authentication is not enabled on this SuperLink."
375
+ ) from None
376
+ if e.details() == NO_ARTIFACT_PROVIDER_MESSAGE: # pylint: disable=E1101
377
+ raise click.ClickException(
378
+ "The SuperLink does not support `flwr pull` command."
379
+ ) from None
380
+ raise click.ClickException(
381
+ "The SuperLink cannot process this request. Please verify that "
382
+ "you set the address to its Control API endpoint correctly in your "
383
+ "SuperLink connection in your Flower Configuration file. You may use "
384
+ "`flwr config list` to see its location in the file system. "
385
+ "Additonally, ensure that the Flower versions used by the CLI and "
386
+ "SuperLink are compatible."
387
+ ) from None
472
388
  if e.code() == grpc.StatusCode.PERMISSION_DENIED:
473
- typer.secho(
474
- "❌ Permission denied.",
475
- fg=typer.colors.RED,
476
- bold=True,
477
- err=True,
478
- )
479
389
  # pylint: disable-next=E1101
480
- typer.secho(e.details(), fg=typer.colors.RED, bold=True)
481
- raise typer.Exit(code=1) from None
390
+ raise click.ClickException(f"Permission denied.\n{e.details()}") from None
482
391
  if e.code() == grpc.StatusCode.UNAVAILABLE:
483
- typer.secho(
392
+ raise click.ClickException(
484
393
  "Connection to the SuperLink is unavailable. Please check your network "
485
- "connection and 'address' in the federation configuration.",
486
- fg=typer.colors.RED,
487
- bold=True,
488
- err=True,
489
- )
490
- raise typer.Exit(code=1) from None
394
+ "connection and 'address' in the SuperLink connection configuration."
395
+ ) from None
491
396
  if e.code() == grpc.StatusCode.NOT_FOUND:
492
397
  if e.details() == RUN_ID_NOT_FOUND_MESSAGE: # pylint: disable=E1101
493
- typer.secho(
494
- "❌ Run ID not found.",
495
- fg=typer.colors.RED,
496
- bold=True,
497
- err=True,
498
- )
499
- raise typer.Exit(code=1) from None
398
+ raise click.ClickException("Run ID not found.") from None
500
399
  if e.details() == NODE_NOT_FOUND_MESSAGE: # pylint: disable=E1101
501
- typer.secho(
502
- "Node ID not found for this account.",
503
- fg=typer.colors.RED,
504
- bold=True,
505
- err=True,
506
- )
507
- raise typer.Exit(code=1) from None
400
+ raise click.ClickException(
401
+ "Node ID not found for this account."
402
+ ) from None
508
403
  if e.code() == grpc.StatusCode.FAILED_PRECONDITION:
509
404
  if e.details() == PULL_UNFINISHED_RUN_MESSAGE: # pylint: disable=E1101
510
- typer.secho(
511
- "Run is not finished yet. Artifacts can only be pulled after "
512
- "the run is finished. You can check the run status with `flwr ls`.",
513
- fg=typer.colors.RED,
514
- bold=True,
515
- err=True,
516
- )
517
- raise typer.Exit(code=1) from None
405
+ raise click.ClickException(
406
+ "Run is not finished yet. Artifacts can only be pulled after "
407
+ "the run is finished. You can check the run status with `flwr ls`."
408
+ ) from None
518
409
  if (
519
410
  e.details() == PUBLIC_KEY_ALREADY_IN_USE_MESSAGE
520
411
  ): # pylint: disable=E1101
521
- typer.secho(
522
- "The provided public key is already in use by another "
523
- "SuperNode.",
524
- fg=typer.colors.RED,
525
- bold=True,
526
- err=True,
527
- )
528
- raise typer.Exit(code=1) from None
412
+ raise click.ClickException(
413
+ "The provided public key is already in use by another SuperNode."
414
+ ) from None
529
415
  if e.details() == PUBLIC_KEY_NOT_VALID: # pylint: disable=E1101
530
- typer.secho(
531
- "The provided public key is invalid. Please provide a valid "
532
- "NIST EC public key.",
533
- fg=typer.colors.RED,
534
- bold=True,
535
- err=True,
536
- )
537
- raise typer.Exit(code=1) from None
416
+ raise click.ClickException(
417
+ "The provided public key is invalid. Please provide a valid "
418
+ "NIST EC public key."
419
+ ) from None
420
+ if e.details() == FEDERATION_NOT_SPECIFIED_MESSAGE: # pylint: disable=E1101
421
+ raise click.ClickException(
422
+ "No federation specified. "
423
+ "Please use the `--federation` flag or set a default federation "
424
+ "in your SuperLink connection configuration."
425
+ ) from None
426
+ patten = re.compile(FEDERATION_NOT_FOUND_MESSAGE.replace("%s", "(.+)"))
427
+ if m := patten.match(e.details()): # pylint: disable=E1101
428
+ raise click.ClickException(
429
+ f"Federation '{m.group(1)}' does not exist. "
430
+ "Please verify the federation name and try again."
431
+ ) from None
538
432
 
539
433
  # Log details from grpc error directly
540
- typer.secho(
541
- f"❌ {e.details()}",
542
- fg=typer.colors.RED,
543
- bold=True,
544
- err=True,
545
- )
546
- raise typer.Exit(code=1) from None
434
+ raise click.ClickException(f"{e.details()}") from None
547
435
  raise
548
436
 
549
437
 
550
- def request_download_link(
551
- app_id: str, app_version: str | None, in_url: str, out_url: str
552
- ) -> str:
553
- """Request a download link for the given app from the Flower platform API.
554
-
555
- Parameters
556
- ----------
557
- app_id : str
558
- The application identifier.
559
- app_version : str | None
560
- The application version, or None for latest.
561
- in_url : str
562
- The API endpoint URL.
563
- out_url : str
564
- The key name for the download URL in the response.
565
-
566
- Returns
567
- -------
568
- str
569
- The download URL for the application.
570
-
571
- Raises
572
- ------
573
- typer.Exit
574
- If connection fails, app not found, or API request fails.
575
- """
576
- headers = {
577
- "Content-Type": "application/json",
578
- "Accept": "application/json",
579
- }
580
- body = {
581
- "app_id": app_id, # send raw string of app_id
582
- "app_version": app_version,
583
- "flwr_version": flwr_version,
584
- }
585
- try:
586
- resp = requests.post(in_url, headers=headers, data=json.dumps(body), timeout=20)
587
- except requests.RequestException as e:
588
- typer.secho(
589
- f"Unable to connect to Platform API: {e}",
590
- fg=typer.colors.RED,
591
- err=True,
592
- )
593
- raise typer.Exit(code=1) from e
594
-
595
- if resp.status_code == 404:
596
- error_message = resp.json()["detail"]
597
- if isinstance(error_message, dict):
598
- available_app_versions = error_message["available_app_versions"]
599
- available_versions_str = (
600
- ", ".join(map(str, available_app_versions))
601
- if available_app_versions
602
- else "None"
603
- )
604
- typer.secho(
605
- f"{app_id}=={app_version} not found in Platform API. "
606
- f"Available app versions for {app_id}: {available_versions_str}",
607
- fg=typer.colors.RED,
608
- err=True,
609
- )
610
- else:
611
- typer.secho(
612
- f"{app_id} not found in Platform API.",
613
- fg=typer.colors.RED,
614
- err=True,
615
- )
616
- raise typer.Exit(code=1)
617
-
618
- if not resp.ok:
619
- typer.secho(
620
- f"Platform API request failed with "
621
- f"status {resp.status_code}. Details: {resp.text}",
622
- fg=typer.colors.RED,
623
- err=True,
624
- )
625
- raise typer.Exit(code=1)
626
-
627
- data = resp.json()
628
- if out_url not in data:
629
- typer.secho(
630
- "Invalid response from Platform API",
631
- fg=typer.colors.RED,
632
- err=True,
633
- )
634
- raise typer.Exit(code=1)
635
- return str(data[out_url])
636
-
637
-
638
438
  def build_pathspec(patterns: Iterable[str]) -> pathspec.PathSpec:
639
439
  """Build a PathSpec from a list of GitIgnore-style patterns.
640
440
 
@@ -691,69 +491,17 @@ def validate_credentials_content(creds_path: Path) -> str:
691
491
  try:
692
492
  creds: dict[str, str] = json.loads(creds_path.read_text(encoding="utf-8"))
693
493
  except (OSError, json.JSONDecodeError) as err:
694
- typer.secho(
695
- f"Invalid credentials file at '{creds_path}': {err}",
696
- fg=typer.colors.RED,
697
- err=True,
698
- )
699
- raise typer.Exit(code=1) from err
494
+ raise click.ClickException(
495
+ f"Invalid credentials file at '{creds_path}': {err}"
496
+ ) from err
700
497
 
701
498
  required_keys = [AUTHN_TYPE_JSON_KEY, ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY]
702
499
  missing = [key for key in required_keys if key not in creds]
703
500
 
704
501
  if missing:
705
- typer.secho(
502
+ raise click.ClickException(
706
503
  f"Credentials file '{creds_path}' is missing "
707
- f"required key(s): {', '.join(missing)}. Please log in again.",
708
- fg=typer.colors.RED,
709
- err=True,
504
+ f"required key(s): {', '.join(missing)}. Please log in again."
710
505
  )
711
- raise typer.Exit(code=1)
712
506
 
713
507
  return creds[ACCESS_TOKEN_KEY]
714
-
715
-
716
- def parse_app_spec(app_spec: str) -> tuple[str, str | None]:
717
- """Parse app specification string into app ID and version.
718
-
719
- Parameters
720
- ----------
721
- app_spec : str
722
- The app specification string in the format '@account/app' or
723
- '@account/app==x.y.z' (digits only).
724
-
725
- Returns
726
- -------
727
- tuple[str, str | None]
728
- A tuple containing the app ID and optional version.
729
-
730
- Raises
731
- ------
732
- typer.Exit
733
- If the app specification format is invalid.
734
- """
735
- if "==" in app_spec:
736
- app_id, app_version = app_spec.split("==")
737
-
738
- # Validate app version format
739
- if not re.match(APP_VERSION_PATTERN, app_version):
740
- typer.secho(
741
- "❌ Invalid app version. Expected format: x.y.z (digits only).",
742
- fg=typer.colors.RED,
743
- err=True,
744
- )
745
- raise typer.Exit(code=1)
746
- else:
747
- app_id = app_spec
748
- app_version = None
749
-
750
- # Validate app_id format
751
- if not re.match(APP_ID_PATTERN, app_id):
752
- typer.secho(
753
- "❌ Invalid remote app ID. Expected format: '@account/app'.",
754
- fg=typer.colors.RED,
755
- err=True,
756
- )
757
- raise typer.Exit(code=1)
758
-
759
- return app_id, app_version