flwr 1.13.1__py3-none-any.whl → 1.15.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 (158) hide show
  1. flwr/cli/app.py +5 -0
  2. flwr/cli/auth_plugin/__init__.py +31 -0
  3. flwr/cli/auth_plugin/oidc_cli_plugin.py +150 -0
  4. flwr/cli/build.py +1 -0
  5. flwr/cli/cli_user_auth_interceptor.py +90 -0
  6. flwr/cli/config_utils.py +43 -149
  7. flwr/cli/constant.py +27 -0
  8. flwr/cli/example.py +1 -0
  9. flwr/cli/install.py +2 -1
  10. flwr/cli/log.py +34 -37
  11. flwr/cli/login/__init__.py +22 -0
  12. flwr/cli/login/login.py +116 -0
  13. flwr/cli/ls.py +214 -106
  14. flwr/cli/new/__init__.py +1 -0
  15. flwr/cli/new/new.py +2 -1
  16. flwr/cli/new/templates/app/.gitignore.tpl +3 -0
  17. flwr/cli/new/templates/app/README.md.tpl +3 -2
  18. flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +4 -4
  19. flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +4 -4
  20. flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +4 -4
  21. flwr/cli/new/templates/app/pyproject.jax.toml.tpl +2 -2
  22. flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +3 -4
  23. flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +2 -2
  24. flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +4 -4
  25. flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +3 -3
  26. flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +2 -2
  27. flwr/cli/run/__init__.py +1 -0
  28. flwr/cli/run/run.py +103 -43
  29. flwr/cli/stop.py +139 -0
  30. flwr/cli/utils.py +186 -8
  31. flwr/client/app.py +49 -50
  32. flwr/client/client.py +1 -32
  33. flwr/client/clientapp/app.py +23 -26
  34. flwr/client/clientapp/utils.py +2 -1
  35. flwr/client/grpc_adapter_client/connection.py +1 -1
  36. flwr/client/grpc_client/connection.py +2 -13
  37. flwr/client/grpc_rere_client/client_interceptor.py +19 -119
  38. flwr/client/grpc_rere_client/connection.py +59 -43
  39. flwr/client/grpc_rere_client/grpc_adapter.py +12 -12
  40. flwr/client/message_handler/message_handler.py +1 -2
  41. flwr/client/message_handler/task_handler.py +0 -17
  42. flwr/client/mod/comms_mods.py +1 -0
  43. flwr/client/mod/localdp_mod.py +1 -1
  44. flwr/client/nodestate/__init__.py +1 -0
  45. flwr/client/nodestate/nodestate.py +1 -0
  46. flwr/client/nodestate/nodestate_factory.py +1 -0
  47. flwr/client/numpy_client.py +0 -44
  48. flwr/client/rest_client/connection.py +37 -29
  49. flwr/client/supernode/app.py +20 -74
  50. flwr/common/address.py +1 -0
  51. flwr/common/args.py +26 -47
  52. flwr/common/auth_plugin/__init__.py +24 -0
  53. flwr/common/auth_plugin/auth_plugin.py +122 -0
  54. flwr/common/config.py +169 -17
  55. flwr/common/constant.py +38 -9
  56. flwr/common/differential_privacy.py +2 -1
  57. flwr/common/exit/__init__.py +24 -0
  58. flwr/common/exit/exit.py +99 -0
  59. flwr/common/exit/exit_code.py +93 -0
  60. flwr/common/exit_handlers.py +24 -10
  61. flwr/common/grpc.py +167 -4
  62. flwr/common/logger.py +66 -7
  63. flwr/common/message.py +1 -0
  64. flwr/common/object_ref.py +57 -54
  65. flwr/common/pyproject.py +1 -0
  66. flwr/common/record/__init__.py +1 -0
  67. flwr/common/record/parametersrecord.py +1 -0
  68. flwr/common/record/recordset.py +1 -1
  69. flwr/common/retry_invoker.py +77 -0
  70. flwr/common/secure_aggregation/crypto/symmetric_encryption.py +45 -0
  71. flwr/common/secure_aggregation/secaggplus_utils.py +2 -2
  72. flwr/common/serde.py +6 -4
  73. flwr/common/telemetry.py +15 -4
  74. flwr/common/typing.py +32 -0
  75. flwr/common/version.py +1 -0
  76. flwr/proto/clientappio_pb2.py +1 -1
  77. flwr/proto/error_pb2.py +1 -1
  78. flwr/proto/exec_pb2.py +27 -15
  79. flwr/proto/exec_pb2.pyi +80 -2
  80. flwr/proto/exec_pb2_grpc.py +102 -0
  81. flwr/proto/exec_pb2_grpc.pyi +39 -0
  82. flwr/proto/fab_pb2.py +5 -5
  83. flwr/proto/fab_pb2.pyi +4 -1
  84. flwr/proto/fleet_pb2.py +31 -31
  85. flwr/proto/fleet_pb2.pyi +23 -23
  86. flwr/proto/fleet_pb2_grpc.py +30 -30
  87. flwr/proto/fleet_pb2_grpc.pyi +20 -20
  88. flwr/proto/grpcadapter_pb2.py +1 -1
  89. flwr/proto/log_pb2.py +1 -1
  90. flwr/proto/message_pb2.py +1 -1
  91. flwr/proto/node_pb2.py +3 -3
  92. flwr/proto/node_pb2.pyi +1 -4
  93. flwr/proto/recordset_pb2.py +1 -1
  94. flwr/proto/run_pb2.py +1 -1
  95. flwr/proto/serverappio_pb2.py +24 -25
  96. flwr/proto/serverappio_pb2.pyi +32 -32
  97. flwr/proto/serverappio_pb2_grpc.py +62 -28
  98. flwr/proto/serverappio_pb2_grpc.pyi +29 -16
  99. flwr/proto/simulationio_pb2.py +3 -3
  100. flwr/proto/simulationio_pb2_grpc.py +34 -0
  101. flwr/proto/simulationio_pb2_grpc.pyi +13 -0
  102. flwr/proto/task_pb2.py +1 -1
  103. flwr/proto/transport_pb2.py +1 -1
  104. flwr/server/app.py +152 -112
  105. flwr/server/compat/app_utils.py +7 -2
  106. flwr/server/compat/driver_client_proxy.py +1 -2
  107. flwr/server/driver/grpc_driver.py +38 -85
  108. flwr/server/driver/inmemory_driver.py +7 -2
  109. flwr/server/run_serverapp.py +8 -9
  110. flwr/server/serverapp/app.py +37 -13
  111. flwr/server/strategy/dpfedavg_fixed.py +1 -0
  112. flwr/server/superlink/driver/serverappio_grpc.py +2 -1
  113. flwr/server/superlink/driver/serverappio_servicer.py +148 -63
  114. flwr/server/superlink/ffs/disk_ffs.py +1 -0
  115. flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer.py +20 -87
  116. flwr/server/superlink/fleet/grpc_bidi/flower_service_servicer.py +1 -0
  117. flwr/server/superlink/fleet/grpc_bidi/grpc_server.py +2 -165
  118. flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py +56 -35
  119. flwr/server/superlink/fleet/grpc_rere/server_interceptor.py +99 -169
  120. flwr/server/superlink/fleet/message_handler/message_handler.py +69 -29
  121. flwr/server/superlink/fleet/rest_rere/rest_api.py +20 -19
  122. flwr/server/superlink/fleet/vce/__init__.py +1 -0
  123. flwr/server/superlink/fleet/vce/backend/__init__.py +1 -0
  124. flwr/server/superlink/fleet/vce/backend/raybackend.py +1 -0
  125. flwr/server/superlink/fleet/vce/vce_api.py +2 -2
  126. flwr/server/superlink/linkstate/in_memory_linkstate.py +60 -99
  127. flwr/server/superlink/linkstate/linkstate.py +30 -36
  128. flwr/server/superlink/linkstate/sqlite_linkstate.py +105 -188
  129. flwr/server/superlink/linkstate/utils.py +18 -8
  130. flwr/server/superlink/simulation/simulationio_grpc.py +1 -1
  131. flwr/server/superlink/simulation/simulationio_servicer.py +33 -0
  132. flwr/server/superlink/utils.py +65 -0
  133. flwr/server/utils/validator.py +9 -34
  134. flwr/simulation/app.py +20 -10
  135. flwr/simulation/legacy_app.py +4 -2
  136. flwr/simulation/ray_transport/ray_actor.py +1 -0
  137. flwr/simulation/ray_transport/utils.py +1 -0
  138. flwr/simulation/run_simulation.py +36 -22
  139. flwr/simulation/simulationio_connection.py +5 -1
  140. flwr/superexec/app.py +1 -0
  141. flwr/superexec/deployment.py +1 -0
  142. flwr/superexec/exec_grpc.py +20 -2
  143. flwr/superexec/exec_servicer.py +97 -2
  144. flwr/superexec/exec_user_auth_interceptor.py +101 -0
  145. flwr/superexec/executor.py +1 -0
  146. {flwr-1.13.1.dist-info → flwr-1.15.0.dist-info}/METADATA +14 -13
  147. {flwr-1.13.1.dist-info → flwr-1.15.0.dist-info}/RECORD +150 -144
  148. flwr/proto/common_pb2.py +0 -36
  149. flwr/proto/common_pb2.pyi +0 -121
  150. flwr/proto/common_pb2_grpc.py +0 -4
  151. flwr/proto/common_pb2_grpc.pyi +0 -4
  152. flwr/proto/control_pb2.py +0 -27
  153. flwr/proto/control_pb2.pyi +0 -7
  154. flwr/proto/control_pb2_grpc.py +0 -135
  155. flwr/proto/control_pb2_grpc.pyi +0 -53
  156. {flwr-1.13.1.dist-info → flwr-1.15.0.dist-info}/LICENSE +0 -0
  157. {flwr-1.13.1.dist-info → flwr-1.15.0.dist-info}/WHEEL +0 -0
  158. {flwr-1.13.1.dist-info → flwr-1.15.0.dist-info}/entry_points.txt +0 -0
flwr/cli/run/run.py CHANGED
@@ -14,28 +14,31 @@
14
14
  # ==============================================================================
15
15
  """Flower command line interface `run` command."""
16
16
 
17
+
18
+ import io
17
19
  import json
18
20
  import subprocess
19
- from logging import DEBUG
20
21
  from pathlib import Path
21
22
  from typing import Annotated, Any, Optional
22
23
 
23
24
  import typer
25
+ from rich.console import Console
24
26
 
25
27
  from flwr.cli.build import build
26
28
  from flwr.cli.config_utils import (
29
+ get_fab_metadata,
27
30
  load_and_validate,
28
- validate_certificate_in_federation_config,
31
+ process_loaded_project_config,
29
32
  validate_federation_in_project_config,
30
- validate_project_config,
31
33
  )
34
+ from flwr.cli.constant import FEDERATION_CONFIG_HELP_MESSAGE
32
35
  from flwr.common.config import (
33
36
  flatten_dict,
34
37
  parse_config_args,
35
38
  user_config_to_configsrecord,
36
39
  )
37
- from flwr.common.grpc import GRPC_MAX_MESSAGE_LENGTH, create_channel
38
- from flwr.common.logger import log
40
+ from flwr.common.constant import CliOutputFormat
41
+ from flwr.common.logger import print_json_error, redirect_output, restore_output
39
42
  from flwr.common.serde import (
40
43
  configs_record_to_proto,
41
44
  fab_to_proto,
@@ -46,16 +49,16 @@ from flwr.proto.exec_pb2 import StartRunRequest # pylint: disable=E0611
46
49
  from flwr.proto.exec_pb2_grpc import ExecStub
47
50
 
48
51
  from ..log import start_stream
52
+ from ..utils import (
53
+ init_channel,
54
+ try_obtain_cli_auth_plugin,
55
+ unauthenticated_exc_handler,
56
+ )
49
57
 
50
58
  CONN_REFRESH_PERIOD = 60 # Connection refresh period for log streaming (seconds)
51
59
 
52
60
 
53
- def on_channel_state_change(channel_connectivity: str) -> None:
54
- """Log channel connectivity."""
55
- log(DEBUG, channel_connectivity)
56
-
57
-
58
- # pylint: disable-next=too-many-locals
61
+ # pylint: disable-next=too-many-locals, R0913, R0917
59
62
  def run(
60
63
  app: Annotated[
61
64
  Path,
@@ -65,16 +68,23 @@ def run(
65
68
  Optional[str],
66
69
  typer.Argument(help="Name of the federation to run the app on."),
67
70
  ] = None,
68
- config_overrides: Annotated[
71
+ run_config_overrides: Annotated[
69
72
  Optional[list[str]],
70
73
  typer.Option(
71
74
  "--run-config",
72
75
  "-c",
73
- help="Override configuration key-value pairs, should be of the format:\n\n"
74
- '`--run-config \'key1="value1" key2="value2"\' '
75
- "--run-config 'key3=\"value3\"'`\n\n"
76
- "Note that `key1`, `key2`, and `key3` in this example need to exist "
77
- "inside the `pyproject.toml` in order to be properly overriden.",
76
+ help="Override run configuration values in the format:\n\n"
77
+ "`--run-config 'key1=value1 key2=value2' --run-config 'key3=value3'`\n\n"
78
+ "Values can be of any type supported in TOML, such as bool, int, "
79
+ "float, or string. Ensure that the keys (`key1`, `key2`, `key3` "
80
+ "in this example) exist in `pyproject.toml` for proper overriding.",
81
+ ),
82
+ ] = None,
83
+ federation_config_overrides: Annotated[
84
+ Optional[list[str]],
85
+ typer.Option(
86
+ "--federation-config",
87
+ help=FEDERATION_CONFIG_HELP_MESSAGE,
78
88
  ),
79
89
  ] = None,
80
90
  stream: Annotated[
@@ -85,46 +95,76 @@ def run(
85
95
  "logs are not streamed by default.",
86
96
  ),
87
97
  ] = False,
98
+ output_format: Annotated[
99
+ str,
100
+ typer.Option(
101
+ "--format",
102
+ case_sensitive=False,
103
+ help="Format output using 'default' view or 'json'",
104
+ ),
105
+ ] = CliOutputFormat.DEFAULT,
88
106
  ) -> None:
89
107
  """Run Flower App."""
90
- typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)
108
+ suppress_output = output_format == CliOutputFormat.JSON
109
+ captured_output = io.StringIO()
110
+ try:
111
+ if suppress_output:
112
+ redirect_output(captured_output)
113
+ typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)
91
114
 
92
- pyproject_path = app / "pyproject.toml" if app else None
93
- config, errors, warnings = load_and_validate(path=pyproject_path)
94
- config = validate_project_config(config, errors, warnings)
95
- federation, federation_config = validate_federation_in_project_config(
96
- federation, config
97
- )
115
+ pyproject_path = app / "pyproject.toml" if app else None
116
+ config, errors, warnings = load_and_validate(path=pyproject_path)
117
+ config = process_loaded_project_config(config, errors, warnings)
118
+ federation, federation_config = validate_federation_in_project_config(
119
+ federation, config, federation_config_overrides
120
+ )
98
121
 
99
- if "address" in federation_config:
100
- _run_with_exec_api(app, federation_config, config_overrides, stream)
101
- else:
102
- _run_without_exec_api(app, federation_config, config_overrides, federation)
122
+ if "address" in federation_config:
123
+ _run_with_exec_api(
124
+ app,
125
+ federation,
126
+ federation_config,
127
+ run_config_overrides,
128
+ stream,
129
+ output_format,
130
+ )
131
+ else:
132
+ _run_without_exec_api(
133
+ app, federation_config, run_config_overrides, federation
134
+ )
135
+ except (typer.Exit, Exception) as err: # pylint: disable=broad-except
136
+ if suppress_output:
137
+ restore_output()
138
+ e_message = captured_output.getvalue()
139
+ print_json_error(e_message, err)
140
+ else:
141
+ typer.secho(
142
+ f"{err}",
143
+ fg=typer.colors.RED,
144
+ bold=True,
145
+ )
146
+ finally:
147
+ if suppress_output:
148
+ restore_output()
149
+ captured_output.close()
103
150
 
104
151
 
105
- # pylint: disable-next=too-many-locals
152
+ # pylint: disable-next=R0913, R0914, R0917
106
153
  def _run_with_exec_api(
107
154
  app: Path,
155
+ federation: str,
108
156
  federation_config: dict[str, Any],
109
157
  config_overrides: Optional[list[str]],
110
158
  stream: bool,
159
+ output_format: str,
111
160
  ) -> None:
112
-
113
- insecure, root_certificates_bytes = validate_certificate_in_federation_config(
114
- app, federation_config
115
- )
116
- channel = create_channel(
117
- server_address=federation_config["address"],
118
- insecure=insecure,
119
- root_certificates=root_certificates_bytes,
120
- max_message_length=GRPC_MAX_MESSAGE_LENGTH,
121
- interceptors=None,
122
- )
123
- channel.subscribe(on_channel_state_change)
161
+ auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
162
+ channel = init_channel(app, federation_config, auth_plugin)
124
163
  stub = ExecStub(channel)
125
164
 
126
165
  fab_path, fab_hash = build(app)
127
166
  content = Path(fab_path).read_bytes()
167
+ fab_id, fab_version = get_fab_metadata(Path(fab_path))
128
168
 
129
169
  # Delete FAB file once the bytes is computed
130
170
  Path(fab_path).unlink()
@@ -140,9 +180,29 @@ def _run_with_exec_api(
140
180
  override_config=user_config_to_proto(parse_config_args(config_overrides)),
141
181
  federation_options=configs_record_to_proto(c_record),
142
182
  )
143
- res = stub.StartRun(req)
183
+ with unauthenticated_exc_handler():
184
+ res = stub.StartRun(req)
144
185
 
145
- typer.secho(f"🎊 Successfully started run {res.run_id}", fg=typer.colors.GREEN)
186
+ if res.HasField("run_id"):
187
+ typer.secho(f"🎊 Successfully started run {res.run_id}", fg=typer.colors.GREEN)
188
+ else:
189
+ typer.secho("❌ Failed to start run", fg=typer.colors.RED)
190
+ raise typer.Exit(code=1)
191
+
192
+ if output_format == CliOutputFormat.JSON:
193
+ run_output = json.dumps(
194
+ {
195
+ "success": res.HasField("run_id"),
196
+ "run-id": res.run_id if res.HasField("run_id") else None,
197
+ "fab-id": fab_id,
198
+ "fab-name": fab_id.rsplit("/", maxsplit=1)[-1],
199
+ "fab-version": fab_version,
200
+ "fab-hash": fab_hash[:8],
201
+ "fab-filename": fab_path,
202
+ }
203
+ )
204
+ restore_output()
205
+ Console().print_json(run_output)
146
206
 
147
207
  if stream:
148
208
  start_stream(res.run_id, channel, CONN_REFRESH_PERIOD)
flwr/cli/stop.py ADDED
@@ -0,0 +1,139 @@
1
+ # Copyright 2024 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 `stop` command."""
16
+
17
+
18
+ import io
19
+ import json
20
+ from pathlib import Path
21
+ from typing import Annotated, Optional
22
+
23
+ import typer
24
+ from rich.console import Console
25
+
26
+ from flwr.cli.config_utils import (
27
+ exit_if_no_address,
28
+ load_and_validate,
29
+ process_loaded_project_config,
30
+ validate_federation_in_project_config,
31
+ )
32
+ from flwr.cli.constant import FEDERATION_CONFIG_HELP_MESSAGE
33
+ from flwr.common.constant import FAB_CONFIG_FILE, CliOutputFormat
34
+ from flwr.common.logger import print_json_error, redirect_output, restore_output
35
+ from flwr.proto.exec_pb2 import StopRunRequest, StopRunResponse # pylint: disable=E0611
36
+ from flwr.proto.exec_pb2_grpc import ExecStub
37
+
38
+ from .utils import init_channel, try_obtain_cli_auth_plugin, unauthenticated_exc_handler
39
+
40
+
41
+ def stop( # pylint: disable=R0914
42
+ run_id: Annotated[ # pylint: disable=unused-argument
43
+ int,
44
+ typer.Argument(help="The Flower run ID to stop"),
45
+ ],
46
+ app: Annotated[
47
+ Path,
48
+ typer.Argument(help="Path of the Flower project"),
49
+ ] = Path("."),
50
+ federation: Annotated[
51
+ Optional[str],
52
+ typer.Argument(help="Name of the federation"),
53
+ ] = None,
54
+ federation_config_overrides: Annotated[
55
+ Optional[list[str]],
56
+ typer.Option(
57
+ "--federation-config",
58
+ help=FEDERATION_CONFIG_HELP_MESSAGE,
59
+ ),
60
+ ] = None,
61
+ output_format: Annotated[
62
+ str,
63
+ typer.Option(
64
+ "--format",
65
+ case_sensitive=False,
66
+ help="Format output using 'default' view or 'json'",
67
+ ),
68
+ ] = CliOutputFormat.DEFAULT,
69
+ ) -> None:
70
+ """Stop a run."""
71
+ suppress_output = output_format == CliOutputFormat.JSON
72
+ captured_output = io.StringIO()
73
+ try:
74
+ if suppress_output:
75
+ redirect_output(captured_output)
76
+
77
+ # Load and validate federation config
78
+ typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)
79
+
80
+ pyproject_path = app / FAB_CONFIG_FILE if app else None
81
+ config, errors, warnings = load_and_validate(path=pyproject_path)
82
+ config = process_loaded_project_config(config, errors, warnings)
83
+ federation, federation_config = validate_federation_in_project_config(
84
+ federation, config, federation_config_overrides
85
+ )
86
+ exit_if_no_address(federation_config, "stop")
87
+ channel = None
88
+ try:
89
+ auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
90
+ channel = init_channel(app, federation_config, auth_plugin)
91
+ stub = ExecStub(channel) # pylint: disable=unused-variable # noqa: F841
92
+
93
+ typer.secho(f"✋ Stopping run ID {run_id}...", fg=typer.colors.GREEN)
94
+ _stop_run(stub=stub, run_id=run_id, output_format=output_format)
95
+
96
+ except ValueError as err:
97
+ typer.secho(
98
+ f"❌ {err}",
99
+ fg=typer.colors.RED,
100
+ bold=True,
101
+ )
102
+ raise typer.Exit(code=1) from err
103
+ finally:
104
+ if channel:
105
+ channel.close()
106
+ except (typer.Exit, Exception) as err: # pylint: disable=broad-except
107
+ if suppress_output:
108
+ restore_output()
109
+ e_message = captured_output.getvalue()
110
+ print_json_error(e_message, err)
111
+ else:
112
+ typer.secho(
113
+ f"{err}",
114
+ fg=typer.colors.RED,
115
+ bold=True,
116
+ )
117
+ finally:
118
+ if suppress_output:
119
+ restore_output()
120
+ captured_output.close()
121
+
122
+
123
+ def _stop_run(stub: ExecStub, run_id: int, output_format: str) -> None:
124
+ """Stop a run."""
125
+ with unauthenticated_exc_handler():
126
+ response: StopRunResponse = stub.StopRun(request=StopRunRequest(run_id=run_id))
127
+ if response.success:
128
+ typer.secho(f"✅ Run {run_id} successfully stopped.", fg=typer.colors.GREEN)
129
+ if output_format == CliOutputFormat.JSON:
130
+ run_output = json.dumps(
131
+ {
132
+ "success": True,
133
+ "run-id": run_id,
134
+ }
135
+ )
136
+ restore_output()
137
+ Console().print_json(run_output)
138
+ else:
139
+ typer.secho(f"❌ Run {run_id} couldn't be stopped.", fg=typer.colors.RED)
flwr/cli/utils.py CHANGED
@@ -14,13 +14,30 @@
14
14
  # ==============================================================================
15
15
  """Flower command line interface utils."""
16
16
 
17
+
17
18
  import hashlib
19
+ import json
18
20
  import re
21
+ from collections.abc import Iterator
22
+ from contextlib import contextmanager
19
23
  from pathlib import Path
20
- from typing import Callable, Optional, cast
24
+ from typing import Any, Callable, Optional, Union, cast
21
25
 
26
+ import grpc
22
27
  import typer
23
28
 
29
+ from flwr.cli.cli_user_auth_interceptor import CliUserAuthInterceptor
30
+ from flwr.common.auth_plugin import CliAuthPlugin
31
+ from flwr.common.constant import AUTH_TYPE_KEY, CREDENTIALS_DIR, FLWR_DIR
32
+ from flwr.common.grpc import (
33
+ GRPC_MAX_MESSAGE_LENGTH,
34
+ create_channel,
35
+ on_channel_state_change,
36
+ )
37
+
38
+ from .auth_plugin import get_cli_auth_plugins
39
+ from .config_utils import validate_certificate_in_federation_config
40
+
24
41
 
25
42
  def prompt_text(
26
43
  text: str,
@@ -126,13 +143,174 @@ def sanitize_project_name(name: str) -> str:
126
143
  return sanitized_name
127
144
 
128
145
 
129
- def get_sha256_hash(file_path: Path) -> str:
146
+ def get_sha256_hash(file_path_or_int: Union[Path, int]) -> str:
130
147
  """Calculate the SHA-256 hash of a file."""
131
148
  sha256 = hashlib.sha256()
132
- with open(file_path, "rb") as f:
133
- while True:
134
- data = f.read(65536) # Read in 64kB blocks
135
- if not data:
136
- break
137
- sha256.update(data)
149
+ if isinstance(file_path_or_int, Path):
150
+ with open(file_path_or_int, "rb") as f:
151
+ while True:
152
+ data = f.read(65536) # Read in 64kB blocks
153
+ if not data:
154
+ break
155
+ sha256.update(data)
156
+ elif isinstance(file_path_or_int, int):
157
+ sha256.update(str(file_path_or_int).encode())
138
158
  return sha256.hexdigest()
159
+
160
+
161
+ def get_user_auth_config_path(root_dir: Path, federation: str) -> Path:
162
+ """Return the path to the user auth config file.
163
+
164
+ Additionally, a `.gitignore` file will be created in the Flower directory to
165
+ include the `.credentials` folder to be excluded from git. If the `.gitignore`
166
+ file already exists, a warning will be displayed if the `.credentials` entry is
167
+ not found.
168
+ """
169
+ # Locate the credentials directory
170
+ abs_flwr_dir = root_dir.absolute() / FLWR_DIR
171
+ credentials_dir = abs_flwr_dir / CREDENTIALS_DIR
172
+ credentials_dir.mkdir(parents=True, exist_ok=True)
173
+
174
+ # Determine the absolute path of the Flower directory for .gitignore
175
+ gitignore_path = abs_flwr_dir / ".gitignore"
176
+ credential_entry = CREDENTIALS_DIR
177
+
178
+ try:
179
+ if gitignore_path.exists():
180
+ with open(gitignore_path, encoding="utf-8") as gitignore_file:
181
+ lines = gitignore_file.read().splitlines()
182
+
183
+ # Warn if .credentials is not already in .gitignore
184
+ if credential_entry not in lines:
185
+ typer.secho(
186
+ f"`.gitignore` exists, but `{credential_entry}` entry not found. "
187
+ "Consider adding it to your `.gitignore` to exclude Flower "
188
+ "credentials from git.",
189
+ fg=typer.colors.YELLOW,
190
+ bold=True,
191
+ )
192
+ else:
193
+ typer.secho(
194
+ f"Creating a new `.gitignore` with `{credential_entry}` entry...",
195
+ fg=typer.colors.BLUE,
196
+ )
197
+ # Create a new .gitignore with .credentials
198
+ with open(gitignore_path, "w", encoding="utf-8") as gitignore_file:
199
+ gitignore_file.write(f"{credential_entry}\n")
200
+ except Exception as err:
201
+ typer.secho(
202
+ "❌ An error occurred while handling `.gitignore.` "
203
+ f"Please check the permissions of `{gitignore_path}` and try again.",
204
+ fg=typer.colors.RED,
205
+ bold=True,
206
+ )
207
+ raise typer.Exit(code=1) from err
208
+
209
+ return credentials_dir / f"{federation}.json"
210
+
211
+
212
+ def try_obtain_cli_auth_plugin(
213
+ root_dir: Path,
214
+ federation: str,
215
+ federation_config: dict[str, Any],
216
+ auth_type: Optional[str] = None,
217
+ ) -> Optional[CliAuthPlugin]:
218
+ """Load the CLI-side user auth plugin for the given auth type."""
219
+ # Check if user auth is enabled
220
+ if not federation_config.get("enable-user-auth", False):
221
+ return None
222
+
223
+ # Check if TLS is enabled. If not, raise an error
224
+ if federation_config.get("root-certificates") is None:
225
+ typer.secho(
226
+ "❌ User authentication requires TLS to be enabled. "
227
+ "Please provide 'root-certificates' in the federation"
228
+ " configuration.",
229
+ fg=typer.colors.RED,
230
+ bold=True,
231
+ )
232
+ raise typer.Exit(code=1)
233
+
234
+ config_path = get_user_auth_config_path(root_dir, federation)
235
+
236
+ # Get the auth type from the config if not provided
237
+ # auth_type will be None for all CLI commands except login
238
+ if auth_type is None:
239
+ try:
240
+ with config_path.open("r", encoding="utf-8") as file:
241
+ json_file = json.load(file)
242
+ auth_type = json_file[AUTH_TYPE_KEY]
243
+ except (FileNotFoundError, KeyError):
244
+ typer.secho(
245
+ "❌ Missing or invalid credentials for user authentication. "
246
+ "Please run `flwr login` to authenticate.",
247
+ fg=typer.colors.RED,
248
+ bold=True,
249
+ )
250
+ raise typer.Exit(code=1) from None
251
+
252
+ # Retrieve auth plugin class and instantiate it
253
+ try:
254
+ all_plugins: dict[str, type[CliAuthPlugin]] = get_cli_auth_plugins()
255
+ auth_plugin_class = all_plugins[auth_type]
256
+ return auth_plugin_class(config_path)
257
+ except KeyError:
258
+ typer.echo(f"❌ Unknown user authentication type: {auth_type}")
259
+ raise typer.Exit(code=1) from None
260
+ except ImportError:
261
+ typer.echo("❌ No authentication plugins are currently supported.")
262
+ raise typer.Exit(code=1) from None
263
+
264
+
265
+ def init_channel(
266
+ app: Path, federation_config: dict[str, Any], auth_plugin: Optional[CliAuthPlugin]
267
+ ) -> grpc.Channel:
268
+ """Initialize gRPC channel to the Exec API."""
269
+ insecure, root_certificates_bytes = validate_certificate_in_federation_config(
270
+ app, federation_config
271
+ )
272
+
273
+ # Initialize the CLI-side user auth interceptor
274
+ interceptors: list[grpc.UnaryUnaryClientInterceptor] = []
275
+ if auth_plugin is not None:
276
+ auth_plugin.load_tokens()
277
+ interceptors.append(CliUserAuthInterceptor(auth_plugin))
278
+
279
+ # Create the gRPC channel
280
+ channel = create_channel(
281
+ server_address=federation_config["address"],
282
+ insecure=insecure,
283
+ root_certificates=root_certificates_bytes,
284
+ max_message_length=GRPC_MAX_MESSAGE_LENGTH,
285
+ interceptors=interceptors or None,
286
+ )
287
+ channel.subscribe(on_channel_state_change)
288
+ return channel
289
+
290
+
291
+ @contextmanager
292
+ def unauthenticated_exc_handler() -> Iterator[None]:
293
+ """Context manager to handle gRPC UNAUTHENTICATED errors.
294
+
295
+ It catches grpc.RpcError exceptions with UNAUTHENTICATED status, informs the user,
296
+ and exits the application. All other exceptions will be allowed to escape.
297
+ """
298
+ try:
299
+ yield
300
+ except grpc.RpcError as e:
301
+ if e.code() == grpc.StatusCode.UNAUTHENTICATED:
302
+ typer.secho(
303
+ "❌ Authentication failed. Please run `flwr login`"
304
+ " to authenticate and try again.",
305
+ fg=typer.colors.RED,
306
+ bold=True,
307
+ )
308
+ raise typer.Exit(code=1) from None
309
+ if e.code() == grpc.StatusCode.UNIMPLEMENTED:
310
+ typer.secho(
311
+ "❌ User authentication is not enabled on this SuperLink.",
312
+ fg=typer.colors.RED,
313
+ bold=True,
314
+ )
315
+ raise typer.Exit(code=1) from None
316
+ raise