modal 1.1.1.dev41__tar.gz → 1.1.2__tar.gz

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 modal might be problematic. Click here for more details.

Files changed (194) hide show
  1. {modal-1.1.1.dev41 → modal-1.1.2}/PKG-INFO +2 -2
  2. {modal-1.1.1.dev41 → modal-1.1.2}/modal/__main__.py +1 -2
  3. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_container_entrypoint.py +18 -7
  4. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_functions.py +135 -13
  5. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_object.py +13 -2
  6. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_partial_function.py +8 -8
  7. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_runtime/asgi.py +3 -2
  8. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_runtime/container_io_manager.py +20 -14
  9. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_runtime/container_io_manager.pyi +38 -13
  10. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_runtime/execution_context.py +18 -2
  11. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_runtime/execution_context.pyi +4 -1
  12. modal-1.1.2/modal/_runtime/gpu_memory_snapshot.py +303 -0
  13. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/blob_utils.py +83 -24
  14. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/function_utils.py +4 -3
  15. modal-1.1.2/modal/_utils/time_utils.py +43 -0
  16. {modal-1.1.1.dev41 → modal-1.1.2}/modal/app.py +8 -4
  17. {modal-1.1.1.dev41 → modal-1.1.2}/modal/app.pyi +8 -8
  18. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/dict.py +14 -11
  19. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/entry_point.py +9 -3
  20. modal-1.1.2/modal/cli/launch.py +195 -0
  21. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/profile.py +1 -0
  22. modal-1.1.2/modal/cli/programs/launch_instance_ssh.py +94 -0
  23. modal-1.1.2/modal/cli/programs/run_marimo.py +95 -0
  24. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/queues.py +49 -19
  25. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/secret.py +45 -18
  26. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/volume.py +14 -16
  27. {modal-1.1.1.dev41 → modal-1.1.2}/modal/client.pyi +2 -10
  28. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cls.py +12 -2
  29. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cls.pyi +9 -1
  30. {modal-1.1.1.dev41 → modal-1.1.2}/modal/config.py +7 -7
  31. {modal-1.1.1.dev41 → modal-1.1.2}/modal/dict.py +206 -12
  32. {modal-1.1.1.dev41 → modal-1.1.2}/modal/dict.pyi +358 -4
  33. {modal-1.1.1.dev41 → modal-1.1.2}/modal/experimental/__init__.py +130 -0
  34. {modal-1.1.1.dev41 → modal-1.1.2}/modal/file_io.py +1 -1
  35. {modal-1.1.1.dev41 → modal-1.1.2}/modal/file_io.pyi +2 -2
  36. {modal-1.1.1.dev41 → modal-1.1.2}/modal/file_pattern_matcher.py +25 -16
  37. {modal-1.1.1.dev41 → modal-1.1.2}/modal/functions.pyi +111 -11
  38. {modal-1.1.1.dev41 → modal-1.1.2}/modal/image.py +9 -3
  39. {modal-1.1.1.dev41 → modal-1.1.2}/modal/image.pyi +7 -7
  40. {modal-1.1.1.dev41 → modal-1.1.2}/modal/mount.py +20 -13
  41. {modal-1.1.1.dev41 → modal-1.1.2}/modal/mount.pyi +16 -3
  42. {modal-1.1.1.dev41 → modal-1.1.2}/modal/network_file_system.py +8 -2
  43. {modal-1.1.1.dev41 → modal-1.1.2}/modal/object.pyi +3 -0
  44. {modal-1.1.1.dev41 → modal-1.1.2}/modal/parallel_map.py +346 -101
  45. {modal-1.1.1.dev41 → modal-1.1.2}/modal/parallel_map.pyi +108 -0
  46. {modal-1.1.1.dev41 → modal-1.1.2}/modal/proxy.py +2 -1
  47. {modal-1.1.1.dev41 → modal-1.1.2}/modal/queue.py +199 -9
  48. {modal-1.1.1.dev41 → modal-1.1.2}/modal/queue.pyi +357 -3
  49. {modal-1.1.1.dev41 → modal-1.1.2}/modal/sandbox.py +6 -5
  50. {modal-1.1.1.dev41 → modal-1.1.2}/modal/sandbox.pyi +17 -14
  51. {modal-1.1.1.dev41 → modal-1.1.2}/modal/secret.py +196 -3
  52. modal-1.1.2/modal/secret.pyi +669 -0
  53. {modal-1.1.1.dev41 → modal-1.1.2}/modal/volume.py +239 -23
  54. {modal-1.1.1.dev41 → modal-1.1.2}/modal/volume.pyi +405 -10
  55. {modal-1.1.1.dev41 → modal-1.1.2}/modal.egg-info/PKG-INFO +2 -2
  56. {modal-1.1.1.dev41 → modal-1.1.2}/modal.egg-info/SOURCES.txt +2 -0
  57. {modal-1.1.1.dev41 → modal-1.1.2}/modal.egg-info/requires.txt +1 -1
  58. {modal-1.1.1.dev41 → modal-1.1.2}/modal_docs/mdmd/mdmd.py +11 -1
  59. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/api.proto +37 -10
  60. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/api_grpc.py +32 -0
  61. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/api_pb2.py +627 -597
  62. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/api_pb2.pyi +107 -19
  63. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/api_pb2_grpc.py +67 -2
  64. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/api_pb2_grpc.pyi +24 -8
  65. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/modal_api_grpc.py +2 -0
  66. {modal-1.1.1.dev41 → modal-1.1.2}/modal_version/__init__.py +1 -1
  67. {modal-1.1.1.dev41 → modal-1.1.2}/pyproject.toml +1 -1
  68. modal-1.1.1.dev41/modal/_runtime/gpu_memory_snapshot.py +0 -199
  69. modal-1.1.1.dev41/modal/_utils/time_utils.py +0 -19
  70. modal-1.1.1.dev41/modal/cli/launch.py +0 -97
  71. modal-1.1.1.dev41/modal/secret.pyi +0 -297
  72. {modal-1.1.1.dev41 → modal-1.1.2}/LICENSE +0 -0
  73. {modal-1.1.1.dev41 → modal-1.1.2}/README.md +0 -0
  74. {modal-1.1.1.dev41 → modal-1.1.2}/modal/__init__.py +0 -0
  75. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_clustered_functions.py +0 -0
  76. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_clustered_functions.pyi +0 -0
  77. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_ipython.py +0 -0
  78. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_location.py +0 -0
  79. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_output.py +0 -0
  80. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_pty.py +0 -0
  81. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_resolver.py +0 -0
  82. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_resources.py +0 -0
  83. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_runtime/__init__.py +0 -0
  84. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_runtime/telemetry.py +0 -0
  85. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_runtime/user_code_imports.py +0 -0
  86. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_serialization.py +0 -0
  87. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_traceback.py +0 -0
  88. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_tunnel.py +0 -0
  89. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_tunnel.pyi +0 -0
  90. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_type_manager.py +0 -0
  91. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/__init__.py +0 -0
  92. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/app_utils.py +0 -0
  93. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/async_utils.py +0 -0
  94. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/auth_token_manager.py +0 -0
  95. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/bytes_io_segment_payload.py +0 -0
  96. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/deprecation.py +0 -0
  97. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/docker_utils.py +0 -0
  98. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/git_utils.py +0 -0
  99. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/grpc_testing.py +0 -0
  100. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/grpc_utils.py +0 -0
  101. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/hash_utils.py +0 -0
  102. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/http_utils.py +0 -0
  103. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/jwt_utils.py +0 -0
  104. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/logger.py +0 -0
  105. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/mount_utils.py +0 -0
  106. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/name_utils.py +0 -0
  107. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/package_utils.py +0 -0
  108. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/pattern_utils.py +0 -0
  109. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/rand_pb_testing.py +0 -0
  110. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_utils/shell_utils.py +0 -0
  111. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_vendor/__init__.py +0 -0
  112. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  113. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_vendor/cloudpickle.py +0 -0
  114. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_vendor/tblib.py +0 -0
  115. {modal-1.1.1.dev41 → modal-1.1.2}/modal/_watcher.py +0 -0
  116. {modal-1.1.1.dev41 → modal-1.1.2}/modal/builder/2023.12.312.txt +0 -0
  117. {modal-1.1.1.dev41 → modal-1.1.2}/modal/builder/2023.12.txt +0 -0
  118. {modal-1.1.1.dev41 → modal-1.1.2}/modal/builder/2024.04.txt +0 -0
  119. {modal-1.1.1.dev41 → modal-1.1.2}/modal/builder/2024.10.txt +0 -0
  120. {modal-1.1.1.dev41 → modal-1.1.2}/modal/builder/2025.06.txt +0 -0
  121. {modal-1.1.1.dev41 → modal-1.1.2}/modal/builder/PREVIEW.txt +0 -0
  122. {modal-1.1.1.dev41 → modal-1.1.2}/modal/builder/README.md +0 -0
  123. {modal-1.1.1.dev41 → modal-1.1.2}/modal/builder/base-images.json +0 -0
  124. {modal-1.1.1.dev41 → modal-1.1.2}/modal/call_graph.py +0 -0
  125. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/__init__.py +0 -0
  126. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/_download.py +0 -0
  127. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/_traceback.py +0 -0
  128. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/app.py +0 -0
  129. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/cluster.py +0 -0
  130. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/config.py +0 -0
  131. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/container.py +0 -0
  132. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/environment.py +0 -0
  133. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/import_refs.py +0 -0
  134. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/network_file_system.py +0 -0
  135. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/programs/__init__.py +0 -0
  136. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/programs/run_jupyter.py +0 -0
  137. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/programs/vscode.py +0 -0
  138. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/run.py +0 -0
  139. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/token.py +0 -0
  140. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cli/utils.py +0 -0
  141. {modal-1.1.1.dev41 → modal-1.1.2}/modal/client.py +0 -0
  142. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cloud_bucket_mount.py +0 -0
  143. {modal-1.1.1.dev41 → modal-1.1.2}/modal/cloud_bucket_mount.pyi +0 -0
  144. {modal-1.1.1.dev41 → modal-1.1.2}/modal/container_process.py +0 -0
  145. {modal-1.1.1.dev41 → modal-1.1.2}/modal/container_process.pyi +0 -0
  146. {modal-1.1.1.dev41 → modal-1.1.2}/modal/environments.py +0 -0
  147. {modal-1.1.1.dev41 → modal-1.1.2}/modal/environments.pyi +0 -0
  148. {modal-1.1.1.dev41 → modal-1.1.2}/modal/exception.py +0 -0
  149. {modal-1.1.1.dev41 → modal-1.1.2}/modal/experimental/flash.py +0 -0
  150. {modal-1.1.1.dev41 → modal-1.1.2}/modal/experimental/flash.pyi +0 -0
  151. {modal-1.1.1.dev41 → modal-1.1.2}/modal/experimental/ipython.py +0 -0
  152. {modal-1.1.1.dev41 → modal-1.1.2}/modal/functions.py +0 -0
  153. {modal-1.1.1.dev41 → modal-1.1.2}/modal/gpu.py +0 -0
  154. {modal-1.1.1.dev41 → modal-1.1.2}/modal/io_streams.py +0 -0
  155. {modal-1.1.1.dev41 → modal-1.1.2}/modal/io_streams.pyi +0 -0
  156. {modal-1.1.1.dev41 → modal-1.1.2}/modal/network_file_system.pyi +0 -0
  157. {modal-1.1.1.dev41 → modal-1.1.2}/modal/object.py +0 -0
  158. {modal-1.1.1.dev41 → modal-1.1.2}/modal/output.py +0 -0
  159. {modal-1.1.1.dev41 → modal-1.1.2}/modal/partial_function.py +0 -0
  160. {modal-1.1.1.dev41 → modal-1.1.2}/modal/partial_function.pyi +0 -0
  161. {modal-1.1.1.dev41 → modal-1.1.2}/modal/proxy.pyi +0 -0
  162. {modal-1.1.1.dev41 → modal-1.1.2}/modal/py.typed +0 -0
  163. {modal-1.1.1.dev41 → modal-1.1.2}/modal/retries.py +0 -0
  164. {modal-1.1.1.dev41 → modal-1.1.2}/modal/runner.py +0 -0
  165. {modal-1.1.1.dev41 → modal-1.1.2}/modal/runner.pyi +0 -0
  166. {modal-1.1.1.dev41 → modal-1.1.2}/modal/running_app.py +0 -0
  167. {modal-1.1.1.dev41 → modal-1.1.2}/modal/schedule.py +0 -0
  168. {modal-1.1.1.dev41 → modal-1.1.2}/modal/scheduler_placement.py +0 -0
  169. {modal-1.1.1.dev41 → modal-1.1.2}/modal/serving.py +0 -0
  170. {modal-1.1.1.dev41 → modal-1.1.2}/modal/serving.pyi +0 -0
  171. {modal-1.1.1.dev41 → modal-1.1.2}/modal/snapshot.py +0 -0
  172. {modal-1.1.1.dev41 → modal-1.1.2}/modal/snapshot.pyi +0 -0
  173. {modal-1.1.1.dev41 → modal-1.1.2}/modal/stream_type.py +0 -0
  174. {modal-1.1.1.dev41 → modal-1.1.2}/modal/token_flow.py +0 -0
  175. {modal-1.1.1.dev41 → modal-1.1.2}/modal/token_flow.pyi +0 -0
  176. {modal-1.1.1.dev41 → modal-1.1.2}/modal.egg-info/dependency_links.txt +0 -0
  177. {modal-1.1.1.dev41 → modal-1.1.2}/modal.egg-info/entry_points.txt +0 -0
  178. {modal-1.1.1.dev41 → modal-1.1.2}/modal.egg-info/top_level.txt +0 -0
  179. {modal-1.1.1.dev41 → modal-1.1.2}/modal_docs/__init__.py +0 -0
  180. {modal-1.1.1.dev41 → modal-1.1.2}/modal_docs/gen_cli_docs.py +0 -0
  181. {modal-1.1.1.dev41 → modal-1.1.2}/modal_docs/gen_reference_docs.py +0 -0
  182. {modal-1.1.1.dev41 → modal-1.1.2}/modal_docs/mdmd/__init__.py +0 -0
  183. {modal-1.1.1.dev41 → modal-1.1.2}/modal_docs/mdmd/signatures.py +0 -0
  184. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/__init__.py +0 -0
  185. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/modal_options_grpc.py +0 -0
  186. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/options.proto +0 -0
  187. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/options_grpc.py +0 -0
  188. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/options_pb2.py +0 -0
  189. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/options_pb2.pyi +0 -0
  190. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/options_pb2_grpc.py +0 -0
  191. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/options_pb2_grpc.pyi +0 -0
  192. {modal-1.1.1.dev41 → modal-1.1.2}/modal_proto/py.typed +0 -0
  193. {modal-1.1.1.dev41 → modal-1.1.2}/modal_version/__main__.py +0 -0
  194. {modal-1.1.1.dev41 → modal-1.1.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.1.1.dev41
3
+ Version: 1.1.2
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License: Apache-2.0
@@ -22,7 +22,7 @@ Requires-Dist: click~=8.1
22
22
  Requires-Dist: grpclib<0.4.9,>=0.4.7
23
23
  Requires-Dist: protobuf!=4.24.0,<7.0,>=3.19
24
24
  Requires-Dist: rich>=12.0.0
25
- Requires-Dist: synchronicity~=0.10.1
25
+ Requires-Dist: synchronicity~=0.10.2
26
26
  Requires-Dist: toml
27
27
  Requires-Dist: typer>=0.9
28
28
  Requires-Dist: types-certifi
@@ -37,7 +37,6 @@ def main():
37
37
 
38
38
  from grpclib import GRPCError, Status
39
39
  from rich.panel import Panel
40
- from rich.text import Text
41
40
 
42
41
  if isinstance(exc, GRPCError):
43
42
  status_map = {
@@ -69,7 +68,7 @@ def main():
69
68
  content = f"{content}\n\nNote: {' '.join(notes)}"
70
69
 
71
70
  console = make_console(stderr=True)
72
- panel = Panel(Text(content), title=title, title_align="left", border_style="red")
71
+ panel = Panel(content, title=title, title_align="left", border_style="red")
73
72
  console.print(panel, highlight=False)
74
73
  sys.exit(1)
75
74
 
@@ -185,8 +185,9 @@ def call_function(
185
185
  ):
186
186
  async def run_input_async(io_context: IOContext) -> None:
187
187
  started_at = time.time()
188
- input_ids, function_call_ids = io_context.input_ids, io_context.function_call_ids
189
- reset_context = execution_context._set_current_context_ids(input_ids, function_call_ids)
188
+ reset_context = execution_context._set_current_context_ids(
189
+ io_context.input_ids, io_context.function_call_ids, io_context.attempt_tokens
190
+ )
190
191
  async with container_io_manager.handle_input_exception.aio(io_context, started_at):
191
192
  res = io_context.call_finalized_function()
192
193
  # TODO(erikbern): any exception below shouldn't be considered a user exception
@@ -195,9 +196,14 @@ def call_function(
195
196
  raise InvalidError(f"Async generator function returned value of type {type(res)}")
196
197
 
197
198
  # Send up to this many outputs at a time.
199
+ current_function_call_id = execution_context.current_function_call_id()
200
+ assert current_function_call_id is not None # Set above.
201
+ current_attempt_token = execution_context.current_attempt_token()
202
+ assert current_attempt_token is not None # Set above, but can be empty string.
198
203
  generator_queue: asyncio.Queue[Any] = await container_io_manager._queue_create.aio(1024)
199
204
  async with container_io_manager.generator_output_sender(
200
- function_call_ids[0],
205
+ current_function_call_id,
206
+ current_attempt_token,
201
207
  io_context.finalized_function.data_format,
202
208
  generator_queue,
203
209
  ):
@@ -230,8 +236,9 @@ def call_function(
230
236
 
231
237
  def run_input_sync(io_context: IOContext) -> None:
232
238
  started_at = time.time()
233
- input_ids, function_call_ids = io_context.input_ids, io_context.function_call_ids
234
- reset_context = execution_context._set_current_context_ids(input_ids, function_call_ids)
239
+ reset_context = execution_context._set_current_context_ids(
240
+ io_context.input_ids, io_context.function_call_ids, io_context.attempt_tokens
241
+ )
235
242
  with container_io_manager.handle_input_exception(io_context, started_at):
236
243
  res = io_context.call_finalized_function()
237
244
 
@@ -241,10 +248,14 @@ def call_function(
241
248
  raise InvalidError(f"Generator function returned value of type {type(res)}")
242
249
 
243
250
  # Send up to this many outputs at a time.
251
+ current_function_call_id = execution_context.current_function_call_id()
252
+ assert current_function_call_id is not None # Set above.
253
+ current_attempt_token = execution_context.current_attempt_token()
254
+ assert current_attempt_token is not None # Set above, but can be empty string.
244
255
  generator_queue: asyncio.Queue[Any] = container_io_manager._queue_create(1024)
245
-
246
256
  with container_io_manager.generator_output_sender(
247
- function_call_ids[0],
257
+ current_function_call_id,
258
+ current_attempt_token,
248
259
  io_context.finalized_function.data_format,
249
260
  generator_queue,
250
261
  ):
@@ -9,7 +9,7 @@ import warnings
9
9
  from collections.abc import AsyncGenerator, Sequence, Sized
10
10
  from dataclasses import dataclass
11
11
  from pathlib import PurePosixPath
12
- from typing import TYPE_CHECKING, Any, Callable, Optional, Union
12
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Optional, Union
13
13
 
14
14
  import typing_extensions
15
15
  from google.protobuf.message import Message
@@ -71,6 +71,8 @@ from .mount import _get_client_mount, _Mount
71
71
  from .network_file_system import _NetworkFileSystem, network_file_system_mount_protos
72
72
  from .output import _get_output_manager
73
73
  from .parallel_map import (
74
+ _experimental_spawn_map_async,
75
+ _experimental_spawn_map_sync,
74
76
  _for_each_async,
75
77
  _for_each_sync,
76
78
  _map_async,
@@ -78,6 +80,7 @@ from .parallel_map import (
78
80
  _map_invocation_inputplane,
79
81
  _map_sync,
80
82
  _spawn_map_async,
83
+ _spawn_map_invocation,
81
84
  _spawn_map_sync,
82
85
  _starmap_async,
83
86
  _starmap_sync,
@@ -214,7 +217,11 @@ class _Invocation:
214
217
  return _Invocation(stub, function_call_id, client, retry_context)
215
218
 
216
219
  async def pop_function_call_outputs(
217
- self, timeout: Optional[float], clear_on_success: bool, input_jwts: Optional[list[str]] = None
220
+ self,
221
+ index: int = 0,
222
+ timeout: Optional[float] = None,
223
+ clear_on_success: bool = False,
224
+ input_jwts: Optional[list[str]] = None,
218
225
  ) -> api_pb2.FunctionGetOutputsResponse:
219
226
  t0 = time.time()
220
227
  if timeout is None:
@@ -232,6 +239,8 @@ class _Invocation:
232
239
  clear_on_success=clear_on_success,
233
240
  requested_at=time.time(),
234
241
  input_jwts=input_jwts,
242
+ start_idx=index,
243
+ end_idx=index,
235
244
  )
236
245
  response: api_pb2.FunctionGetOutputsResponse = await retry_transient_errors(
237
246
  self.stub.FunctionGetOutputs,
@@ -265,6 +274,7 @@ class _Invocation:
265
274
  # waits indefinitely for a single result for the function, and clear the outputs buffer after
266
275
  item: api_pb2.FunctionGetOutputsItem = (
267
276
  await self.pop_function_call_outputs(
277
+ index=0,
268
278
  timeout=None,
269
279
  clear_on_success=True,
270
280
  input_jwts=[expected_jwt] if expected_jwt else None,
@@ -308,14 +318,16 @@ class _Invocation:
308
318
 
309
319
  await self._retry_input()
310
320
 
311
- async def poll_function(self, timeout: Optional[float] = None):
321
+ async def poll_function(self, timeout: Optional[float] = None, *, index: int = 0):
312
322
  """Waits up to timeout for a result from a function.
313
323
 
314
324
  If timeout is `None`, waits indefinitely. This function is not
315
325
  cancellation-safe.
316
326
  """
317
327
  response: api_pb2.FunctionGetOutputsResponse = await self.pop_function_call_outputs(
318
- timeout=timeout, clear_on_success=False
328
+ index=index,
329
+ timeout=timeout,
330
+ clear_on_success=False,
319
331
  )
320
332
  if len(response.outputs) == 0 and response.num_unfinished_inputs == 0:
321
333
  # if no unfinished inputs and no outputs, then function expired
@@ -348,11 +360,47 @@ class _Invocation:
348
360
  if items_total is not None and items_received >= items_total:
349
361
  break
350
362
 
363
+ async def enumerate(self, start_index: int, end_index: int):
364
+ """Iterate over the results of the function call in the range [start_index, end_index)."""
365
+ limit = 49
366
+ current_index = start_index
367
+ while current_index < end_index:
368
+ # batch_end_indx is inclusive, so we subtract 1 to get the last index in the batch.
369
+ batch_end_index = min(current_index + limit, end_index) - 1
370
+ request = api_pb2.FunctionGetOutputsRequest(
371
+ function_call_id=self.function_call_id,
372
+ timeout=0,
373
+ last_entry_id="0-0",
374
+ clear_on_success=False,
375
+ requested_at=time.time(),
376
+ start_idx=current_index,
377
+ end_idx=batch_end_index,
378
+ )
379
+ response: api_pb2.FunctionGetOutputsResponse = await retry_transient_errors(
380
+ self.stub.FunctionGetOutputs,
381
+ request,
382
+ attempt_timeout=ATTEMPT_TIMEOUT_GRACE_PERIOD,
383
+ )
384
+
385
+ outputs = list(response.outputs)
386
+ outputs.sort(key=lambda x: x.idx)
387
+ for output in outputs:
388
+ if output.idx != current_index:
389
+ break
390
+ result = await _process_result(output.result, output.data_format, self.stub, self.client)
391
+ yield output.idx, result
392
+ current_index += 1
393
+
394
+ # We're missing current_index, so we need to poll the function for the next result
395
+ if len(outputs) < (batch_end_index - current_index + 1):
396
+ result = await self.poll_function(index=current_index)
397
+ yield current_index, result
398
+ current_index += 1
399
+
351
400
 
352
401
  class _InputPlaneInvocation:
353
402
  """Internal client representation of a single-input call to a Modal Function using the input
354
- plane server API. As of 4/22/2025, this class is experimental and not used in production.
355
- It is OK to make breaking changes to this class."""
403
+ plane server API."""
356
404
 
357
405
  stub: ModalClientModal
358
406
 
@@ -462,7 +510,7 @@ class _InputPlaneInvocation:
462
510
  _stream_function_call_data(
463
511
  self.client,
464
512
  self.stub,
465
- "",
513
+ function_call_id=None,
466
514
  variant="data_out",
467
515
  attempt_token=self.attempt_token,
468
516
  ),
@@ -603,7 +651,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
603
651
  memory: Optional[Union[int, tuple[int, int]]] = None,
604
652
  proxy: Optional[_Proxy] = None,
605
653
  retries: Optional[Union[int, Retries]] = None,
606
- timeout: Optional[int] = None,
654
+ timeout: int = 300,
607
655
  min_containers: Optional[int] = None,
608
656
  max_containers: Optional[int] = None,
609
657
  buffer_containers: Optional[int] = None,
@@ -1130,6 +1178,8 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1130
1178
  target_concurrent_inputs=options.target_concurrent_inputs,
1131
1179
  batch_max_size=options.batch_max_size,
1132
1180
  batch_linger_ms=options.batch_wait_ms,
1181
+ scheduler_placement=options.scheduler_placement,
1182
+ cloud_provider_str=options.cloud,
1133
1183
  )
1134
1184
  else:
1135
1185
  options_pb = None
@@ -1274,7 +1324,8 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1274
1324
 
1275
1325
  self._hydrate(response.function_id, resolver.client, response.handle_metadata)
1276
1326
 
1277
- rep = f"Function.from_name('{app_name}', '{name}')"
1327
+ environment_rep = f", environment_name={environment_name!r}" if environment_name else ""
1328
+ rep = f"modal.Function.from_name('{app_name}', '{name}'{environment_rep})"
1278
1329
  return cls._from_loader(_load_remote, rep, is_another_app=True, hydrate_lazily=True)
1279
1330
 
1280
1331
  @classmethod
@@ -1543,6 +1594,22 @@ Use the `Function.get_web_url()` method instead.
1543
1594
  async for item in stream:
1544
1595
  yield item
1545
1596
 
1597
+ @live_method
1598
+ async def _spawn_map(self, input_queue: _SynchronizedQueue) -> "_FunctionCall[ReturnType]":
1599
+ self._check_no_web_url("spawn_map")
1600
+ if self._is_generator:
1601
+ raise InvalidError("A generator function cannot be called with `.spawn_map(...)`.")
1602
+
1603
+ assert self._function_name
1604
+ function_call_id, num_inputs = await _spawn_map_invocation(
1605
+ self,
1606
+ input_queue,
1607
+ self.client,
1608
+ )
1609
+ metadata = api_pb2.FunctionCallFromIdResponse(function_call_id=function_call_id, num_inputs=num_inputs)
1610
+ fc: _FunctionCall[ReturnType] = _FunctionCall._new_hydrated(function_call_id, self.client, metadata)
1611
+ return fc
1612
+
1546
1613
  async def _call_function(self, args, kwargs) -> ReturnType:
1547
1614
  invocation: Union[_Invocation, _InputPlaneInvocation]
1548
1615
  if self._input_plane_url:
@@ -1789,6 +1856,7 @@ Use the `Function.get_web_url()` method instead.
1789
1856
  starmap = MethodWithAio(_starmap_sync, _starmap_async, synchronizer)
1790
1857
  for_each = MethodWithAio(_for_each_sync, _for_each_async, synchronizer)
1791
1858
  spawn_map = MethodWithAio(_spawn_map_sync, _spawn_map_async, synchronizer)
1859
+ experimental_spawn_map = MethodWithAio(_experimental_spawn_map_sync, _experimental_spawn_map_async, synchronizer)
1792
1860
 
1793
1861
 
1794
1862
  class _FunctionCall(typing.Generic[ReturnType], _Object, type_prefix="fc"):
@@ -1803,12 +1871,28 @@ class _FunctionCall(typing.Generic[ReturnType], _Object, type_prefix="fc"):
1803
1871
  """
1804
1872
 
1805
1873
  _is_generator: bool = False
1874
+ _num_inputs: Optional[int] = None
1806
1875
 
1807
1876
  def _invocation(self):
1808
1877
  return _Invocation(self.client.stub, self.object_id, self.client)
1809
1878
 
1810
- async def get(self, timeout: Optional[float] = None) -> ReturnType:
1811
- """Get the result of the function call.
1879
+ def _hydrate_metadata(self, metadata: Optional[Message]):
1880
+ if not metadata:
1881
+ return
1882
+ assert isinstance(metadata, api_pb2.FunctionCallFromIdResponse)
1883
+ self._num_inputs = metadata.num_inputs
1884
+
1885
+ @live_method
1886
+ async def num_inputs(self) -> int:
1887
+ """Get the number of inputs in the function call."""
1888
+ # Should have been hydrated.
1889
+ assert self._num_inputs is not None
1890
+ return self._num_inputs
1891
+
1892
+ async def get(self, timeout: Optional[float] = None, *, index: int = 0) -> ReturnType:
1893
+ """Get the result of the index-th input of the function call.
1894
+ `.spawn()` calls have a single output, so only specifying `index=0` is valid.
1895
+ A non-zero index is useful when your function has multiple outputs, like via `.spawn_map()`.
1812
1896
 
1813
1897
  This function waits indefinitely by default. It takes an optional
1814
1898
  `timeout` argument that specifies the maximum number of seconds to wait,
@@ -1816,7 +1900,37 @@ class _FunctionCall(typing.Generic[ReturnType], _Object, type_prefix="fc"):
1816
1900
 
1817
1901
  The returned coroutine is not cancellation-safe.
1818
1902
  """
1819
- return await self._invocation().poll_function(timeout=timeout)
1903
+ return await self._invocation().poll_function(timeout=timeout, index=index)
1904
+
1905
+ @live_method_gen
1906
+ async def iter(self, *, start: int = 0, end: Optional[int] = None) -> AsyncIterator[ReturnType]:
1907
+ """Iterate in-order over the results of the function call.
1908
+
1909
+ Optionally, specify a range [start, end) to iterate over.
1910
+
1911
+ Example:
1912
+ ```python
1913
+ @app.function()
1914
+ def my_func(a):
1915
+ return a ** 2
1916
+
1917
+
1918
+ @app.local_entrypoint()
1919
+ def main():
1920
+ fc = my_func.spawn_map([1, 2, 3, 4])
1921
+ assert list(fc.iter()) == [1, 4, 9, 16]
1922
+ assert list(fc.iter(start=1, end=3)) == [4, 9]
1923
+ ```
1924
+
1925
+ If `end` is not provided, it will iterate over all results.
1926
+ """
1927
+ num_inputs = await self.num_inputs()
1928
+ if end is None:
1929
+ end = num_inputs
1930
+ if start < 0 or end > num_inputs:
1931
+ raise ValueError(f"Invalid index range: {start} to {end} for {num_inputs} inputs")
1932
+ async for _, item in self._invocation().enumerate(start_index=start, end_index=end):
1933
+ yield item
1820
1934
 
1821
1935
  async def get_call_graph(self) -> list[InputInfo]:
1822
1936
  """Returns a structure representing the call graph from a given root
@@ -1870,7 +1984,15 @@ class _FunctionCall(typing.Generic[ReturnType], _Object, type_prefix="fc"):
1870
1984
  if client is None:
1871
1985
  client = await _Client.from_env()
1872
1986
 
1873
- fc: _FunctionCall[Any] = _FunctionCall._new_hydrated(function_call_id, client, None)
1987
+ async def _load(self: _FunctionCall, resolver: Resolver, existing_object_id: Optional[str]):
1988
+ request = api_pb2.FunctionCallFromIdRequest(function_call_id=function_call_id)
1989
+ resp = await retry_transient_errors(resolver.client.stub.FunctionCallFromId, request)
1990
+ self._hydrate(function_call_id, resolver.client, resp)
1991
+
1992
+ rep = f"FunctionCall.from_id({function_call_id!r})"
1993
+ fc: _FunctionCall[Any] = _FunctionCall._from_loader(_load, rep, hydrate_lazily=True)
1994
+ # We already know the object ID, so we can set it directly
1995
+ fc._object_id = function_call_id
1874
1996
  return fc
1875
1997
 
1876
1998
  @staticmethod
@@ -191,9 +191,20 @@ class _Object:
191
191
  def _is_id_type(cls, object_id) -> bool:
192
192
  return cls._get_type_from_id(object_id) == cls
193
193
 
194
+ @classmethod
195
+ def _repr(cls, name: str, environment_name: Optional[str] = None) -> str:
196
+ public_cls = cls.__name__.strip("_")
197
+ environment_repr = f", environment_name={environment_name!r}" if environment_name else ""
198
+ return f"modal.{public_cls}.from_name({name!r}{environment_repr})"
199
+
194
200
  @classmethod
195
201
  def _new_hydrated(
196
- cls, object_id: str, client: _Client, handle_metadata: Optional[Message], is_another_app: bool = False
202
+ cls,
203
+ object_id: str,
204
+ client: _Client,
205
+ handle_metadata: Optional[Message],
206
+ is_another_app: bool = False,
207
+ rep: Optional[str] = None,
197
208
  ) -> Self:
198
209
  obj_cls: type[Self]
199
210
  if cls._type_prefix is not None:
@@ -210,7 +221,7 @@ class _Object:
210
221
 
211
222
  # Instantiate provider
212
223
  obj = _Object.__new__(obj_cls)
213
- rep = f"Object({object_id})" # TODO(erikbern): dumb
224
+ rep = rep or f"modal.{obj_cls.__name__.strip('_')}.from_id({object_id!r})"
214
225
  obj._init(rep, is_another_app=is_another_app)
215
226
  obj._hydrate(object_id, client, handle_metadata)
216
227
 
@@ -282,7 +282,7 @@ class _MethodDecoratorType:
282
282
 
283
283
  # TODO(elias): fix support for coroutine type unwrapping for methods (static typing)
284
284
  def _method(
285
- _warn_parentheses_missing=None,
285
+ _warn_parentheses_missing=None, # mdmd:line-hidden
286
286
  *,
287
287
  # Set this to True if it's a non-generator function returning
288
288
  # a [sync/async] generator object
@@ -337,7 +337,7 @@ def _parse_custom_domains(custom_domains: Optional[Iterable[str]] = None) -> lis
337
337
 
338
338
 
339
339
  def _fastapi_endpoint(
340
- _warn_parentheses_missing=None,
340
+ _warn_parentheses_missing=None, # mdmd:line-hidden
341
341
  *,
342
342
  method: str = "GET", # REST method for the created endpoint.
343
343
  label: Optional[str] = None, # Label for created endpoint. Final subdomain will be <workspace>--<label>.modal.run.
@@ -400,7 +400,7 @@ def _fastapi_endpoint(
400
400
 
401
401
 
402
402
  def _web_endpoint(
403
- _warn_parentheses_missing=None,
403
+ _warn_parentheses_missing=None, # mdmd:line-hidden
404
404
  *,
405
405
  method: str = "GET", # REST method for the created endpoint.
406
406
  label: Optional[str] = None, # Label for created endpoint. Final subdomain will be <workspace>--<label>.modal.run.
@@ -468,7 +468,7 @@ def _web_endpoint(
468
468
 
469
469
 
470
470
  def _asgi_app(
471
- _warn_parentheses_missing=None,
471
+ _warn_parentheses_missing=None, # mdmd:line-hidden
472
472
  *,
473
473
  label: Optional[str] = None, # Label for created endpoint. Final subdomain will be <workspace>--<label>.modal.run.
474
474
  custom_domains: Optional[Iterable[str]] = None, # Deploy this endpoint on a custom domain.
@@ -525,7 +525,7 @@ def _asgi_app(
525
525
 
526
526
 
527
527
  def _wsgi_app(
528
- _warn_parentheses_missing=None,
528
+ _warn_parentheses_missing=None, # mdmd:line-hidden
529
529
  *,
530
530
  label: Optional[str] = None, # Label for created endpoint. Final subdomain will be <workspace>--<label>.modal.run.
531
531
  custom_domains: Optional[Iterable[str]] = None, # Deploy this endpoint on a custom domain.
@@ -645,7 +645,7 @@ def _web_server(
645
645
 
646
646
 
647
647
  def _enter(
648
- _warn_parentheses_missing=None,
648
+ _warn_parentheses_missing=None, # mdmd:line-hidden
649
649
  *,
650
650
  snap: bool = False,
651
651
  ) -> Callable[[Union[_PartialFunction, NullaryMethod]], _PartialFunction]:
@@ -696,7 +696,7 @@ def _exit(_warn_parentheses_missing=None) -> Callable[[NullaryMethod], _PartialF
696
696
 
697
697
 
698
698
  def _batched(
699
- _warn_parentheses_missing=None,
699
+ _warn_parentheses_missing=None, # mdmd:line-hidden
700
700
  *,
701
701
  max_batch_size: int,
702
702
  wait_ms: int,
@@ -758,7 +758,7 @@ def _batched(
758
758
 
759
759
 
760
760
  def _concurrent(
761
- _warn_parentheses_missing=None,
761
+ _warn_parentheses_missing=None, # mdmd:line-hidden
762
762
  *,
763
763
  max_inputs: int, # Hard limit on each container's input concurrency
764
764
  target_inputs: Optional[int] = None, # Input concurrency that Modal's autoscaler should target
@@ -16,7 +16,7 @@ from modal.config import logger
16
16
  from modal.exception import ExecutionError, InvalidError
17
17
  from modal.experimental import stop_fetching_inputs
18
18
 
19
- from .execution_context import current_function_call_id
19
+ from .execution_context import current_attempt_token, current_function_call_id
20
20
 
21
21
  FIRST_MESSAGE_TIMEOUT_SECONDS = 5.0
22
22
 
@@ -106,6 +106,7 @@ def asgi_app_wrapper(asgi_app, container_io_manager) -> tuple[Callable[..., Asyn
106
106
  raise ExecutionError("Unpexected state in ASGI scope")
107
107
  scope["state"] = state
108
108
  function_call_id = current_function_call_id()
109
+ attempt_token = current_attempt_token()
109
110
  assert function_call_id, "internal error: function_call_id not set in asgi_app() scope"
110
111
 
111
112
  messages_from_app: asyncio.Queue[dict[str, Any]] = asyncio.Queue(1)
@@ -142,7 +143,7 @@ def asgi_app_wrapper(asgi_app, container_io_manager) -> tuple[Callable[..., Asyn
142
143
  # This initial message, "http.request" or "websocket.connect", should be sent
143
144
  # immediately after starting the ASGI app's function call. If it is not received, that
144
145
  # indicates a request cancellation or other abnormal circumstance.
145
- message_gen = container_io_manager.get_data_in.aio(function_call_id)
146
+ message_gen = container_io_manager.get_data_in.aio(function_call_id, attempt_token)
146
147
  first_message_task = asyncio.create_task(message_gen.__anext__())
147
148
 
148
149
  try:
@@ -39,7 +39,6 @@ from modal.exception import ClientClosed, InputCancellation, InvalidError, Seria
39
39
  from modal_proto import api_pb2
40
40
 
41
41
  if TYPE_CHECKING:
42
- import modal._runtime.asgi
43
42
  import modal._runtime.user_code_imports
44
43
 
45
44
 
@@ -66,6 +65,7 @@ class IOContext:
66
65
  input_ids: list[str]
67
66
  retry_counts: list[int]
68
67
  function_call_ids: list[str]
68
+ attempt_tokens: list[str]
69
69
  function_inputs: list[api_pb2.FunctionInput]
70
70
  finalized_function: "modal._runtime.user_code_imports.FinalizedFunction"
71
71
 
@@ -77,6 +77,7 @@ class IOContext:
77
77
  input_ids: list[str],
78
78
  retry_counts: list[int],
79
79
  function_call_ids: list[str],
80
+ attempt_tokens: list[str],
80
81
  finalized_function: "modal._runtime.user_code_imports.FinalizedFunction",
81
82
  function_inputs: list[api_pb2.FunctionInput],
82
83
  is_batched: bool,
@@ -85,6 +86,7 @@ class IOContext:
85
86
  self.input_ids = input_ids
86
87
  self.retry_counts = retry_counts
87
88
  self.function_call_ids = function_call_ids
89
+ self.attempt_tokens = attempt_tokens
88
90
  self.finalized_function = finalized_function
89
91
  self.function_inputs = function_inputs
90
92
  self._is_batched = is_batched
@@ -95,11 +97,11 @@ class IOContext:
95
97
  cls,
96
98
  client: _Client,
97
99
  finalized_functions: dict[str, "modal._runtime.user_code_imports.FinalizedFunction"],
98
- inputs: list[tuple[str, int, str, api_pb2.FunctionInput]],
100
+ inputs: list[tuple[str, int, str, str, api_pb2.FunctionInput]],
99
101
  is_batched: bool,
100
102
  ) -> "IOContext":
101
103
  assert len(inputs) >= 1 if is_batched else len(inputs) == 1
102
- input_ids, retry_counts, function_call_ids, function_inputs = zip(*inputs)
104
+ input_ids, retry_counts, function_call_ids, attempt_tokens, function_inputs = zip(*inputs)
103
105
 
104
106
  async def _populate_input_blobs(client: _Client, input: api_pb2.FunctionInput) -> api_pb2.FunctionInput:
105
107
  # If we got a pointer to a blob, download it from S3.
@@ -121,6 +123,7 @@ class IOContext:
121
123
  cast(list[str], input_ids),
122
124
  cast(list[int], retry_counts),
123
125
  cast(list[str], function_call_ids),
126
+ cast(list[str], attempt_tokens),
124
127
  finalized_function,
125
128
  cast(list[api_pb2.FunctionInput], function_inputs),
126
129
  is_batched,
@@ -300,11 +303,7 @@ class _ContainerIOManager:
300
303
  self.function_def = container_args.function_def
301
304
  self.checkpoint_id = container_args.checkpoint_id or None
302
305
 
303
- # We could also have the worker pass this in explicitly.
304
- self.input_plane_server_url = None
305
- for obj in container_args.app_layout.objects:
306
- if obj.object_id == self.function_id:
307
- self.input_plane_server_url = obj.function_handle_metadata.input_plane_url
306
+ self.input_plane_server_url = container_args.input_plane_server_url
308
307
 
309
308
  self.calls_completed = 0
310
309
  self.total_user_time = 0.0
@@ -484,18 +483,21 @@ class _ContainerIOManager:
484
483
  else {"data": data}
485
484
  )
486
485
 
487
- async def get_data_in(self, function_call_id: str) -> AsyncIterator[Any]:
486
+ async def get_data_in(self, function_call_id: str, attempt_token: Optional[str]) -> AsyncIterator[Any]:
488
487
  """Read from the `data_in` stream of a function call."""
489
488
  stub = self._client.stub
490
489
  if self.input_plane_server_url:
491
490
  stub = await self._client.get_stub(self.input_plane_server_url)
492
491
 
493
- async for data in _stream_function_call_data(self._client, stub, function_call_id, "data_in"):
492
+ async for data in _stream_function_call_data(
493
+ self._client, stub, function_call_id, variant="data_in", attempt_token=attempt_token
494
+ ):
494
495
  yield data
495
496
 
496
497
  async def put_data_out(
497
498
  self,
498
499
  function_call_id: str,
500
+ attempt_token: str,
499
501
  start_index: int,
500
502
  data_format: int,
501
503
  serialized_messages: list[Any],
@@ -516,6 +518,8 @@ class _ContainerIOManager:
516
518
  data_chunks.append(chunk)
517
519
 
518
520
  req = api_pb2.FunctionCallPutDataRequest(function_call_id=function_call_id, data_chunks=data_chunks)
521
+ if attempt_token:
522
+ req.attempt_token = attempt_token # oneof clears function_call_id.
519
523
 
520
524
  if self.input_plane_server_url:
521
525
  stub = await self._client.get_stub(self.input_plane_server_url)
@@ -525,7 +529,7 @@ class _ContainerIOManager:
525
529
 
526
530
  @asynccontextmanager
527
531
  async def generator_output_sender(
528
- self, function_call_id: str, data_format: int, message_rx: asyncio.Queue
532
+ self, function_call_id: str, attempt_token: str, data_format: int, message_rx: asyncio.Queue
529
533
  ) -> AsyncGenerator[None, None]:
530
534
  """Runs background task that feeds generator outputs into a function call's `data_out` stream."""
531
535
  GENERATOR_STOP_SENTINEL = Sentinel()
@@ -554,7 +558,7 @@ class _ContainerIOManager:
554
558
  else:
555
559
  serialized_messages.append(serialize_data_format(message, data_format))
556
560
  total_size += len(serialized_messages[-1]) + 512 # 512 bytes for estimated framing overhead
557
- await self.put_data_out(function_call_id, index, data_format, serialized_messages)
561
+ await self.put_data_out(function_call_id, attempt_token, index, data_format, serialized_messages)
558
562
  index += len(serialized_messages)
559
563
 
560
564
  task = asyncio.create_task(generator_output_task())
@@ -590,7 +594,7 @@ class _ContainerIOManager:
590
594
  self,
591
595
  batch_max_size: int,
592
596
  batch_wait_ms: int,
593
- ) -> AsyncIterator[list[tuple[str, int, str, api_pb2.FunctionInput]]]:
597
+ ) -> AsyncIterator[list[tuple[str, int, str, str, api_pb2.FunctionInput]]]:
594
598
  request = api_pb2.FunctionGetInputsRequest(function_id=self.function_id)
595
599
  iteration = 0
596
600
  while self._fetching_inputs:
@@ -625,7 +629,9 @@ class _ContainerIOManager:
625
629
  if item.kill_switch:
626
630
  logger.debug(f"Task {self.task_id} input kill signal input.")
627
631
  return
628
- inputs.append((item.input_id, item.retry_count, item.function_call_id, item.input))
632
+ inputs.append(
633
+ (item.input_id, item.retry_count, item.function_call_id, item.attempt_token, item.input)
634
+ )
629
635
  if item.input.final_input:
630
636
  if request.batch_max_size > 0:
631
637
  logger.debug(f"Task {self.task_id} Final input not expected in batch input stream")