modal 1.2.5.dev10__tar.gz → 1.2.7.dev11__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 (204) hide show
  1. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/PKG-INFO +3 -3
  2. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/README.md +1 -1
  3. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/__init__.py +2 -2
  4. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/__main__.py +4 -29
  5. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_container_entrypoint.py +0 -1
  6. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_functions.py +8 -17
  7. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_grpc_client.py +48 -28
  8. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_output.py +10 -11
  9. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_partial_function.py +1 -2
  10. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_runtime/container_io_manager.py +5 -6
  11. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_runtime/gpu_memory_snapshot.py +9 -7
  12. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_runtime/user_code_imports.py +10 -2
  13. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_traceback.py +1 -1
  14. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_tunnel.py +5 -9
  15. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/blob_utils.py +23 -9
  16. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/function_utils.py +15 -19
  17. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/grpc_utils.py +2 -7
  18. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/package_utils.py +0 -1
  19. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/task_command_router_client.py +125 -126
  20. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/app.py +43 -8
  21. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/app.pyi +8 -4
  22. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/entry_point.py +23 -0
  23. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/environment.py +2 -16
  24. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/launch.py +0 -74
  25. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/network_file_system.py +4 -16
  26. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/run.py +1 -1
  27. modal-1.2.7.dev11/modal/cli/shell.py +375 -0
  28. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/utils.py +1 -13
  29. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/volume.py +4 -16
  30. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/client.pyi +2 -2
  31. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cls.py +0 -6
  32. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/container_process.py +2 -2
  33. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/dict.py +44 -15
  34. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/dict.pyi +2 -0
  35. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/exception.py +146 -16
  36. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/experimental/__init__.py +2 -1
  37. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/experimental/flash.py +46 -4
  38. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/experimental/flash.pyi +22 -0
  39. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/file_io.py +39 -67
  40. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/file_io.pyi +12 -27
  41. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/functions.pyi +1 -1
  42. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/image.py +17 -14
  43. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/io_streams.py +23 -26
  44. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/mount.py +23 -19
  45. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/mount.pyi +3 -3
  46. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/queue.py +12 -14
  47. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/runner.py +1 -8
  48. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/sandbox.py +50 -27
  49. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/sandbox.pyi +39 -0
  50. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/secret.py +4 -20
  51. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/volume.py +19 -18
  52. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal.egg-info/PKG-INFO +3 -3
  53. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal.egg-info/SOURCES.txt +0 -8
  54. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/api.proto +24 -4
  55. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/api_pb2.py +662 -656
  56. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/api_pb2.pyi +37 -8
  57. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/task_command_router.proto +20 -0
  58. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/task_command_router_grpc.py +33 -0
  59. modal-1.2.7.dev11/modal_proto/task_command_router_pb2.py +180 -0
  60. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/task_command_router_pb2.pyi +51 -0
  61. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/task_command_router_pb2_grpc.py +69 -0
  62. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/task_command_router_pb2_grpc.pyi +25 -0
  63. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_version/__init__.py +1 -1
  64. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_version/__main__.py +1 -1
  65. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/pyproject.toml +13 -6
  66. modal-1.2.5.dev10/modal/cli/programs/launch_instance_ssh.py +0 -94
  67. modal-1.2.5.dev10/modal/cli/programs/run_marimo.py +0 -95
  68. modal-1.2.5.dev10/modal/cli/shell.py +0 -237
  69. modal-1.2.5.dev10/modal_proto/sandbox_router.proto +0 -145
  70. modal-1.2.5.dev10/modal_proto/sandbox_router_grpc.py +0 -105
  71. modal-1.2.5.dev10/modal_proto/sandbox_router_pb2.py +0 -149
  72. modal-1.2.5.dev10/modal_proto/sandbox_router_pb2.pyi +0 -333
  73. modal-1.2.5.dev10/modal_proto/sandbox_router_pb2_grpc.py +0 -203
  74. modal-1.2.5.dev10/modal_proto/sandbox_router_pb2_grpc.pyi +0 -75
  75. modal-1.2.5.dev10/modal_proto/task_command_router_pb2.py +0 -149
  76. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/LICENSE +0 -0
  77. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_billing.py +0 -0
  78. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_clustered_functions.py +0 -0
  79. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_clustered_functions.pyi +0 -0
  80. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_ipython.py +0 -0
  81. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_load_context.py +0 -0
  82. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_location.py +0 -0
  83. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_object.py +0 -0
  84. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_pty.py +0 -0
  85. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_resolver.py +0 -0
  86. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_resources.py +0 -0
  87. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_runtime/__init__.py +0 -0
  88. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_runtime/asgi.py +0 -0
  89. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_runtime/container_io_manager.pyi +0 -0
  90. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_runtime/execution_context.py +0 -0
  91. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_runtime/execution_context.pyi +0 -0
  92. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_runtime/telemetry.py +0 -0
  93. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_runtime/user_code_event_loop.py +0 -0
  94. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_serialization.py +0 -0
  95. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_tunnel.pyi +0 -0
  96. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_type_manager.py +0 -0
  97. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/__init__.py +0 -0
  98. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/app_utils.py +0 -0
  99. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/async_utils.py +0 -0
  100. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/auth_token_manager.py +0 -0
  101. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/bytes_io_segment_payload.py +0 -0
  102. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/deprecation.py +0 -0
  103. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/docker_utils.py +0 -0
  104. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/git_utils.py +0 -0
  105. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/grpc_testing.py +0 -0
  106. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/hash_utils.py +0 -0
  107. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/http_utils.py +0 -0
  108. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/jwt_utils.py +0 -0
  109. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/logger.py +0 -0
  110. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/mount_utils.py +0 -0
  111. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/name_utils.py +0 -0
  112. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/pattern_utils.py +0 -0
  113. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/rand_pb_testing.py +0 -0
  114. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/shell_utils.py +0 -0
  115. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_utils/time_utils.py +0 -0
  116. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_vendor/__init__.py +0 -0
  117. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  118. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_vendor/cloudpickle.py +0 -0
  119. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_vendor/tblib.py +0 -0
  120. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/_watcher.py +0 -0
  121. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/billing.py +0 -0
  122. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/builder/2023.12.312.txt +0 -0
  123. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/builder/2023.12.txt +0 -0
  124. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/builder/2024.04.txt +0 -0
  125. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/builder/2024.10.txt +0 -0
  126. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/builder/2025.06.txt +0 -0
  127. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/builder/PREVIEW.txt +0 -0
  128. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/builder/README.md +0 -0
  129. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/builder/base-images.json +0 -0
  130. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/call_graph.py +0 -0
  131. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/__init__.py +0 -0
  132. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/_download.py +0 -0
  133. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/_traceback.py +0 -0
  134. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/app.py +0 -0
  135. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/cluster.py +0 -0
  136. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/config.py +0 -0
  137. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/container.py +0 -0
  138. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/dict.py +0 -0
  139. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/import_refs.py +0 -0
  140. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/profile.py +0 -0
  141. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/programs/__init__.py +0 -0
  142. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/programs/run_jupyter.py +0 -0
  143. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/programs/vscode.py +0 -0
  144. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/queues.py +0 -0
  145. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/secret.py +0 -0
  146. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cli/token.py +0 -0
  147. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/client.py +0 -0
  148. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cloud_bucket_mount.py +0 -0
  149. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cloud_bucket_mount.pyi +0 -0
  150. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/cls.pyi +0 -0
  151. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/config.py +0 -0
  152. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/container_process.pyi +0 -0
  153. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/environments.py +0 -0
  154. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/environments.pyi +0 -0
  155. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/experimental/ipython.py +0 -0
  156. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/file_pattern_matcher.py +0 -0
  157. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/functions.py +0 -0
  158. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/gpu.py +0 -0
  159. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/image.pyi +0 -0
  160. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/io_streams.pyi +0 -0
  161. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/network_file_system.py +0 -0
  162. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/network_file_system.pyi +0 -0
  163. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/object.py +0 -0
  164. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/object.pyi +0 -0
  165. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/output.py +0 -0
  166. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/parallel_map.py +0 -0
  167. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/parallel_map.pyi +0 -0
  168. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/partial_function.py +0 -0
  169. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/partial_function.pyi +0 -0
  170. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/proxy.py +0 -0
  171. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/proxy.pyi +0 -0
  172. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/py.typed +0 -0
  173. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/queue.pyi +0 -0
  174. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/retries.py +0 -0
  175. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/runner.pyi +0 -0
  176. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/running_app.py +0 -0
  177. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/schedule.py +0 -0
  178. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/scheduler_placement.py +0 -0
  179. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/secret.pyi +0 -0
  180. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/serving.py +0 -0
  181. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/serving.pyi +0 -0
  182. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/snapshot.py +0 -0
  183. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/snapshot.pyi +0 -0
  184. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/stream_type.py +0 -0
  185. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/token_flow.py +0 -0
  186. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/token_flow.pyi +0 -0
  187. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal/volume.pyi +0 -0
  188. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal.egg-info/dependency_links.txt +0 -0
  189. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal.egg-info/entry_points.txt +0 -0
  190. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal.egg-info/requires.txt +0 -0
  191. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal.egg-info/top_level.txt +0 -0
  192. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_docs/__init__.py +0 -0
  193. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_docs/gen_cli_docs.py +0 -0
  194. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_docs/gen_reference_docs.py +0 -0
  195. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_docs/mdmd/__init__.py +0 -0
  196. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_docs/mdmd/mdmd.py +0 -0
  197. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_docs/mdmd/signatures.py +0 -0
  198. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/__init__.py +0 -0
  199. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/api_grpc.py +0 -0
  200. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/api_pb2_grpc.py +0 -0
  201. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/api_pb2_grpc.pyi +0 -0
  202. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/modal_api_grpc.py +0 -0
  203. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/modal_proto/py.typed +0 -0
  204. {modal-1.2.5.dev10 → modal-1.2.7.dev11}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.2.5.dev10
3
+ Version: 1.2.7.dev11
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License: Apache-2.0
@@ -13,7 +13,7 @@ Classifier: Topic :: System :: Distributed Computing
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: License :: OSI Approved :: Apache Software License
15
15
  Classifier: Programming Language :: Python :: 3
16
- Requires-Python: <3.14,>=3.9
16
+ Requires-Python: <3.14,>=3.10
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: aiohttp
@@ -51,7 +51,7 @@ a [user guide](https://modal.com/docs/guide), and the detailed
51
51
 
52
52
  ## Installation
53
53
 
54
- **This library requires Python 3.9 – 3.13.**
54
+ **This library requires Python 3.10 – 3.13.**
55
55
 
56
56
  Install the package with `uv` or `pip`:
57
57
 
@@ -17,7 +17,7 @@ a [user guide](https://modal.com/docs/guide), and the detailed
17
17
 
18
18
  ## Installation
19
19
 
20
- **This library requires Python 3.9 – 3.13.**
20
+ **This library requires Python 3.10 – 3.13.**
21
21
 
22
22
  Install the package with `uv` or `pip`:
23
23
 
@@ -1,8 +1,8 @@
1
1
  # Copyright Modal Labs 2022
2
2
  import sys
3
3
 
4
- if sys.version_info[:2] < (3, 9):
5
- raise RuntimeError("This version of Modal requires at least Python 3.9")
4
+ if sys.version_info[:2] < (3, 10):
5
+ raise RuntimeError("This version of Modal requires at least Python 3.10")
6
6
  if sys.version_info[:2] >= (3, 14):
7
7
  raise RuntimeError("This version of Modal does not support Python 3.14+")
8
8
 
@@ -35,37 +35,12 @@ def main():
35
35
  ):
36
36
  raise
37
37
 
38
- from grpclib import GRPCError, Status
39
38
  from rich.panel import Panel
40
39
 
41
- if isinstance(exc, GRPCError):
42
- status_map = {
43
- Status.ABORTED: "Aborted",
44
- Status.ALREADY_EXISTS: "Already exists",
45
- Status.CANCELLED: "Cancelled",
46
- Status.DATA_LOSS: "Data loss",
47
- Status.DEADLINE_EXCEEDED: "Deadline exceeded",
48
- Status.FAILED_PRECONDITION: "Failed precondition",
49
- Status.INTERNAL: "Internal",
50
- Status.INVALID_ARGUMENT: "Invalid",
51
- Status.NOT_FOUND: "Not found",
52
- Status.OUT_OF_RANGE: "Out of range",
53
- Status.PERMISSION_DENIED: "Permission denied",
54
- Status.RESOURCE_EXHAUSTED: "Resource exhausted",
55
- Status.UNAUTHENTICATED: "Unauthenticaed",
56
- Status.UNAVAILABLE: "Unavailable",
57
- Status.UNIMPLEMENTED: "Unimplemented",
58
- Status.UNKNOWN: "Unknown",
59
- }
60
- title = f"Error: {status_map.get(exc.status, 'Unknown')}"
61
- content = str(exc.message)
62
- if exc.details:
63
- content += f"\n\nDetails: {exc.details}"
64
- else:
65
- title = "Error"
66
- content = str(exc)
67
- if notes := getattr(exc, "__notes__", []):
68
- content = f"{content}\n\nNote: {' '.join(notes)}"
40
+ title = "Error"
41
+ content = str(exc)
42
+ if notes := getattr(exc, "__notes__", []):
43
+ content = f"{content}\n\nNote: {' '.join(notes)}"
69
44
 
70
45
  console = make_console(stderr=True)
71
46
  panel = Panel(content, title=title, title_align="left", border_style="red")
@@ -43,7 +43,6 @@ from ._runtime.container_io_manager import (
43
43
  )
44
44
 
45
45
  if TYPE_CHECKING:
46
- import modal._object
47
46
  import modal._runtime.container_io_manager
48
47
  import modal._runtime.user_code_imports
49
48
 
@@ -13,7 +13,7 @@ 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
16
- from grpclib import GRPCError, Status
16
+ from grpclib import Status
17
17
  from synchronicity.combined_types import MethodWithAio
18
18
 
19
19
  from modal_proto import api_pb2
@@ -694,7 +694,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
694
694
  # Experimental: Clustered functions
695
695
  cluster_size: Optional[int] = None,
696
696
  rdma: Optional[bool] = None,
697
- max_inputs: Optional[int] = None,
697
+ single_use_containers: bool = False,
698
698
  ephemeral_disk: Optional[int] = None,
699
699
  include_source: bool = True,
700
700
  experimental_options: Optional[dict[str, str]] = None,
@@ -810,14 +810,6 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
810
810
  if arg.default is not inspect.Parameter.empty:
811
811
  raise InvalidError(f"Modal batched function {func_name} does not accept default arguments.")
812
812
 
813
- if max_inputs is not None:
814
- if not isinstance(max_inputs, int):
815
- raise InvalidError(f"`max_inputs` must be an int, not {type(max_inputs).__name__}")
816
- if max_inputs <= 0:
817
- raise InvalidError("`max_inputs` must be positive")
818
- if max_inputs > 1:
819
- raise InvalidError("Only `max_inputs=1` is currently supported")
820
-
821
813
  # Validate volumes
822
814
  validated_volumes = validate_volumes(volumes)
823
815
  cloud_bucket_mounts = [(k, v) for k, v in validated_volumes if isinstance(v, _CloudBucketMount)]
@@ -988,6 +980,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
988
980
  function_definition = api_pb2.Function(
989
981
  module_name=info.module_name or "",
990
982
  function_name=info.function_name,
983
+ implementation_name=info.implementation_name,
991
984
  mount_ids=loaded_mount_ids,
992
985
  secret_ids=[secret.object_id for secret in secrets],
993
986
  image_id=(image.object_id if image else ""),
@@ -1023,7 +1016,8 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1023
1016
  object_dependencies=object_dependencies,
1024
1017
  block_network=block_network,
1025
1018
  untrusted=restrict_modal_access,
1026
- max_inputs=max_inputs or 0,
1019
+ single_use_containers=single_use_containers,
1020
+ max_inputs=int(single_use_containers), # TODO(michael) remove after worker rollover
1027
1021
  cloud_bucket_mounts=cloud_bucket_mounts_to_proto(cloud_bucket_mounts),
1028
1022
  scheduler_placement=scheduler_placement,
1029
1023
  is_class=info.is_service_class(),
@@ -1054,6 +1048,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1054
1048
  function_data = api_pb2.FunctionData(
1055
1049
  module_name=function_definition.module_name,
1056
1050
  function_name=function_definition.function_name,
1051
+ implementation_name=function_definition.implementation_name,
1057
1052
  function_type=function_definition.function_type,
1058
1053
  warm_pool_size=function_definition.warm_pool_size,
1059
1054
  concurrency_limit=function_definition.concurrency_limit,
@@ -1124,12 +1119,8 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1124
1119
  )
1125
1120
  try:
1126
1121
  response: api_pb2.FunctionCreateResponse = await load_context.client.stub.FunctionCreate(request)
1127
- except GRPCError as exc:
1128
- if exc.status == Status.INVALID_ARGUMENT:
1129
- raise InvalidError(exc.message)
1130
- if exc.status == Status.FAILED_PRECONDITION:
1131
- raise InvalidError(exc.message)
1132
- if exc.message and "Received :status = '413'" in exc.message:
1122
+ except Exception as exc:
1123
+ if "Received :status = '413'" in str(exc):
1133
1124
  raise InvalidError(f"Function {info.function_name} is too large to deploy.")
1134
1125
  raise
1135
1126
  function_creation_status.set_response(response)
@@ -5,10 +5,10 @@ import grpclib.client
5
5
  from google.protobuf.message import Message
6
6
  from grpclib import GRPCError, Status
7
7
 
8
+ from . import exception
8
9
  from ._traceback import suppress_tb_frames
9
10
  from ._utils.grpc_utils import Retry, _retry_transient_errors
10
11
  from .config import config, logger
11
- from .exception import InvalidError, NotFoundError
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from .client import _Client
@@ -20,6 +20,29 @@ RequestType = TypeVar("RequestType", bound=Message)
20
20
  ResponseType = TypeVar("ResponseType", bound=Message)
21
21
 
22
22
 
23
+ class WrappedGRPCError(exception.Error, exception._GRPCErrorWrapper): ...
24
+
25
+
26
+ _STATUS_TO_EXCEPTION: dict[Status, type[exception._GRPCErrorWrapper]] = {
27
+ Status.CANCELLED: exception.ServiceError,
28
+ Status.UNKNOWN: exception.ServiceError,
29
+ Status.INVALID_ARGUMENT: exception.InvalidError,
30
+ Status.DEADLINE_EXCEEDED: exception.ServiceError,
31
+ Status.NOT_FOUND: exception.NotFoundError,
32
+ Status.ALREADY_EXISTS: exception.AlreadyExistsError,
33
+ Status.PERMISSION_DENIED: exception.PermissionDeniedError,
34
+ Status.RESOURCE_EXHAUSTED: exception.ResourceExhaustedError,
35
+ Status.FAILED_PRECONDITION: exception.ConflictError,
36
+ Status.ABORTED: exception.ConflictError,
37
+ Status.OUT_OF_RANGE: exception.InvalidError,
38
+ Status.UNIMPLEMENTED: exception.UnimplementedError,
39
+ Status.INTERNAL: exception.InternalError,
40
+ Status.UNAVAILABLE: exception.ServiceError,
41
+ Status.DATA_LOSS: exception.DataLossError,
42
+ Status.UNAUTHENTICATED: exception.AuthError,
43
+ }
44
+
45
+
23
46
  class grpc_error_converter:
24
47
  def __enter__(self):
25
48
  pass
@@ -29,20 +52,14 @@ class grpc_error_converter:
29
52
  use_full_traceback = config.get("traceback")
30
53
  with suppress_tb_frames(1):
31
54
  if isinstance(exc, GRPCError):
32
- if exc.status == Status.NOT_FOUND:
33
- if use_full_traceback:
34
- raise NotFoundError(exc.message)
35
- else:
36
- raise NotFoundError(exc.message) from None # from None to skip the grpc-internal cause
37
-
38
- if not use_full_traceback:
39
- # just include the frame in grpclib that actually raises the GRPCError
40
- tb = exc.__traceback__
41
- while tb.tb_next:
42
- tb = tb.tb_next
43
- exc.with_traceback(tb)
44
- raise exc from None # from None to skip the grpc-internal cause
45
- raise exc
55
+ modal_exc = _STATUS_TO_EXCEPTION[exc.status](exc.message)
56
+ modal_exc._grpc_message = exc.message
57
+ modal_exc._grpc_status = exc.status
58
+ modal_exc._grpc_details = exc.details
59
+ if use_full_traceback:
60
+ raise modal_exc
61
+ else:
62
+ raise modal_exc from None # from None to skip the grpc-internal cause
46
63
 
47
64
  return False
48
65
 
@@ -100,17 +117,20 @@ class UnaryUnaryWrapper(Generic[RequestType, ResponseType]):
100
117
  ) -> ResponseType:
101
118
  with suppress_tb_frames(1):
102
119
  if timeout is not None and retry is not None:
103
- raise InvalidError("Retry must be None when timeout is set")
120
+ raise exception.InvalidError("Retry must be None when timeout is set")
104
121
 
105
122
  if retry is None:
106
- return await self.direct(req, timeout=timeout, metadata=metadata)
107
-
108
- return await _retry_transient_errors(
109
- self, # type: ignore
110
- req,
111
- retry=retry,
112
- metadata=metadata,
113
- )
123
+ with grpc_error_converter():
124
+ return await self.direct(req, timeout=timeout, metadata=metadata)
125
+
126
+ # TODO do we need suppress_error_frames(1) here too?
127
+ with grpc_error_converter():
128
+ return await _retry_transient_errors(
129
+ self, # type: ignore
130
+ req,
131
+ retry=retry,
132
+ metadata=metadata,
133
+ )
114
134
 
115
135
  async def direct(
116
136
  self,
@@ -135,8 +155,7 @@ class UnaryUnaryWrapper(Generic[RequestType, ResponseType]):
135
155
  #
136
156
  # [1]: https://github.com/vmagamedov/grpclib/blob/62f968a4c84e3f64e6966097574ff0a59969ea9b/grpclib/client.py#L844
137
157
  self.wrapped_method.channel = await self.client._get_channel(self.server_url)
138
- with suppress_tb_frames(1), grpc_error_converter():
139
- return await self.client._call_unary(self.wrapped_method, req, timeout=timeout, metadata=metadata)
158
+ return await self.client._call_unary(self.wrapped_method, req, timeout=timeout, metadata=metadata)
140
159
 
141
160
 
142
161
  class UnaryStreamWrapper(Generic[RequestType, ResponseType]):
@@ -167,5 +186,6 @@ class UnaryStreamWrapper(Generic[RequestType, ResponseType]):
167
186
  logger.debug(f"refreshing client after snapshot for {self.name.rsplit('/', 1)[1]}")
168
187
  self.client = await _Client.from_env()
169
188
  self.wrapped_method.channel = await self.client._get_channel(self.server_url)
170
- async for response in self.client._call_stream(self.wrapped_method, request, metadata=metadata):
171
- yield response
189
+ with grpc_error_converter():
190
+ async for response in self.client._call_stream(self.wrapped_method, request, metadata=metadata):
191
+ yield response
@@ -12,7 +12,7 @@ from collections.abc import Generator
12
12
  from datetime import timedelta
13
13
  from typing import Callable, ClassVar
14
14
 
15
- from grpclib.exceptions import GRPCError, StreamTerminatedError
15
+ from grpclib.exceptions import StreamTerminatedError
16
16
  from rich.console import Console, Group, RenderableType
17
17
  from rich.live import Live
18
18
  from rich.panel import Panel
@@ -34,10 +34,11 @@ from rich.text import Text
34
34
  from modal._utils.time_utils import timestamp_to_localized_str
35
35
  from modal_proto import api_pb2
36
36
 
37
- from ._utils.grpc_utils import RETRYABLE_GRPC_STATUS_CODES, Retry
37
+ from ._utils.grpc_utils import Retry
38
38
  from ._utils.shell_utils import stream_from_stdin, write_to_fd
39
39
  from .client import _Client
40
40
  from .config import logger
41
+ from .exception import InternalError, ServiceError
41
42
 
42
43
  if platform.system() == "Windows":
43
44
  default_spinner = "line"
@@ -556,7 +557,7 @@ async def get_app_logs_loop(
556
557
  async def stop_pty_shell():
557
558
  nonlocal pty_shell_finish_event, pty_shell_input_task
558
559
  if pty_shell_finish_event:
559
- print("\r", end="") # move cursor to beginning of line
560
+ print("\r", end="") # move cursor to beginning of line # noqa: T201
560
561
  pty_shell_finish_event.set()
561
562
  pty_shell_finish_event = None
562
563
 
@@ -623,7 +624,7 @@ async def get_app_logs_loop(
623
624
  # This corresponds to the `modal run -i` use case where a breakpoint
624
625
  # triggers and the task drops into an interactive PTY mode
625
626
  if pty_shell_finish_event:
626
- print("ERROR: concurrent PTY shells are not supported.")
627
+ print("ERROR: concurrent PTY shells are not supported.") # noqa: T201
627
628
  else:
628
629
  pty_shell_stdout = output_mgr._stdout
629
630
  pty_shell_finish_event = asyncio.Event()
@@ -644,13 +645,11 @@ async def get_app_logs_loop(
644
645
  while True:
645
646
  try:
646
647
  await _get_logs()
647
- except (GRPCError, StreamTerminatedError, socket.gaierror, AttributeError) as exc:
648
- if isinstance(exc, GRPCError):
649
- if exc.status in RETRYABLE_GRPC_STATUS_CODES:
650
- # Try again if we had a temporary connection drop,
651
- # for example if computer went to sleep.
652
- logger.debug("Log fetching timed out. Retrying ...")
653
- continue
648
+ except (ServiceError, InternalError, StreamTerminatedError, socket.gaierror, AttributeError) as exc:
649
+ if isinstance(exc, (ServiceError, InternalError)):
650
+ # Try again if we had a temporary connection drop, for example if computer went to sleep.
651
+ logger.debug("Log fetching timed out. Retrying ...")
652
+ continue
654
653
  elif isinstance(exc, StreamTerminatedError):
655
654
  logger.debug("Stream closed. Retrying ...")
656
655
  continue
@@ -160,9 +160,8 @@ class _PartialFunction(typing.Generic[P, ReturnType, OriginalReturnType]):
160
160
  raise InvalidError("Interface decorators cannot be combined with lifecycle decorators.")
161
161
 
162
162
  has_web_interface = self.flags & _PartialFunctionFlags.WEB_INTERFACE
163
- has_http_web_interface = self.flags & _PartialFunctionFlags.HTTP_WEB_INTERFACE
164
163
  has_callable_interface = self.flags & _PartialFunctionFlags.CALLABLE_INTERFACE
165
- if (has_web_interface or has_http_web_interface) and has_callable_interface:
164
+ if has_web_interface and has_callable_interface:
166
165
  self.registered = True # Hacky, avoid false-positive warning
167
166
  raise InvalidError("Callable decorators cannot be combined with web interface decorators.")
168
167
 
@@ -845,8 +845,9 @@ class _ContainerIOManager:
845
845
  yield inputs
846
846
  yielded = True
847
847
 
848
- # We only support max_inputs = 1 at the moment
849
- if final_input_received or self.function_def.max_inputs == 1:
848
+ # TODO(michael): Remove use of max_inputs after worker rollover
849
+ single_use_container = self.function_def.single_use_containers or self.function_def.max_inputs == 1
850
+ if final_input_received or single_use_container:
850
851
  return
851
852
  finally:
852
853
  if not yielded:
@@ -991,12 +992,10 @@ class _ContainerIOManager:
991
992
  # Busy-wait for restore. `/__modal/restore-state.json` is created
992
993
  # by the worker process with updates to the container config.
993
994
  restored_path = Path(config.get("restore_state_path"))
994
- start = time.perf_counter()
995
+ logger.debug("Waiting for restore")
995
996
  while not restored_path.exists():
996
- logger.debug(f"Waiting for restore (elapsed={time.perf_counter() - start:.3f}s)")
997
997
  await asyncio.sleep(0.01)
998
998
  continue
999
-
1000
999
  logger.debug("Container: restored")
1001
1000
 
1002
1001
  # Look for state file and create new client with updated credentials.
@@ -1007,7 +1006,7 @@ class _ContainerIOManager:
1007
1006
  # Start a debugger if the worker tells us to
1008
1007
  if int(restored_state.get("snapshot_debug", 0)):
1009
1008
  logger.debug("Entering snapshot debugger")
1010
- breakpoint()
1009
+ breakpoint() # noqa: T100
1011
1010
 
1012
1011
  # Local ContainerIOManager state.
1013
1012
  for key in ["task_id", "function_id"]:
@@ -18,11 +18,14 @@ from modal.config import config, logger
18
18
 
19
19
  CUDA_CHECKPOINT_PATH: str = config.get("cuda_checkpoint_path")
20
20
 
21
- # Maximum total duration for an entire toggle operation.
22
- CUDA_CHECKPOINT_TOGGLE_TIMEOUT: float = 5 * 60.0
23
-
24
21
  # Maximum total duration for each individual `cuda-checkpoint` invocation.
25
- CUDA_CHECKPOINT_TIMEOUT: float = 90
22
+ CUDA_CHECKPOINT_TIMEOUT: float = 3 * 60.0
23
+
24
+ # Number of retries for each individual `cuda-checkpoint --toggle` invocation.
25
+ CUDA_CHECKPOINT_TOGGLE_NUM_RETRIES: int = 3
26
+
27
+ # Maximum total duration for an entire toggle operation.
28
+ CUDA_CHECKPOINT_TOGGLE_TIMEOUT: float = CUDA_CHECKPOINT_TOGGLE_NUM_RETRIES * CUDA_CHECKPOINT_TIMEOUT
26
29
 
27
30
 
28
31
  class CudaCheckpointState(Enum):
@@ -58,7 +61,7 @@ class CudaCheckpointProcess:
58
61
 
59
62
  start_time = time.monotonic()
60
63
  retry_count = 0
61
- max_retries = 3
64
+ max_retries = CUDA_CHECKPOINT_TOGGLE_NUM_RETRIES
62
65
 
63
66
  attempts = 0
64
67
  while self._should_continue_toggle(
@@ -201,8 +204,7 @@ class CudaCheckpointSession:
201
204
  [CUDA_CHECKPOINT_PATH, "--get-state", "--pid", str(pid)],
202
205
  capture_output=True,
203
206
  text=True,
204
- # This should be quick since no checkpoint has taken place yet
205
- timeout=5,
207
+ timeout=CUDA_CHECKPOINT_TIMEOUT,
206
208
  )
207
209
 
208
210
  # If the command succeeds (return code 0), this PID has a CUDA session
@@ -30,6 +30,7 @@ from modal._utils.function_utils import (
30
30
  from modal.app import _App
31
31
  from modal.config import logger
32
32
  from modal.exception import ExecutionError, InvalidError
33
+ from modal.experimental.flash import _FlashContainerEntry
33
34
  from modal_proto import api_pb2
34
35
 
35
36
  if typing.TYPE_CHECKING:
@@ -242,7 +243,7 @@ def create_breakpoint_wrapper(container_io_manager: "modal._runtime.container_io
242
243
  def breakpoint_wrapper():
243
244
  # note: it would be nice to not have breakpoint_wrapper() included in the backtrace
244
245
  container_io_manager.interact(from_breakpoint=True)
245
- import pdb
246
+ import pdb # noqa: T100
246
247
 
247
248
  current_frame = inspect.currentframe()
248
249
  if current_frame is not None:
@@ -392,18 +393,22 @@ class ImportedClass(Service):
392
393
  event_loop: UserCodeEventLoop,
393
394
  container_io_manager: "modal._runtime.container_io_manager.ContainerIOManager",
394
395
  ):
396
+ flash_entry = _FlashContainerEntry(self.function_def.http_config)
395
397
  # Identify the "enter" methods to run after resuming from a snapshot.
396
398
  if not self.function_def.is_auto_snapshot:
397
399
  post_snapshot_methods = _find_callables_for_obj(
398
400
  self.user_cls_instance, _PartialFunctionFlags.ENTER_POST_SNAPSHOT
399
401
  )
400
402
  call_lifecycle_functions(event_loop, container_io_manager, list(post_snapshot_methods.values()))
403
+ flash_entry.enter()
401
404
  try:
402
405
  yield
403
406
  finally:
404
407
  if not self.function_def.is_auto_snapshot:
408
+ flash_entry.stop()
405
409
  exit_methods = _find_callables_for_obj(self.user_cls_instance, _PartialFunctionFlags.EXIT)
406
410
  call_lifecycle_functions(event_loop, container_io_manager, list(exit_methods.values()))
411
+ flash_entry.close()
407
412
 
408
413
 
409
414
  def get_user_class_instance(_cls: modal.cls._Cls, args: tuple[Any, ...], kwargs: dict[str, Any]) -> typing.Any:
@@ -460,7 +465,10 @@ def import_single_function_service(
460
465
  else:
461
466
  # Load the module dynamically
462
467
  module = importlib.import_module(function_def.module_name)
463
- qual_name: str = function_def.function_name
468
+
469
+ # Fall back to function_name just to be safe around the migration
470
+ # Going forward, implementation_name should always be set
471
+ qual_name: str = function_def.implementation_name or function_def.function_name
464
472
 
465
473
  if not is_global_object(qual_name):
466
474
  raise LocalFunctionError("Attempted to load a function defined in a function scope")
@@ -119,7 +119,7 @@ def print_exception(exc: Optional[type[BaseException]], value: Optional[BaseExce
119
119
  traceback.print_exception(exc, value, tb)
120
120
  if sys.version_info < (3, 11) and value is not None: # type: ignore
121
121
  notes = getattr(value, "__notes__", [])
122
- print(*notes, sep="\n", file=sys.stderr)
122
+ print(*notes, sep="\n", file=sys.stderr) # noqa: T201
123
123
 
124
124
 
125
125
  def print_server_warnings(server_warnings: Iterable[api_pb2.Warning]):
@@ -5,14 +5,13 @@ from collections.abc import AsyncIterator
5
5
  from dataclasses import dataclass
6
6
  from typing import Optional
7
7
 
8
- from grpclib import GRPCError, Status
9
8
  from synchronicity.async_wrap import asynccontextmanager
10
9
 
11
10
  from modal_proto import api_pb2
12
11
 
13
12
  from ._utils.async_utils import synchronize_api
14
13
  from .client import _Client
15
- from .exception import InvalidError, RemoteError
14
+ from .exception import AlreadyExistsError, InvalidError, RemoteError, ServiceError
16
15
 
17
16
 
18
17
  @dataclass(frozen=True)
@@ -186,13 +185,10 @@ async def _forward(
186
185
  response = await client.stub.TunnelStart(
187
186
  api_pb2.TunnelStartRequest(port=port, unencrypted=unencrypted, tunnel_type=tunnel_type)
188
187
  )
189
- except GRPCError as exc:
190
- if exc.status == Status.ALREADY_EXISTS:
191
- raise InvalidError(f"Port {port} is already forwarded")
192
- elif exc.status == Status.UNAVAILABLE:
193
- raise RemoteError("Relay server is unavailable") from exc
194
- else:
195
- raise
188
+ except AlreadyExistsError as exc:
189
+ raise InvalidError(f"Port {port} is already forwarded")
190
+ except ServiceError as exc:
191
+ raise RemoteError("Relay server is unavailable") from exc
196
192
 
197
193
  try:
198
194
  yield Tunnel(response.host, response.port, response.unencrypted_host, response.unencrypted_port)
@@ -152,12 +152,13 @@ async def perform_multipart_upload(
152
152
  part_etags = await TaskContext.gather(*upload_coros)
153
153
 
154
154
  # The body of the complete_multipart_upload command needs some data in xml format:
155
- completion_body = "<CompleteMultipartUpload>\n"
155
+ completion_parts = ["<CompleteMultipartUpload>"]
156
156
  for part_number, etag in enumerate(part_etags, 1):
157
- completion_body += f"""<Part>\n<PartNumber>{part_number}</PartNumber>\n<ETag>"{etag}"</ETag>\n</Part>\n"""
158
- completion_body += "</CompleteMultipartUpload>"
157
+ completion_parts.append(f"""<Part>\n<PartNumber>{part_number}</PartNumber>\n<ETag>"{etag}"</ETag>\n</Part>""")
158
+ completion_parts.append("</CompleteMultipartUpload>")
159
+ completion_body = "\n".join(completion_parts)
159
160
 
160
- # etag of combined object should be md5 hex of concatendated md5 *bytes* from parts + `-{num_parts}`
161
+ # etag of combined object should be md5 hex of concatenated md5 *bytes* from parts + `-{num_parts}`
161
162
  bin_hash_parts = [bytes.fromhex(etag) for etag in part_etags]
162
163
 
163
164
  expected_multipart_etag = hashlib.md5(b"".join(bin_hash_parts)).hexdigest() + f"-{len(part_etags)}"
@@ -371,11 +372,17 @@ class FileUploadSpec:
371
372
  mount_filename: str
372
373
 
373
374
  use_blob: bool
374
- content: Optional[bytes] # typically None if using blob, required otherwise
375
375
  sha256_hex: str
376
376
  md5_hex: str
377
377
  mode: int # file permission bits (last 12 bits of st_mode)
378
378
  size: int
379
+ content: Optional[bytes] = None # Set for very small files to avoid double-read
380
+
381
+ def read_content(self) -> bytes:
382
+ """Read content from source."""
383
+ with self.source() as fp:
384
+ fp.seek(0)
385
+ return fp.read()
379
386
 
380
387
 
381
388
  def _get_file_upload_spec(
@@ -384,6 +391,7 @@ def _get_file_upload_spec(
384
391
  mount_filename: PurePosixPath,
385
392
  mode: int,
386
393
  ) -> FileUploadSpec:
394
+ content = None
387
395
  with source() as fp:
388
396
  # Current position is ignored - we always upload from position 0
389
397
  fp.seek(0, os.SEEK_END)
@@ -394,12 +402,18 @@ def _get_file_upload_spec(
394
402
  # TODO(dano): remove the placeholder md5 once we stop requiring md5 for blobs
395
403
  md5_hex = "baadbaadbaadbaadbaadbaadbaadbaad" if size > MULTIPART_UPLOAD_THRESHOLD else None
396
404
  use_blob = True
397
- content = None
398
405
  hashes = get_upload_hashes(fp, md5_hex=md5_hex)
399
406
  else:
400
407
  use_blob = False
401
- content = fp.read()
402
- hashes = get_upload_hashes(content)
408
+ # For very small files (< 256 KiB), read content once and cache it
409
+ # This avoids double-read penalty while limiting memory usage
410
+ if size < 256 * 1024: # 256 KiB threshold
411
+ fp.seek(0)
412
+ content = fp.read()
413
+ hashes = get_upload_hashes(content)
414
+ else:
415
+ # For medium files (256 KiB - 4 MiB), compute hashes without caching content
416
+ hashes = get_upload_hashes(fp)
403
417
 
404
418
  return FileUploadSpec(
405
419
  source=source,
@@ -407,11 +421,11 @@ def _get_file_upload_spec(
407
421
  source_is_path=isinstance(source_description, Path),
408
422
  mount_filename=mount_filename.as_posix(),
409
423
  use_blob=use_blob,
410
- content=content,
411
424
  sha256_hex=hashes.sha256_hex(),
412
425
  md5_hex=hashes.md5_hex(),
413
426
  mode=mode & 0o7777,
414
427
  size=size,
428
+ content=content,
415
429
  )
416
430
 
417
431