modal 1.3.6.dev12__tar.gz → 1.3.6.dev13__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.
Files changed (211) hide show
  1. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/PKG-INFO +1 -1
  2. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_functions.py +9 -1
  3. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/mount_utils.py +20 -0
  4. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/app.py +4 -1
  5. modal-1.3.6.dev13/modal/cli/container.py +264 -0
  6. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/client.pyi +2 -2
  7. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/functions.pyi +6 -6
  8. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/image.py +35 -8
  9. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/image.pyi +40 -4
  10. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/runner.py +19 -9
  11. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/sandbox.py +5 -2
  12. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal.egg-info/PKG-INFO +1 -1
  13. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_version/__init__.py +1 -1
  14. modal-1.3.6.dev12/modal/cli/container.py +0 -117
  15. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/LICENSE +0 -0
  16. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/README.md +0 -0
  17. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/__init__.py +0 -0
  18. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/__main__.py +0 -0
  19. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_billing.py +0 -0
  20. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_clustered_functions.py +0 -0
  21. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_clustered_functions.pyi +0 -0
  22. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_container_entrypoint.py +0 -0
  23. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_grpc_client.py +0 -0
  24. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_ipython.py +0 -0
  25. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_load_context.py +0 -0
  26. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_location.py +0 -0
  27. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_logs.py +0 -0
  28. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_object.py +0 -0
  29. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_output/__init__.py +0 -0
  30. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_output/manager.py +0 -0
  31. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_output/pty.py +0 -0
  32. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_output/rich.py +0 -0
  33. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_output/status.py +0 -0
  34. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_partial_function.py +0 -0
  35. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_resolver.py +0 -0
  36. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_resources.py +0 -0
  37. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_runtime/__init__.py +0 -0
  38. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_runtime/asgi.py +0 -0
  39. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_runtime/container_io_manager.py +0 -0
  40. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_runtime/container_io_manager.pyi +0 -0
  41. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_runtime/execution_context.py +0 -0
  42. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_runtime/execution_context.pyi +0 -0
  43. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_runtime/gpu_memory_snapshot.py +0 -0
  44. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_runtime/telemetry.py +0 -0
  45. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_runtime/user_code_event_loop.py +0 -0
  46. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_runtime/user_code_imports.py +0 -0
  47. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_serialization.py +0 -0
  48. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_server.py +0 -0
  49. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_traceback.py +0 -0
  50. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_tunnel.py +0 -0
  51. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_tunnel.pyi +0 -0
  52. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_type_manager.py +0 -0
  53. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/__init__.py +0 -0
  54. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/app_utils.py +0 -0
  55. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/async_utils.py +0 -0
  56. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/auth_token_manager.py +0 -0
  57. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/blob_utils.py +0 -0
  58. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/browser_utils.py +0 -0
  59. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/bytes_io_segment_payload.py +0 -0
  60. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/deprecation.py +0 -0
  61. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/docker_utils.py +0 -0
  62. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/function_utils.py +0 -0
  63. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/git_utils.py +0 -0
  64. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/grpc_testing.py +0 -0
  65. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/grpc_utils.py +0 -0
  66. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/hash_utils.py +0 -0
  67. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/http_utils.py +0 -0
  68. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/jwt_utils.py +0 -0
  69. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/logger.py +0 -0
  70. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/name_utils.py +0 -0
  71. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/package_utils.py +0 -0
  72. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/pattern_utils.py +0 -0
  73. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/rand_pb_testing.py +0 -0
  74. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/sandbox_fs_utils.py +0 -0
  75. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/shell_utils.py +0 -0
  76. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/task_command_router_client.py +0 -0
  77. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_utils/time_utils.py +0 -0
  78. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_vendor/__init__.py +0 -0
  79. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  80. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_vendor/cloudpickle.py +0 -0
  81. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_vendor/tblib.py +0 -0
  82. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_vendor/version.py +0 -0
  83. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/_watcher.py +0 -0
  84. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/app.py +0 -0
  85. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/app.pyi +0 -0
  86. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/billing.py +0 -0
  87. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/builder/2023.12.312.txt +0 -0
  88. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/builder/2023.12.txt +0 -0
  89. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/builder/2024.04.txt +0 -0
  90. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/builder/2024.10.txt +0 -0
  91. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/builder/2025.06.txt +0 -0
  92. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/builder/PREVIEW.txt +0 -0
  93. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/builder/README.md +0 -0
  94. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/builder/base-images.json +0 -0
  95. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/call_graph.py +0 -0
  96. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/__init__.py +0 -0
  97. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/_download.py +0 -0
  98. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/_traceback.py +0 -0
  99. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/billing.py +0 -0
  100. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/changelog.py +0 -0
  101. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/cluster.py +0 -0
  102. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/config.py +0 -0
  103. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/dashboard.py +0 -0
  104. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/dict.py +0 -0
  105. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/entry_point.py +0 -0
  106. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/environment.py +0 -0
  107. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/import_refs.py +0 -0
  108. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/launch.py +0 -0
  109. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/network_file_system.py +0 -0
  110. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/profile.py +0 -0
  111. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/programs/__init__.py +0 -0
  112. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/programs/run_jupyter.py +0 -0
  113. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/programs/vscode.py +0 -0
  114. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/queues.py +0 -0
  115. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/run.py +0 -0
  116. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/secret.py +0 -0
  117. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/selector.py +0 -0
  118. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/shell.py +0 -0
  119. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/token.py +0 -0
  120. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/utils.py +0 -0
  121. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cli/volume.py +0 -0
  122. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/client.py +0 -0
  123. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cloud_bucket_mount.py +0 -0
  124. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cloud_bucket_mount.pyi +0 -0
  125. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cls.py +0 -0
  126. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/cls.pyi +0 -0
  127. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/config.py +0 -0
  128. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/container_process.py +0 -0
  129. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/container_process.pyi +0 -0
  130. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/dict.py +0 -0
  131. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/dict.pyi +0 -0
  132. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/environments.py +0 -0
  133. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/environments.pyi +0 -0
  134. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/exception.py +0 -0
  135. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/experimental/__init__.py +0 -0
  136. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/experimental/flash.py +0 -0
  137. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/experimental/flash.pyi +0 -0
  138. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/experimental/ipython.py +0 -0
  139. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/file_io.py +0 -0
  140. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/file_io.pyi +0 -0
  141. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/file_pattern_matcher.py +0 -0
  142. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/functions.py +0 -0
  143. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/gpu.py +0 -0
  144. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/io_streams.py +0 -0
  145. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/io_streams.pyi +0 -0
  146. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/mount.py +0 -0
  147. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/mount.pyi +0 -0
  148. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/network_file_system.py +0 -0
  149. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/network_file_system.pyi +0 -0
  150. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/object.py +0 -0
  151. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/object.pyi +0 -0
  152. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/output.py +0 -0
  153. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/parallel_map.py +0 -0
  154. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/parallel_map.pyi +0 -0
  155. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/partial_function.py +0 -0
  156. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/partial_function.pyi +0 -0
  157. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/proxy.py +0 -0
  158. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/proxy.pyi +0 -0
  159. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/py.typed +0 -0
  160. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/queue.py +0 -0
  161. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/queue.pyi +0 -0
  162. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/retries.py +0 -0
  163. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/runner.pyi +0 -0
  164. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/running_app.py +0 -0
  165. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/sandbox.pyi +0 -0
  166. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/sandbox_fs.py +0 -0
  167. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/sandbox_fs.pyi +0 -0
  168. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/schedule.py +0 -0
  169. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/scheduler_placement.py +0 -0
  170. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/secret.py +0 -0
  171. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/secret.pyi +0 -0
  172. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/server.py +0 -0
  173. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/server.pyi +0 -0
  174. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/serving.py +0 -0
  175. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/serving.pyi +0 -0
  176. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/snapshot.py +0 -0
  177. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/snapshot.pyi +0 -0
  178. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/stream_type.py +0 -0
  179. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/token_flow.py +0 -0
  180. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/token_flow.pyi +0 -0
  181. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/volume.py +0 -0
  182. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal/volume.pyi +0 -0
  183. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal.egg-info/SOURCES.txt +0 -0
  184. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal.egg-info/dependency_links.txt +0 -0
  185. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal.egg-info/entry_points.txt +0 -0
  186. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal.egg-info/requires.txt +0 -0
  187. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal.egg-info/top_level.txt +0 -0
  188. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_docs/__init__.py +0 -0
  189. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_docs/gen_cli_docs.py +0 -0
  190. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_docs/gen_cli_docs_main.py +0 -0
  191. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_docs/gen_reference_docs.py +0 -0
  192. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_docs/gen_reference_docs_main.py +0 -0
  193. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_docs/mdmd/__init__.py +0 -0
  194. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_docs/mdmd/mdmd.py +0 -0
  195. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_docs/mdmd/signatures.py +0 -0
  196. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/__init__.py +0 -0
  197. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/api_grpc.py +0 -0
  198. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/api_pb2.py +0 -0
  199. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/api_pb2.pyi +0 -0
  200. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/api_pb2_grpc.py +0 -0
  201. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/api_pb2_grpc.pyi +0 -0
  202. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/modal_api_grpc.py +0 -0
  203. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/py.typed +0 -0
  204. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/task_command_router_grpc.py +0 -0
  205. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/task_command_router_pb2.py +0 -0
  206. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/task_command_router_pb2.pyi +0 -0
  207. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/task_command_router_pb2_grpc.py +0 -0
  208. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_proto/task_command_router_pb2_grpc.pyi +0 -0
  209. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/modal_version/__main__.py +0 -0
  210. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/pyproject.toml +0 -0
  211. {modal-1.3.6.dev12 → modal-1.3.6.dev13}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.3.6.dev12
3
+ Version: 1.3.6.dev13
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -56,7 +56,7 @@ from ._utils.function_utils import (
56
56
  is_async,
57
57
  )
58
58
  from ._utils.grpc_utils import Retry, RetryWarningMessage
59
- from ._utils.mount_utils import validate_network_file_systems, validate_volumes
59
+ from ._utils.mount_utils import validate_network_file_systems, validate_volumes, validate_volumes_by_object_id
60
60
  from .call_graph import InputInfo, _reconstruct_call_graph
61
61
  from .client import _Client
62
62
  from .cloud_bucket_mount import _CloudBucketMount, cloud_bucket_mounts_to_proto
@@ -966,6 +966,11 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
966
966
  if image._metadata is not None:
967
967
  mount_client_dependencies = image._metadata.image_builder_version > "2024.10"
968
968
 
969
+ # Validate that the same volume (by object_id) isn't mounted at multiple paths
970
+ # This validation happens here (at load time) because volumes need to be hydrated
971
+ # to have their object_id set.
972
+ validate_volumes_by_object_id(validated_volumes_no_cloud_buckets)
973
+
969
974
  # Relies on dicts being ordered (true as of Python 3.6).
970
975
  volume_mounts = [
971
976
  api_pb2.VolumeMount(
@@ -1235,6 +1240,9 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1235
1240
  assert parent is not None and parent.is_hydrated
1236
1241
 
1237
1242
  if options:
1243
+ # Validate that the same volume (by object_id) isn't mounted at multiple paths
1244
+ validate_volumes_by_object_id(options.validated_volumes)
1245
+
1238
1246
  volume_mounts = [
1239
1247
  api_pb2.VolumeMount(
1240
1248
  mount_path=path,
@@ -101,3 +101,23 @@ def validate_only_modal_volumes(
101
101
  raise InvalidError(f"{caller_name} only supports volumes that are modal.Volume")
102
102
 
103
103
  return validated_volumes
104
+
105
+
106
+ def validate_volumes_by_object_id(validated_volumes: Sequence[tuple[str, _Volume]]) -> None:
107
+ """Validate that the same volume (by object_id) is not mounted at multiple paths.
108
+
109
+ This validation happens at load time after volumes are hydrated and have object_ids.
110
+ """
111
+ object_id_to_paths: dict[str, list[str]] = {}
112
+ for path, volume in validated_volumes:
113
+ if not volume.is_hydrated:
114
+ # This should never happen since this function is only called at load time
115
+ raise RuntimeError(f"Internal error: Volume at '{path}' is not hydrated when validating mounts")
116
+ object_id_to_paths.setdefault(volume.object_id, []).append(path)
117
+
118
+ for object_id, paths in object_id_to_paths.items():
119
+ if len(paths) > 1:
120
+ conflicting = ", ".join(sorted(paths))
121
+ raise InvalidError(
122
+ f"The same Volume cannot be mounted in multiple locations for the same function: {conflicting}"
123
+ )
@@ -137,13 +137,14 @@ def logs(
137
137
  "--until",
138
138
  help="End of time range; accepts same argument types as --since",
139
139
  ),
140
+ tail: Optional[int] = typer.Option(None, "--tail", "-n", help="Show only the last N log entries"),
140
141
  search: Optional[str] = typer.Option(None, "--search", help="Filter by search text"),
141
142
  function_id: Optional[str] = typer.Option("", "--function", help="Filter by Function ID (fu-*)"),
142
143
  function_call_id: Optional[str] = typer.Option("", "--function-call", help="Filter by FunctionCall ID (fc-*)"),
144
+ container_id: Optional[str] = typer.Option("", "--container", help="Filter by Container ID (ta-*)"),
143
145
  source: Optional[str] = typer.Option(
144
146
  None, "--source", "-s", help="Filter by source: 'stdout', 'stderr', or 'system'"
145
147
  ),
146
- tail: Optional[int] = typer.Option(None, "--tail", "-n", help="Show only the last N log entries"),
147
148
  timestamps: bool = typer.Option(False, "--timestamps", help="Prefix each line with its timestamp"),
148
149
  show_function_id: bool = typer.Option(False, "--show-function-id", help="Prefix each line with its Function ID"),
149
150
  show_function_call_id: bool = typer.Option(
@@ -236,12 +237,14 @@ def logs(
236
237
  source=source_fd,
237
238
  function_id=function_id or "",
238
239
  function_call_id=function_call_id or "",
240
+ task_id=container_id or "",
239
241
  search_text=search or "",
240
242
  )
241
243
 
242
244
  if follow:
243
245
  stream_app_logs(
244
246
  app_id,
247
+ task_id=container_id or "",
245
248
  show_timestamps=timestamps,
246
249
  follow=True,
247
250
  prefix_fields=prefix_fields,
@@ -0,0 +1,264 @@
1
+ # Copyright Modal Labs 2022
2
+ from datetime import datetime, timezone
3
+ from typing import Optional, Union
4
+
5
+ import typer
6
+ from click import UsageError
7
+ from rich.table import Column
8
+ from rich.text import Text
9
+
10
+ from modal._logs import _FETCH_LIMIT, _MAX_FETCH_RANGE, LogsFilters
11
+ from modal._object import _get_environment_name
12
+ from modal._output.pty import get_pty_info
13
+ from modal._utils.async_utils import synchronizer
14
+ from modal._utils.time_utils import timestamp_to_localized_str
15
+ from modal.cli.app import _DEFAULT_LOGS_TAIL, _SOURCE_OPTIONS, _parse_time_arg
16
+ from modal.cli.utils import ENV_OPTION, display_table, fetch_app_logs, is_tty, stream_app_logs, tail_app_logs
17
+ from modal.client import _Client
18
+ from modal.config import config
19
+ from modal.container_process import _ContainerProcess
20
+ from modal.environments import ensure_env
21
+ from modal.exception import InvalidError
22
+ from modal.stream_type import StreamType
23
+ from modal_proto import api_pb2
24
+
25
+ container_cli = typer.Typer(name="container", help="Manage and connect to running containers.", no_args_is_help=True)
26
+
27
+
28
+ @container_cli.command("list")
29
+ @synchronizer.create_blocking
30
+ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False, app_id: str = ""):
31
+ """List all containers that are currently running."""
32
+ env = ensure_env(env)
33
+ client = await _Client.from_env()
34
+ environment_name = _get_environment_name(env)
35
+ res: api_pb2.TaskListResponse = await client.stub.TaskList(
36
+ api_pb2.TaskListRequest(environment_name=environment_name, app_id=app_id)
37
+ )
38
+
39
+ column_names: list[Union[Column, str]] = [
40
+ Column("Container ID", min_width=29),
41
+ Column("App ID", min_width=25),
42
+ "App Name",
43
+ "Start Time",
44
+ ]
45
+ rows: list[list[Union[Text, str]]] = []
46
+ res.tasks.sort(key=lambda task: task.started_at, reverse=True)
47
+ for task_stats in res.tasks:
48
+ rows.append(
49
+ [
50
+ task_stats.task_id,
51
+ task_stats.app_id,
52
+ task_stats.app_description,
53
+ timestamp_to_localized_str(task_stats.started_at, json) if task_stats.started_at else "Pending",
54
+ ]
55
+ )
56
+
57
+ display_table(column_names, rows, json=json, title=f"Active Containers in environment: {environment_name}")
58
+
59
+
60
+ @container_cli.command("logs", no_args_is_help=True)
61
+ @synchronizer.create_blocking
62
+ async def logs(
63
+ container_id: str = typer.Argument(help="Container ID"),
64
+ follow: bool = typer.Option(False, "-f", "--follow", help="Stream log output until Container stops"),
65
+ all_logs: bool = typer.Option(False, "--all", help="Show all logs for the container"),
66
+ since: Optional[str] = typer.Option(
67
+ None,
68
+ "--since",
69
+ help=(
70
+ "Start of time range. Accepts ISO 8601 datetime or relative time, e.g. '1d' (1 day ago), '2h', '30m', etc."
71
+ ),
72
+ ),
73
+ until: Optional[str] = typer.Option(
74
+ None,
75
+ "--until",
76
+ help="End of time range; accepts same argument types as --since",
77
+ ),
78
+ tail: Optional[int] = typer.Option(None, "--tail", "-n", help="Show only the last N log entries"),
79
+ search: Optional[str] = typer.Option(None, "--search", help="Filter by search text"),
80
+ source: Optional[str] = typer.Option(
81
+ None, "--source", "-s", help="Filter by source: 'stdout', 'stderr', or 'system'"
82
+ ),
83
+ timestamps: bool = typer.Option(False, "--timestamps", help="Prefix each line with its timestamp"),
84
+ ):
85
+ """Fetch or stream logs for a specific container.
86
+
87
+ By default, this command fetches the last 100 log entries and exits. Use ``-f`` to
88
+ live-stream logs from a running container instead. Fetch and follow are mutually exclusive.
89
+
90
+ **Examples:**
91
+
92
+ Get recent logs for a container:
93
+
94
+ ```
95
+ modal container logs ta-123456
96
+ ```
97
+
98
+ Follow (stream) logs from a running container:
99
+
100
+ ```
101
+ modal container logs ta-123456 -f
102
+ ```
103
+
104
+ Fetch logs from the last 2 hours:
105
+
106
+ ```
107
+ modal container logs ta-123456 --since 2h
108
+ ```
109
+
110
+ Fetch logs in a specific time range:
111
+
112
+ ```
113
+ modal container logs ta-123456 --since 2026-03-01T05:00:00 --until 2026-03-01T08:00:00
114
+ ```
115
+
116
+ Fetch all container logs:
117
+
118
+ ```
119
+ modal container logs ta-123456 --all
120
+ ```
121
+ """
122
+ task_id, sandbox_id = None, None
123
+ if container_id.startswith("sb-"):
124
+ sandbox_id = container_id
125
+ elif container_id.startswith("ta-"):
126
+ task_id = container_id
127
+ else:
128
+ raise InvalidError(f"Invalid container ID: {container_id}")
129
+
130
+ if follow and (since or until or tail):
131
+ raise UsageError("--follow cannot be combined with --since, --until, or --tail.")
132
+
133
+ if tail is not None and tail <= 0:
134
+ raise UsageError("--tail value must be positive.")
135
+
136
+ if tail is not None and tail > _FETCH_LIMIT:
137
+ raise UsageError(f"--tail value must not exceed {_FETCH_LIMIT}.")
138
+
139
+ if all_logs and (since or until or tail):
140
+ raise UsageError("--all cannot be combined with --since, --until, or --tail.")
141
+
142
+ if all_logs and follow:
143
+ raise UsageError("--all cannot be combined with --follow.")
144
+
145
+ if source is not None:
146
+ if source not in _SOURCE_OPTIONS:
147
+ raise UsageError(f"Invalid source: '{source}'. Must be 'stdout', 'stderr', or 'system'.")
148
+ source_fd = _SOURCE_OPTIONS[source]
149
+ else:
150
+ source_fd = api_pb2.FILE_DESCRIPTOR_UNSPECIFIED
151
+
152
+ log_filters = LogsFilters(
153
+ source=source_fd,
154
+ task_id=task_id or "",
155
+ sandbox_id=sandbox_id or "",
156
+ search_text=search or "",
157
+ )
158
+
159
+ if follow:
160
+ await stream_app_logs.aio(
161
+ task_id=task_id,
162
+ sandbox_id=sandbox_id,
163
+ show_timestamps=timestamps,
164
+ follow=True,
165
+ filters=log_filters,
166
+ )
167
+ else:
168
+ # Resolve the app_id for the container.
169
+ client = await _Client.from_env()
170
+
171
+ if sandbox_id:
172
+ sb_resp = await client.stub.SandboxGetTaskId(api_pb2.SandboxGetTaskIdRequest(sandbox_id=sandbox_id))
173
+ task_id = sb_resp.task_id
174
+
175
+ task_info_resp = await client.stub.TaskGetInfo(api_pb2.TaskGetInfoRequest(task_id=task_id))
176
+ app_id = task_info_resp.app_id
177
+
178
+ now = datetime.now(timezone.utc)
179
+ if all_logs:
180
+ since_dt = datetime.fromtimestamp(task_info_resp.info.started_at, timezone.utc)
181
+ if task_info_resp.info.finished_at:
182
+ until_dt = datetime.fromtimestamp(task_info_resp.info.finished_at, timezone.utc)
183
+ else:
184
+ until_dt = now
185
+ else:
186
+ since_dt = _parse_time_arg(since, default=now) if since else None
187
+ until_dt = _parse_time_arg(until, default=now) if until else None
188
+
189
+ if since_dt is not None and until_dt is not None and since_dt >= until_dt:
190
+ raise UsageError("--since must be before --until.")
191
+
192
+ if since_dt is not None:
193
+ effective_until = until_dt or now
194
+ if effective_until - since_dt > _MAX_FETCH_RANGE:
195
+ raise UsageError(f"Log fetch time range cannot exceed {_MAX_FETCH_RANGE.days} days.")
196
+
197
+ if all_logs or (since and tail is None):
198
+ # Range mode: --since without --tail fetches everything in the range.
199
+ await fetch_app_logs.aio(
200
+ app_id,
201
+ since_dt,
202
+ until_dt or now,
203
+ show_timestamps=timestamps,
204
+ filters=log_filters,
205
+ )
206
+ else:
207
+ # Tail mode: single fetch with limit.
208
+ # --since is a hard floor, --until shifts the anchor.
209
+ effective_tail = tail if tail is not None else _DEFAULT_LOGS_TAIL
210
+ await tail_app_logs.aio(
211
+ app_id,
212
+ effective_tail,
213
+ show_timestamps=timestamps,
214
+ since=since_dt,
215
+ until=until_dt,
216
+ filters=log_filters,
217
+ )
218
+
219
+
220
+ @container_cli.command("exec")
221
+ @synchronizer.create_blocking
222
+ async def exec(
223
+ pty: Optional[bool] = typer.Option(default=None, help="Run the command using a PTY."),
224
+ container_id: str = typer.Argument(help="Container ID"),
225
+ command: list[str] = typer.Argument(
226
+ help="A command to run inside the container.\n\n"
227
+ "To pass command-line flags or options, add `--` before the start of your commands. "
228
+ "For example: `modal container exec <id> -- /bin/bash -c 'echo hi'`"
229
+ ),
230
+ ):
231
+ """Execute a command in a container."""
232
+
233
+ if pty is None:
234
+ pty = is_tty()
235
+
236
+ client = await _Client.from_env()
237
+
238
+ req = api_pb2.ContainerExecRequest(
239
+ task_id=container_id,
240
+ command=command,
241
+ pty_info=get_pty_info(shell=True) if pty else None,
242
+ runtime_debug=config.get("function_runtime_debug"),
243
+ )
244
+ res: api_pb2.ContainerExecResponse = await client.stub.ContainerExec(req)
245
+
246
+ if pty:
247
+ await _ContainerProcess(res.exec_id, container_id, client).attach()
248
+ else:
249
+ # TODO: redirect stderr to its own stream?
250
+ await _ContainerProcess(
251
+ res.exec_id, container_id, client, stdout=StreamType.STDOUT, stderr=StreamType.STDOUT
252
+ ).wait()
253
+
254
+
255
+ @container_cli.command("stop")
256
+ @synchronizer.create_blocking
257
+ async def stop(container_id: str = typer.Argument(help="Container ID")):
258
+ """Stop a currently-running container and reassign its in-progress inputs.
259
+
260
+ This will send the container a SIGINT signal that Modal will handle.
261
+ """
262
+ client = await _Client.from_env()
263
+ request = api_pb2.ContainerStopRequest(task_id=container_id)
264
+ await client.stub.ContainerStop(request)
@@ -35,7 +35,7 @@ class _Client:
35
35
  server_url: str,
36
36
  client_type: int,
37
37
  credentials: typing.Optional[tuple[str, str]],
38
- version: str = "1.3.6.dev12",
38
+ version: str = "1.3.6.dev13",
39
39
  ):
40
40
  """mdmd:hidden
41
41
  The Modal client object is not intended to be instantiated directly by users.
@@ -171,7 +171,7 @@ class Client:
171
171
  server_url: str,
172
172
  client_type: int,
173
173
  credentials: typing.Optional[tuple[str, str]],
174
- version: str = "1.3.6.dev12",
174
+ version: str = "1.3.6.dev13",
175
175
  ):
176
176
  """mdmd:hidden
177
177
  The Modal client object is not intended to be instantiated directly by users.
@@ -408,7 +408,7 @@ class Function(
408
408
 
409
409
  _call_generator: ___call_generator_spec
410
410
 
411
- class __remote_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
411
+ class __remote_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
412
412
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER:
413
413
  """Calls the function remotely, executing it with the given arguments and returning the execution's result."""
414
414
  ...
@@ -417,7 +417,7 @@ class Function(
417
417
  """Calls the function remotely, executing it with the given arguments and returning the execution's result."""
418
418
  ...
419
419
 
420
- remote: __remote_spec[modal._functions.P, modal._functions.ReturnType]
420
+ remote: __remote_spec[modal._functions.ReturnType, modal._functions.P]
421
421
 
422
422
  class __remote_gen_spec(typing_extensions.Protocol):
423
423
  def __call__(self, /, *args, **kwargs) -> typing.Generator[typing.Any, None, None]:
@@ -444,7 +444,7 @@ class Function(
444
444
  """
445
445
  ...
446
446
 
447
- class ___experimental_spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
447
+ class ___experimental_spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
448
448
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
449
449
  """[Experimental] Calls the function with the given arguments, without waiting for the results.
450
450
 
@@ -467,7 +467,7 @@ class Function(
467
467
  """
468
468
  ...
469
469
 
470
- _experimental_spawn: ___experimental_spawn_spec[modal._functions.P, modal._functions.ReturnType]
470
+ _experimental_spawn: ___experimental_spawn_spec[modal._functions.ReturnType, modal._functions.P]
471
471
 
472
472
  class ___spawn_map_inner_spec(typing_extensions.Protocol[P_INNER]):
473
473
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> None: ...
@@ -475,7 +475,7 @@ class Function(
475
475
 
476
476
  _spawn_map_inner: ___spawn_map_inner_spec[modal._functions.P]
477
477
 
478
- class __spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
478
+ class __spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
479
479
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
480
480
  """Calls the function with the given arguments, without waiting for the results.
481
481
 
@@ -496,7 +496,7 @@ class Function(
496
496
  """
497
497
  ...
498
498
 
499
- spawn: __spawn_spec[modal._functions.P, modal._functions.ReturnType]
499
+ spawn: __spawn_spec[modal._functions.ReturnType, modal._functions.P]
500
500
 
501
501
  def get_raw_f(self) -> collections.abc.Callable[..., typing.Any]:
502
502
  """Return the inner Python object wrapped by this Modal Function."""
@@ -40,7 +40,7 @@ from ._utils.docker_utils import (
40
40
  find_dockerignore_file,
41
41
  )
42
42
  from ._utils.function_utils import FunctionInfo
43
- from ._utils.mount_utils import validate_only_modal_volumes
43
+ from ._utils.mount_utils import validate_only_modal_volumes, validate_volumes_by_object_id
44
44
  from .client import _Client
45
45
  from .cloud_bucket_mount import _CloudBucketMount
46
46
  from .config import config, logger, user_config_path
@@ -434,7 +434,7 @@ class _Image(_Object, type_prefix="im"):
434
434
  self._serve_mounts = other._serve_mounts
435
435
  self._deferred_mounts = other._deferred_mounts
436
436
  self._added_python_source_set = other._added_python_source_set
437
- self._is_empty = other._is_empty
437
+ self._is_empty = False
438
438
 
439
439
  def _get_metadata(self) -> Optional[Message]:
440
440
  return self._metadata
@@ -626,6 +626,9 @@ class _Image(_Object, type_prefix="im"):
626
626
  build_function_id = ""
627
627
  _build_function = None
628
628
 
629
+ # Validate that the same volume (by object_id) isn't mounted at multiple paths
630
+ validate_volumes_by_object_id(validated_volumes)
631
+
629
632
  # Relies on dicts being ordered (true as of Python 3.6).
630
633
  volume_mounts = [
631
634
  api_pb2.VolumeMount(
@@ -724,12 +727,6 @@ class _Image(_Object, type_prefix="im"):
724
727
  )
725
728
  return obj
726
729
 
727
- @staticmethod
728
- def _from_scratch() -> "_Image":
729
- image = _Image.from_registry("scratch")
730
- image._is_empty = True
731
- return image
732
-
733
730
  def _copy_mount(self, mount: _Mount, remote_path: Union[str, Path] = ".") -> "_Image":
734
731
  """mdmd:hidden
735
732
  Internal
@@ -2179,6 +2176,36 @@ class _Image(_Object, type_prefix="im"):
2179
2176
  force_build=force_build,
2180
2177
  )
2181
2178
 
2179
+ @staticmethod
2180
+ def from_scratch(force_build: bool = False) -> "_Image":
2181
+ """Create an empty Image, equivalent to `FROM scratch` in Docker.
2182
+
2183
+ The resulting Image has no operating system, shell, or package manager. It is
2184
+ primarily useful as a lightweight filesystem to mount into a Sandbox via
2185
+ `Sandbox.mount_image`.
2186
+
2187
+ Note that since this Image doesn't contain Python or other standard OS utilities,
2188
+ higher-level Image build steps like `pip_install` cannot be chained onto it. It also
2189
+ cannot be used for `modal.Function` execution, which requires a Python interpreter.
2190
+
2191
+ **Example**
2192
+
2193
+ ```python notest
2194
+ image = modal.Image.from_scratch().add_local_file(local_path, "/bin/my_binary", copy=True)
2195
+ ```
2196
+ """
2197
+
2198
+ def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
2199
+ return DockerfileSpec(commands=["FROM scratch"], context_files={})
2200
+
2201
+ image = _Image._from_args(
2202
+ dockerfile_function=build_dockerfile,
2203
+ force_build=force_build,
2204
+ _namespace=api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
2205
+ )
2206
+ image._is_empty = True
2207
+ return image
2208
+
2182
2209
  @staticmethod
2183
2210
  def debian_slim(python_version: Optional[str] = None, force_build: bool = False) -> "_Image":
2184
2211
  """Default image, based on the official `python` Docker images."""
@@ -187,8 +187,6 @@ class _Image(modal._object._Object):
187
187
  _namespace: int = 1,
188
188
  _do_assert_no_mount_layers: bool = True,
189
189
  ): ...
190
- @staticmethod
191
- def _from_scratch() -> _Image: ...
192
190
  def _copy_mount(self, mount: modal.mount._Mount, remote_path: typing.Union[str, pathlib.Path] = ".") -> _Image:
193
191
  """mdmd:hidden
194
192
  Internal
@@ -901,6 +899,26 @@ class _Image(modal._object._Object):
901
899
  """
902
900
  ...
903
901
 
902
+ @staticmethod
903
+ def from_scratch(force_build: bool = False) -> _Image:
904
+ """Create an empty Image, equivalent to `FROM scratch` in Docker.
905
+
906
+ The resulting Image has no operating system, shell, or package manager. It is
907
+ primarily useful as a lightweight filesystem to mount into a Sandbox via
908
+ `Sandbox.mount_image`.
909
+
910
+ Note that since this Image doesn't contain Python or other standard OS utilities,
911
+ higher-level Image build steps like `pip_install` cannot be chained onto it. It also
912
+ cannot be used for `modal.Function` execution, which requires a Python interpreter.
913
+
914
+ **Example**
915
+
916
+ ```python notest
917
+ image = modal.Image.from_scratch().add_local_file(local_path, "/bin/my_binary", copy=True)
918
+ ```
919
+ """
920
+ ...
921
+
904
922
  @staticmethod
905
923
  def debian_slim(python_version: typing.Optional[str] = None, force_build: bool = False) -> _Image:
906
924
  """Default image, based on the official `python` Docker images."""
@@ -1109,8 +1127,6 @@ class Image(modal.object.Object):
1109
1127
  _namespace: int = 1,
1110
1128
  _do_assert_no_mount_layers: bool = True,
1111
1129
  ): ...
1112
- @staticmethod
1113
- def _from_scratch() -> Image: ...
1114
1130
  def _copy_mount(self, mount: modal.mount.Mount, remote_path: typing.Union[str, pathlib.Path] = ".") -> Image:
1115
1131
  """mdmd:hidden
1116
1132
  Internal
@@ -1879,6 +1895,26 @@ class Image(modal.object.Object):
1879
1895
  """
1880
1896
  ...
1881
1897
 
1898
+ @staticmethod
1899
+ def from_scratch(force_build: bool = False) -> Image:
1900
+ """Create an empty Image, equivalent to `FROM scratch` in Docker.
1901
+
1902
+ The resulting Image has no operating system, shell, or package manager. It is
1903
+ primarily useful as a lightweight filesystem to mount into a Sandbox via
1904
+ `Sandbox.mount_image`.
1905
+
1906
+ Note that since this Image doesn't contain Python or other standard OS utilities,
1907
+ higher-level Image build steps like `pip_install` cannot be chained onto it. It also
1908
+ cannot be used for `modal.Function` execution, which requires a Python interpreter.
1909
+
1910
+ **Example**
1911
+
1912
+ ```python notest
1913
+ image = modal.Image.from_scratch().add_local_file(local_path, "/bin/my_binary", copy=True)
1914
+ ```
1915
+ """
1916
+ ...
1917
+
1882
1918
  @staticmethod
1883
1919
  def debian_slim(python_version: typing.Optional[str] = None, force_build: bool = False) -> Image:
1884
1920
  """Default image, based on the official `python` Docker images."""
@@ -214,23 +214,32 @@ async def _stop_and_wait_for_containers(
214
214
  if container.enqueued_at and container.enqueued_at < deployed_at
215
215
  ]
216
216
 
217
+ stopped_ids = set()
218
+
217
219
  async def stop_containers(container_ids: list[str]):
218
220
  sem = asyncio.Semaphore(32)
219
221
 
220
222
  async def stop_one_container(tid: str):
223
+ if tid in stopped_ids:
224
+ return
225
+
221
226
  async with sem:
222
227
  await client.stub.ContainerStop(api_pb2.ContainerStopRequest(task_id=tid))
228
+ stopped_ids.add(tid)
223
229
 
224
- results = await asyncio.gather(*[stop_one_container(tid) for tid in container_ids], return_exceptions=True)
230
+ stop_tasks = [stop_one_container(tid) for tid in container_ids if tid not in stopped_ids]
231
+ if not stop_tasks:
232
+ return
233
+
234
+ results = await asyncio.gather(*stop_tasks, return_exceptions=True)
225
235
  exceptions = [r for r in results if isinstance(r, BaseException)]
226
236
  if exceptions:
227
237
  raise exceptions[0]
228
238
 
229
- async def poll_until_stopped(initial_ids: list[str]):
230
- await stop_containers(initial_ids)
231
- await asyncio.sleep(WAIT_FOR_CONTAINER_STOP_SLEEP_INTERVAL)
232
-
239
+ async def poll_until_stopped():
233
240
  while ids := await get_old_container_ids():
241
+ # Just in case there are new ids from `get_old_container_ids`, we call `stop_containers` again,
242
+ # which will no-op for containers that was already stopped.
234
243
  await stop_containers(ids)
235
244
  await asyncio.sleep(WAIT_FOR_CONTAINER_STOP_SLEEP_INTERVAL)
236
245
 
@@ -239,12 +248,13 @@ async def _stop_and_wait_for_containers(
239
248
  return
240
249
 
241
250
  output = OutputManager.get()
242
- output.print("♻️ Restarting containers...")
251
+ output.print("🧹 Terminating running containers")
252
+ await stop_containers(ids)
243
253
 
244
254
  try:
245
- await asyncio.wait_for(poll_until_stopped(ids), timeout=WAIT_FOR_CONTAINER_STOP_TIMEOUT)
255
+ await asyncio.wait_for(poll_until_stopped(), timeout=WAIT_FOR_CONTAINER_STOP_TIMEOUT)
246
256
  except asyncio.TimeoutError:
247
- raise asyncio.TimeoutError(f"Containers did not restart in under {WAIT_FOR_CONTAINER_STOP_TIMEOUT} seconds.")
257
+ raise asyncio.TimeoutError(f"Containers did not terminate in under {WAIT_FOR_CONTAINER_STOP_TIMEOUT} seconds.")
248
258
 
249
259
 
250
260
  def _validate_deployment_strategy(strategy: str) -> DEPLOYMENT_STRATEGY_TYPE:
@@ -294,7 +304,7 @@ async def _publish_app(
294
304
  )
295
305
  except Exception as exc:
296
306
  warnings.warn(
297
- f"App updated successfully, but containers did not all restart. {exc}",
307
+ f"App updated successfully, but containers did not all terminate. {exc}",
298
308
  UserWarning,
299
309
  )
300
310
 
@@ -30,7 +30,7 @@ from ._resolver import Resolver
30
30
  from ._resources import convert_fn_config_to_resources_config
31
31
  from ._utils.async_utils import TaskContext, synchronize_api
32
32
  from ._utils.deprecation import deprecation_warning
33
- from ._utils.mount_utils import validate_network_file_systems, validate_volumes
33
+ from ._utils.mount_utils import validate_network_file_systems, validate_volumes, validate_volumes_by_object_id
34
34
  from ._utils.name_utils import check_object_name
35
35
  from ._utils.task_command_router_client import TaskCommandRouterClient
36
36
  from .client import _Client
@@ -214,6 +214,9 @@ class _Sandbox(_Object, type_prefix="sb"):
214
214
  async def _load(
215
215
  self: _Sandbox, resolver: Resolver, load_context: LoadContext, _existing_object_id: Optional[str]
216
216
  ):
217
+ # Validate that the same volume (by object_id) isn't mounted at multiple paths
218
+ validate_volumes_by_object_id(validated_volumes)
219
+
217
220
  # Relies on dicts being ordered (true as of Python 3.6).
218
221
  volume_mounts = [
219
222
  api_pb2.VolumeMount(
@@ -739,7 +742,7 @@ class _Sandbox(_Object, type_prefix="sb"):
739
742
  "The `Sandbox._experimental_mount_image()` method is deprecated. Use `Sandbox.mount_image()` instead.",
740
743
  )
741
744
  if image is None:
742
- image = _Image._from_scratch()
745
+ image = _Image.from_scratch()
743
746
  await self.mount_image(path, image)
744
747
 
745
748
  async def snapshot_directory(self, path: Union[PurePosixPath, str]) -> _Image: