modAL 1.4.2__tar.gz → 1.4.2.dev1__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 (212) hide show
  1. {modal-1.4.2 → modal-1.4.2.dev1}/PKG-INFO +1 -1
  2. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_functions.py +3 -0
  3. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_runtime/asgi.py +2 -5
  4. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/grpc_utils.py +5 -29
  5. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/rand_pb_testing.py +1 -3
  6. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/sandbox_fs_utils.py +0 -37
  7. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/task_command_router_client.py +1 -11
  8. {modal-1.4.2 → modal-1.4.2.dev1}/modal/app.py +4 -0
  9. {modal-1.4.2 → modal-1.4.2.dev1}/modal/app.pyi +4 -0
  10. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/app.py +79 -163
  11. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/container.py +2 -20
  12. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/entry_point.py +24 -8
  13. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/selector.py +4 -12
  14. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/utils.py +17 -10
  15. {modal-1.4.2 → modal-1.4.2.dev1}/modal/client.py +0 -7
  16. {modal-1.4.2 → modal-1.4.2.dev1}/modal/client.pyi +10 -14
  17. {modal-1.4.2 → modal-1.4.2.dev1}/modal/dict.py +5 -5
  18. {modal-1.4.2 → modal-1.4.2.dev1}/modal/dict.pyi +8 -8
  19. {modal-1.4.2 → modal-1.4.2.dev1}/modal/exception.py +0 -6
  20. {modal-1.4.2 → modal-1.4.2.dev1}/modal/file_io.py +2 -2
  21. {modal-1.4.2 → modal-1.4.2.dev1}/modal/functions.pyi +7 -6
  22. {modal-1.4.2 → modal-1.4.2.dev1}/modal/image.py +0 -2
  23. {modal-1.4.2 → modal-1.4.2.dev1}/modal/image.pyi +0 -2
  24. {modal-1.4.2 → modal-1.4.2.dev1}/modal/queue.py +5 -5
  25. {modal-1.4.2 → modal-1.4.2.dev1}/modal/queue.pyi +8 -8
  26. {modal-1.4.2 → modal-1.4.2.dev1}/modal/sandbox.py +18 -90
  27. {modal-1.4.2 → modal-1.4.2.dev1}/modal/sandbox.pyi +27 -87
  28. {modal-1.4.2 → modal-1.4.2.dev1}/modal/sandbox_fs.py +7 -44
  29. modal-1.4.2.dev1/modal/sandbox_fs.pyi +627 -0
  30. {modal-1.4.2 → modal-1.4.2.dev1}/modal/secret.py +5 -5
  31. {modal-1.4.2 → modal-1.4.2.dev1}/modal/secret.pyi +8 -8
  32. {modal-1.4.2 → modal-1.4.2.dev1}/modal/volume.py +5 -5
  33. {modal-1.4.2 → modal-1.4.2.dev1}/modal/volume.pyi +8 -8
  34. {modal-1.4.2 → modal-1.4.2.dev1}/modal.egg-info/PKG-INFO +1 -1
  35. {modal-1.4.2 → modal-1.4.2.dev1}/modal.egg-info/SOURCES.txt +0 -2
  36. {modal-1.4.2 → modal-1.4.2.dev1}/modal_docs/mdmd/mdmd.py +13 -30
  37. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/api_grpc.py +0 -80
  38. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/api_pb2.py +1067 -1185
  39. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/api_pb2.pyi +5 -305
  40. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/api_pb2_grpc.py +0 -166
  41. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/api_pb2_grpc.pyi +0 -52
  42. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/modal_api_grpc.py +0 -5
  43. {modal-1.4.2 → modal-1.4.2.dev1}/modal_version/__init__.py +1 -1
  44. {modal-1.4.2 → modal-1.4.2.dev1}/pyproject.toml +3 -3
  45. modal-1.4.2/modal/cli/bootstrap.py +0 -136
  46. modal-1.4.2/modal/cli/logo.py +0 -70
  47. modal-1.4.2/modal/sandbox_fs.pyi +0 -672
  48. {modal-1.4.2 → modal-1.4.2.dev1}/LICENSE +0 -0
  49. {modal-1.4.2 → modal-1.4.2.dev1}/README.md +0 -0
  50. {modal-1.4.2 → modal-1.4.2.dev1}/modal/__init__.py +0 -0
  51. {modal-1.4.2 → modal-1.4.2.dev1}/modal/__main__.py +0 -0
  52. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_billing.py +0 -0
  53. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_clustered_functions.py +0 -0
  54. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_clustered_functions.pyi +0 -0
  55. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_container_entrypoint.py +0 -0
  56. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_grpc_client.py +0 -0
  57. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_ipython.py +0 -0
  58. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_load_context.py +0 -0
  59. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_location.py +0 -0
  60. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_logs.py +0 -0
  61. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_object.py +0 -0
  62. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_output/__init__.py +0 -0
  63. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_output/manager.py +0 -0
  64. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_output/pty.py +0 -0
  65. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_output/rich.py +0 -0
  66. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_output/status.py +0 -0
  67. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_partial_function.py +0 -0
  68. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_resolver.py +0 -0
  69. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_resources.py +0 -0
  70. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_runtime/__init__.py +0 -0
  71. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_runtime/container_io_manager.py +0 -0
  72. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_runtime/container_io_manager.pyi +0 -0
  73. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_runtime/execution_context.py +0 -0
  74. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_runtime/execution_context.pyi +0 -0
  75. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_runtime/gpu_memory_snapshot.py +0 -0
  76. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_runtime/telemetry.py +0 -0
  77. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_runtime/user_code_event_loop.py +0 -0
  78. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_runtime/user_code_imports.py +0 -0
  79. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_serialization.py +0 -0
  80. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_server.py +0 -0
  81. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_traceback.py +0 -0
  82. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_tunnel.py +0 -0
  83. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_tunnel.pyi +0 -0
  84. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_type_manager.py +0 -0
  85. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/__init__.py +0 -0
  86. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/app_utils.py +0 -0
  87. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/async_utils.py +0 -0
  88. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/auth_token_manager.py +0 -0
  89. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/blob_utils.py +0 -0
  90. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/browser_utils.py +0 -0
  91. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/bytes_io_segment_payload.py +0 -0
  92. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/deprecation.py +0 -0
  93. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/docker_utils.py +0 -0
  94. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/function_utils.py +0 -0
  95. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/git_utils.py +0 -0
  96. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/grpc_testing.py +0 -0
  97. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/hash_utils.py +0 -0
  98. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/http_utils.py +0 -0
  99. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/jwt_utils.py +0 -0
  100. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/logger.py +0 -0
  101. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/mount_utils.py +0 -0
  102. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/name_utils.py +0 -0
  103. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/package_utils.py +0 -0
  104. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/pattern_utils.py +0 -0
  105. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/shell_utils.py +0 -0
  106. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_utils/time_utils.py +0 -0
  107. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_vendor/__init__.py +0 -0
  108. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  109. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_vendor/cloudpickle.py +0 -0
  110. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_vendor/tblib.py +0 -0
  111. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_vendor/version.py +0 -0
  112. {modal-1.4.2 → modal-1.4.2.dev1}/modal/_watcher.py +0 -0
  113. {modal-1.4.2 → modal-1.4.2.dev1}/modal/billing.py +0 -0
  114. {modal-1.4.2 → modal-1.4.2.dev1}/modal/builder/2023.12.312.txt +0 -0
  115. {modal-1.4.2 → modal-1.4.2.dev1}/modal/builder/2023.12.txt +0 -0
  116. {modal-1.4.2 → modal-1.4.2.dev1}/modal/builder/2024.04.txt +0 -0
  117. {modal-1.4.2 → modal-1.4.2.dev1}/modal/builder/2024.10.txt +0 -0
  118. {modal-1.4.2 → modal-1.4.2.dev1}/modal/builder/2025.06.txt +0 -0
  119. {modal-1.4.2 → modal-1.4.2.dev1}/modal/builder/PREVIEW.txt +0 -0
  120. {modal-1.4.2 → modal-1.4.2.dev1}/modal/builder/README.md +0 -0
  121. {modal-1.4.2 → modal-1.4.2.dev1}/modal/builder/base-images.json +0 -0
  122. {modal-1.4.2 → modal-1.4.2.dev1}/modal/call_graph.py +0 -0
  123. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/__init__.py +0 -0
  124. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/_download.py +0 -0
  125. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/_traceback.py +0 -0
  126. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/billing.py +0 -0
  127. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/changelog.py +0 -0
  128. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/cluster.py +0 -0
  129. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/config.py +0 -0
  130. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/dashboard.py +0 -0
  131. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/dict.py +0 -0
  132. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/environment.py +0 -0
  133. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/import_refs.py +0 -0
  134. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/launch.py +0 -0
  135. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/network_file_system.py +0 -0
  136. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/profile.py +0 -0
  137. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/programs/__init__.py +0 -0
  138. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/programs/run_jupyter.py +0 -0
  139. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/programs/vscode.py +0 -0
  140. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/queues.py +0 -0
  141. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/run.py +0 -0
  142. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/secret.py +0 -0
  143. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/shell.py +0 -0
  144. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/token.py +0 -0
  145. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cli/volume.py +0 -0
  146. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cloud_bucket_mount.py +0 -0
  147. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cloud_bucket_mount.pyi +0 -0
  148. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cls.py +0 -0
  149. {modal-1.4.2 → modal-1.4.2.dev1}/modal/cls.pyi +0 -0
  150. {modal-1.4.2 → modal-1.4.2.dev1}/modal/config.py +0 -0
  151. {modal-1.4.2 → modal-1.4.2.dev1}/modal/container_process.py +0 -0
  152. {modal-1.4.2 → modal-1.4.2.dev1}/modal/container_process.pyi +0 -0
  153. {modal-1.4.2 → modal-1.4.2.dev1}/modal/environments.py +0 -0
  154. {modal-1.4.2 → modal-1.4.2.dev1}/modal/environments.pyi +0 -0
  155. {modal-1.4.2 → modal-1.4.2.dev1}/modal/experimental/__init__.py +0 -0
  156. {modal-1.4.2 → modal-1.4.2.dev1}/modal/experimental/flash.py +0 -0
  157. {modal-1.4.2 → modal-1.4.2.dev1}/modal/experimental/flash.pyi +0 -0
  158. {modal-1.4.2 → modal-1.4.2.dev1}/modal/experimental/ipython.py +0 -0
  159. {modal-1.4.2 → modal-1.4.2.dev1}/modal/file_io.pyi +0 -0
  160. {modal-1.4.2 → modal-1.4.2.dev1}/modal/file_pattern_matcher.py +0 -0
  161. {modal-1.4.2 → modal-1.4.2.dev1}/modal/functions.py +0 -0
  162. {modal-1.4.2 → modal-1.4.2.dev1}/modal/io_streams.py +0 -0
  163. {modal-1.4.2 → modal-1.4.2.dev1}/modal/io_streams.pyi +0 -0
  164. {modal-1.4.2 → modal-1.4.2.dev1}/modal/mount.py +0 -0
  165. {modal-1.4.2 → modal-1.4.2.dev1}/modal/mount.pyi +0 -0
  166. {modal-1.4.2 → modal-1.4.2.dev1}/modal/network_file_system.py +0 -0
  167. {modal-1.4.2 → modal-1.4.2.dev1}/modal/network_file_system.pyi +0 -0
  168. {modal-1.4.2 → modal-1.4.2.dev1}/modal/object.py +0 -0
  169. {modal-1.4.2 → modal-1.4.2.dev1}/modal/object.pyi +0 -0
  170. {modal-1.4.2 → modal-1.4.2.dev1}/modal/output.py +0 -0
  171. {modal-1.4.2 → modal-1.4.2.dev1}/modal/parallel_map.py +0 -0
  172. {modal-1.4.2 → modal-1.4.2.dev1}/modal/parallel_map.pyi +0 -0
  173. {modal-1.4.2 → modal-1.4.2.dev1}/modal/partial_function.py +0 -0
  174. {modal-1.4.2 → modal-1.4.2.dev1}/modal/partial_function.pyi +0 -0
  175. {modal-1.4.2 → modal-1.4.2.dev1}/modal/proxy.py +0 -0
  176. {modal-1.4.2 → modal-1.4.2.dev1}/modal/proxy.pyi +0 -0
  177. {modal-1.4.2 → modal-1.4.2.dev1}/modal/py.typed +0 -0
  178. {modal-1.4.2 → modal-1.4.2.dev1}/modal/retries.py +0 -0
  179. {modal-1.4.2 → modal-1.4.2.dev1}/modal/runner.py +0 -0
  180. {modal-1.4.2 → modal-1.4.2.dev1}/modal/runner.pyi +0 -0
  181. {modal-1.4.2 → modal-1.4.2.dev1}/modal/running_app.py +0 -0
  182. {modal-1.4.2 → modal-1.4.2.dev1}/modal/schedule.py +0 -0
  183. {modal-1.4.2 → modal-1.4.2.dev1}/modal/scheduler_placement.py +0 -0
  184. {modal-1.4.2 → modal-1.4.2.dev1}/modal/server.py +0 -0
  185. {modal-1.4.2 → modal-1.4.2.dev1}/modal/server.pyi +0 -0
  186. {modal-1.4.2 → modal-1.4.2.dev1}/modal/serving.py +0 -0
  187. {modal-1.4.2 → modal-1.4.2.dev1}/modal/serving.pyi +0 -0
  188. {modal-1.4.2 → modal-1.4.2.dev1}/modal/snapshot.py +0 -0
  189. {modal-1.4.2 → modal-1.4.2.dev1}/modal/snapshot.pyi +0 -0
  190. {modal-1.4.2 → modal-1.4.2.dev1}/modal/stream_type.py +0 -0
  191. {modal-1.4.2 → modal-1.4.2.dev1}/modal/token_flow.py +0 -0
  192. {modal-1.4.2 → modal-1.4.2.dev1}/modal/token_flow.pyi +0 -0
  193. {modal-1.4.2 → modal-1.4.2.dev1}/modal.egg-info/dependency_links.txt +0 -0
  194. {modal-1.4.2 → modal-1.4.2.dev1}/modal.egg-info/entry_points.txt +0 -0
  195. {modal-1.4.2 → modal-1.4.2.dev1}/modal.egg-info/requires.txt +0 -0
  196. {modal-1.4.2 → modal-1.4.2.dev1}/modal.egg-info/top_level.txt +0 -0
  197. {modal-1.4.2 → modal-1.4.2.dev1}/modal_docs/__init__.py +0 -0
  198. {modal-1.4.2 → modal-1.4.2.dev1}/modal_docs/gen_cli_docs.py +0 -0
  199. {modal-1.4.2 → modal-1.4.2.dev1}/modal_docs/gen_cli_docs_main.py +0 -0
  200. {modal-1.4.2 → modal-1.4.2.dev1}/modal_docs/gen_reference_docs.py +0 -0
  201. {modal-1.4.2 → modal-1.4.2.dev1}/modal_docs/gen_reference_docs_main.py +0 -0
  202. {modal-1.4.2 → modal-1.4.2.dev1}/modal_docs/mdmd/__init__.py +0 -0
  203. {modal-1.4.2 → modal-1.4.2.dev1}/modal_docs/mdmd/signatures.py +0 -0
  204. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/__init__.py +0 -0
  205. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/py.typed +0 -0
  206. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/task_command_router_grpc.py +0 -0
  207. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/task_command_router_pb2.py +0 -0
  208. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/task_command_router_pb2.pyi +0 -0
  209. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/task_command_router_pb2_grpc.py +0 -0
  210. {modal-1.4.2 → modal-1.4.2.dev1}/modal_proto/task_command_router_pb2_grpc.pyi +0 -0
  211. {modal-1.4.2 → modal-1.4.2.dev1}/modal_version/__main__.py +0 -0
  212. {modal-1.4.2 → modal-1.4.2.dev1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.4.2
3
+ Version: 1.4.2.dev1
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -700,6 +700,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
700
700
  ephemeral_disk: Optional[int] = None,
701
701
  include_source: bool = True,
702
702
  experimental_options: Optional[dict[str, str]] = None,
703
+ _experimental_proxy_ip: Optional[str] = None,
703
704
  restrict_output: bool = False,
704
705
  http_config: Optional[api_pb2.HTTPConfig] = None,
705
706
  ) -> "_Function":
@@ -1040,6 +1041,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1040
1041
  # ---
1041
1042
  _experimental_group_size=cluster_size or 0, # Experimental: Clustered functions
1042
1043
  _experimental_concurrent_cancellations=True,
1044
+ _experimental_proxy_ip=_experimental_proxy_ip,
1043
1045
  # --- These are deprecated in favor of autoscaler_settings
1044
1046
  warm_pool_size=min_containers or 0,
1045
1047
  concurrency_limit=max_containers or 0,
@@ -1082,6 +1084,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1082
1084
  _experimental_group_size=function_definition._experimental_group_size,
1083
1085
  _experimental_buffer_containers=function_definition._experimental_buffer_containers,
1084
1086
  _experimental_custom_scaling=function_definition._experimental_custom_scaling,
1087
+ _experimental_proxy_ip=function_definition._experimental_proxy_ip,
1085
1088
  snapshot_debug=function_definition.snapshot_debug,
1086
1089
  runtime_perf_record=function_definition.runtime_perf_record,
1087
1090
  function_schema=function_schema,
@@ -474,11 +474,8 @@ async def _proxy_lifespan_request(base_url, scope, receive, send) -> None:
474
474
  auto_decompress=False,
475
475
  read_bufsize=1024 * 1024, # 1 MiB
476
476
  connector=aiohttp.TCPConnector(
477
- limit=1000, # 100 is the default max, but 1000 is the max for `@modal.concurrent`.
478
- # Disable keep-alive connection reuse to avoid ServerDisconnectedError
479
- # from stale pooled connections.
480
- force_close=True,
481
- ),
477
+ limit=1000
478
+ ), # 100 is the default max, but 1000 is the max for `@modal.concurrent`.
482
479
  # Note: these values will need to be kept in sync.
483
480
  **(
484
481
  # These options were introduced in aiohttp 3.9, and we can remove the
@@ -216,11 +216,7 @@ def create_channel(
216
216
  for k, v in metadata.items():
217
217
  event.metadata[k] = v
218
218
 
219
- idempotency_key = typing.cast(Optional[str], event.metadata.get("x-idempotency-key"))
220
- if idempotency_key is None:
221
- logger.debug(f"Sending request to {event.method_name}")
222
- else:
223
- logger.debug(f"Sending request to {event.method_name} ({idempotency_key[:8]})")
219
+ logger.debug(f"Sending request to {event.method_name}")
224
220
 
225
221
  grpclib.events.listen(channel, grpclib.events.SendRequest, send_request)
226
222
 
@@ -292,14 +288,12 @@ def process_exception_before_retry(
292
288
  n_retries: int,
293
289
  delay: float,
294
290
  idempotency_key: str,
295
- rpc_elapsed: float,
296
291
  ):
297
292
  """Process exception before retry, used by `_retry_transient_errors`."""
298
293
  with suppress_tb_frame():
299
294
  if final_attempt:
300
295
  logger.debug(
301
- f"Final attempt failed with {repr(exc)} {n_retries=} {delay=} {rpc_elapsed=:0.2f}s "
302
- f"for {fn_name} ({idempotency_key[:8]})"
296
+ f"Final attempt failed with {repr(exc)} {n_retries=} {delay=} for {fn_name} ({idempotency_key[:8]})"
303
297
  )
304
298
  if isinstance(exc, OSError):
305
299
  raise ConnectionError(str(exc))
@@ -316,10 +310,7 @@ def process_exception_before_retry(
316
310
  # we handle in the retry logic once we drop this check!
317
311
  raise exc
318
312
 
319
- logger.debug(
320
- f"Retryable failure {repr(exc)} {n_retries=} {delay=} {rpc_elapsed=:0.2f}s "
321
- f"for {fn_name} ({idempotency_key[:8]})"
322
- )
313
+ logger.debug(f"Retryable failure {repr(exc)} {n_retries=} {delay=} for {fn_name} ({idempotency_key[:8]})")
323
314
 
324
315
 
325
316
  async def _retry_transient_errors(
@@ -382,7 +373,6 @@ async def _retry_transient_errors(
382
373
  else:
383
374
  timeout = None
384
375
 
385
- attempt_started_at = time.monotonic()
386
376
  try:
387
377
  with suppress_tb_frame():
388
378
  return await fn_callable(req, metadata=attempt_metadata, timeout=timeout)
@@ -419,13 +409,7 @@ async def _retry_transient_errors(
419
409
 
420
410
  with suppress_tb_frame():
421
411
  process_exception_before_retry(
422
- exc,
423
- final_attempt,
424
- fn.name,
425
- n_retries,
426
- server_delay,
427
- idempotency_key,
428
- time.monotonic() - attempt_started_at,
412
+ exc, final_attempt, fn.name, n_retries, server_delay, idempotency_key
429
413
  )
430
414
 
431
415
  now = time.time()
@@ -454,15 +438,7 @@ async def _retry_transient_errors(
454
438
  final_attempt = False
455
439
 
456
440
  with suppress_tb_frame():
457
- process_exception_before_retry(
458
- exc,
459
- final_attempt,
460
- fn.name,
461
- n_retries,
462
- delay,
463
- idempotency_key,
464
- time.monotonic() - attempt_started_at,
465
- )
441
+ process_exception_before_retry(exc, final_attempt, fn.name, n_retries, delay, idempotency_key)
466
442
 
467
443
  n_retries += 1
468
444
 
@@ -52,9 +52,8 @@ def _fill(msg, desc: Descriptor, rand: Random) -> None:
52
52
  if hasattr(field, "is_repeated"):
53
53
  is_repeated = field.is_repeated # type: ignore
54
54
  else:
55
- is_repeated = field.label == FieldDescriptor.LABEL_REPEATED # type: ignore[attr-defined]
55
+ is_repeated = field.label == FieldDescriptor.LABEL_REPEATED
56
56
  if is_message:
57
- assert field.message_type is not None
58
57
  msg_field = getattr(msg, field.name)
59
58
  if is_repeated:
60
59
  num = rand.randint(0, 2)
@@ -65,7 +64,6 @@ def _fill(msg, desc: Descriptor, rand: Random) -> None:
65
64
  _fill(msg_field, field.message_type, rand)
66
65
  else:
67
66
  if field.type == FieldDescriptor.TYPE_ENUM:
68
- assert field.enum_type is not None
69
67
  enum_values = [x.number for x in field.enum_type.values]
70
68
  generator = lambda rand: rand.choice(enum_values) # noqa: E731
71
69
 
@@ -18,7 +18,6 @@ from ..exception import (
18
18
  SandboxFilesystemIsADirectoryError,
19
19
  SandboxFilesystemNotADirectoryError,
20
20
  SandboxFilesystemNotFoundError,
21
- SandboxFilesystemPathAlreadyExistsError,
22
21
  SandboxFilesystemPermissionError,
23
22
  ServiceError,
24
23
  )
@@ -189,42 +188,6 @@ def raise_remove_error(returncode: int, stderr: Union[str, bytes], remote_path:
189
188
  raise SandboxFilesystemError(f"Operation on '{remote_path}' failed with exit code {returncode}")
190
189
 
191
190
 
192
- def make_make_directory_command(remote_path: str, create_parents: bool) -> str:
193
- """Build the JSON command string for a MakeDirectory operation.
194
-
195
- The returned JSON must match the `Command` enum in the modal-sandbox-fs-tools
196
- Rust crate (crates/modal-sandbox-fs-tools/src/lib.rs). Treat changes to
197
- this schema like protobuf changes: fields must not be removed or renamed,
198
- only added with backwards-compatible defaults.
199
- """
200
- return json.dumps({"MakeDirectory": {"path": remote_path, "parents": create_parents}})
201
-
202
-
203
- def raise_make_directory_error(returncode: int, stderr: Union[str, bytes], remote_path: str) -> NoReturn:
204
- if payload := try_parse_error_payload(stderr):
205
- logger.debug(
206
- f"sandbox-fs-tools make_directory error: path={remote_path}, "
207
- f"error_kind={payload.error_kind}, message={payload.message}, detail={payload.detail}"
208
- )
209
- if payload.error_kind == "NotFound":
210
- raise SandboxFilesystemNotFoundError(f"{payload.message}: {remote_path}")
211
- if payload.error_kind == "PathAlreadyExists":
212
- raise SandboxFilesystemPathAlreadyExistsError(f"{payload.message}: {remote_path}")
213
- if payload.error_kind == "NotDirectory":
214
- raise SandboxFilesystemNotADirectoryError(f"{payload.message}: {remote_path}")
215
- if payload.error_kind == "PermissionDenied":
216
- raise SandboxFilesystemPermissionError(f"{payload.message}: {remote_path}")
217
- if payload.error_kind == "NotSupported":
218
- raise InvalidError(
219
- f"{payload.message}: {remote_path} - this operation is not supported for CloudBucketMounts"
220
- )
221
- raise SandboxFilesystemError(payload.message)
222
-
223
- if stderr_text := _stderr_to_text(stderr):
224
- logger.debug(f"Unstructured modal-sandbox-fs-tools stderr: {stderr_text}")
225
- raise SandboxFilesystemError(f"Operation on '{remote_path}' failed with exit code {returncode}")
226
-
227
-
228
191
  def validate_absolute_remote_path(remote_path: str, operation: str) -> None:
229
192
  if not PurePosixPath(remote_path).is_absolute():
230
193
  raise InvalidError(f"Sandbox.filesystem.{operation}() currently only supports absolute remote_path values")
@@ -126,11 +126,8 @@ async def fetch_command_router_access_v2(
126
126
  server_client, sandbox_id: str
127
127
  ) -> api_pb2.SandboxGetCommandRouterAccessResponse:
128
128
  """Fetch direct command router access info from Modal server for a V2 sandbox."""
129
- assert server_client._auth_token_manager
130
- auth_token = await server_client._auth_token_manager.get_token()
131
129
  return await server_client.stub.SandboxGetCommandRouterAccess(
132
130
  api_pb2.SandboxGetCommandRouterAccessRequest(sandbox_id=sandbox_id),
133
- metadata=[("x-modal-auth-token", auth_token)],
134
131
  )
135
132
 
136
133
 
@@ -505,8 +502,7 @@ class TaskCommandRouterClient:
505
502
  jwt, url = v1_resp.jwt, v1_resp.url
506
503
 
507
504
  # Ensure the server URL remains stable for the lifetime of this client.
508
- if url != self._server_url:
509
- logger.warning("Task router URL changed during session")
505
+ assert url == self._server_url, "Task router URL changed during session"
510
506
  self._jwt = jwt
511
507
  self._jwt_exp = _parse_jwt_expiration(jwt)
512
508
 
@@ -620,12 +616,6 @@ class TaskCommandRouterClient:
620
616
  lambda: self._call_with_auth_retry(self._stub.TaskMountDirectory, request)
621
617
  )
622
618
 
623
- async def unmount_image(self, request: sr_pb2.TaskUnmountDirectoryRequest):
624
- with grpc_error_converter():
625
- return await call_with_retries_on_transient_errors(
626
- lambda: self._call_with_auth_retry(self._stub.TaskUnmountDirectory, request)
627
- )
628
-
629
619
  async def snapshot_directory(
630
620
  self, request: sr_pb2.TaskSnapshotDirectoryRequest
631
621
  ) -> sr_pb2.TaskSnapshotDirectoryResponse:
@@ -753,6 +753,7 @@ class _App:
753
753
  include_source: Optional[bool] = None,
754
754
  experimental_options: Optional[dict[str, Any]] = None,
755
755
  # Parameters below here are experimental. Use with caution!
756
+ _experimental_proxy_ip: Optional[str] = None, # IP address of proxy
756
757
  _experimental_restrict_output: bool = False, # Don't use pickle for return values
757
758
  # Parameters below here are deprecated. Please update your code as suggested
758
759
  max_inputs: Optional[int] = None, # Replaced with `single_use_containers`
@@ -913,6 +914,7 @@ class _App:
913
914
  rdma=rdma,
914
915
  include_source=include_source if include_source is not None else local_state.include_source_default,
915
916
  experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
917
+ _experimental_proxy_ip=_experimental_proxy_ip,
916
918
  restrict_output=_experimental_restrict_output,
917
919
  )
918
920
 
@@ -966,6 +968,7 @@ class _App:
966
968
  include_source: Optional[bool] = None, # When `False`, don't automatically add the App source to the container.
967
969
  experimental_options: Optional[dict[str, Any]] = None,
968
970
  # Parameters below here are experimental. Use with caution!
971
+ _experimental_proxy_ip: Optional[str] = None, # IP address of proxy
969
972
  _experimental_restrict_output: bool = False, # Don't use pickle for return values
970
973
  # Parameters below here are deprecated. Please update your code as suggested
971
974
  max_inputs: Optional[int] = None, # Replaced with `single_use_containers`
@@ -1112,6 +1115,7 @@ class _App:
1112
1115
  rdma=rdma,
1113
1116
  include_source=include_source if include_source is not None else local_state.include_source_default,
1114
1117
  experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
1118
+ _experimental_proxy_ip=_experimental_proxy_ip,
1115
1119
  restrict_output=_experimental_restrict_output,
1116
1120
  )
1117
1121
 
@@ -497,6 +497,7 @@ class _App:
497
497
  i6pn: typing.Optional[bool] = None,
498
498
  include_source: typing.Optional[bool] = None,
499
499
  experimental_options: typing.Optional[dict[str, typing.Any]] = None,
500
+ _experimental_proxy_ip: typing.Optional[str] = None,
500
501
  _experimental_restrict_output: bool = False,
501
502
  max_inputs: typing.Optional[int] = None,
502
503
  ) -> _FunctionDecoratorType:
@@ -544,6 +545,7 @@ class _App:
544
545
  i6pn: typing.Optional[bool] = None,
545
546
  include_source: typing.Optional[bool] = None,
546
547
  experimental_options: typing.Optional[dict[str, typing.Any]] = None,
548
+ _experimental_proxy_ip: typing.Optional[str] = None,
547
549
  _experimental_restrict_output: bool = False,
548
550
  max_inputs: typing.Optional[int] = None,
549
551
  ) -> collections.abc.Callable[[typing.Union[CLS_T, modal._partial_function._PartialFunction]], CLS_T]:
@@ -1213,6 +1215,7 @@ class App:
1213
1215
  i6pn: typing.Optional[bool] = None,
1214
1216
  include_source: typing.Optional[bool] = None,
1215
1217
  experimental_options: typing.Optional[dict[str, typing.Any]] = None,
1218
+ _experimental_proxy_ip: typing.Optional[str] = None,
1216
1219
  _experimental_restrict_output: bool = False,
1217
1220
  max_inputs: typing.Optional[int] = None,
1218
1221
  ) -> _FunctionDecoratorType:
@@ -1260,6 +1263,7 @@ class App:
1260
1263
  i6pn: typing.Optional[bool] = None,
1261
1264
  include_source: typing.Optional[bool] = None,
1262
1265
  experimental_options: typing.Optional[dict[str, typing.Any]] = None,
1266
+ _experimental_proxy_ip: typing.Optional[str] = None,
1263
1267
  _experimental_restrict_output: bool = False,
1264
1268
  max_inputs: typing.Optional[int] = None,
1265
1269
  ) -> collections.abc.Callable[[typing.Union[CLS_T, modal.partial_function.PartialFunction]], CLS_T]:
@@ -20,22 +20,13 @@ from modal._utils.async_utils import synchronizer
20
20
  from modal._utils.browser_utils import open_url_and_display
21
21
  from modal.client import _Client
22
22
  from modal.environments import ensure_env
23
- from modal.exception import InvalidError, NotFoundError
24
23
  from modal.output import OutputManager
25
24
  from modal.runner import DEPLOYMENT_STRATEGY_TYPE, _stop_and_wait_for_containers
26
25
  from modal_proto import api_pb2
27
26
 
28
27
  from .._logs import _FETCH_LIMIT, _MAX_FETCH_RANGE, LogsFilters
29
28
  from .._utils.time_utils import locale_tz, timestamp_to_localized_str
30
- from .utils import (
31
- ENV_OPTION,
32
- YES_OPTION,
33
- confirm_or_suggest_yes,
34
- display_table,
35
- fetch_app_logs,
36
- stream_app_logs,
37
- tail_app_logs,
38
- )
29
+ from .utils import ENV_OPTION, display_table, fetch_app_logs, get_app_id_from_name, stream_app_logs, tail_app_logs
39
30
 
40
31
  APP_IDENTIFIER = Argument("", help="App name or ID")
41
32
  NAME_OPTION = typer.Option("", "-n", "--name", help="Deprecated: Pass App name as a positional argument")
@@ -54,50 +45,12 @@ APP_STATE_TO_MESSAGE = {
54
45
  }
55
46
 
56
47
 
57
- async def resolve_app_identifier(
58
- app_identifier: str, env: Optional[str], client: Optional[_Client] = None
59
- ) -> tuple[str, str, api_pb2.AppLifecycle]: # Return app_id, environment_name, lifecycle
60
- """Handle an App ID or an App name and return context about the App it points at.
61
-
62
- When a name is provided, we may retrieve either a currently deployed App or an App that
63
- was recently stopped (if no other App with that name has been deployed since).
64
- It is up to callers of this function to decide whether it's valid to use the App ID
65
- based on the lifecycle returned and their specific operations.
66
-
67
- Can also raise a NotFoundError if the argument matches the App ID regex but the App
68
- doesn't exist on the backend, or if there is no currently deployed or recently stopped App
69
- with that name.
70
-
71
- The function also always returns a valid environment name for any name-based lookups,
72
- which may reflect the server-defined default environment when the provided argument was null.
73
-
74
- """
75
- if client is None:
76
- client = await _Client.from_env()
48
+ @synchronizer.create_blocking
49
+ async def get_app_id(app_identifier: str, env: Optional[str], client: Optional[_Client] = None) -> str:
50
+ """Resolve an app_identifier that may be a name or an ID into an ID."""
77
51
  if re.match(r"^ap-[a-zA-Z0-9]{22}$", app_identifier):
78
- # Identifier is an App ID. This is unambiguous, so we can make the request and return
79
- # the lifecycle. AppGetLifecycle will raise NotFoundError if the ID doesn't point at an App.
80
- # If we return, it's a real App, but it's up to the caller to decide what to do based on
81
- # the App's current state as conveyed by the lifecycle. We do propagate a NotFoundError
82
- # from the server if the App ID doesn't actually exist.
83
- request = api_pb2.AppGetLifecycleRequest(app_id=app_identifier)
84
- resp = await client.stub.AppGetLifecycle(request)
85
- return app_identifier, "", resp.lifecycle
86
- else:
87
- # Identifier is treated as a name, which may or may not point at a currently deployed App
88
- # (inside a specific environment)
89
- request = api_pb2.AppGetByDeploymentNameRequest(name=app_identifier, environment_name=env or "")
90
- resp = await client.stub.AppGetByDeploymentName(request)
91
- if resp.app_id:
92
- # App is currently deployed
93
- return resp.app_id, resp.environment_name, resp.lifecycle
94
- elif resp.previous_app_id:
95
- # An App with this name was recently stopped. Return the ID of the stopped App
96
- # and let callers decide what to do based on the lifecycle.
97
- return resp.previous_app_id, resp.environment_name, resp.lifecycle
98
- else:
99
- msg = f"No App with name '{app_identifier}' found in the '{resp.environment_name}' environment."
100
- raise NotFoundError(msg)
52
+ return app_identifier
53
+ return await get_app_id_from_name.aio(app_identifier, env, client)
101
54
 
102
55
 
103
56
  @app_cli.command("list")
@@ -176,8 +129,7 @@ _SOURCE_OPTIONS = {
176
129
 
177
130
 
178
131
  @app_cli.command("logs", no_args_is_help=True)
179
- @synchronizer.create_blocking
180
- async def logs(
132
+ def logs(
181
133
  app_identifier: str = APP_IDENTIFIER,
182
134
  follow: bool = typer.Option(False, "-f", "--follow", help="Stream log output until App stops"),
183
135
  since: Optional[str] = typer.Option(
@@ -277,7 +229,7 @@ async def logs(
277
229
  if tail is not None and tail > _FETCH_LIMIT:
278
230
  raise UsageError(f"--tail value must not exceed {_FETCH_LIMIT}.")
279
231
 
280
- app_id, _, _ = await resolve_app_identifier(app_identifier, env)
232
+ app_id = get_app_id(app_identifier, env)
281
233
 
282
234
  if source is not None:
283
235
  if source not in _SOURCE_OPTIONS:
@@ -303,7 +255,7 @@ async def logs(
303
255
  )
304
256
 
305
257
  if follow:
306
- await stream_app_logs.aio(
258
+ stream_app_logs(
307
259
  app_id,
308
260
  task_id=container_id or "",
309
261
  show_timestamps=timestamps,
@@ -326,7 +278,7 @@ async def logs(
326
278
 
327
279
  if since and tail is None:
328
280
  # Range mode: --since without --tail fetches everything in the range.
329
- await fetch_app_logs.aio(
281
+ fetch_app_logs(
330
282
  app_id,
331
283
  since_dt,
332
284
  until_dt or now,
@@ -338,7 +290,7 @@ async def logs(
338
290
  # Tail mode: single fetch with limit.
339
291
  # --since is a hard floor, --until shifts the anchor.
340
292
  effective_tail = tail if tail is not None else _DEFAULT_LOGS_TAIL
341
- await tail_app_logs.aio(
293
+ tail_app_logs(
342
294
  app_id,
343
295
  effective_tail,
344
296
  show_timestamps=timestamps,
@@ -386,125 +338,29 @@ async def rollback(
386
338
  """
387
339
  env = ensure_env(env)
388
340
  client = await _Client.from_env()
389
- app_id, environment_name, lifecycle = await resolve_app_identifier(app_identifier, env, client)
390
- if lifecycle.app_state != api_pb2.APP_STATE_DEPLOYED:
391
- env_suffix = f" in the '{environment_name}' environment" if environment_name else ""
392
- raise InvalidError(f"App '{app_identifier}' is not deployed{env_suffix}.")
393
-
341
+ app_id = await get_app_id.aio(app_identifier, env, client)
394
342
  if not version:
395
343
  version_number = -1
396
344
  else:
397
345
  if m := re.match(r"v(\d+)", version):
398
346
  version_number = int(m.group(1))
399
347
  else:
400
- raise UsageError(f"Invalid version specifier: {version}")
348
+ raise UsageError(f"Invalid version specifer: {version}")
401
349
  req = api_pb2.AppRollbackRequest(app_id=app_id, version=version_number)
402
350
  await client.stub.AppRollback(req)
403
351
  rich.print("[green]✓[/green] Deployment rollback successful!")
404
352
 
405
353
 
406
- @app_cli.command("rollover", no_args_is_help=True)
407
- @synchronizer.create_blocking
408
- async def rollover(
409
- app_identifier: str = APP_IDENTIFIER,
410
- *,
411
- strategy: str = typer.Option(
412
- "rolling",
413
- help="Strategy for rollover",
414
- click_type=click.Choice(get_args(DEPLOYMENT_STRATEGY_TYPE)),
415
- ),
416
- env: Optional[str] = ENV_OPTION,
417
- ):
418
- """Redeploy an App to get new containers without code changes.
419
-
420
- A rollover replaces existing containers with fresh ones built from the same
421
- App version — useful for refreshing containers without changing your code.
422
- The rollover appears as a new entry in the App's deployment history.
423
-
424
- **Examples:**
425
-
426
- Rollover an App using a rolling deployment. Running containers are now considered
427
- outdated and will be gracefully replaced by new ones.
428
-
429
- ```
430
- modal app rollover my-app
431
- ```
432
-
433
- Rollover an App by terminating any running containers. Inputs on the queue will
434
- start new containers.
435
-
436
- ```
437
- modal app rollover my-app --strategy recreate
438
- ```
439
- """
440
- env = ensure_env(env)
441
- client = await _Client.from_env()
442
-
443
- app_id, environment_name, lifecycle = await resolve_app_identifier(app_identifier, env, client)
444
- if lifecycle.app_state != api_pb2.APP_STATE_DEPLOYED:
445
- env_suffix = f" in the '{environment_name}' environment" if environment_name else ""
446
- raise InvalidError(f"App '{app_identifier}' is not deployed{env_suffix}.")
447
-
448
- output_mgr = OutputManager.get()
449
- output_mgr.print(f"🔨 Starting app rollover with {strategy} strategy")
450
- t0 = time.monotonic()
451
-
452
- req = api_pb2.AppRolloverRequest(app_id=app_id)
453
- response = await client.stub.AppRollover(req)
454
- print_server_warnings(response.server_warnings)
455
-
456
- if strategy == "recreate":
457
- try:
458
- await _stop_and_wait_for_containers(client, app_id, response.deployed_at, env)
459
- except Exception as exc:
460
- warnings.warn(f"App updated successfully, but containers did not all terminate. {exc}", UserWarning)
461
- output_mgr.print(f"\nView Deployment: [magenta]{response.url}[/magenta]")
462
- sys.exit(1)
463
-
464
- duration = time.monotonic() - t0
465
- output_mgr.step_completed(f"Rollover completed in {duration:.3f}s with {strategy} strategy! 🎉")
466
- output_mgr.print(f"\nView Deployment: [magenta]{response.url}[/magenta]")
467
-
468
-
469
354
  @app_cli.command("stop", no_args_is_help=True)
470
355
  @synchronizer.create_blocking
471
356
  async def stop(
472
357
  app_identifier: str = APP_IDENTIFIER,
473
358
  *,
474
- yes: bool = YES_OPTION,
475
359
  env: Optional[str] = ENV_OPTION,
476
360
  ):
477
- """Permanently stop an App and terminate its running containers."""
478
- env = ensure_env(env)
361
+ """Stop an app."""
479
362
  client = await _Client.from_env()
480
- app_id, environment_name, lifecycle = await resolve_app_identifier(app_identifier, env, client)
481
-
482
- if lifecycle.app_state == api_pb2.APP_STATE_STOPPED:
483
- msg = "App is already stopped."
484
- if lifecycle.stopped_at:
485
- stopped_at = timestamp_to_localized_str(lifecycle.stopped_at)
486
- verb = "Stopped" if lifecycle.stopped_by else "Finished"
487
- attribution = f" by '{lifecycle.stopped_by}'" if lifecycle.stopped_by else ""
488
- msg += f" ({verb} at {stopped_at}{attribution})."
489
- raise SystemExit(msg)
490
-
491
- if not yes:
492
- res = await client.stub.TaskList(api_pb2.TaskListRequest(app_id=app_id))
493
- num_containers = len(res.tasks)
494
-
495
- if environment_name:
496
- msg = f"Are you sure you want to stop App '{app_identifier}' in the '{environment_name}' environment?"
497
- else:
498
- msg = f"Are you sure you want to stop App '{app_identifier}'?"
499
-
500
- if num_containers:
501
- msg += (
502
- f" This will immediately terminate {num_containers} running"
503
- f" container{'s' if num_containers != 1 else ''}."
504
- )
505
- else:
506
- msg += " No containers are currently running."
507
- confirm_or_suggest_yes(msg)
363
+ app_id = await get_app_id.aio(app_identifier, env)
508
364
  req = api_pb2.AppStopRequest(app_id=app_id, source=api_pb2.APP_STOP_SOURCE_CLI)
509
365
  await client.stub.AppStop(req)
510
366
 
@@ -517,7 +373,7 @@ async def history(
517
373
  env: Optional[str] = ENV_OPTION,
518
374
  json: bool = False,
519
375
  ):
520
- """Show an App's deployment history.
376
+ """Show App deployment history, for a currently deployed app
521
377
 
522
378
  **Examples:**
523
379
 
@@ -527,7 +383,7 @@ async def history(
527
383
  modal app history ap-123456
528
384
  ```
529
385
 
530
- Get the history for an App based on its name:
386
+ Get the history for a currently deployed App based on its name:
531
387
 
532
388
  ```
533
389
  modal app history my-app
@@ -536,7 +392,7 @@ async def history(
536
392
  """
537
393
  env = ensure_env(env)
538
394
  client = await _Client.from_env()
539
- app_id, _, _ = await resolve_app_identifier(app_identifier, env, client)
395
+ app_id = await get_app_id.aio(app_identifier, env, client)
540
396
  resp = await client.stub.AppDeploymentHistory(api_pb2.AppDeploymentHistoryRequest(app_id=app_id))
541
397
 
542
398
  columns = [
@@ -611,6 +467,66 @@ async def dashboard(
611
467
  ```
612
468
  """
613
469
  client = await _Client.from_env()
614
- app_id, _, _ = await resolve_app_identifier(app_identifier, env, client)
470
+ app_id = await get_app_id.aio(app_identifier, env, client)
471
+
615
472
  url = f"https://modal.com/id/{app_id}"
616
473
  open_url_and_display(url, "App dashboard")
474
+
475
+
476
+ @app_cli.command("rollover", no_args_is_help=True, context_settings={"ignore_unknown_options": True})
477
+ @synchronizer.create_blocking
478
+ async def rollover(
479
+ app_identifier: str = APP_IDENTIFIER,
480
+ *,
481
+ strategy: str = typer.Option(
482
+ "rolling",
483
+ help="Strategy for rollover",
484
+ click_type=click.Choice(get_args(DEPLOYMENT_STRATEGY_TYPE)),
485
+ ),
486
+ env: Optional[str] = ENV_OPTION,
487
+ ):
488
+ """Rollover an App.
489
+
490
+ A rollover replaces existing containers with fresh ones built from the same
491
+ App version — useful for refreshing containers without changing your code.
492
+ The rollover appears as a new entry in the App's deployment history.
493
+
494
+ **Examples:**
495
+
496
+ Rollover an App using a rolling deployment. Running containers are now considered
497
+ outdated and new containers will replace them.
498
+
499
+ ```
500
+ modal app rollover my-app
501
+ ```
502
+
503
+ Rollover an App by termatining all running containers. Inputs on the queue will
504
+ start new containers.
505
+
506
+ ```
507
+ modal app rollover my-app --strategy recreate
508
+ ```
509
+ """
510
+ env = ensure_env(env)
511
+ output_mgr = OutputManager.get()
512
+ output_mgr.print(f"🔨 Starting app rollover with {strategy} strategy")
513
+ t0 = time.monotonic()
514
+
515
+ client = await _Client.from_env()
516
+ app_id = await get_app_id.aio(app_identifier, env, client)
517
+
518
+ req = api_pb2.AppRolloverRequest(app_id=app_id)
519
+ response = await client.stub.AppRollover(req)
520
+ print_server_warnings(response.server_warnings)
521
+
522
+ if strategy == "recreate":
523
+ try:
524
+ await _stop_and_wait_for_containers(client, app_id, response.deployed_at, env)
525
+ except Exception as exc:
526
+ warnings.warn(f"App updated successfully, but containers did not all terminate. {exc}", UserWarning)
527
+ output_mgr.print(f"\nView Deployment: [magenta]{response.url}[/magenta]")
528
+ sys.exit(1)
529
+
530
+ duration = time.monotonic() - t0
531
+ output_mgr.step_completed(f"Rollover completed in {duration:.3f}s with {strategy} strategy! 🎉")
532
+ output_mgr.print(f"\nView Deployment: [magenta]{response.url}[/magenta]")