flwr-nightly 1.22.0.dev20250916__py3-none-any.whl → 1.22.0.dev20250918__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 (43) hide show
  1. flwr/cli/app.py +2 -0
  2. flwr/cli/new/new.py +4 -2
  3. flwr/cli/new/templates/app/README.flowertune.md.tpl +1 -1
  4. flwr/cli/new/templates/app/code/client.baseline.py.tpl +64 -47
  5. flwr/cli/new/templates/app/code/client.xgboost.py.tpl +110 -0
  6. flwr/cli/new/templates/app/code/flwr_tune/client_app.py.tpl +56 -90
  7. flwr/cli/new/templates/app/code/flwr_tune/models.py.tpl +1 -23
  8. flwr/cli/new/templates/app/code/flwr_tune/server_app.py.tpl +37 -58
  9. flwr/cli/new/templates/app/code/flwr_tune/strategy.py.tpl +39 -44
  10. flwr/cli/new/templates/app/code/model.baseline.py.tpl +0 -14
  11. flwr/cli/new/templates/app/code/server.baseline.py.tpl +27 -29
  12. flwr/cli/new/templates/app/code/server.xgboost.py.tpl +56 -0
  13. flwr/cli/new/templates/app/code/task.xgboost.py.tpl +67 -0
  14. flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +3 -3
  15. flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +1 -1
  16. flwr/cli/new/templates/app/pyproject.xgboost.toml.tpl +61 -0
  17. flwr/cli/pull.py +100 -0
  18. flwr/cli/utils.py +17 -0
  19. flwr/common/constant.py +2 -0
  20. flwr/common/exit/exit_code.py +4 -0
  21. flwr/proto/control_pb2.py +7 -3
  22. flwr/proto/control_pb2.pyi +24 -0
  23. flwr/proto/control_pb2_grpc.py +34 -0
  24. flwr/proto/control_pb2_grpc.pyi +13 -0
  25. flwr/server/app.py +13 -0
  26. flwr/serverapp/strategy/__init__.py +4 -0
  27. flwr/serverapp/strategy/fedprox.py +174 -0
  28. flwr/serverapp/strategy/fedxgb_cyclic.py +220 -0
  29. flwr/simulation/app.py +1 -1
  30. flwr/simulation/run_simulation.py +25 -30
  31. flwr/supercore/cli/flower_superexec.py +26 -1
  32. flwr/supercore/constant.py +19 -0
  33. flwr/supercore/superexec/plugin/exec_plugin.py +11 -1
  34. flwr/supercore/superexec/run_superexec.py +16 -2
  35. flwr/superlink/artifact_provider/__init__.py +22 -0
  36. flwr/superlink/artifact_provider/artifact_provider.py +37 -0
  37. flwr/superlink/servicer/control/control_grpc.py +3 -0
  38. flwr/superlink/servicer/control/control_servicer.py +59 -2
  39. {flwr_nightly-1.22.0.dev20250916.dist-info → flwr_nightly-1.22.0.dev20250918.dist-info}/METADATA +1 -1
  40. {flwr_nightly-1.22.0.dev20250916.dist-info → flwr_nightly-1.22.0.dev20250918.dist-info}/RECORD +42 -33
  41. flwr/serverapp/strategy/strategy_utils_tests.py +0 -323
  42. {flwr_nightly-1.22.0.dev20250916.dist-info → flwr_nightly-1.22.0.dev20250918.dist-info}/WHEEL +0 -0
  43. {flwr_nightly-1.22.0.dev20250916.dist-info → flwr_nightly-1.22.0.dev20250918.dist-info}/entry_points.txt +0 -0
@@ -143,6 +143,15 @@ def run_simulation_from_cli() -> None:
143
143
  run = Run.create_empty(run_id)
144
144
  run.override_config = override_config
145
145
 
146
+ # Create Context
147
+ server_app_context = Context(
148
+ run_id=run_id,
149
+ node_id=0,
150
+ node_config=UserConfig(),
151
+ state=RecordDict(),
152
+ run_config=fused_config,
153
+ )
154
+
146
155
  _ = _run_simulation(
147
156
  server_app_attr=server_app_attr,
148
157
  client_app_attr=client_app_attr,
@@ -153,7 +162,7 @@ def run_simulation_from_cli() -> None:
153
162
  run=run,
154
163
  enable_tf_gpu_growth=args.enable_tf_gpu_growth,
155
164
  verbose_logging=args.verbose,
156
- server_app_run_config=fused_config,
165
+ server_app_context=server_app_context,
157
166
  is_app=True,
158
167
  exit_event=EventType.CLI_FLOWER_SIMULATION_LEAVE,
159
168
  )
@@ -241,13 +250,12 @@ def run_simulation(
241
250
  def run_serverapp_th(
242
251
  server_app_attr: Optional[str],
243
252
  server_app: Optional[ServerApp],
244
- server_app_run_config: UserConfig,
253
+ server_app_context: Context,
245
254
  grid: Grid,
246
255
  app_dir: str,
247
256
  f_stop: threading.Event,
248
257
  has_exception: threading.Event,
249
258
  enable_tf_gpu_growth: bool,
250
- run_id: int,
251
259
  ctx_queue: "Queue[Context]",
252
260
  ) -> threading.Thread:
253
261
  """Run SeverApp in a thread."""
@@ -258,7 +266,6 @@ def run_serverapp_th(
258
266
  exception_event: threading.Event,
259
267
  _grid: Grid,
260
268
  _server_app_dir: str,
261
- _server_app_run_config: UserConfig,
262
269
  _server_app_attr: Optional[str],
263
270
  _server_app: Optional[ServerApp],
264
271
  _ctx_queue: "Queue[Context]",
@@ -272,19 +279,10 @@ def run_serverapp_th(
272
279
  log(INFO, "Enabling GPU growth for Tensorflow on the server thread.")
273
280
  enable_gpu_growth()
274
281
 
275
- # Initialize Context
276
- context = Context(
277
- run_id=run_id,
278
- node_id=0,
279
- node_config={},
280
- state=RecordDict(),
281
- run_config=_server_app_run_config,
282
- )
283
-
284
282
  # Run ServerApp
285
283
  updated_context = _run(
286
284
  grid=_grid,
287
- context=context,
285
+ context=server_app_context,
288
286
  server_app_dir=_server_app_dir,
289
287
  server_app_attr=_server_app_attr,
290
288
  loaded_server_app=_server_app,
@@ -310,7 +308,6 @@ def run_serverapp_th(
310
308
  has_exception,
311
309
  grid,
312
310
  app_dir,
313
- server_app_run_config,
314
311
  server_app_attr,
315
312
  server_app,
316
313
  ctx_queue,
@@ -335,7 +332,7 @@ def _main_loop(
335
332
  client_app_attr: Optional[str] = None,
336
333
  server_app: Optional[ServerApp] = None,
337
334
  server_app_attr: Optional[str] = None,
338
- server_app_run_config: Optional[UserConfig] = None,
335
+ server_app_context: Optional[Context] = None,
339
336
  ) -> Context:
340
337
  """Start ServerApp on a separate thread, then launch Simulation Engine."""
341
338
  # Initialize StateFactory
@@ -346,13 +343,15 @@ def _main_loop(
346
343
  server_app_thread_has_exception = threading.Event()
347
344
  serverapp_th = None
348
345
  success = True
349
- updated_context = Context(
350
- run_id=run.run_id,
351
- node_id=0,
352
- node_config=UserConfig(),
353
- state=RecordDict(),
354
- run_config=UserConfig(),
355
- )
346
+ if server_app_context is None:
347
+ server_app_context = Context(
348
+ run_id=run.run_id,
349
+ node_id=0,
350
+ node_config=UserConfig(),
351
+ state=RecordDict(),
352
+ run_config=UserConfig(),
353
+ )
354
+ updated_context = server_app_context
356
355
  try:
357
356
  # Register run
358
357
  log(DEBUG, "Pre-registering run with id %s", run.run_id)
@@ -361,9 +360,6 @@ def _main_loop(
361
360
  run.running_at = run.starting_at
362
361
  state_factory.state().run_ids[run.run_id] = RunRecord(run=run) # type: ignore
363
362
 
364
- if server_app_run_config is None:
365
- server_app_run_config = {}
366
-
367
363
  # Initialize Grid
368
364
  grid = InMemoryGrid(state_factory=state_factory)
369
365
  grid.set_run(run_id=run.run_id)
@@ -373,13 +369,12 @@ def _main_loop(
373
369
  serverapp_th = run_serverapp_th(
374
370
  server_app_attr=server_app_attr,
375
371
  server_app=server_app,
376
- server_app_run_config=server_app_run_config,
372
+ server_app_context=server_app_context,
377
373
  grid=grid,
378
374
  app_dir=app_dir,
379
375
  f_stop=f_stop,
380
376
  has_exception=server_app_thread_has_exception,
381
377
  enable_tf_gpu_growth=enable_tf_gpu_growth,
382
- run_id=run.run_id,
383
378
  ctx_queue=output_context_queue,
384
379
  )
385
380
 
@@ -438,7 +433,7 @@ def _run_simulation(
438
433
  backend_config: Optional[BackendConfig] = None,
439
434
  client_app_attr: Optional[str] = None,
440
435
  server_app_attr: Optional[str] = None,
441
- server_app_run_config: Optional[UserConfig] = None,
436
+ server_app_context: Optional[Context] = None,
442
437
  app_dir: str = "",
443
438
  flwr_dir: Optional[str] = None,
444
439
  run: Optional[Run] = None,
@@ -502,7 +497,7 @@ def _run_simulation(
502
497
  client_app_attr,
503
498
  server_app,
504
499
  server_app_attr,
505
- server_app_run_config,
500
+ server_app_context,
506
501
  )
507
502
  # Detect if there is an Asyncio event loop already running.
508
503
  # If yes, disable logger propagation. In environmnets
@@ -17,7 +17,9 @@
17
17
 
18
18
  import argparse
19
19
  from logging import INFO
20
- from typing import Optional
20
+ from typing import Any, Optional
21
+
22
+ import yaml
21
23
 
22
24
  from flwr.common import EventType, event
23
25
  from flwr.common.constant import ExecPluginType
@@ -26,6 +28,7 @@ from flwr.common.logger import log
26
28
  from flwr.proto.clientappio_pb2_grpc import ClientAppIoStub
27
29
  from flwr.proto.serverappio_pb2_grpc import ServerAppIoStub
28
30
  from flwr.proto.simulationio_pb2_grpc import SimulationIoStub
31
+ from flwr.supercore.constant import EXEC_PLUGIN_SECTION
29
32
  from flwr.supercore.grpc_health import add_args_health
30
33
  from flwr.supercore.superexec.plugin import (
31
34
  ClientAppExecPlugin,
@@ -36,6 +39,7 @@ from flwr.supercore.superexec.plugin import (
36
39
  from flwr.supercore.superexec.run_superexec import run_superexec
37
40
 
38
41
  try:
42
+ from flwr.ee import add_ee_args_superexec
39
43
  from flwr.ee.constant import ExecEePluginType
40
44
  from flwr.ee.exec_plugin import get_ee_plugin_and_stub_class
41
45
  except ImportError:
@@ -54,6 +58,10 @@ except ImportError:
54
58
  """Get the EE plugin class and stub class based on the plugin type."""
55
59
  return None
56
60
 
61
+ # pylint: disable-next=unused-argument
62
+ def add_ee_args_superexec(parser: argparse.ArgumentParser) -> None:
63
+ """Add EE-specific arguments to the parser."""
64
+
57
65
 
58
66
  def flower_superexec() -> None:
59
67
  """Run `flower-superexec` command."""
@@ -70,12 +78,28 @@ def flower_superexec() -> None:
70
78
  # Trigger telemetry event
71
79
  event(EventType.RUN_SUPEREXEC_ENTER, {"plugin_type": args.plugin_type})
72
80
 
81
+ # Load plugin config from YAML file if provided
82
+ plugin_config = None
83
+ if plugin_config_path := getattr(args, "plugin_config", None):
84
+ try:
85
+ with open(plugin_config_path, encoding="utf-8") as file:
86
+ yaml_config: Optional[dict[str, Any]] = yaml.safe_load(file)
87
+ if yaml_config is None or EXEC_PLUGIN_SECTION not in yaml_config:
88
+ raise ValueError(f"Missing '{EXEC_PLUGIN_SECTION}' section.")
89
+ plugin_config = yaml_config[EXEC_PLUGIN_SECTION]
90
+ except (FileNotFoundError, yaml.YAMLError, ValueError) as e:
91
+ flwr_exit(
92
+ ExitCode.SUPEREXEC_INVALID_PLUGIN_CONFIG,
93
+ f"Failed to load plugin config from '{plugin_config_path}': {e!r}",
94
+ )
95
+
73
96
  # Get the plugin class and stub class based on the plugin type
74
97
  plugin_class, stub_class = _get_plugin_and_stub_class(args.plugin_type)
75
98
  run_superexec(
76
99
  plugin_class=plugin_class,
77
100
  stub_class=stub_class, # type: ignore
78
101
  appio_api_address=args.appio_api_address,
102
+ plugin_config=plugin_config,
79
103
  flwr_dir=args.flwr_dir,
80
104
  parent_pid=args.parent_pid,
81
105
  health_server_address=args.health_server_address,
@@ -122,6 +146,7 @@ def _parse_args() -> argparse.ArgumentParser:
122
146
  help="The PID of the parent process. When set, the process will terminate "
123
147
  "when the parent process exits.",
124
148
  )
149
+ add_ee_args_superexec(parser)
125
150
  add_args_health(parser)
126
151
  return parser
127
152
 
@@ -0,0 +1,19 @@
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
+ """Constants for Flower infrastructure."""
16
+
17
+
18
+ # Top-level key in YAML config for exec plugin settings
19
+ EXEC_PLUGIN_SECTION = "exec_plugin"
@@ -17,7 +17,7 @@
17
17
 
18
18
  from abc import ABC, abstractmethod
19
19
  from collections.abc import Sequence
20
- from typing import Callable, Optional
20
+ from typing import Any, Callable, Optional
21
21
 
22
22
  from flwr.common.typing import Run
23
23
 
@@ -69,3 +69,13 @@ class ExecPlugin(ABC):
69
69
  The ID of the run associated with the token, used for tracking or
70
70
  logging purposes.
71
71
  """
72
+
73
+ # This method is optional to implement
74
+ def load_config(self, yaml_config: dict[str, Any]) -> None:
75
+ """Load configuration from a YAML dictionary.
76
+
77
+ Parameters
78
+ ----------
79
+ yaml_config : dict[str, Any]
80
+ A dictionary representing the YAML configuration.
81
+ """
@@ -17,10 +17,10 @@
17
17
 
18
18
  import time
19
19
  from logging import WARN
20
- from typing import Optional, Union
20
+ from typing import Any, Optional, Union
21
21
 
22
22
  from flwr.common.config import get_flwr_dir
23
- from flwr.common.exit import register_signal_handlers
23
+ from flwr.common.exit import ExitCode, flwr_exit, register_signal_handlers
24
24
  from flwr.common.grpc import create_channel, on_channel_state_change
25
25
  from flwr.common.logger import log
26
26
  from flwr.common.retry_invoker import _make_simple_grpc_retry_invoker, _wrap_stub
@@ -47,6 +47,7 @@ def run_superexec( # pylint: disable=R0913,R0914,R0917
47
47
  type[ClientAppIoStub], type[ServerAppIoStub], type[SimulationIoStub]
48
48
  ],
49
49
  appio_api_address: str,
50
+ plugin_config: Optional[dict[str, Any]] = None,
50
51
  flwr_dir: Optional[str] = None,
51
52
  parent_pid: Optional[int] = None,
52
53
  health_server_address: Optional[str] = None,
@@ -61,6 +62,9 @@ def run_superexec( # pylint: disable=R0913,R0914,R0917
61
62
  The gRPC stub class for the AppIO API.
62
63
  appio_api_address : str
63
64
  The address of the AppIO API.
65
+ plugin_config : Optional[dict[str, Any]] (default: None)
66
+ The configuration dictionary for the plugin. If `None`, the plugin will use
67
+ its default configuration.
64
68
  flwr_dir : Optional[str] (default: None)
65
69
  The Flower directory.
66
70
  parent_pid : Optional[int] (default: None)
@@ -113,6 +117,16 @@ def run_superexec( # pylint: disable=R0913,R0914,R0917
113
117
  get_run=get_run,
114
118
  )
115
119
 
120
+ # Load plugin configuration from file if provided
121
+ try:
122
+ if plugin_config is not None:
123
+ plugin.load_config(plugin_config)
124
+ except (KeyError, ValueError) as e:
125
+ flwr_exit(
126
+ code=ExitCode.SUPEREXEC_INVALID_PLUGIN_CONFIG,
127
+ message=f"Invalid plugin config: {e!r}",
128
+ )
129
+
116
130
  # Start the main loop
117
131
  try:
118
132
  while True:
@@ -0,0 +1,22 @@
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
+ """ArtifactProvider for SuperLink."""
16
+
17
+
18
+ from .artifact_provider import ArtifactProvider
19
+
20
+ __all__ = [
21
+ "ArtifactProvider",
22
+ ]
@@ -0,0 +1,37 @@
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
+ """Abstract base class for ArtifactProvider."""
16
+
17
+
18
+ from abc import ABC, abstractmethod
19
+ from typing import Optional
20
+
21
+
22
+ class ArtifactProvider(ABC):
23
+ """ArtifactProvider interface for providing artifact download links."""
24
+
25
+ @abstractmethod
26
+ def get_url(self, run_id: int) -> Optional[str]:
27
+ """Return the artifact download link for the given run ID."""
28
+
29
+ @property
30
+ @abstractmethod
31
+ def output_dir(self) -> str:
32
+ """Permanent storage directory."""
33
+
34
+ @property
35
+ @abstractmethod
36
+ def tmp_dir(self) -> str:
37
+ """Temporary storage directory."""
@@ -31,6 +31,7 @@ from flwr.server.superlink.linkstate import LinkStateFactory
31
31
  from flwr.supercore.ffs import FfsFactory
32
32
  from flwr.supercore.license_plugin import LicensePlugin
33
33
  from flwr.supercore.object_store import ObjectStoreFactory
34
+ from flwr.superlink.artifact_provider import ArtifactProvider
34
35
 
35
36
  from .control_event_log_interceptor import ControlEventLogInterceptor
36
37
  from .control_license_interceptor import ControlLicenseInterceptor
@@ -56,6 +57,7 @@ def run_control_api_grpc(
56
57
  auth_plugin: Optional[ControlAuthPlugin] = None,
57
58
  authz_plugin: Optional[ControlAuthzPlugin] = None,
58
59
  event_log_plugin: Optional[EventLogWriterPlugin] = None,
60
+ artifact_provider: Optional[ArtifactProvider] = None,
59
61
  ) -> grpc.Server:
60
62
  """Run Control API (gRPC, request-response)."""
61
63
  license_plugin: Optional[LicensePlugin] = get_license_plugin()
@@ -68,6 +70,7 @@ def run_control_api_grpc(
68
70
  objectstore_factory=objectstore_factory,
69
71
  is_simulation=is_simulation,
70
72
  auth_plugin=auth_plugin,
73
+ artifact_provider=artifact_provider,
71
74
  )
72
75
  interceptors: list[grpc.ServerInterceptor] = []
73
76
  if license_plugin is not None:
@@ -29,7 +29,9 @@ from flwr.common.auth_plugin import ControlAuthPlugin
29
29
  from flwr.common.constant import (
30
30
  FAB_MAX_SIZE,
31
31
  LOG_STREAM_INTERVAL,
32
+ NO_ARTIFACT_PROVIDER_MESSAGE,
32
33
  NO_USER_AUTH_MESSAGE,
34
+ PULL_UNFINISHED_RUN_MESSAGE,
33
35
  RUN_ID_NOT_FOUND_MESSAGE,
34
36
  Status,
35
37
  SubStatus,
@@ -49,6 +51,8 @@ from flwr.proto.control_pb2 import ( # pylint: disable=E0611
49
51
  GetLoginDetailsResponse,
50
52
  ListRunsRequest,
51
53
  ListRunsResponse,
54
+ PullArtifactsRequest,
55
+ PullArtifactsResponse,
52
56
  StartRunRequest,
53
57
  StartRunResponse,
54
58
  StopRunRequest,
@@ -59,6 +63,7 @@ from flwr.proto.control_pb2 import ( # pylint: disable=E0611
59
63
  from flwr.server.superlink.linkstate import LinkState, LinkStateFactory
60
64
  from flwr.supercore.ffs import FfsFactory
61
65
  from flwr.supercore.object_store import ObjectStore, ObjectStoreFactory
66
+ from flwr.superlink.artifact_provider import ArtifactProvider
62
67
 
63
68
  from .control_user_auth_interceptor import shared_account_info
64
69
 
@@ -73,14 +78,16 @@ class ControlServicer(control_pb2_grpc.ControlServicer):
73
78
  objectstore_factory: ObjectStoreFactory,
74
79
  is_simulation: bool,
75
80
  auth_plugin: Optional[ControlAuthPlugin] = None,
81
+ artifact_provider: Optional[ArtifactProvider] = None,
76
82
  ) -> None:
77
83
  self.linkstate_factory = linkstate_factory
78
84
  self.ffs_factory = ffs_factory
79
85
  self.objectstore_factory = objectstore_factory
80
86
  self.is_simulation = is_simulation
81
87
  self.auth_plugin = auth_plugin
88
+ self.artifact_provider = artifact_provider
82
89
 
83
- def StartRun(
90
+ def StartRun( # pylint: disable=too-many-locals
84
91
  self, request: StartRunRequest, context: grpc.ServicerContext
85
92
  ) -> StartRunResponse:
86
93
  """Create run ID."""
@@ -126,11 +133,20 @@ class ControlServicer(control_pb2_grpc.ControlServicer):
126
133
  flwr_aid,
127
134
  )
128
135
 
136
+ # Initialize node config
137
+ node_config = {}
138
+ if self.artifact_provider is not None:
139
+ node_config = {
140
+ "output_dir": self.artifact_provider.output_dir,
141
+ "tmp_dir": self.artifact_provider.tmp_dir,
142
+ }
143
+
129
144
  # Create an empty context for the Run
130
145
  context = Context(
131
146
  run_id=run_id,
132
147
  node_id=0,
133
- node_config={},
148
+ # Dict is invariant in mypy
149
+ node_config=node_config, # type: ignore[arg-type]
134
150
  state=RecordDict(),
135
151
  run_config={},
136
152
  )
@@ -335,6 +351,47 @@ class ControlServicer(control_pb2_grpc.ControlServicer):
335
351
  refresh_token=credentials.refresh_token,
336
352
  )
337
353
 
354
+ def PullArtifacts(
355
+ self, request: PullArtifactsRequest, context: grpc.ServicerContext
356
+ ) -> PullArtifactsResponse:
357
+ """Pull artifacts for a given run ID."""
358
+ log(INFO, "ControlServicer.PullArtifacts")
359
+
360
+ # Check if artifact provider is configured
361
+ if self.artifact_provider is None:
362
+ context.abort(
363
+ grpc.StatusCode.UNIMPLEMENTED,
364
+ NO_ARTIFACT_PROVIDER_MESSAGE,
365
+ )
366
+ raise grpc.RpcError() # This line is unreachable
367
+
368
+ # Init link state
369
+ state = self.linkstate_factory.state()
370
+
371
+ # Retrieve run ID and run
372
+ run_id = request.run_id
373
+ run = state.get_run(run_id)
374
+
375
+ # Exit if `run_id` not found
376
+ if not run:
377
+ context.abort(grpc.StatusCode.NOT_FOUND, RUN_ID_NOT_FOUND_MESSAGE)
378
+ raise grpc.RpcError() # This line is unreachable
379
+
380
+ # Exit if the run is not finished yet
381
+ if run.status.status != Status.FINISHED:
382
+ context.abort(
383
+ grpc.StatusCode.FAILED_PRECONDITION, PULL_UNFINISHED_RUN_MESSAGE
384
+ )
385
+
386
+ # Check if `flwr_aid` matches the run's `flwr_aid` when user auth is enabled
387
+ if self.auth_plugin:
388
+ flwr_aid = shared_account_info.get().flwr_aid
389
+ _check_flwr_aid_in_run(flwr_aid=flwr_aid, run=run, context=context)
390
+
391
+ # Call artifact provider
392
+ download_url = self.artifact_provider.get_url(run_id)
393
+ return PullArtifactsResponse(url=download_url)
394
+
338
395
 
339
396
  def _create_list_runs_response(
340
397
  run_ids: set[int], state: LinkState, store: ObjectStore
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: flwr-nightly
3
- Version: 1.22.0.dev20250916
3
+ Version: 1.22.0.dev20250918
4
4
  Summary: Flower: A Friendly Federated AI Framework
5
5
  License: Apache-2.0
6
6
  Keywords: Artificial Intelligence,Federated AI,Federated Analytics,Federated Evaluation,Federated Learning,Flower,Machine Learning