flwr 1.19.0__py3-none-any.whl → 1.20.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. flwr/cli/build.py +15 -5
  2. flwr/cli/new/new.py +12 -4
  3. flwr/cli/new/templates/app/README.flowertune.md.tpl +2 -0
  4. flwr/cli/new/templates/app/README.md.tpl +5 -0
  5. flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +14 -3
  6. flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +13 -1
  7. flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +21 -2
  8. flwr/cli/new/templates/app/pyproject.jax.toml.tpl +18 -1
  9. flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +19 -2
  10. flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +18 -1
  11. flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +20 -3
  12. flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +18 -1
  13. flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +18 -1
  14. flwr/cli/run/run.py +45 -38
  15. flwr/cli/utils.py +12 -5
  16. flwr/client/grpc_adapter_client/connection.py +11 -4
  17. flwr/client/grpc_rere_client/connection.py +92 -117
  18. flwr/client/rest_client/connection.py +131 -164
  19. flwr/common/constant.py +3 -1
  20. flwr/common/exit/exit_code.py +16 -1
  21. flwr/common/grpc.py +12 -1
  22. flwr/common/{inflatable_grpc_utils.py → inflatable_protobuf_utils.py} +52 -10
  23. flwr/common/inflatable_utils.py +191 -24
  24. flwr/common/record/array.py +101 -22
  25. flwr/common/record/arraychunk.py +59 -0
  26. flwr/common/serde.py +0 -28
  27. flwr/compat/client/app.py +14 -31
  28. flwr/proto/appio_pb2.py +43 -0
  29. flwr/proto/appio_pb2.pyi +151 -0
  30. flwr/proto/appio_pb2_grpc.py +4 -0
  31. flwr/proto/appio_pb2_grpc.pyi +4 -0
  32. flwr/proto/clientappio_pb2.py +12 -19
  33. flwr/proto/clientappio_pb2.pyi +23 -101
  34. flwr/proto/clientappio_pb2_grpc.py +269 -28
  35. flwr/proto/clientappio_pb2_grpc.pyi +114 -20
  36. flwr/proto/fleet_pb2.py +12 -20
  37. flwr/proto/fleet_pb2.pyi +6 -36
  38. flwr/proto/serverappio_pb2.py +8 -31
  39. flwr/proto/serverappio_pb2.pyi +0 -152
  40. flwr/proto/serverappio_pb2_grpc.py +39 -38
  41. flwr/proto/serverappio_pb2_grpc.pyi +21 -20
  42. flwr/server/app.py +1 -1
  43. flwr/server/fleet_event_log_interceptor.py +4 -0
  44. flwr/server/grid/grpc_grid.py +91 -54
  45. flwr/server/serverapp/app.py +27 -17
  46. flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer.py +8 -0
  47. flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py +1 -1
  48. flwr/server/superlink/fleet/grpc_rere/server_interceptor.py +2 -5
  49. flwr/server/superlink/fleet/message_handler/message_handler.py +10 -16
  50. flwr/server/superlink/fleet/rest_rere/rest_api.py +1 -2
  51. flwr/server/superlink/serverappio/serverappio_grpc.py +1 -1
  52. flwr/server/superlink/serverappio/serverappio_servicer.py +35 -43
  53. flwr/server/superlink/simulation/simulationio_grpc.py +1 -1
  54. flwr/server/superlink/simulation/simulationio_servicer.py +1 -1
  55. flwr/server/superlink/utils.py +0 -35
  56. flwr/simulation/app.py +8 -0
  57. flwr/simulation/run_simulation.py +17 -0
  58. flwr/{server/superlink → supercore}/ffs/disk_ffs.py +1 -1
  59. flwr/supercore/grpc_health/__init__.py +22 -0
  60. flwr/supercore/grpc_health/simple_health_servicer.py +38 -0
  61. flwr/supercore/license_plugin/__init__.py +22 -0
  62. flwr/supercore/license_plugin/license_plugin.py +26 -0
  63. flwr/supercore/object_store/in_memory_object_store.py +31 -31
  64. flwr/supercore/object_store/object_store.py +20 -42
  65. flwr/supercore/object_store/utils.py +43 -0
  66. flwr/supercore/scheduler/__init__.py +22 -0
  67. flwr/supercore/scheduler/plugin.py +71 -0
  68. flwr/supercore/utils.py +32 -0
  69. flwr/superexec/deployment.py +1 -2
  70. flwr/superexec/exec_event_log_interceptor.py +4 -0
  71. flwr/superexec/exec_grpc.py +18 -2
  72. flwr/superexec/exec_license_interceptor.py +82 -0
  73. flwr/superexec/exec_servicer.py +10 -1
  74. flwr/superexec/exec_user_auth_interceptor.py +10 -2
  75. flwr/superexec/executor.py +1 -1
  76. flwr/superexec/simulation.py +1 -2
  77. flwr/supernode/cli/flower_supernode.py +0 -7
  78. flwr/supernode/cli/flwr_clientapp.py +10 -3
  79. flwr/supernode/nodestate/in_memory_nodestate.py +11 -2
  80. flwr/supernode/nodestate/nodestate.py +15 -0
  81. flwr/supernode/runtime/run_clientapp.py +110 -33
  82. flwr/supernode/scheduler/__init__.py +22 -0
  83. flwr/supernode/scheduler/simple_clientapp_scheduler_plugin.py +49 -0
  84. flwr/supernode/servicer/clientappio/__init__.py +1 -3
  85. flwr/supernode/servicer/clientappio/clientappio_servicer.py +223 -164
  86. flwr/supernode/start_client_internal.py +202 -104
  87. {flwr-1.19.0.dist-info → flwr-1.20.0.dist-info}/METADATA +2 -1
  88. {flwr-1.19.0.dist-info → flwr-1.20.0.dist-info}/RECORD +93 -78
  89. flwr/common/inflatable_rest_utils.py +0 -99
  90. /flwr/{server/superlink → supercore}/ffs/__init__.py +0 -0
  91. /flwr/{server/superlink → supercore}/ffs/ffs.py +0 -0
  92. /flwr/{server/superlink → supercore}/ffs/ffs_factory.py +0 -0
  93. {flwr-1.19.0.dist-info → flwr-1.20.0.dist-info}/WHEEL +0 -0
  94. {flwr-1.19.0.dist-info → flwr-1.20.0.dist-info}/entry_points.txt +0 -0
@@ -20,8 +20,8 @@ import subprocess
20
20
  import time
21
21
  from collections.abc import Iterator
22
22
  from contextlib import contextmanager
23
+ from functools import partial
23
24
  from logging import INFO, WARN
24
- from os import urandom
25
25
  from pathlib import Path
26
26
  from typing import Callable, Optional, Union, cast
27
27
 
@@ -39,7 +39,6 @@ from flwr.common.constant import (
39
39
  CLIENTAPPIO_API_DEFAULT_SERVER_ADDRESS,
40
40
  ISOLATION_MODE_SUBPROCESS,
41
41
  MAX_RETRY_DELAY,
42
- RUN_ID_NUM_BYTES,
43
42
  SERVER_OCTET,
44
43
  TRANSPORT_TYPE_GRPC_ADAPTER,
45
44
  TRANSPORT_TYPE_GRPC_RERE,
@@ -47,15 +46,23 @@ from flwr.common.constant import (
47
46
  TRANSPORT_TYPES,
48
47
  )
49
48
  from flwr.common.exit import ExitCode, flwr_exit
49
+ from flwr.common.exit_handlers import register_exit_handlers
50
50
  from flwr.common.grpc import generic_create_grpc_server
51
+ from flwr.common.inflatable import iterate_object_tree
52
+ from flwr.common.inflatable_utils import (
53
+ pull_objects,
54
+ push_object_contents_from_iterable,
55
+ )
51
56
  from flwr.common.logger import log
52
57
  from flwr.common.retry_invoker import RetryInvoker, RetryState, exponential
58
+ from flwr.common.telemetry import EventType
53
59
  from flwr.common.typing import Fab, Run, RunNotRunningException, UserConfig
54
60
  from flwr.proto.clientappio_pb2_grpc import add_ClientAppIoServicer_to_server
55
- from flwr.server.superlink.ffs import Ffs, FfsFactory
61
+ from flwr.proto.message_pb2 import ObjectTree # pylint: disable=E0611
62
+ from flwr.supercore.ffs import Ffs, FfsFactory
56
63
  from flwr.supercore.object_store import ObjectStore, ObjectStoreFactory
57
64
  from flwr.supernode.nodestate import NodeState, NodeStateFactory
58
- from flwr.supernode.servicer.clientappio import ClientAppInputs, ClientAppIoServicer
65
+ from flwr.supernode.servicer.clientappio import ClientAppIoServicer
59
66
 
60
67
  DEFAULT_FFS_DIR = get_flwr_dir() / "supernode" / "ffs"
61
68
 
@@ -132,16 +139,27 @@ def start_client_internal(
132
139
  if insecure is None:
133
140
  insecure = root_certificates is None
134
141
 
135
- _clientappio_grpc_server, clientappio_servicer = run_clientappio_api_grpc(
136
- address=clientappio_api_address,
137
- certificates=None,
138
- )
139
-
140
142
  # Initialize factories
141
143
  state_factory = NodeStateFactory()
142
144
  ffs_factory = FfsFactory(get_flwr_dir(flwr_path) / "supernode" / "ffs") # type: ignore
143
145
  object_store_factory = ObjectStoreFactory()
144
146
 
147
+ # Launch ClientAppIo API server
148
+ clientappio_server = run_clientappio_api_grpc(
149
+ address=clientappio_api_address,
150
+ state_factory=state_factory,
151
+ ffs_factory=ffs_factory,
152
+ objectstore_factory=object_store_factory,
153
+ certificates=None,
154
+ )
155
+
156
+ # Register handlers for graceful shutdown
157
+ register_exit_handlers(
158
+ event_type=EventType.RUN_SUPERNODE_LEAVE,
159
+ exit_message="SuperNode terminated gracefully.",
160
+ grpc_servers=[clientappio_server],
161
+ )
162
+
145
163
  # Initialize NodeState, Ffs, and ObjectStore
146
164
  state = state_factory.state()
147
165
  ffs = ffs_factory.ffs()
@@ -156,7 +174,17 @@ def start_client_internal(
156
174
  max_retries=max_retries,
157
175
  max_wait_time=max_wait_time,
158
176
  ) as conn:
159
- receive, send, create_node, _, get_run, get_fab = conn
177
+ (
178
+ receive,
179
+ send,
180
+ create_node,
181
+ _,
182
+ get_run,
183
+ get_fab,
184
+ pull_object,
185
+ push_object,
186
+ confirm_message_received,
187
+ ) = conn
160
188
 
161
189
  # Call create_node fn to register node
162
190
  # and store node_id in state
@@ -176,106 +204,63 @@ def start_client_internal(
176
204
  receive=receive,
177
205
  get_run=get_run,
178
206
  get_fab=get_fab,
207
+ pull_object=pull_object,
208
+ confirm_message_received=confirm_message_received,
179
209
  )
180
210
 
211
+ # Two isolation modes:
212
+ # 1. `subprocess`: SuperNode is starting the ClientApp
213
+ # process as a subprocess.
214
+ # 2. `process`: ClientApp process gets started separately
215
+ # (via `flwr-clientapp`), for example, in a separate
216
+ # Docker container.
217
+
218
+ # Mode 1: SuperNode starts ClientApp as subprocess
219
+ start_subprocess = isolation == ISOLATION_MODE_SUBPROCESS
220
+
221
+ if start_subprocess and run_id is not None:
222
+ _octet, _colon, _port = clientappio_api_address.rpartition(":")
223
+ io_address = (
224
+ f"{CLIENT_OCTET}:{_port}"
225
+ if _octet == SERVER_OCTET
226
+ else clientappio_api_address
227
+ )
228
+ # Start ClientApp subprocess
229
+ command = [
230
+ "flwr-clientapp",
231
+ "--clientappio-api-address",
232
+ io_address,
233
+ "--parent-pid",
234
+ str(os.getpid()),
235
+ "--insecure",
236
+ "--run-once",
237
+ ]
238
+ subprocess.run(command, check=False)
239
+
240
+ # No message has been pulled therefore we can skip the push stage.
181
241
  if run_id is None:
182
- time.sleep(3) # Wait for 3s before asking again
242
+ # If no message was received, wait for a while
243
+ time.sleep(3)
183
244
  continue
184
245
 
185
- try:
186
- # Retrieve message, context, run and fab for this run
187
- message = state.get_messages(run_ids=[run_id], is_reply=False)[0]
188
- context = cast(Context, state.get_context(run_id))
189
- run = cast(Run, state.get_run(run_id))
190
- fab = Fab(run.fab_hash, ffs.get(run.fab_hash)[0]) # type: ignore
191
-
192
- # Two isolation modes:
193
- # 1. `subprocess`: SuperNode is starting the ClientApp
194
- # process as a subprocess.
195
- # 2. `process`: ClientApp process gets started separately
196
- # (via `flwr-clientapp`), for example, in a separate
197
- # Docker container.
198
-
199
- # Generate SuperNode token
200
- token = int.from_bytes(urandom(RUN_ID_NUM_BYTES), "little")
201
-
202
- # Mode 1: SuperNode starts ClientApp as subprocess
203
- start_subprocess = isolation == ISOLATION_MODE_SUBPROCESS
204
-
205
- # Share Message and Context with servicer
206
- clientappio_servicer.set_inputs(
207
- clientapp_input=ClientAppInputs(
208
- message=message,
209
- context=context,
210
- run=run,
211
- fab=fab,
212
- token=token,
213
- ),
214
- token_returned=start_subprocess,
215
- )
216
-
217
- if start_subprocess:
218
- _octet, _colon, _port = clientappio_api_address.rpartition(":")
219
- io_address = (
220
- f"{CLIENT_OCTET}:{_port}"
221
- if _octet == SERVER_OCTET
222
- else clientappio_api_address
223
- )
224
- # Start ClientApp subprocess
225
- command = [
226
- "flwr-clientapp",
227
- "--clientappio-api-address",
228
- io_address,
229
- "--token",
230
- str(token),
231
- "--parent-pid",
232
- str(os.getpid()),
233
- "--insecure",
234
- ]
235
- subprocess.run(command, check=False)
236
- else:
237
- # Wait for output to become available
238
- while not clientappio_servicer.has_outputs():
239
- time.sleep(0.1)
240
-
241
- outputs = clientappio_servicer.get_outputs()
242
- reply_message, context = outputs.message, outputs.context
243
-
244
- # Update context in the state
245
- state.store_context(context)
246
-
247
- # Send
248
- send(reply_message)
249
-
250
- # Delete messages from the state
251
- state.delete_messages(
252
- message_ids=[
253
- message.metadata.message_id,
254
- message.metadata.reply_to_message_id,
255
- ]
256
- )
257
-
258
- log(INFO, "Sent reply")
259
-
260
- except RunNotRunningException:
261
- log(INFO, "")
262
- log(
263
- INFO,
264
- "SuperNode aborted sending the reply message. "
265
- "Run ID %s is not in `RUNNING` status.",
266
- run_id,
267
- )
268
- log(INFO, "")
246
+ _push_messages(
247
+ state=state,
248
+ object_store=store,
249
+ send=send,
250
+ push_object=push_object,
251
+ )
269
252
 
270
253
 
271
254
  def _pull_and_store_message( # pylint: disable=too-many-positional-arguments
272
255
  state: NodeState,
273
256
  ffs: Ffs,
274
- object_store: ObjectStore, # pylint: disable=unused-argument
257
+ object_store: ObjectStore,
275
258
  node_config: UserConfig,
276
- receive: Callable[[], Optional[Message]],
259
+ receive: Callable[[], Optional[tuple[Message, ObjectTree]]],
277
260
  get_run: Callable[[int], Run],
278
261
  get_fab: Callable[[str, int], Fab],
262
+ pull_object: Callable[[int, str], bytes],
263
+ confirm_message_received: Callable[[int, str], None],
279
264
  ) -> Optional[int]:
280
265
  """Pull a message from the SuperLink and store it in the state.
281
266
 
@@ -287,8 +272,9 @@ def _pull_and_store_message( # pylint: disable=too-many-positional-arguments
287
272
  message = None
288
273
  try:
289
274
  # Pull message
290
- if (message := receive()) is None:
275
+ if (recv := receive()) is None:
291
276
  return None
277
+ message, object_tree = recv
292
278
 
293
279
  # Log message reception
294
280
  log(INFO, "")
@@ -332,8 +318,23 @@ def _pull_and_store_message( # pylint: disable=too-many-positional-arguments
332
318
  )
333
319
  state.store_context(run_ctx)
334
320
 
335
- # Store the message in the state
321
+ # Preregister the object tree of the message
322
+ obj_ids_to_pull = object_store.preregister(run_id, object_tree)
323
+
324
+ # Store the message in the state (note this message has no content)
336
325
  state.store_message(message)
326
+
327
+ # Pull and store objects of the message in the ObjectStore
328
+ obj_contents = pull_objects(
329
+ obj_ids_to_pull,
330
+ pull_object_fn=lambda obj_id: pull_object(run_id, obj_id),
331
+ )
332
+ for obj_id in list(obj_contents.keys()):
333
+ object_store.put(obj_id, obj_contents.pop(obj_id))
334
+
335
+ # Confirm that the message was received
336
+ confirm_message_received(run_id, message.metadata.message_id)
337
+
337
338
  except RunNotRunningException:
338
339
  if message is None:
339
340
  log(
@@ -353,6 +354,93 @@ def _pull_and_store_message( # pylint: disable=too-many-positional-arguments
353
354
  return run_id
354
355
 
355
356
 
357
+ def _push_messages(
358
+ state: NodeState,
359
+ object_store: ObjectStore,
360
+ send: Callable[[Message, ObjectTree], set[str]],
361
+ push_object: Callable[[int, str, bytes], None],
362
+ ) -> None:
363
+ """Push reply messages to the SuperLink."""
364
+ # This is to ensure that only one message is processed at a time
365
+ # Wait until a reply message is available
366
+ while not (reply_messages := state.get_messages(is_reply=True)):
367
+ time.sleep(0.5)
368
+
369
+ for message in reply_messages:
370
+ # Log message sending
371
+ log(INFO, "")
372
+ if message.metadata.group_id:
373
+ log(
374
+ INFO,
375
+ "[RUN %s, ROUND %s]",
376
+ message.metadata.run_id,
377
+ message.metadata.group_id,
378
+ )
379
+ else:
380
+ log(INFO, "[RUN %s]", message.metadata.run_id)
381
+ log(
382
+ INFO,
383
+ "Sending: %s message",
384
+ message.metadata.message_type,
385
+ )
386
+
387
+ # Get the object tree for the message
388
+ object_tree = object_store.get_object_tree(message.metadata.message_id)
389
+
390
+ # Define the iterator for yielding object contents
391
+ # This will yield (object_id, content) pairs
392
+ def yield_object_contents(
393
+ _obj_tree: ObjectTree, obj_id_set: set[str]
394
+ ) -> Iterator[tuple[str, bytes]]:
395
+ for tree in iterate_object_tree(_obj_tree):
396
+ if tree.object_id not in obj_id_set:
397
+ continue
398
+ while (content := object_store.get(tree.object_id)) == b"":
399
+ # Wait for the content to be available
400
+ time.sleep(0.5)
401
+ # At this point, content is guaranteed to be available
402
+ # therefore we can yield it after casting it to bytes
403
+ yield tree.object_id, cast(bytes, content)
404
+
405
+ # Send the message
406
+ try:
407
+ # Send the reply message with its ObjectTree
408
+ # Get the IDs of objects to send
409
+ ids_obj_to_send = send(message, object_tree)
410
+
411
+ # Push object contents from the ObjectStore
412
+ run_id = message.metadata.run_id
413
+ push_object_contents_from_iterable(
414
+ yield_object_contents(object_tree, ids_obj_to_send),
415
+ # Use functools.partial to bind run_id explicitly,
416
+ # avoiding late binding issues and satisfying flake8 (B023)
417
+ # Equivalent to:
418
+ # lambda object_id, content: push_object(run_id, object_id, content)
419
+ push_object_fn=partial(push_object, run_id),
420
+ )
421
+ log(INFO, "Sent successfully")
422
+ except RunNotRunningException:
423
+ log(
424
+ INFO,
425
+ "Run ID %s is not in `RUNNING` status. Ignoring reply message %s.",
426
+ message.metadata.run_id,
427
+ message.metadata.message_id,
428
+ )
429
+ finally:
430
+ # Delete the message from the state
431
+ state.delete_messages(
432
+ message_ids=[
433
+ message.metadata.message_id,
434
+ message.metadata.reply_to_message_id,
435
+ ]
436
+ )
437
+
438
+ # Delete all its objects from the ObjectStore
439
+ # No need to delete objects of the message it replies to, as it is
440
+ # already deleted when the ClientApp calls `ConfirmMessageReceived`
441
+ object_store.delete(message.metadata.message_id)
442
+
443
+
356
444
  @contextmanager
357
445
  def _init_connection( # pylint: disable=too-many-positional-arguments
358
446
  transport: str,
@@ -366,12 +454,15 @@ def _init_connection( # pylint: disable=too-many-positional-arguments
366
454
  max_wait_time: Optional[float] = None,
367
455
  ) -> Iterator[
368
456
  tuple[
369
- Callable[[], Optional[Message]],
370
- Callable[[Message], None],
457
+ Callable[[], Optional[tuple[Message, ObjectTree]]],
458
+ Callable[[Message, ObjectTree], set[str]],
371
459
  Callable[[], Optional[int]],
372
460
  Callable[[], None],
373
461
  Callable[[int], Run],
374
462
  Callable[[str, int], Fab],
463
+ Callable[[int, str], bytes],
464
+ Callable[[int, str, bytes], None],
465
+ Callable[[int, str], None],
375
466
  ]
376
467
  ]:
377
468
  """Establish a connection to the Fleet API server at SuperLink."""
@@ -472,10 +563,17 @@ def _make_fleet_connection_retry_invoker(
472
563
 
473
564
  def run_clientappio_api_grpc(
474
565
  address: str,
566
+ state_factory: NodeStateFactory,
567
+ ffs_factory: FfsFactory,
568
+ objectstore_factory: ObjectStoreFactory,
475
569
  certificates: Optional[tuple[bytes, bytes, bytes]],
476
- ) -> tuple[grpc.Server, ClientAppIoServicer]:
570
+ ) -> grpc.Server:
477
571
  """Run ClientAppIo API gRPC server."""
478
- clientappio_servicer: grpc.Server = ClientAppIoServicer()
572
+ clientappio_servicer: grpc.Server = ClientAppIoServicer(
573
+ state_factory=state_factory,
574
+ ffs_factory=ffs_factory,
575
+ objectstore_factory=objectstore_factory,
576
+ )
479
577
  clientappio_add_servicer_to_server_fn = add_ClientAppIoServicer_to_server
480
578
  clientappio_grpc_server = generic_create_grpc_server(
481
579
  servicer_and_add_fn=(
@@ -488,4 +586,4 @@ def run_clientappio_api_grpc(
488
586
  )
489
587
  log(INFO, "Starting Flower ClientAppIo gRPC server on %s", address)
490
588
  clientappio_grpc_server.start()
491
- return clientappio_grpc_server, clientappio_servicer
589
+ return clientappio_grpc_server
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: flwr
3
- Version: 1.19.0
3
+ Version: 1.20.0
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
@@ -34,6 +34,7 @@ Provides-Extra: simulation
34
34
  Requires-Dist: click (<8.2.0)
35
35
  Requires-Dist: cryptography (>=44.0.1,<45.0.0)
36
36
  Requires-Dist: grpcio (>=1.62.3,<2.0.0,!=1.65.0)
37
+ Requires-Dist: grpcio-health-checking (>=1.62.3,<2.0.0)
37
38
  Requires-Dist: iterators (>=0.0.2,<0.0.3)
38
39
  Requires-Dist: numpy (>=1.26.0,<3.0.0)
39
40
  Requires-Dist: pathspec (>=0.12.1,<0.13.0)