modAL 1.3.3__tar.gz → 1.3.4__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 (203) hide show
  1. {modal-1.3.3 → modal-1.3.4}/PKG-INFO +1 -1
  2. {modal-1.3.3 → modal-1.3.4}/modal/_utils/grpc_utils.py +17 -1
  3. {modal-1.3.3 → modal-1.3.4}/modal/_utils/task_command_router_client.py +3 -1
  4. {modal-1.3.3 → modal-1.3.4}/modal/app.py +5 -0
  5. {modal-1.3.3 → modal-1.3.4}/modal/app.pyi +7 -0
  6. {modal-1.3.3 → modal-1.3.4}/modal/client.pyi +2 -2
  7. {modal-1.3.3 → modal-1.3.4}/modal/cloud_bucket_mount.py +3 -1
  8. {modal-1.3.3 → modal-1.3.4}/modal/cloud_bucket_mount.pyi +3 -1
  9. {modal-1.3.3 → modal-1.3.4}/modal/gpu.py +3 -0
  10. {modal-1.3.3 → modal-1.3.4}/modal/image.py +9 -0
  11. {modal-1.3.3 → modal-1.3.4}/modal/image.pyi +6 -0
  12. {modal-1.3.3 → modal-1.3.4}/modal/io_streams.py +4 -1
  13. {modal-1.3.3 → modal-1.3.4}/modal/network_file_system.py +1 -0
  14. {modal-1.3.3 → modal-1.3.4}/modal/network_file_system.pyi +3 -1
  15. {modal-1.3.3 → modal-1.3.4}/modal/sandbox.py +148 -18
  16. {modal-1.3.3 → modal-1.3.4}/modal/sandbox.pyi +203 -29
  17. {modal-1.3.3 → modal-1.3.4}/modal/volume.py +44 -0
  18. {modal-1.3.3 → modal-1.3.4}/modal/volume.pyi +52 -0
  19. {modal-1.3.3 → modal-1.3.4}/modal.egg-info/PKG-INFO +1 -1
  20. {modal-1.3.3 → modal-1.3.4}/modal_docs/gen_reference_docs.py +1 -0
  21. {modal-1.3.3 → modal-1.3.4}/modal_docs/mdmd/mdmd.py +10 -2
  22. {modal-1.3.3 → modal-1.3.4}/modal_proto/api.proto +10 -0
  23. {modal-1.3.3 → modal-1.3.4}/modal_proto/api_grpc.py +16 -0
  24. {modal-1.3.3 → modal-1.3.4}/modal_proto/api_pb2.py +155 -135
  25. {modal-1.3.3 → modal-1.3.4}/modal_proto/api_pb2.pyi +33 -0
  26. {modal-1.3.3 → modal-1.3.4}/modal_proto/api_pb2_grpc.py +33 -0
  27. {modal-1.3.3 → modal-1.3.4}/modal_proto/api_pb2_grpc.pyi +10 -0
  28. {modal-1.3.3 → modal-1.3.4}/modal_proto/modal_api_grpc.py +1 -0
  29. {modal-1.3.3 → modal-1.3.4}/modal_version/__init__.py +1 -1
  30. {modal-1.3.3 → modal-1.3.4}/LICENSE +0 -0
  31. {modal-1.3.3 → modal-1.3.4}/README.md +0 -0
  32. {modal-1.3.3 → modal-1.3.4}/modal/__init__.py +0 -0
  33. {modal-1.3.3 → modal-1.3.4}/modal/__main__.py +0 -0
  34. {modal-1.3.3 → modal-1.3.4}/modal/_billing.py +0 -0
  35. {modal-1.3.3 → modal-1.3.4}/modal/_clustered_functions.py +0 -0
  36. {modal-1.3.3 → modal-1.3.4}/modal/_clustered_functions.pyi +0 -0
  37. {modal-1.3.3 → modal-1.3.4}/modal/_container_entrypoint.py +0 -0
  38. {modal-1.3.3 → modal-1.3.4}/modal/_functions.py +0 -0
  39. {modal-1.3.3 → modal-1.3.4}/modal/_grpc_client.py +0 -0
  40. {modal-1.3.3 → modal-1.3.4}/modal/_ipython.py +0 -0
  41. {modal-1.3.3 → modal-1.3.4}/modal/_load_context.py +0 -0
  42. {modal-1.3.3 → modal-1.3.4}/modal/_location.py +0 -0
  43. {modal-1.3.3 → modal-1.3.4}/modal/_object.py +0 -0
  44. {modal-1.3.3 → modal-1.3.4}/modal/_output/__init__.py +0 -0
  45. {modal-1.3.3 → modal-1.3.4}/modal/_output/manager.py +0 -0
  46. {modal-1.3.3 → modal-1.3.4}/modal/_output/pty.py +0 -0
  47. {modal-1.3.3 → modal-1.3.4}/modal/_output/rich.py +0 -0
  48. {modal-1.3.3 → modal-1.3.4}/modal/_output/status.py +0 -0
  49. {modal-1.3.3 → modal-1.3.4}/modal/_partial_function.py +0 -0
  50. {modal-1.3.3 → modal-1.3.4}/modal/_resolver.py +0 -0
  51. {modal-1.3.3 → modal-1.3.4}/modal/_resources.py +0 -0
  52. {modal-1.3.3 → modal-1.3.4}/modal/_runtime/__init__.py +0 -0
  53. {modal-1.3.3 → modal-1.3.4}/modal/_runtime/asgi.py +0 -0
  54. {modal-1.3.3 → modal-1.3.4}/modal/_runtime/container_io_manager.py +0 -0
  55. {modal-1.3.3 → modal-1.3.4}/modal/_runtime/container_io_manager.pyi +0 -0
  56. {modal-1.3.3 → modal-1.3.4}/modal/_runtime/execution_context.py +0 -0
  57. {modal-1.3.3 → modal-1.3.4}/modal/_runtime/execution_context.pyi +0 -0
  58. {modal-1.3.3 → modal-1.3.4}/modal/_runtime/gpu_memory_snapshot.py +0 -0
  59. {modal-1.3.3 → modal-1.3.4}/modal/_runtime/telemetry.py +0 -0
  60. {modal-1.3.3 → modal-1.3.4}/modal/_runtime/user_code_event_loop.py +0 -0
  61. {modal-1.3.3 → modal-1.3.4}/modal/_runtime/user_code_imports.py +0 -0
  62. {modal-1.3.3 → modal-1.3.4}/modal/_serialization.py +0 -0
  63. {modal-1.3.3 → modal-1.3.4}/modal/_server.py +0 -0
  64. {modal-1.3.3 → modal-1.3.4}/modal/_traceback.py +0 -0
  65. {modal-1.3.3 → modal-1.3.4}/modal/_tunnel.py +0 -0
  66. {modal-1.3.3 → modal-1.3.4}/modal/_tunnel.pyi +0 -0
  67. {modal-1.3.3 → modal-1.3.4}/modal/_type_manager.py +0 -0
  68. {modal-1.3.3 → modal-1.3.4}/modal/_utils/__init__.py +0 -0
  69. {modal-1.3.3 → modal-1.3.4}/modal/_utils/app_utils.py +0 -0
  70. {modal-1.3.3 → modal-1.3.4}/modal/_utils/async_utils.py +0 -0
  71. {modal-1.3.3 → modal-1.3.4}/modal/_utils/auth_token_manager.py +0 -0
  72. {modal-1.3.3 → modal-1.3.4}/modal/_utils/blob_utils.py +0 -0
  73. {modal-1.3.3 → modal-1.3.4}/modal/_utils/browser_utils.py +0 -0
  74. {modal-1.3.3 → modal-1.3.4}/modal/_utils/bytes_io_segment_payload.py +0 -0
  75. {modal-1.3.3 → modal-1.3.4}/modal/_utils/deprecation.py +0 -0
  76. {modal-1.3.3 → modal-1.3.4}/modal/_utils/docker_utils.py +0 -0
  77. {modal-1.3.3 → modal-1.3.4}/modal/_utils/function_utils.py +0 -0
  78. {modal-1.3.3 → modal-1.3.4}/modal/_utils/git_utils.py +0 -0
  79. {modal-1.3.3 → modal-1.3.4}/modal/_utils/grpc_testing.py +0 -0
  80. {modal-1.3.3 → modal-1.3.4}/modal/_utils/hash_utils.py +0 -0
  81. {modal-1.3.3 → modal-1.3.4}/modal/_utils/http_utils.py +0 -0
  82. {modal-1.3.3 → modal-1.3.4}/modal/_utils/jwt_utils.py +0 -0
  83. {modal-1.3.3 → modal-1.3.4}/modal/_utils/logger.py +0 -0
  84. {modal-1.3.3 → modal-1.3.4}/modal/_utils/mount_utils.py +0 -0
  85. {modal-1.3.3 → modal-1.3.4}/modal/_utils/name_utils.py +0 -0
  86. {modal-1.3.3 → modal-1.3.4}/modal/_utils/package_utils.py +0 -0
  87. {modal-1.3.3 → modal-1.3.4}/modal/_utils/pattern_utils.py +0 -0
  88. {modal-1.3.3 → modal-1.3.4}/modal/_utils/rand_pb_testing.py +0 -0
  89. {modal-1.3.3 → modal-1.3.4}/modal/_utils/shell_utils.py +0 -0
  90. {modal-1.3.3 → modal-1.3.4}/modal/_utils/time_utils.py +0 -0
  91. {modal-1.3.3 → modal-1.3.4}/modal/_vendor/__init__.py +0 -0
  92. {modal-1.3.3 → modal-1.3.4}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  93. {modal-1.3.3 → modal-1.3.4}/modal/_vendor/cloudpickle.py +0 -0
  94. {modal-1.3.3 → modal-1.3.4}/modal/_vendor/tblib.py +0 -0
  95. {modal-1.3.3 → modal-1.3.4}/modal/_watcher.py +0 -0
  96. {modal-1.3.3 → modal-1.3.4}/modal/billing.py +0 -0
  97. {modal-1.3.3 → modal-1.3.4}/modal/builder/2023.12.312.txt +0 -0
  98. {modal-1.3.3 → modal-1.3.4}/modal/builder/2023.12.txt +0 -0
  99. {modal-1.3.3 → modal-1.3.4}/modal/builder/2024.04.txt +0 -0
  100. {modal-1.3.3 → modal-1.3.4}/modal/builder/2024.10.txt +0 -0
  101. {modal-1.3.3 → modal-1.3.4}/modal/builder/2025.06.txt +0 -0
  102. {modal-1.3.3 → modal-1.3.4}/modal/builder/PREVIEW.txt +0 -0
  103. {modal-1.3.3 → modal-1.3.4}/modal/builder/README.md +0 -0
  104. {modal-1.3.3 → modal-1.3.4}/modal/builder/base-images.json +0 -0
  105. {modal-1.3.3 → modal-1.3.4}/modal/call_graph.py +0 -0
  106. {modal-1.3.3 → modal-1.3.4}/modal/cli/__init__.py +0 -0
  107. {modal-1.3.3 → modal-1.3.4}/modal/cli/_download.py +0 -0
  108. {modal-1.3.3 → modal-1.3.4}/modal/cli/_traceback.py +0 -0
  109. {modal-1.3.3 → modal-1.3.4}/modal/cli/app.py +0 -0
  110. {modal-1.3.3 → modal-1.3.4}/modal/cli/billing.py +0 -0
  111. {modal-1.3.3 → modal-1.3.4}/modal/cli/cluster.py +0 -0
  112. {modal-1.3.3 → modal-1.3.4}/modal/cli/config.py +0 -0
  113. {modal-1.3.3 → modal-1.3.4}/modal/cli/container.py +0 -0
  114. {modal-1.3.3 → modal-1.3.4}/modal/cli/dashboard.py +0 -0
  115. {modal-1.3.3 → modal-1.3.4}/modal/cli/dict.py +0 -0
  116. {modal-1.3.3 → modal-1.3.4}/modal/cli/entry_point.py +0 -0
  117. {modal-1.3.3 → modal-1.3.4}/modal/cli/environment.py +0 -0
  118. {modal-1.3.3 → modal-1.3.4}/modal/cli/import_refs.py +0 -0
  119. {modal-1.3.3 → modal-1.3.4}/modal/cli/launch.py +0 -0
  120. {modal-1.3.3 → modal-1.3.4}/modal/cli/network_file_system.py +0 -0
  121. {modal-1.3.3 → modal-1.3.4}/modal/cli/profile.py +0 -0
  122. {modal-1.3.3 → modal-1.3.4}/modal/cli/programs/__init__.py +0 -0
  123. {modal-1.3.3 → modal-1.3.4}/modal/cli/programs/run_jupyter.py +0 -0
  124. {modal-1.3.3 → modal-1.3.4}/modal/cli/programs/vscode.py +0 -0
  125. {modal-1.3.3 → modal-1.3.4}/modal/cli/queues.py +0 -0
  126. {modal-1.3.3 → modal-1.3.4}/modal/cli/run.py +0 -0
  127. {modal-1.3.3 → modal-1.3.4}/modal/cli/secret.py +0 -0
  128. {modal-1.3.3 → modal-1.3.4}/modal/cli/shell.py +0 -0
  129. {modal-1.3.3 → modal-1.3.4}/modal/cli/token.py +0 -0
  130. {modal-1.3.3 → modal-1.3.4}/modal/cli/utils.py +0 -0
  131. {modal-1.3.3 → modal-1.3.4}/modal/cli/volume.py +0 -0
  132. {modal-1.3.3 → modal-1.3.4}/modal/client.py +0 -0
  133. {modal-1.3.3 → modal-1.3.4}/modal/cls.py +0 -0
  134. {modal-1.3.3 → modal-1.3.4}/modal/cls.pyi +0 -0
  135. {modal-1.3.3 → modal-1.3.4}/modal/config.py +0 -0
  136. {modal-1.3.3 → modal-1.3.4}/modal/container_process.py +0 -0
  137. {modal-1.3.3 → modal-1.3.4}/modal/container_process.pyi +0 -0
  138. {modal-1.3.3 → modal-1.3.4}/modal/dict.py +0 -0
  139. {modal-1.3.3 → modal-1.3.4}/modal/dict.pyi +0 -0
  140. {modal-1.3.3 → modal-1.3.4}/modal/environments.py +0 -0
  141. {modal-1.3.3 → modal-1.3.4}/modal/environments.pyi +0 -0
  142. {modal-1.3.3 → modal-1.3.4}/modal/exception.py +0 -0
  143. {modal-1.3.3 → modal-1.3.4}/modal/experimental/__init__.py +0 -0
  144. {modal-1.3.3 → modal-1.3.4}/modal/experimental/flash.py +0 -0
  145. {modal-1.3.3 → modal-1.3.4}/modal/experimental/flash.pyi +0 -0
  146. {modal-1.3.3 → modal-1.3.4}/modal/experimental/ipython.py +0 -0
  147. {modal-1.3.3 → modal-1.3.4}/modal/file_io.py +0 -0
  148. {modal-1.3.3 → modal-1.3.4}/modal/file_io.pyi +0 -0
  149. {modal-1.3.3 → modal-1.3.4}/modal/file_pattern_matcher.py +0 -0
  150. {modal-1.3.3 → modal-1.3.4}/modal/functions.py +0 -0
  151. {modal-1.3.3 → modal-1.3.4}/modal/functions.pyi +0 -0
  152. {modal-1.3.3 → modal-1.3.4}/modal/io_streams.pyi +0 -0
  153. {modal-1.3.3 → modal-1.3.4}/modal/mount.py +0 -0
  154. {modal-1.3.3 → modal-1.3.4}/modal/mount.pyi +0 -0
  155. {modal-1.3.3 → modal-1.3.4}/modal/object.py +0 -0
  156. {modal-1.3.3 → modal-1.3.4}/modal/object.pyi +0 -0
  157. {modal-1.3.3 → modal-1.3.4}/modal/output.py +0 -0
  158. {modal-1.3.3 → modal-1.3.4}/modal/parallel_map.py +0 -0
  159. {modal-1.3.3 → modal-1.3.4}/modal/parallel_map.pyi +0 -0
  160. {modal-1.3.3 → modal-1.3.4}/modal/partial_function.py +0 -0
  161. {modal-1.3.3 → modal-1.3.4}/modal/partial_function.pyi +0 -0
  162. {modal-1.3.3 → modal-1.3.4}/modal/proxy.py +0 -0
  163. {modal-1.3.3 → modal-1.3.4}/modal/proxy.pyi +0 -0
  164. {modal-1.3.3 → modal-1.3.4}/modal/py.typed +0 -0
  165. {modal-1.3.3 → modal-1.3.4}/modal/queue.py +0 -0
  166. {modal-1.3.3 → modal-1.3.4}/modal/queue.pyi +0 -0
  167. {modal-1.3.3 → modal-1.3.4}/modal/retries.py +0 -0
  168. {modal-1.3.3 → modal-1.3.4}/modal/runner.py +0 -0
  169. {modal-1.3.3 → modal-1.3.4}/modal/runner.pyi +0 -0
  170. {modal-1.3.3 → modal-1.3.4}/modal/running_app.py +0 -0
  171. {modal-1.3.3 → modal-1.3.4}/modal/schedule.py +0 -0
  172. {modal-1.3.3 → modal-1.3.4}/modal/scheduler_placement.py +0 -0
  173. {modal-1.3.3 → modal-1.3.4}/modal/secret.py +0 -0
  174. {modal-1.3.3 → modal-1.3.4}/modal/secret.pyi +0 -0
  175. {modal-1.3.3 → modal-1.3.4}/modal/server.py +0 -0
  176. {modal-1.3.3 → modal-1.3.4}/modal/server.pyi +0 -0
  177. {modal-1.3.3 → modal-1.3.4}/modal/serving.py +0 -0
  178. {modal-1.3.3 → modal-1.3.4}/modal/serving.pyi +0 -0
  179. {modal-1.3.3 → modal-1.3.4}/modal/snapshot.py +0 -0
  180. {modal-1.3.3 → modal-1.3.4}/modal/snapshot.pyi +0 -0
  181. {modal-1.3.3 → modal-1.3.4}/modal/stream_type.py +0 -0
  182. {modal-1.3.3 → modal-1.3.4}/modal/token_flow.py +0 -0
  183. {modal-1.3.3 → modal-1.3.4}/modal/token_flow.pyi +0 -0
  184. {modal-1.3.3 → modal-1.3.4}/modal.egg-info/SOURCES.txt +0 -0
  185. {modal-1.3.3 → modal-1.3.4}/modal.egg-info/dependency_links.txt +0 -0
  186. {modal-1.3.3 → modal-1.3.4}/modal.egg-info/entry_points.txt +0 -0
  187. {modal-1.3.3 → modal-1.3.4}/modal.egg-info/requires.txt +0 -0
  188. {modal-1.3.3 → modal-1.3.4}/modal.egg-info/top_level.txt +0 -0
  189. {modal-1.3.3 → modal-1.3.4}/modal_docs/__init__.py +0 -0
  190. {modal-1.3.3 → modal-1.3.4}/modal_docs/gen_cli_docs.py +0 -0
  191. {modal-1.3.3 → modal-1.3.4}/modal_docs/mdmd/__init__.py +0 -0
  192. {modal-1.3.3 → modal-1.3.4}/modal_docs/mdmd/signatures.py +0 -0
  193. {modal-1.3.3 → modal-1.3.4}/modal_proto/__init__.py +0 -0
  194. {modal-1.3.3 → modal-1.3.4}/modal_proto/py.typed +0 -0
  195. {modal-1.3.3 → modal-1.3.4}/modal_proto/task_command_router.proto +0 -0
  196. {modal-1.3.3 → modal-1.3.4}/modal_proto/task_command_router_grpc.py +0 -0
  197. {modal-1.3.3 → modal-1.3.4}/modal_proto/task_command_router_pb2.py +0 -0
  198. {modal-1.3.3 → modal-1.3.4}/modal_proto/task_command_router_pb2.pyi +0 -0
  199. {modal-1.3.3 → modal-1.3.4}/modal_proto/task_command_router_pb2_grpc.py +0 -0
  200. {modal-1.3.3 → modal-1.3.4}/modal_proto/task_command_router_pb2_grpc.pyi +0 -0
  201. {modal-1.3.3 → modal-1.3.4}/modal_version/__main__.py +0 -0
  202. {modal-1.3.3 → modal-1.3.4}/pyproject.toml +0 -0
  203. {modal-1.3.3 → modal-1.3.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.3.3
3
+ Version: 1.3.4
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License: Apache-2.0
@@ -23,7 +23,7 @@ from grpclib.encoding.base import StatusDetailsCodecBase
23
23
  from grpclib.exceptions import StreamTerminatedError
24
24
  from grpclib.protocol import H2Protocol
25
25
 
26
- from modal.exception import ConnectionError
26
+ from modal.exception import ClientClosed, ConnectionError
27
27
  from modal_proto import api_pb2
28
28
  from modal_version import __version__
29
29
 
@@ -65,6 +65,22 @@ class Subchannel:
65
65
  return True
66
66
 
67
67
 
68
+ class PermanentCloseableChannel(grpclib.client.Channel):
69
+ def __init__(self, *args, closed_error_message: str, **kwargs):
70
+ super().__init__(*args, **kwargs)
71
+ self.__closed = False
72
+ self.__closed_error_message = closed_error_message
73
+
74
+ async def __connect__(self):
75
+ if self.__closed:
76
+ raise ClientClosed(self.__closed_error_message)
77
+ return await super().__connect__()
78
+
79
+ def close(self):
80
+ self.__closed = True
81
+ return super().close()
82
+
83
+
68
84
  RETRYABLE_GRPC_STATUS_CODES = [
69
85
  Status.DEADLINE_EXCEEDED,
70
86
  Status.UNAVAILABLE,
@@ -21,6 +21,7 @@ from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
21
21
  from modal_proto.task_command_router_grpc import TaskCommandRouterStub
22
22
 
23
23
  from .._grpc_client import grpc_error_converter
24
+ from .._utils.grpc_utils import PermanentCloseableChannel
24
25
  from .async_utils import aclosing
25
26
  from .grpc_utils import RETRYABLE_GRPC_STATUS_CODES, connect_channel
26
27
 
@@ -157,7 +158,7 @@ class TaskCommandRouterClient:
157
158
  ssl_context.check_hostname = False
158
159
  ssl_context.verify_mode = ssl.CERT_NONE
159
160
 
160
- channel = grpclib.client.Channel(
161
+ channel = PermanentCloseableChannel(
161
162
  host,
162
163
  port,
163
164
  ssl=ssl_context,
@@ -165,6 +166,7 @@ class TaskCommandRouterClient:
165
166
  http2_connection_window_size=64 * 1024 * 1024, # 64 MiB
166
167
  http2_stream_window_size=64 * 1024 * 1024, # 64 MiB
167
168
  ),
169
+ closed_error_message="Unable to perform operation on a detached sandbox",
168
170
  )
169
171
 
170
172
  await connect_channel(channel)
@@ -324,6 +324,11 @@ class _App:
324
324
  app._running_app = RunningApp(response.app_id, interactive=False)
325
325
  return app
326
326
 
327
+ async def get_dashboard_url(self) -> str:
328
+ if self._app_id is None:
329
+ raise InvalidError("App is not running")
330
+ return f"https://modal.com/id/{self._app_id}"
331
+
327
332
  def set_description(self, description: str):
328
333
  """mdmd:hidden
329
334
  Set the description of the App before it starts running.
@@ -226,6 +226,7 @@ class _App:
226
226
  """
227
227
  ...
228
228
 
229
+ async def get_dashboard_url(self) -> str: ...
229
230
  def set_description(self, description: str):
230
231
  """mdmd:hidden
231
232
  Set the description of the App before it starts running.
@@ -833,6 +834,12 @@ class App:
833
834
 
834
835
  lookup: typing.ClassVar[__lookup_spec]
835
836
 
837
+ class __get_dashboard_url_spec(typing_extensions.Protocol):
838
+ def __call__(self, /) -> str: ...
839
+ async def aio(self, /) -> str: ...
840
+
841
+ get_dashboard_url: __get_dashboard_url_spec
842
+
836
843
  def set_description(self, description: str):
837
844
  """mdmd:hidden
838
845
  Set the description of the App before it starts running.
@@ -31,7 +31,7 @@ class _Client:
31
31
  client_type: int
32
32
 
33
33
  def __init__(
34
- self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "1.3.3"
34
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "1.3.4"
35
35
  ):
36
36
  """mdmd:hidden
37
37
  The Modal client object is not intended to be instantiated directly by users.
@@ -163,7 +163,7 @@ class Client:
163
163
  client_type: int
164
164
 
165
165
  def __init__(
166
- self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "1.3.3"
166
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "1.3.4"
167
167
  ):
168
168
  """mdmd:hidden
169
169
  The Modal client object is not intended to be instantiated directly by users.
@@ -121,7 +121,9 @@ class _CloudBucketMount:
121
121
 
122
122
 
123
123
  def cloud_bucket_mounts_to_proto(mounts: Sequence[tuple[str, _CloudBucketMount]]) -> list[api_pb2.CloudBucketMount]:
124
- """Helper function to convert `CloudBucketMount` to a list of protobufs that can be passed to the server."""
124
+ """mdmd:hidden
125
+ Helper function to convert `CloudBucketMount` to a list of protobufs that can be passed to the server.
126
+ """
125
127
  cloud_bucket_mounts: list[api_pb2.CloudBucketMount] = []
126
128
 
127
129
  for path, mount in mounts:
@@ -126,7 +126,9 @@ class _CloudBucketMount:
126
126
  def cloud_bucket_mounts_to_proto(
127
127
  mounts: typing.Sequence[tuple[str, _CloudBucketMount]],
128
128
  ) -> list[modal_proto.api_pb2.CloudBucketMount]:
129
- """Helper function to convert `CloudBucketMount` to a list of protobufs that can be passed to the server."""
129
+ """mdmd:hidden
130
+ Helper function to convert `CloudBucketMount` to a list of protobufs that can be passed to the server.
131
+ """
130
132
  ...
131
133
 
132
134
  class CloudBucketMount:
@@ -199,6 +199,9 @@ GPU_T = Union[None, str, _GPUConfig]
199
199
 
200
200
 
201
201
  def parse_gpu_config(value: GPU_T) -> api_pb2.GPUConfig:
202
+ """mdmd:hidden
203
+ Parses a GPU config into a protobuf representation.
204
+ """
202
205
  if isinstance(value, _GPUConfig):
203
206
  return value._to_proto()
204
207
  elif isinstance(value, str):
@@ -418,6 +418,7 @@ class _Image(_Object, type_prefix="im"):
418
418
  ] # added as mounts on any container referencing the Image, see `def _mount_layers`
419
419
  _added_python_source_set: frozenset[str] # used to warn about missing mounts during auto-mount deprecation
420
420
  _metadata: Optional[api_pb2.ImageMetadata] = None # set on hydration, private for now
421
+ _is_empty: bool
421
422
 
422
423
  def _initialize_from_empty(self):
423
424
  self.inside_exceptions = []
@@ -425,6 +426,7 @@ class _Image(_Object, type_prefix="im"):
425
426
  self._deferred_mounts = ()
426
427
  self._added_python_source_set = frozenset()
427
428
  self.force_build = False
429
+ self._is_empty = False
428
430
 
429
431
  def _initialize_from_other(self, other: "_Image"):
430
432
  self.inside_exceptions = other.inside_exceptions
@@ -432,6 +434,7 @@ class _Image(_Object, type_prefix="im"):
432
434
  self._serve_mounts = other._serve_mounts
433
435
  self._deferred_mounts = other._deferred_mounts
434
436
  self._added_python_source_set = other._added_python_source_set
437
+ self._is_empty = other._is_empty
435
438
 
436
439
  def _get_metadata(self) -> Optional[Message]:
437
440
  return self._metadata
@@ -721,6 +724,12 @@ class _Image(_Object, type_prefix="im"):
721
724
  )
722
725
  return obj
723
726
 
727
+ @staticmethod
728
+ def _from_scratch() -> "_Image":
729
+ image = _Image.from_registry("scratch")
730
+ image._is_empty = True
731
+ return image
732
+
724
733
  def _copy_mount(self, mount: _Mount, remote_path: Union[str, Path] = ".") -> "_Image":
725
734
  """mdmd:hidden
726
735
  Internal
@@ -143,6 +143,7 @@ class _Image(modal._object._Object):
143
143
  _deferred_mounts: collections.abc.Sequence[modal.mount._Mount]
144
144
  _added_python_source_set: frozenset[str]
145
145
  _metadata: typing.Optional[modal_proto.api_pb2.ImageMetadata]
146
+ _is_empty: bool
146
147
 
147
148
  def _initialize_from_empty(self): ...
148
149
  def _initialize_from_other(self, other: _Image): ...
@@ -186,6 +187,8 @@ class _Image(modal._object._Object):
186
187
  _namespace: int = 1,
187
188
  _do_assert_no_mount_layers: bool = True,
188
189
  ): ...
190
+ @staticmethod
191
+ def _from_scratch() -> _Image: ...
189
192
  def _copy_mount(self, mount: modal.mount._Mount, remote_path: typing.Union[str, pathlib.Path] = ".") -> _Image:
190
193
  """mdmd:hidden
191
194
  Internal
@@ -1058,6 +1061,7 @@ class Image(modal.object.Object):
1058
1061
  _deferred_mounts: collections.abc.Sequence[modal.mount.Mount]
1059
1062
  _added_python_source_set: frozenset[str]
1060
1063
  _metadata: typing.Optional[modal_proto.api_pb2.ImageMetadata]
1064
+ _is_empty: bool
1061
1065
 
1062
1066
  def __init__(self, *args, **kwargs):
1063
1067
  """mdmd:hidden"""
@@ -1105,6 +1109,8 @@ class Image(modal.object.Object):
1105
1109
  _namespace: int = 1,
1106
1110
  _do_assert_no_mount_layers: bool = True,
1107
1111
  ): ...
1112
+ @staticmethod
1113
+ def _from_scratch() -> Image: ...
1108
1114
  def _copy_mount(self, mount: modal.mount.Mount, remote_path: typing.Union[str, pathlib.Path] = ".") -> Image:
1109
1115
  """mdmd:hidden
1110
1116
  Internal
@@ -628,6 +628,9 @@ class _StreamReader(Generic[T]):
628
628
 
629
629
 
630
630
  MAX_BUFFER_SIZE = 2 * 1024 * 1024
631
+ # Larger buffer limit for the new exec path via the task command router.
632
+ # This applies only to task_exec_stdin_write; legacy ContainerExec paths keep MAX_BUFFER_SIZE.
633
+ TASK_COMMAND_ROUTER_MAX_BUFFER_SIZE = 16 * 1024 * 1024
631
634
 
632
635
 
633
636
  class _StreamWriterThroughServer:
@@ -721,7 +724,7 @@ class _StreamWriterThroughCommandRouter:
721
724
  if isinstance(data, (bytes, bytearray, memoryview, str)):
722
725
  if isinstance(data, str):
723
726
  data = data.encode("utf-8")
724
- if len(self._buffer) + len(data) > MAX_BUFFER_SIZE:
727
+ if len(self._buffer) + len(data) > TASK_COMMAND_ROUTER_MAX_BUFFER_SIZE:
725
728
  raise BufferError("Buffer size exceed limit. Call drain to flush the buffer.")
726
729
  self._buffer.extend(data)
727
730
  else:
@@ -37,6 +37,7 @@ NETWORK_FILE_SYSTEM_PUT_FILE_CLIENT_TIMEOUT = (
37
37
  def network_file_system_mount_protos(
38
38
  validated_network_file_systems: list[tuple[str, "_NetworkFileSystem"]],
39
39
  ) -> list[api_pb2.SharedVolumeMount]:
40
+ """mdmd:hidden"""
40
41
  network_file_system_mounts = []
41
42
  # Relies on dicts being ordered (true as of Python 3.6).
42
43
  for path, volume in validated_network_file_systems:
@@ -11,7 +11,9 @@ import typing_extensions
11
11
 
12
12
  def network_file_system_mount_protos(
13
13
  validated_network_file_systems: list[tuple[str, _NetworkFileSystem]],
14
- ) -> list[modal_proto.api_pb2.SharedVolumeMount]: ...
14
+ ) -> list[modal_proto.api_pb2.SharedVolumeMount]:
15
+ """mdmd:hidden"""
16
+ ...
15
17
 
16
18
  class _NetworkFileSystem(modal._object._Object):
17
19
  """A shared, writable file system accessible by one or more Modal functions.
@@ -35,7 +35,7 @@ from ._utils.name_utils import check_object_name
35
35
  from ._utils.task_command_router_client import TaskCommandRouterClient
36
36
  from .client import _Client
37
37
  from .container_process import _ContainerProcess
38
- from .exception import ExecutionError, InvalidError, SandboxTerminatedError, SandboxTimeoutError
38
+ from .exception import ClientClosed, ExecutionError, InvalidError, SandboxTerminatedError, SandboxTimeoutError
39
39
  from .file_io import FileWatchEvent, FileWatchEventType, _FileIO
40
40
  from .gpu import GPU_T
41
41
  from .image import _Image
@@ -116,6 +116,7 @@ class _Sandbox(_Object, type_prefix="sb"):
116
116
  _tunnels: Optional[dict[int, Tunnel]]
117
117
  _enable_snapshot: bool
118
118
  _command_router_client: Optional[TaskCommandRouterClient]
119
+ _attached: bool
119
120
 
120
121
  @staticmethod
121
122
  def _default_pty_info() -> api_pb2.PTYInfo:
@@ -516,6 +517,40 @@ class _Sandbox(_Object, type_prefix="sb"):
516
517
  self._enable_snapshot = False
517
518
  self._command_router_client = None
518
519
 
520
+ def _initialize_from_other(self, other):
521
+ super()._initialize_from_other(other)
522
+ self._attached = other._attached
523
+
524
+ def _initialize_from_empty(self):
525
+ super()._initialize_from_empty()
526
+ self._attached = True
527
+
528
+ async def detach(self):
529
+ """Disconnects your client from the sandbox and cleans up resources assoicated with the connection.
530
+
531
+ Be sure to only call `detach` when you are done interacting with the sandbox. After calling `detach`,
532
+ any operation using the Sandbox object is not guaranteed to work anymore. If you want to continue interacting
533
+ with a running sandbox, use `Sandbox.from_id` to get a new Sandbox object.
534
+ """
535
+ if not self._attached:
536
+ return
537
+ if self._command_router_client is not None:
538
+ await self._command_router_client.close()
539
+ self._attached = False
540
+
541
+ @property
542
+ def _client(self):
543
+ self._ensure_attached()
544
+ return self.__client
545
+
546
+ @_client.setter
547
+ def _client(self, value):
548
+ self.__client = value
549
+
550
+ def _ensure_attached(self):
551
+ if not self._attached:
552
+ raise ClientClosed("Unable to perform operation on a detached sandbox")
553
+
519
554
  @staticmethod
520
555
  async def from_name(
521
556
  app_name: str,
@@ -609,21 +644,57 @@ class _Sandbox(_Object, type_prefix="sb"):
609
644
 
610
645
  return image
611
646
 
612
- async def _experimental_mount_image(self, path: Union[PurePosixPath, str], image: Optional[_Image]):
613
- """Mount an Image at a path in the Sandbox filesystem."""
647
+ async def mount_image(self, path: Union[PurePosixPath, str], image: _Image):
648
+ """Mount an Image at a specified path in a running Sandbox.
649
+
650
+ `path` should be a directory. If it doesn't exist it will be created. If it exists and contains
651
+ data, the previous directory will be replaced by the mount.
652
+
653
+ The `image` argument currently only supports Images that are either:
654
+ - prebuilt using `image.build()`
655
+ - referenced by image id, e.g. `Image.from_id(...)`
656
+ - filesystem/directory snapshots e.g. created by `.snapshot_directory()`
657
+ or `.snapshot_filesystem()`"
658
+
659
+ Usage:
660
+ ```py notest
661
+ user_project_snapshot: Image = sandbox_session_1.snapshot_directory("/user_project")
614
662
 
615
- image_id = None
663
+ # You can later mount this snapshot to another Sandbox:
664
+ sandbox_session_2 = modal.Sandbox.create(...)
665
+ sandbox_session_2.mount_image("/user_project", user_project_snapshot)
666
+ sandbox_session_2.ls("/user_project")
667
+ ```
668
+ """
616
669
 
617
- if image:
618
- if not image._object_id:
619
- # FIXME
620
- raise InvalidError("Image has not been built.")
670
+ if not isinstance(image, _Image):
671
+ raise TypeError(f"Sandbox.mount_image(image=...) expects an Image object, got {image!r}")
672
+
673
+ if image._mount_layers:
674
+ raise InvalidError(
675
+ "Sandbox.mount_image() only supports pre-built images. When using `add_local*` methods, "
676
+ "specify `copy=True` and call `.build()` before passing the image to `mount_image()`:\n\nE.g.\n"
677
+ 'img = modal.Image.debian_slim().add_local_file("foo", "/foo", copy=True).build(app)\n'
678
+ "sandbox.mount_image(path, img)"
679
+ )
680
+ if image._is_empty:
681
+ image_id = ""
682
+ elif image._object_id:
621
683
  image_id = image._object_id
622
684
  else:
623
- image_id = "" # empty string indicates mount an empty dir
685
+ raise InvalidError(
686
+ "Sandbox.mount_image() currently only supports Images that are either:\n"
687
+ "- prebuilt using `image.build()`\n"
688
+ "- referenced by id, e.g. `Image.from_id()`\n"
689
+ "- filesystem/directory snapshots e.g. created by `.snapshot_directory()` "
690
+ "or `.snapshot_filesystem()`\n"
691
+ )
624
692
 
625
693
  task_id = await self._get_task_id()
626
694
  if (command_router_client := await self._get_command_router_client(task_id)) is None:
695
+ # It used to be the case that sandboxes could either be controlled through the control
696
+ # plane or through direct connections, but nowadays they should always use direct control
697
+ # so this error should be unexpected
627
698
  raise InvalidError("Mounting directories requires direct Sandbox control - please contact Modal support.")
628
699
 
629
700
  posix_path = PurePosixPath(path)
@@ -634,8 +705,31 @@ class _Sandbox(_Object, type_prefix="sb"):
634
705
  req = sr_pb2.TaskMountDirectoryRequest(task_id=task_id, path=path_bytes, image_id=image_id)
635
706
  await command_router_client.mount_image(req)
636
707
 
637
- async def _experimental_snapshot_directory(self, path: Union[PurePosixPath, str]) -> _Image:
638
- """Snapshot local changes to a previously mounted Image, creating a new Image."""
708
+ async def _experimental_mount_image(self, path: Union[PurePosixPath, str], image: Optional[_Image]):
709
+ """Deprecated alias for `Sandbox.mount_image()`."""
710
+ deprecation_warning(
711
+ (2026, 2, 20),
712
+ "The `Sandbox._experimental_mount_image()` method is deprecated. Use `Sandbox.mount_image()` instead.",
713
+ )
714
+ if image is None:
715
+ image = _Image._from_scratch()
716
+ await self.mount_image(path, image)
717
+
718
+ async def snapshot_directory(self, path: Union[PurePosixPath, str]) -> _Image:
719
+ """Snapshot a directory in a running Sandbox, creating a new Image with its content.
720
+
721
+ Directory snapshots are currently persisted for 30 days after they were last created or used.
722
+
723
+ Usage:
724
+ ```py notest
725
+ user_project_snapshot: Image = sandbox_session_1.snapshot_directory("/user_project")
726
+
727
+ # You can later mount this snapshot to another Sandbox:
728
+ sandbox_session_2 = modal.Sandbox.create(...)
729
+ sandbox_session_2.mount_image("/user_project", user_project_snapshot)
730
+ sandbox_session_2.ls("/user_project")
731
+ ```
732
+ """
639
733
 
640
734
  task_id = await self._get_task_id()
641
735
  if (command_router_client := await self._get_command_router_client(task_id)) is None:
@@ -652,6 +746,15 @@ class _Sandbox(_Object, type_prefix="sb"):
652
746
  res = await command_router_client.snapshot_directory(req)
653
747
  return _Image._new_hydrated(res.image_id, self._client, None)
654
748
 
749
+ async def _experimental_snapshot_directory(self, path: Union[PurePosixPath, str]) -> _Image:
750
+ """Deprecated alias for `Sandbox.snapshot_directory()`."""
751
+ deprecation_warning(
752
+ (2026, 2, 20),
753
+ "The `Sandbox._experimental_snapshot_directory()` method is deprecated. "
754
+ "Use `Sandbox.snapshot_directory()` instead.",
755
+ )
756
+ return await self.snapshot_directory(path)
757
+
655
758
  # Live handle methods
656
759
 
657
760
  async def wait(self, raise_on_termination: bool = True):
@@ -659,7 +762,8 @@ class _Sandbox(_Object, type_prefix="sb"):
659
762
 
660
763
  while True:
661
764
  req = api_pb2.SandboxWaitRequest(sandbox_id=self.object_id, timeout=10)
662
- resp = await self._client.stub.SandboxWait(req)
765
+ # Use the private __client to allow `wait` to work with a detached sandbox
766
+ resp = await self.__client.stub.SandboxWait(req)
663
767
  if resp.result.status:
664
768
  logger.debug(f"Sandbox {self.object_id} wait completed with status {resp.result.status}")
665
769
  self._result = resp.result
@@ -702,7 +806,7 @@ class _Sandbox(_Object, type_prefix="sb"):
702
806
  self, user_metadata: Optional[Union[str, dict[str, Any]]] = None
703
807
  ) -> SandboxConnectCredentials:
704
808
  """
705
- [Alpha] Create a token for making HTTP connections to the Sandbox.
809
+ Create a token for making HTTP connections to the Sandbox.
706
810
 
707
811
  Also accepts an optional user_metadata string or dict to associate with the token. This metadata
708
812
  will be added to the headers by the proxy when forwarding requests to the Sandbox."""
@@ -728,12 +832,32 @@ class _Sandbox(_Object, type_prefix="sb"):
728
832
  ),
729
833
  )
730
834
 
731
- async def terminate(self) -> None:
835
+ @overload
836
+ async def terminate(
837
+ self,
838
+ *,
839
+ wait: Literal[True],
840
+ ) -> int: ...
841
+
842
+ @overload
843
+ async def terminate(
844
+ self,
845
+ *,
846
+ wait: Literal[False] = False,
847
+ ) -> None: ...
848
+
849
+ async def terminate(
850
+ self,
851
+ *,
852
+ wait: bool = False, # wait for terminate to complete and return the exit code.
853
+ ) -> int | None:
732
854
  """Terminate Sandbox execution.
733
855
 
734
856
  This is a no-op if the Sandbox has already finished running."""
735
-
736
857
  await self._client.stub.SandboxTerminate(api_pb2.SandboxTerminateRequest(sandbox_id=self.object_id))
858
+ if wait:
859
+ await self.wait(raise_on_termination=False)
860
+ return self.returncode
737
861
 
738
862
  async def poll(self) -> Optional[int]:
739
863
  """Check if the Sandbox has finished running.
@@ -1079,6 +1203,12 @@ class _Sandbox(_Object, type_prefix="sb"):
1079
1203
  raise ExecutionError(resp.task_result.exception)
1080
1204
  return sandbox
1081
1205
 
1206
+ @overload
1207
+ async def open(
1208
+ self,
1209
+ path: str,
1210
+ ) -> _FileIO[str]: ...
1211
+
1082
1212
  @overload
1083
1213
  async def open(
1084
1214
  self,
@@ -1147,7 +1277,7 @@ class _Sandbox(_Object, type_prefix="sb"):
1147
1277
  [`StreamReader`](https://modal.com/docs/reference/modal.io_streams#modalio_streamsstreamreader) for
1148
1278
  the sandbox's stdout stream.
1149
1279
  """
1150
-
1280
+ self._ensure_attached()
1151
1281
  return self._stdout
1152
1282
 
1153
1283
  @property
@@ -1155,7 +1285,7 @@ class _Sandbox(_Object, type_prefix="sb"):
1155
1285
  """[`StreamReader`](https://modal.com/docs/reference/modal.io_streams#modalio_streamsstreamreader) for
1156
1286
  the Sandbox's stderr stream.
1157
1287
  """
1158
-
1288
+ self._ensure_attached()
1159
1289
  return self._stderr
1160
1290
 
1161
1291
  @property
@@ -1164,7 +1294,7 @@ class _Sandbox(_Object, type_prefix="sb"):
1164
1294
  [`StreamWriter`](https://modal.com/docs/reference/modal.io_streams#modalio_streamsstreamwriter) for
1165
1295
  the Sandbox's stdin stream.
1166
1296
  """
1167
-
1297
+ self._ensure_attached()
1168
1298
  return self._stdin
1169
1299
 
1170
1300
  @property