flwr-nightly 1.13.0.dev20241022__py3-none-any.whl → 1.13.0.dev20241023__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.

Potentially problematic release.


This version of flwr-nightly might be problematic. Click here for more details.

flwr/client/app.py CHANGED
@@ -37,6 +37,8 @@ from flwr.common import GRPC_MAX_MESSAGE_LENGTH, Context, EventType, Message, ev
37
37
  from flwr.common.address import parse_address
38
38
  from flwr.common.constant import (
39
39
  CLIENTAPPIO_API_DEFAULT_ADDRESS,
40
+ ISOLATION_MODE_PROCESS,
41
+ ISOLATION_MODE_SUBPROCESS,
40
42
  MISSING_EXTRA_REST,
41
43
  RUN_ID_NUM_BYTES,
42
44
  TRANSPORT_TYPE_GRPC_ADAPTER,
@@ -62,9 +64,6 @@ from .message_handler.message_handler import handle_control_message
62
64
  from .numpy_client import NumPyClient
63
65
  from .run_info_store import DeprecatedRunInfoStore
64
66
 
65
- ISOLATION_MODE_SUBPROCESS = "subprocess"
66
- ISOLATION_MODE_PROCESS = "process"
67
-
68
67
 
69
68
  def _check_actionable_client(
70
69
  client: Optional[Client], client_fn: Optional[ClientFnExt]
@@ -31,6 +31,8 @@ from flwr.common import EventType, event
31
31
  from flwr.common.config import parse_config_args
32
32
  from flwr.common.constant import (
33
33
  FLEET_API_GRPC_RERE_DEFAULT_ADDRESS,
34
+ ISOLATION_MODE_PROCESS,
35
+ ISOLATION_MODE_SUBPROCESS,
34
36
  TRANSPORT_TYPE_GRPC_ADAPTER,
35
37
  TRANSPORT_TYPE_GRPC_RERE,
36
38
  TRANSPORT_TYPE_REST,
@@ -38,11 +40,7 @@ from flwr.common.constant import (
38
40
  from flwr.common.exit_handlers import register_exit_handlers
39
41
  from flwr.common.logger import log, warn_deprecated_feature
40
42
 
41
- from ..app import (
42
- ISOLATION_MODE_PROCESS,
43
- ISOLATION_MODE_SUBPROCESS,
44
- start_client_internal,
45
- )
43
+ from ..app import start_client_internal
46
44
  from ..clientapp.utils import get_load_client_app_fn
47
45
 
48
46
 
@@ -200,10 +198,10 @@ def _parse_args_run_supernode() -> argparse.ArgumentParser:
200
198
  ISOLATION_MODE_SUBPROCESS,
201
199
  ISOLATION_MODE_PROCESS,
202
200
  ],
203
- help="Isolation mode when running `ClientApp` (optional, possible values: "
204
- "`subprocess`, `process`). By default, `ClientApp` runs in the same process "
201
+ help="Isolation mode when running a `ClientApp` (optional, possible values: "
202
+ "`subprocess`, `process`). By default, a `ClientApp` runs in the same process "
205
203
  "that executes the SuperNode. Use `subprocess` to configure SuperNode to run "
206
- "`ClientApp` in a subprocess. Use `process` to indicate that a separate "
204
+ "a `ClientApp` in a subprocess. Use `process` to indicate that a separate "
207
205
  "independent process gets created outside of SuperNode.",
208
206
  )
209
207
  parser.add_argument(
flwr/common/constant.py CHANGED
@@ -83,6 +83,10 @@ GRPC_ADAPTER_METADATA_MESSAGE_QUALNAME_KEY = "grpc-message-qualname"
83
83
  # Message TTL
84
84
  MESSAGE_TTL_TOLERANCE = 1e-1
85
85
 
86
+ # Isolation modes
87
+ ISOLATION_MODE_SUBPROCESS = "subprocess"
88
+ ISOLATION_MODE_PROCESS = "process"
89
+
86
90
 
87
91
  class MessageType:
88
92
  """Message type."""
@@ -128,3 +132,28 @@ class ErrorCode:
128
132
  def __new__(cls) -> ErrorCode:
129
133
  """Prevent instantiation."""
130
134
  raise TypeError(f"{cls.__name__} cannot be instantiated.")
135
+
136
+
137
+ class Status:
138
+ """Run status."""
139
+
140
+ PENDING = "pending"
141
+ STARTING = "starting"
142
+ RUNNING = "running"
143
+ FINISHED = "finished"
144
+
145
+ def __new__(cls) -> Status:
146
+ """Prevent instantiation."""
147
+ raise TypeError(f"{cls.__name__} cannot be instantiated.")
148
+
149
+
150
+ class SubStatus:
151
+ """Run sub-status."""
152
+
153
+ COMPLETED = "completed"
154
+ FAILED = "failed"
155
+ STOPPED = "stopped"
156
+
157
+ def __new__(cls) -> SubStatus:
158
+ """Prevent instantiation."""
159
+ raise TypeError(f"{cls.__name__} cannot be instantiated.")
flwr/common/typing.py CHANGED
@@ -218,6 +218,15 @@ class Run:
218
218
  override_config: UserConfig
219
219
 
220
220
 
221
+ @dataclass
222
+ class RunStatus:
223
+ """Run status information."""
224
+
225
+ status: str
226
+ sub_status: str
227
+ details: str
228
+
229
+
221
230
  @dataclass
222
231
  class Fab:
223
232
  """Fab file representation."""
flwr/server/app.py CHANGED
@@ -17,12 +17,14 @@
17
17
  import argparse
18
18
  import csv
19
19
  import importlib.util
20
+ import subprocess
20
21
  import sys
21
22
  import threading
22
23
  from collections.abc import Sequence
23
- from logging import INFO, WARN
24
+ from logging import DEBUG, INFO, WARN
24
25
  from os.path import isfile
25
26
  from pathlib import Path
27
+ from time import sleep
26
28
  from typing import Optional
27
29
 
28
30
  import grpc
@@ -42,10 +44,13 @@ from flwr.common.constant import (
42
44
  FLEET_API_GRPC_BIDI_DEFAULT_ADDRESS,
43
45
  FLEET_API_GRPC_RERE_DEFAULT_ADDRESS,
44
46
  FLEET_API_REST_DEFAULT_ADDRESS,
47
+ ISOLATION_MODE_PROCESS,
48
+ ISOLATION_MODE_SUBPROCESS,
45
49
  MISSING_EXTRA_REST,
46
50
  TRANSPORT_TYPE_GRPC_ADAPTER,
47
51
  TRANSPORT_TYPE_GRPC_RERE,
48
52
  TRANSPORT_TYPE_REST,
53
+ Status,
49
54
  )
50
55
  from flwr.common.exit_handlers import register_exit_handlers
51
56
  from flwr.common.logger import log
@@ -53,6 +58,7 @@ from flwr.common.secure_aggregation.crypto.symmetric_encryption import (
53
58
  private_key_to_bytes,
54
59
  public_key_to_bytes,
55
60
  )
61
+ from flwr.common.typing import RunStatus
56
62
  from flwr.proto.fleet_pb2_grpc import ( # pylint: disable=E0611
57
63
  add_FleetServicer_to_server,
58
64
  )
@@ -333,6 +339,15 @@ def run_superlink() -> None:
333
339
  )
334
340
  grpc_servers.append(exec_server)
335
341
 
342
+ if args.isolation == ISOLATION_MODE_SUBPROCESS:
343
+ # Scheduler thread
344
+ scheduler_th = threading.Thread(
345
+ target=_flwr_serverapp_scheduler,
346
+ args=(state_factory, args.driver_api_address),
347
+ )
348
+ scheduler_th.start()
349
+ bckg_threads.append(scheduler_th)
350
+
336
351
  # Graceful shutdown
337
352
  register_exit_handlers(
338
353
  event_type=EventType.RUN_SUPERLINK_LEAVE,
@@ -349,6 +364,47 @@ def run_superlink() -> None:
349
364
  driver_server.wait_for_termination(timeout=1)
350
365
 
351
366
 
367
+ def _flwr_serverapp_scheduler(
368
+ state_factory: LinkStateFactory, driver_api_address: str
369
+ ) -> None:
370
+ log(DEBUG, "Started flwr-serverapp scheduler thread.")
371
+
372
+ state = state_factory.state()
373
+
374
+ # Periodically check for a pending run in the LinkState
375
+ while True:
376
+ sleep(3)
377
+ pending_run_id = state.get_pending_run_id()
378
+
379
+ if pending_run_id:
380
+
381
+ # Set run as starting
382
+ state.update_run_status(
383
+ run_id=pending_run_id, new_status=RunStatus(Status.STARTING, "", "")
384
+ )
385
+ log(
386
+ INFO,
387
+ "Launching `flwr-serverapp` subprocess with run-id %d. "
388
+ "Connects to SuperLink on %s",
389
+ pending_run_id,
390
+ driver_api_address,
391
+ )
392
+ # Start ServerApp subprocess
393
+ command = [
394
+ "flwr-serverapp",
395
+ "--superlink",
396
+ driver_api_address,
397
+ "--run-id",
398
+ str(pending_run_id),
399
+ ]
400
+ subprocess.run(
401
+ command,
402
+ stdout=None,
403
+ stderr=None,
404
+ check=True,
405
+ )
406
+
407
+
352
408
  def _format_address(address: str) -> tuple[str, str, int]:
353
409
  parsed_address = parse_address(address)
354
410
  if not parsed_address:
@@ -634,6 +690,19 @@ def _add_args_common(parser: argparse.ArgumentParser) -> None:
634
690
  "to create a secure connection.",
635
691
  type=str,
636
692
  )
693
+ parser.add_argument(
694
+ "--isolation",
695
+ default=ISOLATION_MODE_SUBPROCESS,
696
+ required=False,
697
+ choices=[
698
+ ISOLATION_MODE_SUBPROCESS,
699
+ ISOLATION_MODE_PROCESS,
700
+ ],
701
+ help="Isolation mode when running a `ServerApp` (`subprocess` by default, "
702
+ "possible values: `subprocess`, `process`). Use `subprocess` to configure "
703
+ "SuperLink to run a `ServerApp` in a subprocess. Use `process` to indicate "
704
+ "that a separate independent process gets created outside of SuperLink.",
705
+ )
637
706
  parser.add_argument(
638
707
  "--database",
639
708
  help="A string representing the path to the database "
@@ -14,7 +14,65 @@
14
14
  # ==============================================================================
15
15
  """Flower ServerApp process."""
16
16
 
17
+ import argparse
18
+ from logging import DEBUG, INFO
19
+ from typing import Optional
20
+
21
+ from flwr.common.logger import log
22
+ from flwr.server.driver.grpc_driver import GrpcDriver
23
+
17
24
 
18
25
  def flwr_serverapp() -> None:
19
26
  """Run process-isolated Flower ServerApp."""
20
- raise NotImplementedError()
27
+ log(INFO, "Starting Flower ServerApp")
28
+
29
+ parser = argparse.ArgumentParser(
30
+ description="Run a Flower ServerApp",
31
+ )
32
+ parser.add_argument(
33
+ "--superlink",
34
+ type=str,
35
+ help="Address of SuperLink's DriverAPI",
36
+ )
37
+ parser.add_argument(
38
+ "--run-id",
39
+ type=int,
40
+ required=False,
41
+ help="Id of the Run this process should start. If not supplied, this "
42
+ "function will request a pending run to the LinkState.",
43
+ )
44
+ args = parser.parse_args()
45
+
46
+ log(
47
+ DEBUG,
48
+ "Staring isolated `ServerApp` connected to SuperLink DriverAPI at %s "
49
+ "for run-id %s",
50
+ args.superlink,
51
+ args.run_id,
52
+ )
53
+ run_serverapp(superlink=args.superlink, run_id=args.run_id)
54
+
55
+
56
+ def run_serverapp( # pylint: disable=R0914
57
+ superlink: str,
58
+ run_id: Optional[int] = None,
59
+ ) -> None:
60
+ """Run Flower ServerApp process.
61
+
62
+ Parameters
63
+ ----------
64
+ superlink : str
65
+ Address of SuperLink
66
+ run_id : Optional[int] (default: None)
67
+ Unique identifier of a Run registered at the LinkState. If not supplied,
68
+ this function will request a pending run to the LinkState.
69
+ """
70
+ _ = GrpcDriver(
71
+ run_id=run_id if run_id else 0,
72
+ driver_service_address=superlink,
73
+ root_certificates=None,
74
+ )
75
+
76
+ # Then, GetServerInputs
77
+
78
+ # Then, run ServerApp
@@ -17,6 +17,7 @@
17
17
 
18
18
  import threading
19
19
  import time
20
+ from dataclasses import dataclass
20
21
  from logging import ERROR, WARNING
21
22
  from typing import Optional
22
23
  from uuid import UUID, uuid4
@@ -26,13 +27,31 @@ from flwr.common.constant import (
26
27
  MESSAGE_TTL_TOLERANCE,
27
28
  NODE_ID_NUM_BYTES,
28
29
  RUN_ID_NUM_BYTES,
30
+ Status,
29
31
  )
30
- from flwr.common.typing import Run, UserConfig
32
+ from flwr.common.typing import Run, RunStatus, UserConfig
31
33
  from flwr.proto.task_pb2 import TaskIns, TaskRes # pylint: disable=E0611
32
34
  from flwr.server.superlink.linkstate.linkstate import LinkState
33
35
  from flwr.server.utils import validate_task_ins_or_res
34
36
 
35
- from .utils import generate_rand_int_from_bytes, make_node_unavailable_taskres
37
+ from .utils import (
38
+ generate_rand_int_from_bytes,
39
+ has_valid_sub_status,
40
+ is_valid_transition,
41
+ make_node_unavailable_taskres,
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class RunRecord:
47
+ """The record of a specific run, including its status and timestamps."""
48
+
49
+ run: Run
50
+ status: RunStatus
51
+ pending_at: str = ""
52
+ starting_at: str = ""
53
+ running_at: str = ""
54
+ finished_at: str = ""
36
55
 
37
56
 
38
57
  class InMemoryLinkState(LinkState): # pylint: disable=R0902,R0904
@@ -44,8 +63,8 @@ class InMemoryLinkState(LinkState): # pylint: disable=R0902,R0904
44
63
  self.node_ids: dict[int, tuple[float, float]] = {}
45
64
  self.public_key_to_node_id: dict[bytes, int] = {}
46
65
 
47
- # Map run_id to (fab_id, fab_version)
48
- self.run_ids: dict[int, Run] = {}
66
+ # Map run_id to RunRecord
67
+ self.run_ids: dict[int, RunRecord] = {}
49
68
  self.task_ins_store: dict[UUID, TaskIns] = {}
50
69
  self.task_res_store: dict[UUID, TaskRes] = {}
51
70
 
@@ -351,13 +370,22 @@ class InMemoryLinkState(LinkState): # pylint: disable=R0902,R0904
351
370
  run_id = generate_rand_int_from_bytes(RUN_ID_NUM_BYTES)
352
371
 
353
372
  if run_id not in self.run_ids:
354
- self.run_ids[run_id] = Run(
355
- run_id=run_id,
356
- fab_id=fab_id if fab_id else "",
357
- fab_version=fab_version if fab_version else "",
358
- fab_hash=fab_hash if fab_hash else "",
359
- override_config=override_config,
373
+ run_record = RunRecord(
374
+ run=Run(
375
+ run_id=run_id,
376
+ fab_id=fab_id if fab_id else "",
377
+ fab_version=fab_version if fab_version else "",
378
+ fab_hash=fab_hash if fab_hash else "",
379
+ override_config=override_config,
380
+ ),
381
+ status=RunStatus(
382
+ status=Status.PENDING,
383
+ sub_status="",
384
+ details="",
385
+ ),
386
+ pending_at=now().isoformat(),
360
387
  )
388
+ self.run_ids[run_id] = run_record
361
389
  return run_id
362
390
  log(ERROR, "Unexpected run creation failure.")
363
391
  return 0
@@ -401,7 +429,69 @@ class InMemoryLinkState(LinkState): # pylint: disable=R0902,R0904
401
429
  if run_id not in self.run_ids:
402
430
  log(ERROR, "`run_id` is invalid")
403
431
  return None
404
- return self.run_ids[run_id]
432
+ return self.run_ids[run_id].run
433
+
434
+ def get_run_status(self, run_ids: set[int]) -> dict[int, RunStatus]:
435
+ """Retrieve the statuses for the specified runs."""
436
+ with self.lock:
437
+ return {
438
+ run_id: self.run_ids[run_id].status
439
+ for run_id in set(run_ids)
440
+ if run_id in self.run_ids
441
+ }
442
+
443
+ def update_run_status(self, run_id: int, new_status: RunStatus) -> bool:
444
+ """Update the status of the run with the specified `run_id`."""
445
+ with self.lock:
446
+ # Check if the run_id exists
447
+ if run_id not in self.run_ids:
448
+ log(ERROR, "`run_id` is invalid")
449
+ return False
450
+
451
+ # Check if the status transition is valid
452
+ current_status = self.run_ids[run_id].status
453
+ if not is_valid_transition(current_status, new_status):
454
+ log(
455
+ ERROR,
456
+ 'Invalid status transition: from "%s" to "%s"',
457
+ current_status.status,
458
+ new_status.status,
459
+ )
460
+ return False
461
+
462
+ # Check if the sub-status is valid
463
+ if not has_valid_sub_status(current_status):
464
+ log(
465
+ ERROR,
466
+ 'Invalid sub-status "%s" for status "%s"',
467
+ current_status.sub_status,
468
+ current_status.status,
469
+ )
470
+ return False
471
+
472
+ # Update the status
473
+ run_record = self.run_ids[run_id]
474
+ if new_status.status == Status.STARTING:
475
+ run_record.starting_at = now().isoformat()
476
+ elif new_status.status == Status.RUNNING:
477
+ run_record.running_at = now().isoformat()
478
+ elif new_status.status == Status.FINISHED:
479
+ run_record.finished_at = now().isoformat()
480
+ run_record.status = new_status
481
+ return True
482
+
483
+ def get_pending_run_id(self) -> Optional[int]:
484
+ """Get the `run_id` of a run with `Status.PENDING` status, if any."""
485
+ pending_run_id = None
486
+
487
+ # Loop through all registered runs
488
+ for run_id, run_rec in self.run_ids.items():
489
+ # Break once a pending run is found
490
+ if run_rec.status.status == Status.PENDING:
491
+ pending_run_id = run_id
492
+ break
493
+
494
+ return pending_run_id
405
495
 
406
496
  def acknowledge_ping(self, node_id: int, ping_interval: float) -> bool:
407
497
  """Acknowledge a ping received from a node, serving as a heartbeat."""
@@ -19,7 +19,7 @@ import abc
19
19
  from typing import Optional
20
20
  from uuid import UUID
21
21
 
22
- from flwr.common.typing import Run, UserConfig
22
+ from flwr.common.typing import Run, RunStatus, UserConfig
23
23
  from flwr.proto.task_pb2 import TaskIns, TaskRes # pylint: disable=E0611
24
24
 
25
25
 
@@ -178,6 +178,54 @@ class LinkState(abc.ABC): # pylint: disable=R0904
178
178
  - `fab_version`: The version of the FAB used in the specified run.
179
179
  """
180
180
 
181
+ @abc.abstractmethod
182
+ def get_run_status(self, run_ids: set[int]) -> dict[int, RunStatus]:
183
+ """Retrieve the statuses for the specified runs.
184
+
185
+ Parameters
186
+ ----------
187
+ run_ids : set[int]
188
+ A set of run identifiers for which to retrieve statuses.
189
+
190
+ Returns
191
+ -------
192
+ dict[int, RunStatus]
193
+ A dictionary mapping each valid run ID to its corresponding status.
194
+
195
+ Notes
196
+ -----
197
+ Only valid run IDs that exist in the State will be included in the returned
198
+ dictionary. If a run ID is not found, it will be omitted from the result.
199
+ """
200
+
201
+ @abc.abstractmethod
202
+ def update_run_status(self, run_id: int, new_status: RunStatus) -> bool:
203
+ """Update the status of the run with the specified `run_id`.
204
+
205
+ Parameters
206
+ ----------
207
+ run_id : int
208
+ The identifier of the run.
209
+ new_status : RunStatus
210
+ The new status to be assigned to the run.
211
+
212
+ Returns
213
+ -------
214
+ bool
215
+ True if the status update is successful; False otherwise.
216
+ """
217
+
218
+ @abc.abstractmethod
219
+ def get_pending_run_id(self) -> Optional[int]:
220
+ """Get the `run_id` of a run with `Status.PENDING` status.
221
+
222
+ Returns
223
+ -------
224
+ Optional[int]
225
+ The `run_id` of a `Run` that is pending to be started; None if
226
+ there is no Run pending.
227
+ """
228
+
181
229
  @abc.abstractmethod
182
230
  def store_server_private_public_key(
183
231
  self, private_key: bytes, public_key: bytes
@@ -30,8 +30,9 @@ from flwr.common.constant import (
30
30
  MESSAGE_TTL_TOLERANCE,
31
31
  NODE_ID_NUM_BYTES,
32
32
  RUN_ID_NUM_BYTES,
33
+ Status,
33
34
  )
34
- from flwr.common.typing import Run, UserConfig
35
+ from flwr.common.typing import Run, RunStatus, UserConfig
35
36
  from flwr.proto.node_pb2 import Node # pylint: disable=E0611
36
37
  from flwr.proto.recordset_pb2 import RecordSet # pylint: disable=E0611
37
38
  from flwr.proto.task_pb2 import Task, TaskIns, TaskRes # pylint: disable=E0611
@@ -44,6 +45,8 @@ from .utils import (
44
45
  convert_uint64_to_sint64,
45
46
  convert_uint64_values_in_dict_to_sint64,
46
47
  generate_rand_int_from_bytes,
48
+ has_valid_sub_status,
49
+ is_valid_transition,
47
50
  make_node_unavailable_taskres,
48
51
  )
49
52
 
@@ -79,7 +82,13 @@ CREATE TABLE IF NOT EXISTS run(
79
82
  fab_id TEXT,
80
83
  fab_version TEXT,
81
84
  fab_hash TEXT,
82
- override_config TEXT
85
+ override_config TEXT,
86
+ pending_at TEXT,
87
+ starting_at TEXT,
88
+ running_at TEXT,
89
+ finished_at TEXT,
90
+ sub_status TEXT,
91
+ details TEXT
83
92
  );
84
93
  """
85
94
 
@@ -133,7 +142,7 @@ class SqliteLinkState(LinkState): # pylint: disable=R0904
133
142
  self,
134
143
  database_path: str,
135
144
  ) -> None:
136
- """Initialize an SqliteState.
145
+ """Initialize an SqliteLinkState.
137
146
 
138
147
  Parameters
139
148
  ----------
@@ -773,26 +782,16 @@ class SqliteLinkState(LinkState): # pylint: disable=R0904
773
782
  if self.query(query, (sint64_run_id,))[0]["COUNT(*)"] == 0:
774
783
  query = (
775
784
  "INSERT INTO run "
776
- "(run_id, fab_id, fab_version, fab_hash, override_config)"
777
- "VALUES (?, ?, ?, ?, ?);"
785
+ "(run_id, fab_id, fab_version, fab_hash, override_config, pending_at, "
786
+ "starting_at, running_at, finished_at, sub_status, details)"
787
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"
778
788
  )
779
789
  if fab_hash:
780
- self.query(
781
- query,
782
- (sint64_run_id, "", "", fab_hash, json.dumps(override_config)),
783
- )
784
- else:
785
- self.query(
786
- query,
787
- (
788
- sint64_run_id,
789
- fab_id,
790
- fab_version,
791
- "",
792
- json.dumps(override_config),
793
- ),
794
- )
795
- # Note: we need to return the uint64 value of the run_id
790
+ fab_id, fab_version = "", ""
791
+ override_config_json = json.dumps(override_config)
792
+ data = [sint64_run_id, fab_id, fab_version, fab_hash, override_config_json]
793
+ data += [now().isoformat(), "", "", "", "", ""]
794
+ self.query(query, tuple(data))
796
795
  return uint64_run_id
797
796
  log(ERROR, "Unexpected run creation failure.")
798
797
  return 0
@@ -868,6 +867,94 @@ class SqliteLinkState(LinkState): # pylint: disable=R0904
868
867
  log(ERROR, "`run_id` does not exist.")
869
868
  return None
870
869
 
870
+ def get_run_status(self, run_ids: set[int]) -> dict[int, RunStatus]:
871
+ """Retrieve the statuses for the specified runs."""
872
+ # Convert the uint64 value to sint64 for SQLite
873
+ sint64_run_ids = (convert_uint64_to_sint64(run_id) for run_id in set(run_ids))
874
+ query = f"SELECT * FROM run WHERE run_id IN ({','.join(['?'] * len(run_ids))});"
875
+ rows = self.query(query, tuple(sint64_run_ids))
876
+
877
+ return {
878
+ # Restore uint64 run IDs
879
+ convert_sint64_to_uint64(row["run_id"]): RunStatus(
880
+ status=determine_run_status(row),
881
+ sub_status=row["sub_status"],
882
+ details=row["details"],
883
+ )
884
+ for row in rows
885
+ }
886
+
887
+ def update_run_status(self, run_id: int, new_status: RunStatus) -> bool:
888
+ """Update the status of the run with the specified `run_id`."""
889
+ # Convert the uint64 value to sint64 for SQLite
890
+ sint64_run_id = convert_uint64_to_sint64(run_id)
891
+ query = "SELECT * FROM run WHERE run_id = ?;"
892
+ rows = self.query(query, (sint64_run_id,))
893
+
894
+ # Check if the run_id exists
895
+ if not rows:
896
+ log(ERROR, "`run_id` is invalid")
897
+ return False
898
+
899
+ # Check if the status transition is valid
900
+ row = rows[0]
901
+ current_status = RunStatus(
902
+ status=determine_run_status(row),
903
+ sub_status=row["sub_status"],
904
+ details=row["details"],
905
+ )
906
+ if not is_valid_transition(current_status, new_status):
907
+ log(
908
+ ERROR,
909
+ 'Invalid status transition: from "%s" to "%s"',
910
+ current_status.status,
911
+ new_status.status,
912
+ )
913
+ return False
914
+
915
+ # Check if the sub-status is valid
916
+ if not has_valid_sub_status(current_status):
917
+ log(
918
+ ERROR,
919
+ 'Invalid sub-status "%s" for status "%s"',
920
+ current_status.sub_status,
921
+ current_status.status,
922
+ )
923
+ return False
924
+
925
+ # Update the status
926
+ query = "UPDATE run SET %s= ?, sub_status = ?, details = ? "
927
+ query += "WHERE run_id = ?;"
928
+
929
+ timestamp_fld = ""
930
+ if new_status.status == Status.STARTING:
931
+ timestamp_fld = "starting_at"
932
+ elif new_status.status == Status.RUNNING:
933
+ timestamp_fld = "running_at"
934
+ elif new_status.status == Status.FINISHED:
935
+ timestamp_fld = "finished_at"
936
+
937
+ data = (
938
+ now().isoformat(),
939
+ new_status.sub_status,
940
+ new_status.details,
941
+ sint64_run_id,
942
+ )
943
+ self.query(query % timestamp_fld, data)
944
+ return True
945
+
946
+ def get_pending_run_id(self) -> Optional[int]:
947
+ """Get the `run_id` of a run with `Status.PENDING` status, if any."""
948
+ pending_run_id = None
949
+
950
+ # Fetch all runs with unset `starting_at` (i.e. they are in PENDING status)
951
+ query = "SELECT * FROM run WHERE starting_at = '' LIMIT 1;"
952
+ rows = self.query(query)
953
+ if rows:
954
+ pending_run_id = convert_sint64_to_uint64(rows[0]["run_id"])
955
+
956
+ return pending_run_id
957
+
871
958
  def acknowledge_ping(self, node_id: int, ping_interval: float) -> bool:
872
959
  """Acknowledge a ping received from a node, serving as a heartbeat."""
873
960
  sint64_node_id = convert_uint64_to_sint64(node_id)
@@ -1023,3 +1110,17 @@ def dict_to_task_res(task_dict: dict[str, Any]) -> TaskRes:
1023
1110
  ),
1024
1111
  )
1025
1112
  return result
1113
+
1114
+
1115
+ def determine_run_status(row: dict[str, Any]) -> str:
1116
+ """Determine the status of the run based on timestamp fields."""
1117
+ if row["pending_at"]:
1118
+ if row["starting_at"]:
1119
+ if row["running_at"]:
1120
+ if row["finished_at"]:
1121
+ return Status.FINISHED
1122
+ return Status.RUNNING
1123
+ return Status.STARTING
1124
+ return Status.PENDING
1125
+ run_id = convert_sint64_to_uint64(row["run_id"])
1126
+ raise sqlite3.IntegrityError(f"The run {run_id} does not have a valid status.")
@@ -21,7 +21,8 @@ from os import urandom
21
21
  from uuid import uuid4
22
22
 
23
23
  from flwr.common import log
24
- from flwr.common.constant import ErrorCode
24
+ from flwr.common.constant import ErrorCode, Status, SubStatus
25
+ from flwr.common.typing import RunStatus
25
26
  from flwr.proto.error_pb2 import Error # pylint: disable=E0611
26
27
  from flwr.proto.node_pb2 import Node # pylint: disable=E0611
27
28
  from flwr.proto.task_pb2 import Task, TaskIns, TaskRes # pylint: disable=E0611
@@ -31,6 +32,17 @@ NODE_UNAVAILABLE_ERROR_REASON = (
31
32
  "It exceeds the time limit specified in its last ping."
32
33
  )
33
34
 
35
+ VALID_RUN_STATUS_TRANSITIONS = {
36
+ (Status.PENDING, Status.STARTING),
37
+ (Status.STARTING, Status.RUNNING),
38
+ (Status.RUNNING, Status.FINISHED),
39
+ }
40
+ VALID_RUN_SUB_STATUSES = {
41
+ SubStatus.COMPLETED,
42
+ SubStatus.FAILED,
43
+ SubStatus.STOPPED,
44
+ }
45
+
34
46
 
35
47
  def generate_rand_int_from_bytes(num_bytes: int) -> int:
36
48
  """Generate a random unsigned integer from `num_bytes` bytes."""
@@ -146,3 +158,47 @@ def make_node_unavailable_taskres(ref_taskins: TaskIns) -> TaskRes:
146
158
  ),
147
159
  ),
148
160
  )
161
+
162
+
163
+ def is_valid_transition(current_status: RunStatus, new_status: RunStatus) -> bool:
164
+ """Check if a transition between two run statuses is valid.
165
+
166
+ Parameters
167
+ ----------
168
+ current_status : RunStatus
169
+ The current status of the run.
170
+ new_status : RunStatus
171
+ The new status to transition to.
172
+
173
+ Returns
174
+ -------
175
+ bool
176
+ True if the transition is valid, False otherwise.
177
+ """
178
+ return (
179
+ current_status.status,
180
+ new_status.status,
181
+ ) in VALID_RUN_STATUS_TRANSITIONS
182
+
183
+
184
+ def has_valid_sub_status(status: RunStatus) -> bool:
185
+ """Check if the 'sub_status' field of the given status is valid.
186
+
187
+ Parameters
188
+ ----------
189
+ status : RunStatus
190
+ The status object to be checked.
191
+
192
+ Returns
193
+ -------
194
+ bool
195
+ True if the status object has a valid sub-status, False otherwise.
196
+
197
+ Notes
198
+ -----
199
+ Only an empty string (i.e., "") is considered a valid sub-status for
200
+ non-finished statuses. The sub-status of a finished status cannot be empty.
201
+ """
202
+ if status.status == Status.FINISHED:
203
+ return status.sub_status in VALID_RUN_SUB_STATUSES
204
+ return status.sub_status == ""
@@ -29,22 +29,23 @@ from typing import Any, Optional
29
29
 
30
30
  from flwr.cli.config_utils import load_and_validate
31
31
  from flwr.client import ClientApp
32
- from flwr.common import EventType, event, log
32
+ from flwr.common import EventType, event, log, now
33
33
  from flwr.common.config import get_fused_config_from_dir, parse_config_args
34
- from flwr.common.constant import RUN_ID_NUM_BYTES
34
+ from flwr.common.constant import RUN_ID_NUM_BYTES, Status
35
35
  from flwr.common.logger import (
36
36
  set_logger_propagation,
37
37
  update_console_handler,
38
38
  warn_deprecated_feature,
39
39
  warn_deprecated_feature_with_example,
40
40
  )
41
- from flwr.common.typing import Run, UserConfig
41
+ from flwr.common.typing import Run, RunStatus, UserConfig
42
42
  from flwr.server.driver import Driver, InMemoryDriver
43
43
  from flwr.server.run_serverapp import run as run_server_app
44
44
  from flwr.server.server_app import ServerApp
45
45
  from flwr.server.superlink.fleet import vce
46
46
  from flwr.server.superlink.fleet.vce.backend.backend import BackendConfig
47
47
  from flwr.server.superlink.linkstate import LinkStateFactory
48
+ from flwr.server.superlink.linkstate.in_memory_linkstate import RunRecord
48
49
  from flwr.server.superlink.linkstate.utils import generate_rand_int_from_bytes
49
50
  from flwr.simulation.ray_transport.utils import (
50
51
  enable_tf_gpu_growth as enable_gpu_growth,
@@ -399,7 +400,14 @@ def _main_loop(
399
400
  try:
400
401
  # Register run
401
402
  log(DEBUG, "Pre-registering run with id %s", run.run_id)
402
- state_factory.state().run_ids[run.run_id] = run # type: ignore
403
+ init_status = RunStatus(Status.RUNNING, "", "")
404
+ state_factory.state().run_ids[run.run_id] = RunRecord( # type: ignore
405
+ run=run,
406
+ status=init_status,
407
+ starting_at=now().isoformat(),
408
+ running_at=now().isoformat(),
409
+ finished_at="",
410
+ )
403
411
 
404
412
  if server_app_run_config is None:
405
413
  server_app_run_config = {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: flwr-nightly
3
- Version: 1.13.0.dev20241022
3
+ Version: 1.13.0.dev20241023
4
4
  Summary: Flower: A Friendly Federated Learning Framework
5
5
  Home-page: https://flower.ai
6
6
  License: Apache-2.0
@@ -64,7 +64,7 @@ flwr/cli/run/__init__.py,sha256=oCd6HmQDx-sqver1gecgx-uMA38BLTSiiKpl7RGNceg,789
64
64
  flwr/cli/run/run.py,sha256=NMCeDfImxta1VEeBqqkP05xsuBK6YWFTd7Qj_bIEA2Y,8394
65
65
  flwr/cli/utils.py,sha256=emMUdthvoHBTB0iGQp-oFBmA5wV46lw3y3FmfXQPCsc,4500
66
66
  flwr/client/__init__.py,sha256=DGDoO0AEAfz-0CUFmLdyUUweAS64-07AOnmDfWUefK4,1192
67
- flwr/client/app.py,sha256=y_GhK5bCEfUS__PgNx1mlybtWUQvKMq247_lBRRe8a4,32826
67
+ flwr/client/app.py,sha256=fEUTXz_uNwZe-otCbUf5F3sJozGfvkrMNS3u7DE6sOo,32808
68
68
  flwr/client/client.py,sha256=gy6WVlMUFAp8oevN4xpQPX30vPOIYGVqdbuFlTWkyG4,9080
69
69
  flwr/client/client_app.py,sha256=cTig-N00YzTucbo9zNi6I21J8PlbflU_8J_f5CI-Wpw,10390
70
70
  flwr/client/clientapp/__init__.py,sha256=kZqChGnTChQ1WGSUkIlW2S5bc0d0mzDubCAmZUGRpEY,800
@@ -98,12 +98,12 @@ flwr/client/rest_client/__init__.py,sha256=5KGlp7pjc1dhNRkKlaNtUfQmg8wrRFh9lS3P3
98
98
  flwr/client/rest_client/connection.py,sha256=k-RqgUFqidACAGlMFPIUM8aawXI5h2LvKUri2OAK7Bg,12817
99
99
  flwr/client/run_info_store.py,sha256=SKpvq8vvNJgB7LDd-DLMndH99kNRztg2UXvnMh8fOEU,4005
100
100
  flwr/client/supernode/__init__.py,sha256=SUhWOzcgXRNXk1V9UgB5-FaWukqqrOEajVUHEcPkwyQ,865
101
- flwr/client/supernode/app.py,sha256=came3AQMOs4VBQ0yGc2Jn3Kgb5BEiP_Nno-3Sj9_0V4,12242
101
+ flwr/client/supernode/app.py,sha256=JN24tRBHLbFJ0KeCTA8eS24KUJHCl9J2xGwWjyPQ7Vg,12239
102
102
  flwr/client/typing.py,sha256=dxoTBnTMfqXr5J7G3y-uNjqxYCddvxhu89spfj4Lm2U,1048
103
103
  flwr/common/__init__.py,sha256=TVaoFEJE158aui1TPZQiJCDZX4RNHRyI8I55VC80HhI,3901
104
104
  flwr/common/address.py,sha256=7kM2Rqjw86-c8aKwAvrXerWqznnVv4TFJ62aSAeTn10,3017
105
105
  flwr/common/config.py,sha256=nYA1vjiiqSWx5JjSdlQd1i_0N_Dh9kEGUse1Qze3JMs,7803
106
- flwr/common/constant.py,sha256=BSGOSie1cHsupk3ZfKOO_LblYYe_UoOOfOqpkvxAFzA,3745
106
+ flwr/common/constant.py,sha256=iv2O8vQdrIqsGy-RFluRDd0R0oaqWO046KPm14yPzw0,4376
107
107
  flwr/common/context.py,sha256=5Bd9RCrhLkYZOVR7vr97OVhzVBHQkS1fUsYiIKTwpxU,2239
108
108
  flwr/common/date.py,sha256=OcQuwpb2HxcblTqYm6H223ufop5UZw5N_fzalbpOVzY,891
109
109
  flwr/common/differential_privacy.py,sha256=XwcJ3rWr8S8BZUocc76vLSJAXIf6OHnWkBV6-xlIRuw,6106
@@ -135,7 +135,7 @@ flwr/common/secure_aggregation/secaggplus_constants.py,sha256=9MF-oQh62uD7rt9VeN
135
135
  flwr/common/secure_aggregation/secaggplus_utils.py,sha256=o7IhHH6J9xqinhQy3TdPgQpoj1XyEpyv3OQFyx81RVQ,3193
136
136
  flwr/common/serde.py,sha256=74nN5uqASdqfykSWPOhaTJARA07Iznyg3Nyr-dh-uy4,29918
137
137
  flwr/common/telemetry.py,sha256=PvdlipCPYciqEgmXRwQ1HklP1uyECcNqt9HTBzthmAg,8904
138
- flwr/common/typing.py,sha256=ZVviEABqDeGCyo_yM9ft8EbIGA9RaLOeoNHmMnTkmUo,4985
138
+ flwr/common/typing.py,sha256=fS_KmVdg0c1B87yMnccIPfjBzQ3CTRwYJcaWfmvZzEA,5103
139
139
  flwr/common/version.py,sha256=tCcl_FvxVK206C1dxIJCs4TjL06WmyaODBP19FRHE1c,1324
140
140
  flwr/proto/__init__.py,sha256=hbY7JYakwZwCkYgCNlmHdc8rtvfoJbAZLalMdc--CGc,683
141
141
  flwr/proto/clientappio_pb2.py,sha256=Y3PMv-JMaBGehpslgbvGY6l2u5vNpfCTFWu-fmAmBJ4,3703
@@ -200,7 +200,7 @@ flwr/proto/transport_pb2_grpc.py,sha256=vLN3EHtx2aEEMCO4f1Upu-l27BPzd3-5pV-u8wPc
200
200
  flwr/proto/transport_pb2_grpc.pyi,sha256=AGXf8RiIiW2J5IKMlm_3qT3AzcDa4F3P5IqUjve_esA,766
201
201
  flwr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
202
202
  flwr/server/__init__.py,sha256=cEg1oecBu4cKB69iJCqWEylC8b5XW47bl7rQiJsdTvM,1528
203
- flwr/server/app.py,sha256=UXrhA5_Amo0ujInm3ryBrGy1Rm6cmPt2xPQI4UvMFKQ,26045
203
+ flwr/server/app.py,sha256=qChpmZm68wCkXsi9UgNGXQDRxTAm8klrH-66A9Oxsus,28255
204
204
  flwr/server/client_manager.py,sha256=7Ese0tgrH-i-ms363feYZJKwB8gWnXSmg_hYF2Bju4U,6227
205
205
  flwr/server/client_proxy.py,sha256=4G-oTwhb45sfWLx2uZdcXD98IZwdTS6F88xe3akCdUg,2399
206
206
  flwr/server/compat/__init__.py,sha256=VxnJtJyOjNFQXMNi9hIuzNlZM5n0Hj1p3aq_Pm2udw4,892
@@ -219,7 +219,7 @@ flwr/server/server.py,sha256=1ZsFEptmAV-L2vP2etNC9Ed5CLSxpuKzUFkAPQ4l5Xc,17893
219
219
  flwr/server/server_app.py,sha256=RsgS6PRS5Z74cMUAHzsm8r3LWddwn00MjRs6rlacHt8,6297
220
220
  flwr/server/server_config.py,sha256=CZaHVAsMvGLjpWVcLPkiYxgJN4xfIyAiUrCI3fETKY4,1349
221
221
  flwr/server/serverapp/__init__.py,sha256=L0K-94UDdTyEZ8LDtYybGIIIv3HW6AhSVjXMUfYJQnQ,800
222
- flwr/server/serverapp/app.py,sha256=IZegGyyOj2EdL0nz-M9n_CBr25tU5yTrnAd5wM-wkTo,828
222
+ flwr/server/serverapp/app.py,sha256=qrkJVCORJbDQN0I5JR_7t26GGwLLlApELQBWdbgButo,2353
223
223
  flwr/server/serverapp_components.py,sha256=-IV_CitOfrJclJj2jNdbN1Q65PyFmtKtrTIg1hc6WQw,2118
224
224
  flwr/server/strategy/__init__.py,sha256=tQer2SwjDnvgFFuJMZM-S01Z615N5XK6MaCvpm4BMU0,2836
225
225
  flwr/server/strategy/aggregate.py,sha256=iFZ8lp7PV_a2m9kywV-FK0iM33ofxavOs5TIaEQY8nU,13961
@@ -274,11 +274,11 @@ flwr/server/superlink/fleet/vce/backend/backend.py,sha256=LBAQxnbfPAphVOVIvYMj0Q
274
274
  flwr/server/superlink/fleet/vce/backend/raybackend.py,sha256=7kB3re3mR53b7E6L6DPSioTSKD3YGtS3uJsPD7Hn2Fw,7155
275
275
  flwr/server/superlink/fleet/vce/vce_api.py,sha256=VL6e_Jwf4uxA-X1EelxJZMv6Eji-_p2J9D0MdHG10a4,13029
276
276
  flwr/server/superlink/linkstate/__init__.py,sha256=v-2JyJlCB3qyhMNwMjmcNVOq4rkooqFU0LHH8Zo1jls,1064
277
- flwr/server/superlink/linkstate/in_memory_linkstate.py,sha256=7rLsC3wpwR0ccuXXSG4xgLGrfaTIUaWDeFldoKCBvIQ,15732
278
- flwr/server/superlink/linkstate/linkstate.py,sha256=_b8bxSBa4_ODm-4dKk7nYZate2_lLordzSIZf2NxKIU,7891
277
+ flwr/server/superlink/linkstate/in_memory_linkstate.py,sha256=PCN3O96GTR4-f_EhzTo8wqcerVTvXoPOwEjNyrKsQJ8,18830
278
+ flwr/server/superlink/linkstate/linkstate.py,sha256=2vGG_XkLM4OsAFLMBDqml9ZV90eAYNtnbIzhlnHRSNU,9346
279
279
  flwr/server/superlink/linkstate/linkstate_factory.py,sha256=ISSMjDlwuN7swxjOeYlTNpI_kuZ8PGkMcJnf1dbhUSE,2069
280
- flwr/server/superlink/linkstate/sqlite_linkstate.py,sha256=-C59fAxv_QD7hqiT32E6yAAv8MMi9NBpAw4_4QR2rEs,35689
281
- flwr/server/superlink/linkstate/utils.py,sha256=AC5u_A2toAW_ocCrng5ioxwoNhobocJAJMgjFvg-dJI,4997
280
+ flwr/server/superlink/linkstate/sqlite_linkstate.py,sha256=aH7XJ19QRB9KfhbBn45M9XunZXYkAqL9Jr6KvqjQwk0,39547
281
+ flwr/server/superlink/linkstate/utils.py,sha256=PBPsCruHLZQC2N71-ZWue5zqs1tkv6ULgSzpQJWKkro,6481
282
282
  flwr/server/typing.py,sha256=5kaRLZuxTEse9A0g7aVna2VhYxU3wTq1f3d3mtw7kXs,1019
283
283
  flwr/server/utils/__init__.py,sha256=pltsPHJoXmUIr3utjwwYxu7_ZAGy5u4MVHzv9iA5Un8,908
284
284
  flwr/server/utils/tensorboard.py,sha256=gEBD8w_5uaIfp5aw5RYH66lYZpd_SfkObHQ7eDd9MUk,5466
@@ -295,7 +295,7 @@ flwr/simulation/ray_transport/__init__.py,sha256=wzcEEwUUlulnXsg6raCA1nGpP3LlAQD
295
295
  flwr/simulation/ray_transport/ray_actor.py,sha256=9-XBguAm5IFqm2ddPFsQtnuuFN6lzqdb00SnCxGUGBo,18996
296
296
  flwr/simulation/ray_transport/ray_client_proxy.py,sha256=2vjOKoom3B74C6XU-jC3N6DwYmsLdB-lmkHZ_Xrv96o,7367
297
297
  flwr/simulation/ray_transport/utils.py,sha256=TYdtfg1P9VfTdLMOJlifInGpxWHYs9UfUqIv2wfkRLA,2392
298
- flwr/simulation/run_simulation.py,sha256=9JaSr8WiuHLCnIZ1u6TebfKIJXsCxwsSc2iI97eQBgk,22880
298
+ flwr/simulation/run_simulation.py,sha256=W3wGFXlrrTW7f1Zl1xWxtGRd2Vui7gXfIVO10a3eX8c,23217
299
299
  flwr/superexec/__init__.py,sha256=fcj366jh4RFby_vDwLroU4kepzqbnJgseZD_jUr_Mko,715
300
300
  flwr/superexec/app.py,sha256=KynGHvQjQpVfqydAaVHizbE3GbFFFZjOQQZleMt3SH0,6827
301
301
  flwr/superexec/deployment.py,sha256=TbzOAAaY2sNt7O516w1GS6N5xvt0UV-dML74O6WA2O4,6344
@@ -303,8 +303,8 @@ flwr/superexec/exec_grpc.py,sha256=ZPq7EP55Vwj0kRcLVuTCokFqfIgBk-7YmDykZoMKi-c,1
303
303
  flwr/superexec/exec_servicer.py,sha256=TRpwPVl7eI0Y_xlCY6DmVpAo0yFU1gLwzyIeqFw9pyk,4746
304
304
  flwr/superexec/executor.py,sha256=-5J-ZLs-uArro3T2pCq0YQRC65cs18M888nufzdYE4E,2375
305
305
  flwr/superexec/simulation.py,sha256=3z9mFhSw29Fup4RwV8ucr5eCrXI3urILWDrdC1sogik,7424
306
- flwr_nightly-1.13.0.dev20241022.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
307
- flwr_nightly-1.13.0.dev20241022.dist-info/METADATA,sha256=wnEI8ssMUR9UD6xhA1QZfpeGAVliP6Rc4CBgK_xI5tw,15618
308
- flwr_nightly-1.13.0.dev20241022.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
309
- flwr_nightly-1.13.0.dev20241022.dist-info/entry_points.txt,sha256=FxJQ96pmcNF2OvkTH6XF-Ip2PNrHvykjArkvkjQC7Mk,486
310
- flwr_nightly-1.13.0.dev20241022.dist-info/RECORD,,
306
+ flwr_nightly-1.13.0.dev20241023.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
307
+ flwr_nightly-1.13.0.dev20241023.dist-info/METADATA,sha256=nWx39T1F-7bEhxqNK3UgdAaMPIEB8CxTkKxY1cLDa6w,15618
308
+ flwr_nightly-1.13.0.dev20241023.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
309
+ flwr_nightly-1.13.0.dev20241023.dist-info/entry_points.txt,sha256=FxJQ96pmcNF2OvkTH6XF-Ip2PNrHvykjArkvkjQC7Mk,486
310
+ flwr_nightly-1.13.0.dev20241023.dist-info/RECORD,,