flwr 1.20.0__py3-none-any.whl → 1.22.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.
- flwr/__init__.py +4 -1
- flwr/app/__init__.py +28 -0
- flwr/app/exception.py +31 -0
- flwr/cli/app.py +2 -0
- flwr/cli/auth_plugin/oidc_cli_plugin.py +4 -4
- flwr/cli/cli_user_auth_interceptor.py +1 -1
- flwr/cli/config_utils.py +3 -3
- flwr/cli/constant.py +25 -8
- flwr/cli/log.py +9 -9
- flwr/cli/login/login.py +3 -3
- flwr/cli/ls.py +5 -5
- flwr/cli/new/new.py +15 -2
- flwr/cli/new/templates/app/README.flowertune.md.tpl +1 -1
- flwr/cli/new/templates/app/code/__init__.pytorch_legacy_api.py.tpl +1 -0
- flwr/cli/new/templates/app/code/client.baseline.py.tpl +64 -47
- flwr/cli/new/templates/app/code/client.huggingface.py.tpl +68 -30
- flwr/cli/new/templates/app/code/client.jax.py.tpl +63 -42
- flwr/cli/new/templates/app/code/client.mlx.py.tpl +80 -51
- flwr/cli/new/templates/app/code/client.numpy.py.tpl +36 -13
- flwr/cli/new/templates/app/code/client.pytorch.py.tpl +71 -46
- flwr/cli/new/templates/app/code/client.pytorch_legacy_api.py.tpl +55 -0
- flwr/cli/new/templates/app/code/client.sklearn.py.tpl +75 -30
- flwr/cli/new/templates/app/code/client.tensorflow.py.tpl +69 -44
- flwr/cli/new/templates/app/code/client.xgboost.py.tpl +110 -0
- flwr/cli/new/templates/app/code/flwr_tune/client_app.py.tpl +56 -90
- flwr/cli/new/templates/app/code/flwr_tune/models.py.tpl +1 -23
- flwr/cli/new/templates/app/code/flwr_tune/server_app.py.tpl +37 -58
- flwr/cli/new/templates/app/code/flwr_tune/strategy.py.tpl +39 -44
- flwr/cli/new/templates/app/code/model.baseline.py.tpl +0 -14
- flwr/cli/new/templates/app/code/server.baseline.py.tpl +27 -29
- flwr/cli/new/templates/app/code/server.huggingface.py.tpl +23 -19
- flwr/cli/new/templates/app/code/server.jax.py.tpl +27 -14
- flwr/cli/new/templates/app/code/server.mlx.py.tpl +29 -19
- flwr/cli/new/templates/app/code/server.numpy.py.tpl +30 -17
- flwr/cli/new/templates/app/code/server.pytorch.py.tpl +36 -26
- flwr/cli/new/templates/app/code/server.pytorch_legacy_api.py.tpl +31 -0
- flwr/cli/new/templates/app/code/server.sklearn.py.tpl +29 -21
- flwr/cli/new/templates/app/code/server.tensorflow.py.tpl +28 -19
- flwr/cli/new/templates/app/code/server.xgboost.py.tpl +56 -0
- flwr/cli/new/templates/app/code/task.huggingface.py.tpl +16 -20
- flwr/cli/new/templates/app/code/task.jax.py.tpl +1 -1
- flwr/cli/new/templates/app/code/task.numpy.py.tpl +1 -1
- flwr/cli/new/templates/app/code/task.pytorch.py.tpl +14 -27
- flwr/cli/new/templates/app/code/task.pytorch_legacy_api.py.tpl +111 -0
- flwr/cli/new/templates/app/code/task.tensorflow.py.tpl +1 -2
- flwr/cli/new/templates/app/code/task.xgboost.py.tpl +67 -0
- flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +4 -4
- flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +2 -2
- flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +4 -4
- flwr/cli/new/templates/app/pyproject.jax.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +2 -2
- flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +3 -3
- flwr/cli/new/templates/app/pyproject.pytorch_legacy_api.toml.tpl +53 -0
- flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.xgboost.toml.tpl +61 -0
- flwr/cli/pull.py +100 -0
- flwr/cli/run/run.py +9 -13
- flwr/cli/stop.py +7 -4
- flwr/cli/utils.py +36 -8
- flwr/client/grpc_rere_client/connection.py +1 -12
- flwr/client/rest_client/connection.py +3 -0
- flwr/clientapp/__init__.py +10 -0
- flwr/clientapp/mod/__init__.py +29 -0
- flwr/clientapp/mod/centraldp_mods.py +248 -0
- flwr/clientapp/mod/localdp_mod.py +169 -0
- flwr/clientapp/typing.py +22 -0
- flwr/common/args.py +20 -6
- flwr/common/auth_plugin/__init__.py +4 -4
- flwr/common/auth_plugin/auth_plugin.py +7 -7
- flwr/common/constant.py +26 -4
- flwr/common/event_log_plugin/event_log_plugin.py +1 -1
- flwr/common/exit/__init__.py +4 -0
- flwr/common/exit/exit.py +8 -1
- flwr/common/exit/exit_code.py +30 -7
- flwr/common/exit/exit_handler.py +62 -0
- flwr/common/{exit_handlers.py → exit/signal_handler.py} +20 -37
- flwr/common/grpc.py +0 -11
- flwr/common/inflatable_utils.py +1 -1
- flwr/common/logger.py +1 -1
- flwr/common/record/typeddict.py +12 -0
- flwr/common/retry_invoker.py +30 -11
- flwr/common/telemetry.py +4 -0
- flwr/compat/server/app.py +2 -2
- flwr/proto/appio_pb2.py +25 -17
- flwr/proto/appio_pb2.pyi +46 -2
- flwr/proto/clientappio_pb2.py +3 -11
- flwr/proto/clientappio_pb2.pyi +0 -47
- flwr/proto/clientappio_pb2_grpc.py +19 -20
- flwr/proto/clientappio_pb2_grpc.pyi +10 -11
- flwr/proto/control_pb2.py +66 -0
- flwr/proto/{exec_pb2.pyi → control_pb2.pyi} +24 -0
- flwr/proto/{exec_pb2_grpc.py → control_pb2_grpc.py} +88 -54
- flwr/proto/control_pb2_grpc.pyi +106 -0
- flwr/proto/serverappio_pb2.py +2 -2
- flwr/proto/serverappio_pb2_grpc.py +68 -0
- flwr/proto/serverappio_pb2_grpc.pyi +26 -0
- flwr/proto/simulationio_pb2.py +4 -11
- flwr/proto/simulationio_pb2.pyi +0 -58
- flwr/proto/simulationio_pb2_grpc.py +129 -27
- flwr/proto/simulationio_pb2_grpc.pyi +52 -13
- flwr/server/app.py +142 -152
- flwr/server/grid/grpc_grid.py +3 -0
- flwr/server/grid/inmemory_grid.py +1 -0
- flwr/server/serverapp/app.py +157 -146
- flwr/server/superlink/fleet/vce/backend/raybackend.py +3 -1
- flwr/server/superlink/fleet/vce/vce_api.py +6 -6
- flwr/server/superlink/linkstate/in_memory_linkstate.py +34 -0
- flwr/server/superlink/linkstate/linkstate.py +2 -1
- flwr/server/superlink/linkstate/sqlite_linkstate.py +45 -0
- flwr/server/superlink/serverappio/serverappio_grpc.py +1 -1
- flwr/server/superlink/serverappio/serverappio_servicer.py +61 -6
- flwr/server/superlink/simulation/simulationio_servicer.py +97 -21
- flwr/serverapp/__init__.py +12 -0
- flwr/serverapp/exception.py +38 -0
- flwr/serverapp/strategy/__init__.py +64 -0
- flwr/serverapp/strategy/bulyan.py +238 -0
- flwr/serverapp/strategy/dp_adaptive_clipping.py +335 -0
- flwr/serverapp/strategy/dp_fixed_clipping.py +374 -0
- flwr/serverapp/strategy/fedadagrad.py +159 -0
- flwr/serverapp/strategy/fedadam.py +178 -0
- flwr/serverapp/strategy/fedavg.py +320 -0
- flwr/serverapp/strategy/fedavgm.py +198 -0
- flwr/serverapp/strategy/fedmedian.py +105 -0
- flwr/serverapp/strategy/fedopt.py +218 -0
- flwr/serverapp/strategy/fedprox.py +174 -0
- flwr/serverapp/strategy/fedtrimmedavg.py +176 -0
- flwr/serverapp/strategy/fedxgb_bagging.py +117 -0
- flwr/serverapp/strategy/fedxgb_cyclic.py +220 -0
- flwr/serverapp/strategy/fedyogi.py +170 -0
- flwr/serverapp/strategy/krum.py +112 -0
- flwr/serverapp/strategy/multikrum.py +247 -0
- flwr/serverapp/strategy/qfedavg.py +252 -0
- flwr/serverapp/strategy/result.py +105 -0
- flwr/serverapp/strategy/strategy.py +285 -0
- flwr/serverapp/strategy/strategy_utils.py +299 -0
- flwr/simulation/app.py +161 -164
- flwr/simulation/run_simulation.py +25 -30
- flwr/supercore/app_utils.py +58 -0
- flwr/{supernode/scheduler → supercore/cli}/__init__.py +3 -3
- flwr/supercore/cli/flower_superexec.py +166 -0
- flwr/supercore/constant.py +19 -0
- flwr/supercore/{scheduler → corestate}/__init__.py +3 -3
- flwr/supercore/corestate/corestate.py +81 -0
- flwr/supercore/grpc_health/__init__.py +3 -0
- flwr/supercore/grpc_health/health_server.py +53 -0
- flwr/supercore/grpc_health/simple_health_servicer.py +2 -2
- flwr/{superexec → supercore/superexec}/__init__.py +1 -1
- flwr/supercore/superexec/plugin/__init__.py +28 -0
- flwr/{supernode/scheduler/simple_clientapp_scheduler_plugin.py → supercore/superexec/plugin/base_exec_plugin.py} +10 -6
- flwr/supercore/superexec/plugin/clientapp_exec_plugin.py +28 -0
- flwr/supercore/{scheduler/plugin.py → superexec/plugin/exec_plugin.py} +15 -5
- flwr/supercore/superexec/plugin/serverapp_exec_plugin.py +28 -0
- flwr/supercore/superexec/plugin/simulation_exec_plugin.py +28 -0
- flwr/supercore/superexec/run_superexec.py +199 -0
- flwr/superlink/artifact_provider/__init__.py +22 -0
- flwr/superlink/artifact_provider/artifact_provider.py +37 -0
- flwr/superlink/servicer/__init__.py +15 -0
- flwr/superlink/servicer/control/__init__.py +22 -0
- flwr/{superexec/exec_event_log_interceptor.py → superlink/servicer/control/control_event_log_interceptor.py} +7 -7
- flwr/{superexec/exec_grpc.py → superlink/servicer/control/control_grpc.py} +27 -29
- flwr/{superexec/exec_license_interceptor.py → superlink/servicer/control/control_license_interceptor.py} +6 -6
- flwr/{superexec/exec_servicer.py → superlink/servicer/control/control_servicer.py} +127 -31
- flwr/{superexec/exec_user_auth_interceptor.py → superlink/servicer/control/control_user_auth_interceptor.py} +10 -10
- flwr/supernode/cli/flower_supernode.py +3 -0
- flwr/supernode/cli/flwr_clientapp.py +18 -21
- flwr/supernode/nodestate/in_memory_nodestate.py +2 -2
- flwr/supernode/nodestate/nodestate.py +3 -59
- flwr/supernode/runtime/run_clientapp.py +39 -102
- flwr/supernode/servicer/clientappio/clientappio_servicer.py +10 -17
- flwr/supernode/start_client_internal.py +35 -76
- {flwr-1.20.0.dist-info → flwr-1.22.0.dist-info}/METADATA +9 -18
- {flwr-1.20.0.dist-info → flwr-1.22.0.dist-info}/RECORD +176 -128
- {flwr-1.20.0.dist-info → flwr-1.22.0.dist-info}/entry_points.txt +1 -0
- flwr/proto/exec_pb2.py +0 -62
- flwr/proto/exec_pb2_grpc.pyi +0 -93
- flwr/superexec/app.py +0 -45
- flwr/superexec/deployment.py +0 -191
- flwr/superexec/executor.py +0 -100
- flwr/superexec/simulation.py +0 -129
- {flwr-1.20.0.dist-info → flwr-1.22.0.dist-info}/WHEEL +0 -0
flwr/cli/pull.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Copyright 2025 Flower Labs GmbH. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
# ==============================================================================
|
|
15
|
+
"""Flower command line interface `pull` command."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated, Optional
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from flwr.cli.config_utils import (
|
|
24
|
+
exit_if_no_address,
|
|
25
|
+
load_and_validate,
|
|
26
|
+
process_loaded_project_config,
|
|
27
|
+
validate_federation_in_project_config,
|
|
28
|
+
)
|
|
29
|
+
from flwr.cli.constant import FEDERATION_CONFIG_HELP_MESSAGE
|
|
30
|
+
from flwr.common.constant import FAB_CONFIG_FILE
|
|
31
|
+
from flwr.proto.control_pb2 import ( # pylint: disable=E0611
|
|
32
|
+
PullArtifactsRequest,
|
|
33
|
+
PullArtifactsResponse,
|
|
34
|
+
)
|
|
35
|
+
from flwr.proto.control_pb2_grpc import ControlStub
|
|
36
|
+
|
|
37
|
+
from .utils import flwr_cli_grpc_exc_handler, init_channel, try_obtain_cli_auth_plugin
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def pull( # pylint: disable=R0914
|
|
41
|
+
run_id: Annotated[
|
|
42
|
+
int,
|
|
43
|
+
typer.Option(
|
|
44
|
+
"--run-id",
|
|
45
|
+
help="Run ID to pull artifacts from.",
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
app: Annotated[
|
|
49
|
+
Path,
|
|
50
|
+
typer.Argument(help="Path of the Flower App to run."),
|
|
51
|
+
] = Path("."),
|
|
52
|
+
federation: Annotated[
|
|
53
|
+
Optional[str],
|
|
54
|
+
typer.Argument(help="Name of the federation."),
|
|
55
|
+
] = None,
|
|
56
|
+
federation_config_overrides: Annotated[
|
|
57
|
+
Optional[list[str]],
|
|
58
|
+
typer.Option(
|
|
59
|
+
"--federation-config",
|
|
60
|
+
help=FEDERATION_CONFIG_HELP_MESSAGE,
|
|
61
|
+
),
|
|
62
|
+
] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Pull artifacts from a Flower run."""
|
|
65
|
+
typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)
|
|
66
|
+
|
|
67
|
+
pyproject_path = app / FAB_CONFIG_FILE if app else None
|
|
68
|
+
config, errors, warnings = load_and_validate(path=pyproject_path)
|
|
69
|
+
config = process_loaded_project_config(config, errors, warnings)
|
|
70
|
+
federation, federation_config = validate_federation_in_project_config(
|
|
71
|
+
federation, config, federation_config_overrides
|
|
72
|
+
)
|
|
73
|
+
exit_if_no_address(federation_config, "pull")
|
|
74
|
+
channel = None
|
|
75
|
+
try:
|
|
76
|
+
|
|
77
|
+
auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
|
|
78
|
+
channel = init_channel(app, federation_config, auth_plugin)
|
|
79
|
+
stub = ControlStub(channel)
|
|
80
|
+
with flwr_cli_grpc_exc_handler():
|
|
81
|
+
res: PullArtifactsResponse = stub.PullArtifacts(
|
|
82
|
+
PullArtifactsRequest(run_id=run_id)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if not res.url:
|
|
86
|
+
typer.secho(
|
|
87
|
+
f"❌ A download URL for artifacts from run {run_id} couldn't be "
|
|
88
|
+
"obtained.",
|
|
89
|
+
fg=typer.colors.RED,
|
|
90
|
+
bold=True,
|
|
91
|
+
)
|
|
92
|
+
raise typer.Exit(code=1)
|
|
93
|
+
|
|
94
|
+
typer.secho(
|
|
95
|
+
f"✅ Artifacts for run {run_id} can be downloaded from: {res.url}",
|
|
96
|
+
fg=typer.colors.GREEN,
|
|
97
|
+
)
|
|
98
|
+
finally:
|
|
99
|
+
if channel:
|
|
100
|
+
channel.close()
|
flwr/cli/run/run.py
CHANGED
|
@@ -30,7 +30,7 @@ from flwr.cli.config_utils import (
|
|
|
30
30
|
process_loaded_project_config,
|
|
31
31
|
validate_federation_in_project_config,
|
|
32
32
|
)
|
|
33
|
-
from flwr.cli.constant import FEDERATION_CONFIG_HELP_MESSAGE
|
|
33
|
+
from flwr.cli.constant import FEDERATION_CONFIG_HELP_MESSAGE, RUN_CONFIG_HELP_MESSAGE
|
|
34
34
|
from flwr.common.config import (
|
|
35
35
|
flatten_dict,
|
|
36
36
|
get_metadata_from_config,
|
|
@@ -41,8 +41,8 @@ from flwr.common.constant import CliOutputFormat
|
|
|
41
41
|
from flwr.common.logger import print_json_error, redirect_output, restore_output
|
|
42
42
|
from flwr.common.serde import config_record_to_proto, fab_to_proto, user_config_to_proto
|
|
43
43
|
from flwr.common.typing import Fab
|
|
44
|
-
from flwr.proto.
|
|
45
|
-
from flwr.proto.
|
|
44
|
+
from flwr.proto.control_pb2 import StartRunRequest # pylint: disable=E0611
|
|
45
|
+
from flwr.proto.control_pb2_grpc import ControlStub
|
|
46
46
|
|
|
47
47
|
from ..log import start_stream
|
|
48
48
|
from ..utils import flwr_cli_grpc_exc_handler, init_channel, try_obtain_cli_auth_plugin
|
|
@@ -65,11 +65,7 @@ def run(
|
|
|
65
65
|
typer.Option(
|
|
66
66
|
"--run-config",
|
|
67
67
|
"-c",
|
|
68
|
-
help=
|
|
69
|
-
"`--run-config 'key1=value1 key2=value2' --run-config 'key3=value3'`\n\n"
|
|
70
|
-
"Values can be of any type supported in TOML, such as bool, int, "
|
|
71
|
-
"float, or string. Ensure that the keys (`key1`, `key2`, `key3` "
|
|
72
|
-
"in this example) exist in `pyproject.toml` for proper overriding.",
|
|
68
|
+
help=RUN_CONFIG_HELP_MESSAGE,
|
|
73
69
|
),
|
|
74
70
|
] = None,
|
|
75
71
|
federation_config_overrides: Annotated[
|
|
@@ -112,7 +108,7 @@ def run(
|
|
|
112
108
|
)
|
|
113
109
|
|
|
114
110
|
if "address" in federation_config:
|
|
115
|
-
|
|
111
|
+
_run_with_control_api(
|
|
116
112
|
app,
|
|
117
113
|
federation,
|
|
118
114
|
federation_config,
|
|
@@ -121,7 +117,7 @@ def run(
|
|
|
121
117
|
output_format,
|
|
122
118
|
)
|
|
123
119
|
else:
|
|
124
|
-
|
|
120
|
+
_run_without_control_api(
|
|
125
121
|
app, federation_config, run_config_overrides, federation
|
|
126
122
|
)
|
|
127
123
|
except (typer.Exit, Exception) as err: # pylint: disable=broad-except
|
|
@@ -142,7 +138,7 @@ def run(
|
|
|
142
138
|
|
|
143
139
|
|
|
144
140
|
# pylint: disable-next=R0913, R0914, R0917
|
|
145
|
-
def
|
|
141
|
+
def _run_with_control_api(
|
|
146
142
|
app: Path,
|
|
147
143
|
federation: str,
|
|
148
144
|
federation_config: dict[str, Any],
|
|
@@ -154,7 +150,7 @@ def _run_with_exec_api(
|
|
|
154
150
|
try:
|
|
155
151
|
auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
|
|
156
152
|
channel = init_channel(app, federation_config, auth_plugin)
|
|
157
|
-
stub =
|
|
153
|
+
stub = ControlStub(channel)
|
|
158
154
|
|
|
159
155
|
fab_bytes, fab_hash, config = build_fab(app)
|
|
160
156
|
fab_id, fab_version = get_metadata_from_config(config)
|
|
@@ -203,7 +199,7 @@ def _run_with_exec_api(
|
|
|
203
199
|
channel.close()
|
|
204
200
|
|
|
205
201
|
|
|
206
|
-
def
|
|
202
|
+
def _run_without_control_api(
|
|
207
203
|
app: Optional[Path],
|
|
208
204
|
federation_config: dict[str, Any],
|
|
209
205
|
config_overrides: Optional[list[str]],
|
flwr/cli/stop.py
CHANGED
|
@@ -32,8 +32,11 @@ from flwr.cli.config_utils import (
|
|
|
32
32
|
from flwr.cli.constant import FEDERATION_CONFIG_HELP_MESSAGE
|
|
33
33
|
from flwr.common.constant import FAB_CONFIG_FILE, CliOutputFormat
|
|
34
34
|
from flwr.common.logger import print_json_error, redirect_output, restore_output
|
|
35
|
-
from flwr.proto.
|
|
36
|
-
|
|
35
|
+
from flwr.proto.control_pb2 import ( # pylint: disable=E0611
|
|
36
|
+
StopRunRequest,
|
|
37
|
+
StopRunResponse,
|
|
38
|
+
)
|
|
39
|
+
from flwr.proto.control_pb2_grpc import ControlStub
|
|
37
40
|
|
|
38
41
|
from .utils import flwr_cli_grpc_exc_handler, init_channel, try_obtain_cli_auth_plugin
|
|
39
42
|
|
|
@@ -88,7 +91,7 @@ def stop( # pylint: disable=R0914
|
|
|
88
91
|
try:
|
|
89
92
|
auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
|
|
90
93
|
channel = init_channel(app, federation_config, auth_plugin)
|
|
91
|
-
stub =
|
|
94
|
+
stub = ControlStub(channel) # pylint: disable=unused-variable # noqa: F841
|
|
92
95
|
|
|
93
96
|
typer.secho(f"✋ Stopping run ID {run_id}...", fg=typer.colors.GREEN)
|
|
94
97
|
_stop_run(stub=stub, run_id=run_id, output_format=output_format)
|
|
@@ -120,7 +123,7 @@ def stop( # pylint: disable=R0914
|
|
|
120
123
|
captured_output.close()
|
|
121
124
|
|
|
122
125
|
|
|
123
|
-
def _stop_run(stub:
|
|
126
|
+
def _stop_run(stub: ControlStub, run_id: int, output_format: str) -> None:
|
|
124
127
|
"""Stop a run."""
|
|
125
128
|
with flwr_cli_grpc_exc_handler():
|
|
126
129
|
response: StopRunResponse = stub.StopRun(request=StopRunRequest(run_id=run_id))
|
flwr/cli/utils.py
CHANGED
|
@@ -32,6 +32,9 @@ from flwr.common.constant import (
|
|
|
32
32
|
AUTH_TYPE_JSON_KEY,
|
|
33
33
|
CREDENTIALS_DIR,
|
|
34
34
|
FLWR_DIR,
|
|
35
|
+
NO_ARTIFACT_PROVIDER_MESSAGE,
|
|
36
|
+
NO_USER_AUTH_MESSAGE,
|
|
37
|
+
PULL_UNFINISHED_RUN_MESSAGE,
|
|
35
38
|
RUN_ID_NOT_FOUND_MESSAGE,
|
|
36
39
|
)
|
|
37
40
|
from flwr.common.grpc import (
|
|
@@ -259,7 +262,7 @@ def try_obtain_cli_auth_plugin(
|
|
|
259
262
|
def init_channel(
|
|
260
263
|
app: Path, federation_config: dict[str, Any], auth_plugin: Optional[CliAuthPlugin]
|
|
261
264
|
) -> grpc.Channel:
|
|
262
|
-
"""Initialize gRPC channel to the
|
|
265
|
+
"""Initialize gRPC channel to the Control API."""
|
|
263
266
|
insecure, root_certificates_bytes = validate_certificate_in_federation_config(
|
|
264
267
|
app, federation_config
|
|
265
268
|
)
|
|
@@ -312,11 +315,27 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
|
|
|
312
315
|
)
|
|
313
316
|
raise typer.Exit(code=1) from None
|
|
314
317
|
if e.code() == grpc.StatusCode.UNIMPLEMENTED:
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
if e.details() == NO_USER_AUTH_MESSAGE: # pylint: disable=E1101
|
|
319
|
+
typer.secho(
|
|
320
|
+
"❌ User authentication is not enabled on this SuperLink.",
|
|
321
|
+
fg=typer.colors.RED,
|
|
322
|
+
bold=True,
|
|
323
|
+
)
|
|
324
|
+
elif e.details() == NO_ARTIFACT_PROVIDER_MESSAGE: # pylint: disable=E1101
|
|
325
|
+
typer.secho(
|
|
326
|
+
"❌ The SuperLink does not support `flwr pull` command.",
|
|
327
|
+
fg=typer.colors.RED,
|
|
328
|
+
bold=True,
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
typer.secho(
|
|
332
|
+
"❌ The SuperLink cannot process this request. Please verify that "
|
|
333
|
+
"you set the address to its Control API endpoint correctly in your "
|
|
334
|
+
"`pyproject.toml`, and ensure that the Flower versions used by "
|
|
335
|
+
"the CLI and SuperLink are compatible.",
|
|
336
|
+
fg=typer.colors.RED,
|
|
337
|
+
bold=True,
|
|
338
|
+
)
|
|
320
339
|
raise typer.Exit(code=1) from None
|
|
321
340
|
if e.code() == grpc.StatusCode.PERMISSION_DENIED:
|
|
322
341
|
typer.secho(
|
|
@@ -324,7 +343,7 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
|
|
|
324
343
|
fg=typer.colors.RED,
|
|
325
344
|
bold=True,
|
|
326
345
|
)
|
|
327
|
-
# pylint: disable=E1101
|
|
346
|
+
# pylint: disable-next=E1101
|
|
328
347
|
typer.secho(e.details(), fg=typer.colors.RED, bold=True)
|
|
329
348
|
raise typer.Exit(code=1) from None
|
|
330
349
|
if e.code() == grpc.StatusCode.UNAVAILABLE:
|
|
@@ -337,7 +356,7 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
|
|
|
337
356
|
raise typer.Exit(code=1) from None
|
|
338
357
|
if (
|
|
339
358
|
e.code() == grpc.StatusCode.NOT_FOUND
|
|
340
|
-
and e.details() == RUN_ID_NOT_FOUND_MESSAGE
|
|
359
|
+
and e.details() == RUN_ID_NOT_FOUND_MESSAGE # pylint: disable=E1101
|
|
341
360
|
):
|
|
342
361
|
typer.secho(
|
|
343
362
|
"❌ Run ID not found.",
|
|
@@ -345,4 +364,13 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
|
|
|
345
364
|
bold=True,
|
|
346
365
|
)
|
|
347
366
|
raise typer.Exit(code=1) from None
|
|
367
|
+
if e.code() == grpc.StatusCode.FAILED_PRECONDITION:
|
|
368
|
+
if e.details() == PULL_UNFINISHED_RUN_MESSAGE: # pylint: disable=E1101
|
|
369
|
+
typer.secho(
|
|
370
|
+
"❌ Run is not finished yet. Artifacts can only be pulled after "
|
|
371
|
+
"the run is finished. You can check the run status with `flwr ls`.",
|
|
372
|
+
fg=typer.colors.RED,
|
|
373
|
+
bold=True,
|
|
374
|
+
)
|
|
375
|
+
raise typer.Exit(code=1) from None
|
|
348
376
|
raise
|
|
@@ -40,7 +40,7 @@ from flwr.common.secure_aggregation.crypto.symmetric_encryption import (
|
|
|
40
40
|
generate_key_pairs,
|
|
41
41
|
)
|
|
42
42
|
from flwr.common.serde import message_from_proto, message_to_proto, run_from_proto
|
|
43
|
-
from flwr.common.typing import Fab, Run
|
|
43
|
+
from flwr.common.typing import Fab, Run
|
|
44
44
|
from flwr.proto.fab_pb2 import GetFabRequest, GetFabResponse # pylint: disable=E0611
|
|
45
45
|
from flwr.proto.fleet_pb2 import ( # pylint: disable=E0611
|
|
46
46
|
CreateNodeRequest,
|
|
@@ -157,17 +157,6 @@ def grpc_request_response( # pylint: disable=R0913,R0914,R0915,R0917
|
|
|
157
157
|
stub = adapter_cls(channel)
|
|
158
158
|
node: Optional[Node] = None
|
|
159
159
|
|
|
160
|
-
def _should_giveup_fn(e: Exception) -> bool:
|
|
161
|
-
if e.code() == grpc.StatusCode.PERMISSION_DENIED: # type: ignore
|
|
162
|
-
raise RunNotRunningException
|
|
163
|
-
if e.code() == grpc.StatusCode.UNAVAILABLE: # type: ignore
|
|
164
|
-
return False
|
|
165
|
-
return True
|
|
166
|
-
|
|
167
|
-
# Restrict retries to cases where the status code is UNAVAILABLE
|
|
168
|
-
# If the status code is PERMISSION_DENIED, additionally raise RunNotRunningException
|
|
169
|
-
retry_invoker.should_giveup = _should_giveup_fn
|
|
170
|
-
|
|
171
160
|
# Wrap stub
|
|
172
161
|
_wrap_stub(stub, retry_invoker)
|
|
173
162
|
###########################################################################
|
|
@@ -176,6 +176,9 @@ def http_request_response( # pylint: disable=R0913,R0914,R0915,R0917
|
|
|
176
176
|
# Shared variables for inner functions
|
|
177
177
|
node: Optional[Node] = None
|
|
178
178
|
|
|
179
|
+
# Remove should_giveup from RetryInvoker as REST does not support gRPC status codes
|
|
180
|
+
retry_invoker.should_giveup = None
|
|
181
|
+
|
|
179
182
|
###########################################################################
|
|
180
183
|
# heartbeat/create_node/delete_node/receive/send/get_run functions
|
|
181
184
|
###########################################################################
|
flwr/clientapp/__init__.py
CHANGED
|
@@ -13,3 +13,13 @@
|
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
# ==============================================================================
|
|
15
15
|
"""Public Flower ClientApp APIs."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from flwr.client.client_app import ClientApp
|
|
19
|
+
|
|
20
|
+
from . import mod
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ClientApp",
|
|
24
|
+
"mod",
|
|
25
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Copyright 2025 Flower Labs GmbH. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
# ==============================================================================
|
|
15
|
+
"""Flower Built-in Mods."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from flwr.client.mod.comms_mods import arrays_size_mod, message_size_mod
|
|
19
|
+
|
|
20
|
+
from .centraldp_mods import adaptiveclipping_mod, fixedclipping_mod
|
|
21
|
+
from .localdp_mod import LocalDpMod
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"LocalDpMod",
|
|
25
|
+
"adaptiveclipping_mod",
|
|
26
|
+
"arrays_size_mod",
|
|
27
|
+
"fixedclipping_mod",
|
|
28
|
+
"message_size_mod",
|
|
29
|
+
]
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Copyright 2025 Flower Labs GmbH. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
# ==============================================================================
|
|
15
|
+
"""Clipping modifiers for central DP with client-side clipping."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from collections import OrderedDict
|
|
19
|
+
from logging import ERROR, INFO
|
|
20
|
+
from typing import cast
|
|
21
|
+
|
|
22
|
+
from flwr.app import Error
|
|
23
|
+
from flwr.clientapp.typing import ClientAppCallable
|
|
24
|
+
from flwr.common import (
|
|
25
|
+
Array,
|
|
26
|
+
ArrayRecord,
|
|
27
|
+
ConfigRecord,
|
|
28
|
+
Context,
|
|
29
|
+
Message,
|
|
30
|
+
MetricRecord,
|
|
31
|
+
log,
|
|
32
|
+
)
|
|
33
|
+
from flwr.common.constant import ErrorCode
|
|
34
|
+
from flwr.common.differential_privacy import (
|
|
35
|
+
compute_adaptive_clip_model_update,
|
|
36
|
+
compute_clip_model_update,
|
|
37
|
+
)
|
|
38
|
+
from flwr.common.differential_privacy_constants import KEY_CLIPPING_NORM, KEY_NORM_BIT
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# pylint: disable=too-many-return-statements
|
|
42
|
+
def fixedclipping_mod(
|
|
43
|
+
msg: Message, ctxt: Context, call_next: ClientAppCallable
|
|
44
|
+
) -> Message:
|
|
45
|
+
"""Client-side fixed clipping modifier.
|
|
46
|
+
|
|
47
|
+
This mod needs to be used with the `DifferentialPrivacyClientSideFixedClipping`
|
|
48
|
+
server-side strategy wrapper.
|
|
49
|
+
|
|
50
|
+
The wrapper sends the clipping_norm value to the client.
|
|
51
|
+
|
|
52
|
+
This mod clips the client model updates before sending them to the server.
|
|
53
|
+
|
|
54
|
+
It operates on messages of type `MessageType.TRAIN`.
|
|
55
|
+
|
|
56
|
+
Notes
|
|
57
|
+
-----
|
|
58
|
+
Consider the order of mods when using multiple.
|
|
59
|
+
|
|
60
|
+
Typically, fixedclipping_mod should be the last to operate on params.
|
|
61
|
+
"""
|
|
62
|
+
if len(msg.content.array_records) != 1:
|
|
63
|
+
return _handle_multi_record_err("fixedclipping_mod", msg, ArrayRecord)
|
|
64
|
+
if len(msg.content.config_records) != 1:
|
|
65
|
+
return _handle_multi_record_err("fixedclipping_mod", msg, ConfigRecord)
|
|
66
|
+
|
|
67
|
+
# Get keys in the single ConfigRecord
|
|
68
|
+
keys_in_config = set(next(iter(msg.content.config_records.values())).keys())
|
|
69
|
+
if KEY_CLIPPING_NORM not in keys_in_config:
|
|
70
|
+
return _handle_no_key_err("fixedclipping_mod", msg)
|
|
71
|
+
# Record array record communicated to client and clipping norm
|
|
72
|
+
original_array_record = next(iter(msg.content.array_records.values()))
|
|
73
|
+
clipping_norm = cast(
|
|
74
|
+
float, next(iter(msg.content.config_records.values()))[KEY_CLIPPING_NORM]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Call inner app
|
|
78
|
+
out_msg = call_next(msg, ctxt)
|
|
79
|
+
|
|
80
|
+
# Check if the msg has error
|
|
81
|
+
if out_msg.has_error():
|
|
82
|
+
return out_msg
|
|
83
|
+
|
|
84
|
+
# Ensure reply has a single ArrayRecord
|
|
85
|
+
if len(out_msg.content.array_records) != 1:
|
|
86
|
+
return _handle_multi_record_err("fixedclipping_mod", out_msg, ArrayRecord)
|
|
87
|
+
|
|
88
|
+
new_array_record_key, client_to_server_arrecord = next(
|
|
89
|
+
iter(out_msg.content.array_records.items())
|
|
90
|
+
)
|
|
91
|
+
# Ensure keys in returned ArrayRecord match those in the one sent from server
|
|
92
|
+
if list(original_array_record.keys()) != list(client_to_server_arrecord.keys()):
|
|
93
|
+
return _handle_array_key_mismatch_err("fixedclipping_mod", out_msg)
|
|
94
|
+
|
|
95
|
+
client_to_server_ndarrays = client_to_server_arrecord.to_numpy_ndarrays()
|
|
96
|
+
# Clip the client update
|
|
97
|
+
compute_clip_model_update(
|
|
98
|
+
param1=client_to_server_ndarrays,
|
|
99
|
+
param2=original_array_record.to_numpy_ndarrays(),
|
|
100
|
+
clipping_norm=clipping_norm,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
log(
|
|
104
|
+
INFO, "fixedclipping_mod: parameters are clipped by value: %.4f.", clipping_norm
|
|
105
|
+
)
|
|
106
|
+
# Replace outgoing ArrayRecord's Array while preserving their keys
|
|
107
|
+
out_msg.content.array_records[new_array_record_key] = ArrayRecord(
|
|
108
|
+
OrderedDict(
|
|
109
|
+
{
|
|
110
|
+
k: Array(v)
|
|
111
|
+
for k, v in zip(
|
|
112
|
+
client_to_server_arrecord.keys(), client_to_server_ndarrays
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return out_msg
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def adaptiveclipping_mod(
|
|
121
|
+
msg: Message, ctxt: Context, call_next: ClientAppCallable
|
|
122
|
+
) -> Message:
|
|
123
|
+
"""Client-side adaptive clipping modifier.
|
|
124
|
+
|
|
125
|
+
This mod needs to be used with the DifferentialPrivacyClientSideAdaptiveClipping
|
|
126
|
+
server-side strategy wrapper.
|
|
127
|
+
|
|
128
|
+
The wrapper sends the clipping_norm value to the client.
|
|
129
|
+
|
|
130
|
+
This mod clips the client model updates before sending them to the server.
|
|
131
|
+
|
|
132
|
+
It also sends KEY_NORM_BIT to the server for computing the new clipping value.
|
|
133
|
+
|
|
134
|
+
It operates on messages of type `MessageType.TRAIN`.
|
|
135
|
+
|
|
136
|
+
Notes
|
|
137
|
+
-----
|
|
138
|
+
Consider the order of mods when using multiple.
|
|
139
|
+
|
|
140
|
+
Typically, adaptiveclipping_mod should be the last to operate on params.
|
|
141
|
+
"""
|
|
142
|
+
if len(msg.content.array_records) != 1:
|
|
143
|
+
return _handle_multi_record_err("adaptiveclipping_mod", msg, ArrayRecord)
|
|
144
|
+
if len(msg.content.config_records) != 1:
|
|
145
|
+
return _handle_multi_record_err("adaptiveclipping_mod", msg, ConfigRecord)
|
|
146
|
+
|
|
147
|
+
# Get keys in the single ConfigRecord
|
|
148
|
+
keys_in_config = set(next(iter(msg.content.config_records.values())).keys())
|
|
149
|
+
if KEY_CLIPPING_NORM not in keys_in_config:
|
|
150
|
+
return _handle_no_key_err("adaptiveclipping_mod", msg)
|
|
151
|
+
|
|
152
|
+
# Record array record communicated to client and clipping norm
|
|
153
|
+
original_array_record = next(iter(msg.content.array_records.values()))
|
|
154
|
+
clipping_norm = cast(
|
|
155
|
+
float, next(iter(msg.content.config_records.values()))[KEY_CLIPPING_NORM]
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Call inner app
|
|
159
|
+
out_msg = call_next(msg, ctxt)
|
|
160
|
+
|
|
161
|
+
# Check if the msg has error
|
|
162
|
+
if out_msg.has_error():
|
|
163
|
+
return out_msg
|
|
164
|
+
|
|
165
|
+
# Ensure reply has a single ArrayRecord
|
|
166
|
+
if len(out_msg.content.array_records) != 1:
|
|
167
|
+
return _handle_multi_record_err("adaptiveclipping_mod", out_msg, ArrayRecord)
|
|
168
|
+
|
|
169
|
+
# Ensure reply has a single MetricRecord
|
|
170
|
+
if len(out_msg.content.metric_records) != 1:
|
|
171
|
+
return _handle_multi_record_err("adaptiveclipping_mod", out_msg, MetricRecord)
|
|
172
|
+
|
|
173
|
+
new_array_record_key, client_to_server_arrecord = next(
|
|
174
|
+
iter(out_msg.content.array_records.items())
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Ensure keys in returned ArrayRecord match those in the one sent from server
|
|
178
|
+
if list(original_array_record.keys()) != list(client_to_server_arrecord.keys()):
|
|
179
|
+
return _handle_array_key_mismatch_err("adaptiveclipping_mod", out_msg)
|
|
180
|
+
|
|
181
|
+
client_to_server_ndarrays = client_to_server_arrecord.to_numpy_ndarrays()
|
|
182
|
+
# Clip the client update
|
|
183
|
+
norm_bit = compute_adaptive_clip_model_update(
|
|
184
|
+
client_to_server_ndarrays,
|
|
185
|
+
original_array_record.to_numpy_ndarrays(),
|
|
186
|
+
clipping_norm,
|
|
187
|
+
)
|
|
188
|
+
log(
|
|
189
|
+
INFO,
|
|
190
|
+
"adaptiveclipping_mod: ndarrays are clipped by value: %.4f.",
|
|
191
|
+
clipping_norm,
|
|
192
|
+
)
|
|
193
|
+
# Replace outgoing ArrayRecord's Array while preserving their keys
|
|
194
|
+
out_msg.content.array_records[new_array_record_key] = ArrayRecord(
|
|
195
|
+
OrderedDict(
|
|
196
|
+
{
|
|
197
|
+
k: Array(v)
|
|
198
|
+
for k, v in zip(
|
|
199
|
+
client_to_server_arrecord.keys(), client_to_server_ndarrays
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
# Add to the MetricRecords the norm bit (recall reply messages only contain
|
|
205
|
+
# one MetricRecord)
|
|
206
|
+
metric_record_key = list(out_msg.content.metric_records.keys())[0]
|
|
207
|
+
# We cast it to `int` because MetricRecord does not support `bool` values
|
|
208
|
+
out_msg.content.metric_records[metric_record_key][KEY_NORM_BIT] = int(norm_bit)
|
|
209
|
+
return out_msg
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _handle_err(msg: Message, reason: str) -> Message:
|
|
213
|
+
"""Log and return error message."""
|
|
214
|
+
log(ERROR, reason)
|
|
215
|
+
return Message(
|
|
216
|
+
Error(code=ErrorCode.MOD_FAILED_PRECONDITION, reason=reason),
|
|
217
|
+
reply_to=msg,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _handle_multi_record_err(mod_name: str, msg: Message, record_type: type) -> Message:
|
|
222
|
+
"""Log and return multi-record error."""
|
|
223
|
+
cnt = sum(isinstance(_, record_type) for _ in msg.content.values())
|
|
224
|
+
return _handle_err(
|
|
225
|
+
msg,
|
|
226
|
+
f"{mod_name} expects exactly one {record_type.__name__}, "
|
|
227
|
+
f"but found {cnt} {record_type.__name__}(s).",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _handle_no_key_err(mod_name: str, msg: Message) -> Message:
|
|
232
|
+
"""Log and return no-key error."""
|
|
233
|
+
return _handle_err(
|
|
234
|
+
msg,
|
|
235
|
+
f"{mod_name} requires the key '{KEY_CLIPPING_NORM}' to be present in the "
|
|
236
|
+
"ConfigRecord, but it was not found. "
|
|
237
|
+
"Please ensure the `DifferentialPrivacyClientSideFixedClipping` wrapper "
|
|
238
|
+
"is used in the ServerApp.",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _handle_array_key_mismatch_err(mod_name: str, msg: Message) -> Message:
|
|
243
|
+
"""Create array-key-mismatch error reasons."""
|
|
244
|
+
return _handle_err(
|
|
245
|
+
msg,
|
|
246
|
+
f"{mod_name} expects the keys in the ArrayRecord of the reply message to match "
|
|
247
|
+
"those from the ArrayRecord that the ClientApp received, but they do not.",
|
|
248
|
+
)
|