modAL 1.2.2.dev38__tar.gz → 1.2.3__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 (200) hide show
  1. {modal-1.2.2.dev38 → modal-1.2.3}/PKG-INFO +1 -1
  2. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_functions.py +10 -4
  3. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_load_context.py +2 -1
  4. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_object.py +12 -6
  5. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_partial_function.py +1 -1
  6. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/async_utils.py +15 -5
  7. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/grpc_utils.py +157 -24
  8. {modal-1.2.2.dev38 → modal-1.2.3}/modal/app.py +47 -21
  9. {modal-1.2.2.dev38 → modal-1.2.3}/modal/app.pyi +8 -4
  10. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/run.py +24 -1
  11. {modal-1.2.2.dev38 → modal-1.2.3}/modal/client.py +9 -9
  12. {modal-1.2.2.dev38 → modal-1.2.3}/modal/client.pyi +8 -17
  13. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cloud_bucket_mount.py +1 -1
  14. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cls.py +6 -4
  15. {modal-1.2.2.dev38 → modal-1.2.3}/modal/config.py +5 -0
  16. {modal-1.2.2.dev38 → modal-1.2.3}/modal/functions.pyi +8 -8
  17. {modal-1.2.2.dev38 → modal-1.2.3}/modal/image.py +1 -4
  18. {modal-1.2.2.dev38 → modal-1.2.3}/modal/io_streams.py +101 -32
  19. {modal-1.2.2.dev38 → modal-1.2.3}/modal/io_streams.pyi +30 -5
  20. {modal-1.2.2.dev38 → modal-1.2.3}/modal/sandbox.py +8 -21
  21. {modal-1.2.2.dev38 → modal-1.2.3}/modal/sandbox.pyi +0 -9
  22. {modal-1.2.2.dev38 → modal-1.2.3}/modal.egg-info/PKG-INFO +1 -1
  23. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/api.proto +31 -1
  24. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/api_grpc.py +1 -0
  25. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/api_pb2.py +1052 -1021
  26. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/api_pb2.pyi +103 -10
  27. {modal-1.2.2.dev38 → modal-1.2.3}/modal_version/__init__.py +1 -1
  28. {modal-1.2.2.dev38 → modal-1.2.3}/LICENSE +0 -0
  29. {modal-1.2.2.dev38 → modal-1.2.3}/README.md +0 -0
  30. {modal-1.2.2.dev38 → modal-1.2.3}/modal/__init__.py +0 -0
  31. {modal-1.2.2.dev38 → modal-1.2.3}/modal/__main__.py +0 -0
  32. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_billing.py +0 -0
  33. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_clustered_functions.py +0 -0
  34. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_clustered_functions.pyi +0 -0
  35. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_container_entrypoint.py +0 -0
  36. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_grpc_client.py +0 -0
  37. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_ipython.py +0 -0
  38. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_location.py +0 -0
  39. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_output.py +0 -0
  40. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_pty.py +0 -0
  41. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_resolver.py +0 -0
  42. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_resources.py +0 -0
  43. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_runtime/__init__.py +0 -0
  44. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_runtime/asgi.py +0 -0
  45. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_runtime/container_io_manager.py +0 -0
  46. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_runtime/container_io_manager.pyi +0 -0
  47. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_runtime/execution_context.py +0 -0
  48. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_runtime/execution_context.pyi +0 -0
  49. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_runtime/gpu_memory_snapshot.py +0 -0
  50. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_runtime/telemetry.py +0 -0
  51. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_runtime/user_code_imports.py +0 -0
  52. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_serialization.py +0 -0
  53. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_traceback.py +0 -0
  54. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_tunnel.py +0 -0
  55. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_tunnel.pyi +0 -0
  56. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_type_manager.py +0 -0
  57. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/__init__.py +0 -0
  58. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/app_utils.py +0 -0
  59. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/auth_token_manager.py +0 -0
  60. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/blob_utils.py +0 -0
  61. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/bytes_io_segment_payload.py +0 -0
  62. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/deprecation.py +0 -0
  63. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/docker_utils.py +0 -0
  64. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/function_utils.py +0 -0
  65. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/git_utils.py +0 -0
  66. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/grpc_testing.py +0 -0
  67. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/hash_utils.py +0 -0
  68. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/http_utils.py +0 -0
  69. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/jwt_utils.py +0 -0
  70. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/logger.py +0 -0
  71. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/mount_utils.py +0 -0
  72. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/name_utils.py +0 -0
  73. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/package_utils.py +0 -0
  74. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/pattern_utils.py +0 -0
  75. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/rand_pb_testing.py +0 -0
  76. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/shell_utils.py +0 -0
  77. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/task_command_router_client.py +0 -0
  78. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_utils/time_utils.py +0 -0
  79. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_vendor/__init__.py +0 -0
  80. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  81. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_vendor/cloudpickle.py +0 -0
  82. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_vendor/tblib.py +0 -0
  83. {modal-1.2.2.dev38 → modal-1.2.3}/modal/_watcher.py +0 -0
  84. {modal-1.2.2.dev38 → modal-1.2.3}/modal/billing.py +0 -0
  85. {modal-1.2.2.dev38 → modal-1.2.3}/modal/builder/2023.12.312.txt +0 -0
  86. {modal-1.2.2.dev38 → modal-1.2.3}/modal/builder/2023.12.txt +0 -0
  87. {modal-1.2.2.dev38 → modal-1.2.3}/modal/builder/2024.04.txt +0 -0
  88. {modal-1.2.2.dev38 → modal-1.2.3}/modal/builder/2024.10.txt +0 -0
  89. {modal-1.2.2.dev38 → modal-1.2.3}/modal/builder/2025.06.txt +0 -0
  90. {modal-1.2.2.dev38 → modal-1.2.3}/modal/builder/PREVIEW.txt +0 -0
  91. {modal-1.2.2.dev38 → modal-1.2.3}/modal/builder/README.md +0 -0
  92. {modal-1.2.2.dev38 → modal-1.2.3}/modal/builder/base-images.json +0 -0
  93. {modal-1.2.2.dev38 → modal-1.2.3}/modal/call_graph.py +0 -0
  94. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/__init__.py +0 -0
  95. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/_download.py +0 -0
  96. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/_traceback.py +0 -0
  97. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/app.py +0 -0
  98. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/cluster.py +0 -0
  99. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/config.py +0 -0
  100. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/container.py +0 -0
  101. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/dict.py +0 -0
  102. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/entry_point.py +0 -0
  103. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/environment.py +0 -0
  104. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/import_refs.py +0 -0
  105. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/launch.py +0 -0
  106. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/network_file_system.py +0 -0
  107. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/profile.py +0 -0
  108. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/programs/__init__.py +0 -0
  109. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/programs/launch_instance_ssh.py +0 -0
  110. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/programs/run_jupyter.py +0 -0
  111. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/programs/run_marimo.py +0 -0
  112. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/programs/vscode.py +0 -0
  113. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/queues.py +0 -0
  114. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/secret.py +0 -0
  115. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/token.py +0 -0
  116. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/utils.py +0 -0
  117. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cli/volume.py +0 -0
  118. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cloud_bucket_mount.pyi +0 -0
  119. {modal-1.2.2.dev38 → modal-1.2.3}/modal/cls.pyi +0 -0
  120. {modal-1.2.2.dev38 → modal-1.2.3}/modal/container_process.py +0 -0
  121. {modal-1.2.2.dev38 → modal-1.2.3}/modal/container_process.pyi +0 -0
  122. {modal-1.2.2.dev38 → modal-1.2.3}/modal/dict.py +0 -0
  123. {modal-1.2.2.dev38 → modal-1.2.3}/modal/dict.pyi +0 -0
  124. {modal-1.2.2.dev38 → modal-1.2.3}/modal/environments.py +0 -0
  125. {modal-1.2.2.dev38 → modal-1.2.3}/modal/environments.pyi +0 -0
  126. {modal-1.2.2.dev38 → modal-1.2.3}/modal/exception.py +0 -0
  127. {modal-1.2.2.dev38 → modal-1.2.3}/modal/experimental/__init__.py +0 -0
  128. {modal-1.2.2.dev38 → modal-1.2.3}/modal/experimental/flash.py +0 -0
  129. {modal-1.2.2.dev38 → modal-1.2.3}/modal/experimental/flash.pyi +0 -0
  130. {modal-1.2.2.dev38 → modal-1.2.3}/modal/experimental/ipython.py +0 -0
  131. {modal-1.2.2.dev38 → modal-1.2.3}/modal/file_io.py +0 -0
  132. {modal-1.2.2.dev38 → modal-1.2.3}/modal/file_io.pyi +0 -0
  133. {modal-1.2.2.dev38 → modal-1.2.3}/modal/file_pattern_matcher.py +0 -0
  134. {modal-1.2.2.dev38 → modal-1.2.3}/modal/functions.py +0 -0
  135. {modal-1.2.2.dev38 → modal-1.2.3}/modal/gpu.py +0 -0
  136. {modal-1.2.2.dev38 → modal-1.2.3}/modal/image.pyi +0 -0
  137. {modal-1.2.2.dev38 → modal-1.2.3}/modal/mount.py +0 -0
  138. {modal-1.2.2.dev38 → modal-1.2.3}/modal/mount.pyi +0 -0
  139. {modal-1.2.2.dev38 → modal-1.2.3}/modal/network_file_system.py +0 -0
  140. {modal-1.2.2.dev38 → modal-1.2.3}/modal/network_file_system.pyi +0 -0
  141. {modal-1.2.2.dev38 → modal-1.2.3}/modal/object.py +0 -0
  142. {modal-1.2.2.dev38 → modal-1.2.3}/modal/object.pyi +0 -0
  143. {modal-1.2.2.dev38 → modal-1.2.3}/modal/output.py +0 -0
  144. {modal-1.2.2.dev38 → modal-1.2.3}/modal/parallel_map.py +0 -0
  145. {modal-1.2.2.dev38 → modal-1.2.3}/modal/parallel_map.pyi +0 -0
  146. {modal-1.2.2.dev38 → modal-1.2.3}/modal/partial_function.py +0 -0
  147. {modal-1.2.2.dev38 → modal-1.2.3}/modal/partial_function.pyi +0 -0
  148. {modal-1.2.2.dev38 → modal-1.2.3}/modal/proxy.py +0 -0
  149. {modal-1.2.2.dev38 → modal-1.2.3}/modal/proxy.pyi +0 -0
  150. {modal-1.2.2.dev38 → modal-1.2.3}/modal/py.typed +0 -0
  151. {modal-1.2.2.dev38 → modal-1.2.3}/modal/queue.py +0 -0
  152. {modal-1.2.2.dev38 → modal-1.2.3}/modal/queue.pyi +0 -0
  153. {modal-1.2.2.dev38 → modal-1.2.3}/modal/retries.py +0 -0
  154. {modal-1.2.2.dev38 → modal-1.2.3}/modal/runner.py +0 -0
  155. {modal-1.2.2.dev38 → modal-1.2.3}/modal/runner.pyi +0 -0
  156. {modal-1.2.2.dev38 → modal-1.2.3}/modal/running_app.py +0 -0
  157. {modal-1.2.2.dev38 → modal-1.2.3}/modal/schedule.py +0 -0
  158. {modal-1.2.2.dev38 → modal-1.2.3}/modal/scheduler_placement.py +0 -0
  159. {modal-1.2.2.dev38 → modal-1.2.3}/modal/secret.py +0 -0
  160. {modal-1.2.2.dev38 → modal-1.2.3}/modal/secret.pyi +0 -0
  161. {modal-1.2.2.dev38 → modal-1.2.3}/modal/serving.py +0 -0
  162. {modal-1.2.2.dev38 → modal-1.2.3}/modal/serving.pyi +0 -0
  163. {modal-1.2.2.dev38 → modal-1.2.3}/modal/snapshot.py +0 -0
  164. {modal-1.2.2.dev38 → modal-1.2.3}/modal/snapshot.pyi +0 -0
  165. {modal-1.2.2.dev38 → modal-1.2.3}/modal/stream_type.py +0 -0
  166. {modal-1.2.2.dev38 → modal-1.2.3}/modal/token_flow.py +0 -0
  167. {modal-1.2.2.dev38 → modal-1.2.3}/modal/token_flow.pyi +0 -0
  168. {modal-1.2.2.dev38 → modal-1.2.3}/modal/volume.py +0 -0
  169. {modal-1.2.2.dev38 → modal-1.2.3}/modal/volume.pyi +0 -0
  170. {modal-1.2.2.dev38 → modal-1.2.3}/modal.egg-info/SOURCES.txt +0 -0
  171. {modal-1.2.2.dev38 → modal-1.2.3}/modal.egg-info/dependency_links.txt +0 -0
  172. {modal-1.2.2.dev38 → modal-1.2.3}/modal.egg-info/entry_points.txt +0 -0
  173. {modal-1.2.2.dev38 → modal-1.2.3}/modal.egg-info/requires.txt +0 -0
  174. {modal-1.2.2.dev38 → modal-1.2.3}/modal.egg-info/top_level.txt +0 -0
  175. {modal-1.2.2.dev38 → modal-1.2.3}/modal_docs/__init__.py +0 -0
  176. {modal-1.2.2.dev38 → modal-1.2.3}/modal_docs/gen_cli_docs.py +0 -0
  177. {modal-1.2.2.dev38 → modal-1.2.3}/modal_docs/gen_reference_docs.py +0 -0
  178. {modal-1.2.2.dev38 → modal-1.2.3}/modal_docs/mdmd/__init__.py +0 -0
  179. {modal-1.2.2.dev38 → modal-1.2.3}/modal_docs/mdmd/mdmd.py +0 -0
  180. {modal-1.2.2.dev38 → modal-1.2.3}/modal_docs/mdmd/signatures.py +0 -0
  181. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/__init__.py +0 -0
  182. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/api_pb2_grpc.py +0 -0
  183. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/api_pb2_grpc.pyi +0 -0
  184. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/modal_api_grpc.py +0 -0
  185. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/py.typed +0 -0
  186. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/sandbox_router.proto +0 -0
  187. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/sandbox_router_grpc.py +0 -0
  188. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/sandbox_router_pb2.py +0 -0
  189. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/sandbox_router_pb2.pyi +0 -0
  190. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/sandbox_router_pb2_grpc.py +0 -0
  191. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/sandbox_router_pb2_grpc.pyi +0 -0
  192. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/task_command_router.proto +0 -0
  193. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/task_command_router_grpc.py +0 -0
  194. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/task_command_router_pb2.py +0 -0
  195. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/task_command_router_pb2.pyi +0 -0
  196. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/task_command_router_pb2_grpc.py +0 -0
  197. {modal-1.2.2.dev38 → modal-1.2.3}/modal_proto/task_command_router_pb2_grpc.pyi +0 -0
  198. {modal-1.2.2.dev38 → modal-1.2.3}/modal_version/__main__.py +0 -0
  199. {modal-1.2.2.dev38 → modal-1.2.3}/pyproject.toml +0 -0
  200. {modal-1.2.2.dev38 → modal-1.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.2.2.dev38
3
+ Version: 1.2.3
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License: Apache-2.0
@@ -90,7 +90,6 @@ from .parallel_map import (
90
90
  from .proxy import _Proxy
91
91
  from .retries import Retries, RetryManager
92
92
  from .schedule import Schedule
93
- from .scheduler_placement import SchedulerPlacement
94
93
  from .secret import _Secret
95
94
  from .volume import _Volume
96
95
 
@@ -537,6 +536,7 @@ class _InputPlaneInvocation:
537
536
  async def _get_metadata(input_plane_region: str, client: _Client) -> list[tuple[str, str]]:
538
537
  if not input_plane_region:
539
538
  return []
539
+ assert client._auth_token_manager, "Client is not open"
540
540
  token = await client._auth_token_manager.get_token()
541
541
  return [("x-modal-input-plane-region", input_plane_region), ("x-modal-auth-token", token)]
542
542
 
@@ -589,7 +589,7 @@ class _FunctionSpec:
589
589
  cpu: Optional[Union[float, tuple[float, float]]]
590
590
  memory: Optional[Union[int, tuple[int, int]]]
591
591
  ephemeral_disk: Optional[int]
592
- scheduler_placement: Optional[SchedulerPlacement]
592
+ scheduler_placement: Optional[api_pb2.SchedulerPlacement]
593
593
  proxy: Optional[_Proxy]
594
594
 
595
595
 
@@ -683,7 +683,8 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
683
683
  batch_max_size: Optional[int] = None,
684
684
  batch_wait_ms: Optional[int] = None,
685
685
  cloud: Optional[str] = None,
686
- scheduler_placement: Optional[SchedulerPlacement] = None,
686
+ region: Optional[Union[str, Sequence[str]]] = None,
687
+ nonpreemptible: bool = False,
687
688
  is_builder_function: bool = False,
688
689
  is_auto_snapshot: bool = False,
689
690
  enable_memory_snapshot: bool = False,
@@ -746,6 +747,11 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
746
747
  if env:
747
748
  secrets = [*secrets, _Secret.from_dict(env)]
748
749
 
750
+ scheduler_placement: Optional[api_pb2.SchedulerPlacement] = None
751
+ if region or nonpreemptible:
752
+ regions = [region] if isinstance(region, str) else (list(region) if region else None)
753
+ scheduler_placement = api_pb2.SchedulerPlacement(regions=regions, nonpreemptible=nonpreemptible)
754
+
749
755
  function_spec = _FunctionSpec(
750
756
  mounts=all_mounts,
751
757
  secrets=secrets,
@@ -1018,7 +1024,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1018
1024
  untrusted=restrict_modal_access,
1019
1025
  max_inputs=max_inputs or 0,
1020
1026
  cloud_bucket_mounts=cloud_bucket_mounts_to_proto(cloud_bucket_mounts),
1021
- scheduler_placement=scheduler_placement.proto if scheduler_placement else None,
1027
+ scheduler_placement=scheduler_placement,
1022
1028
  is_class=info.is_service_class(),
1023
1029
  class_parameter_info=info.class_parameter_info(),
1024
1030
  i6pn_enabled=i6pn_enabled,
@@ -66,8 +66,9 @@ class LoadContext:
66
66
 
67
67
  Returns a new instance (no in place mutation)"""
68
68
 
69
+ is_valid_client = self._client is not None and not self._client._snapshotted
69
70
  return LoadContext(
70
- client=await _Client.from_env() if self._client is None else self.client,
71
+ client=self.client if is_valid_client else await _Client.from_env(),
71
72
  environment_name=self._environment_name or config.get("environment") or "",
72
73
  app_id=self._app_id,
73
74
  )
@@ -43,7 +43,7 @@ class _Object:
43
43
  _prefix_to_type: ClassVar[dict[str, type]] = {}
44
44
 
45
45
  # For constructors
46
- _load: Optional[Callable[[Self, Resolver, LoadContext, Optional[str]], Awaitable[None]]]
46
+ _load: Optional[Callable[[Self, Resolver, LoadContext, Optional[str]], Awaitable[None]]] = None
47
47
  _preload: Optional[Callable[[Self, Resolver, LoadContext, Optional[str]], Awaitable[None]]]
48
48
  _rep: str
49
49
  _is_another_app: bool
@@ -308,11 +308,17 @@ class _Object:
308
308
  # memory snapshots capture references which must be rehydrated
309
309
  # on restore to handle staleness.
310
310
  logger.debug(f"rehydrating {self} after snapshot")
311
- self._is_hydrated = False # un-hydrate and re-resolve
312
- # Set the client on LoadContext before loading
313
- root_load_context = LoadContext(client=client)
314
- resolver = Resolver()
315
- await resolver.load(typing.cast(_Object, self), root_load_context)
311
+ if self._hydrate_lazily:
312
+ logger.debug(f"reloading lazy {self} from server")
313
+ self._is_hydrated = False # un-hydrate and re-resolve
314
+ # we don't set an explicit Client here, relying on the default
315
+ # env client to be applied by LoadContext.apply_default
316
+ root_load_context = LoadContext.empty()
317
+ resolver = Resolver()
318
+ await resolver.load(typing.cast(_Object, self), root_load_context)
319
+ else:
320
+ logger.debug(f"reloading non-lazy {self} by replacing client")
321
+ self._client = client or await _Client.from_env()
316
322
  self._is_rehydrated = True
317
323
  logger.debug(f"rehydrated {self} with client {id(self.client)}")
318
324
  elif not self._hydrate_lazily:
@@ -219,7 +219,7 @@ class _PartialFunction(typing.Generic[P, ReturnType, OriginalReturnType]):
219
219
  # of the type PartialFunction and this descriptor would be triggered when accessing it,
220
220
  #
221
221
  # However, modal classes are *actually* Cls instances (which isn't reflected in type checkers
222
- # due to Python's lack of type chekcing intersection types), so at runtime the Cls instance would
222
+ # due to Python's lack of type checking intersection types), so at runtime the Cls instance would
223
223
  # use its __getattr__ rather than this descriptor.
224
224
  assert self.raw_f is not None # Should only be relevant in a method context
225
225
  k = self.raw_f.__name__
@@ -1,12 +1,14 @@
1
1
  # Copyright Modal Labs 2022
2
2
  import asyncio
3
3
  import concurrent.futures
4
+ import contextlib
4
5
  import functools
5
6
  import inspect
6
7
  import itertools
7
8
  import sys
8
9
  import time
9
10
  import typing
11
+ import warnings
10
12
  from collections.abc import AsyncGenerator, AsyncIterable, Awaitable, Iterable, Iterator
11
13
  from contextlib import asynccontextmanager
12
14
  from dataclasses import dataclass
@@ -134,7 +136,8 @@ class TaskContext:
134
136
  _loops: set[asyncio.Task]
135
137
 
136
138
  def __init__(self, grace: Optional[float] = None):
137
- self._grace = grace
139
+ self._grace = grace # grace is the time we want for tasks to finish before cancelling them
140
+ self._cancellation_grace: float = 1.0 # extra graceperiod for the cancellation itself to "bubble up"
138
141
  self._loops = set()
139
142
 
140
143
  async def start(self):
@@ -166,22 +169,29 @@ class TaskContext:
166
169
  # still needs to be handled
167
170
  # (https://stackoverflow.com/a/63356323/2475114)
168
171
  if gather_future:
169
- try:
172
+ with contextlib.suppress(asyncio.CancelledError):
170
173
  await gather_future
171
- except asyncio.CancelledError:
172
- pass
173
174
 
175
+ cancelled_tasks: list[asyncio.Task] = []
174
176
  for task in self._tasks:
175
177
  if task.done() and not task.cancelled():
176
178
  # Raise any exceptions if they happened.
177
179
  # Only tasks without a done_callback will still be present in self._tasks
178
180
  task.result()
179
181
 
180
- if task.done() or task in self._loops: # Note: Legacy code, we can probably cancel loops.
182
+ if task.done():
181
183
  continue
182
184
 
183
185
  # Cancel any remaining unfinished tasks.
184
186
  task.cancel()
187
+ cancelled_tasks.append(task)
188
+
189
+ cancellation_gather = asyncio.gather(*cancelled_tasks, return_exceptions=True)
190
+ try:
191
+ await asyncio.wait_for(cancellation_gather, timeout=self._cancellation_grace)
192
+ except asyncio.TimeoutError:
193
+ warnings.warn(f"Internal warning: Tasks did not cancel in a timely manner: {cancelled_tasks}")
194
+
185
195
  await asyncio.sleep(0) # wake up coroutines waiting for cancellations
186
196
 
187
197
  async def __aexit__(self, exc_type, value, tb):
@@ -9,7 +9,8 @@ import urllib.parse
9
9
  import uuid
10
10
  from collections.abc import AsyncIterator
11
11
  from dataclasses import dataclass, field
12
- from typing import Any, Optional, TypeVar
12
+ from functools import cache
13
+ from typing import Any, Optional, Sequence, TypeVar
13
14
 
14
15
  import grpclib.client
15
16
  import grpclib.config
@@ -17,14 +18,18 @@ import grpclib.events
17
18
  import grpclib.protocol
18
19
  import grpclib.stream
19
20
  from google.protobuf.message import Message
21
+ from google.protobuf.symbol_database import SymbolDatabase
20
22
  from grpclib import GRPCError, Status
23
+ from grpclib.encoding.base import StatusDetailsCodecBase
21
24
  from grpclib.exceptions import StreamTerminatedError
22
25
  from grpclib.protocol import H2Protocol
23
26
 
24
27
  from modal.exception import AuthError, ConnectionError
28
+ from modal_proto import api_pb2
25
29
  from modal_version import __version__
26
30
 
27
31
  from .._traceback import suppress_tb_frames
32
+ from ..config import config
28
33
  from .async_utils import retry
29
34
  from .logger import logger
30
35
 
@@ -68,6 +73,7 @@ RETRYABLE_GRPC_STATUS_CODES = [
68
73
  Status.INTERNAL,
69
74
  Status.UNKNOWN,
70
75
  ]
76
+ SERVER_RETRY_WARNING_TIME_INTERVAL = 30.0
71
77
 
72
78
 
73
79
  @dataclass
@@ -107,6 +113,56 @@ class ConnectionManager:
107
113
  self._channels.clear()
108
114
 
109
115
 
116
+ @cache
117
+ def _sym_db() -> SymbolDatabase:
118
+ from google.protobuf.symbol_database import Default
119
+
120
+ return Default()
121
+
122
+
123
+ class CustomProtoStatusDetailsCodec(StatusDetailsCodecBase):
124
+ """grpclib compatible details codec.
125
+
126
+ The server can encode the details using `google.rpc.Status` using grpclib's default codec and this custom codec
127
+ can decode it into a `api_pb2.RPCStatus`.
128
+ """
129
+
130
+ def encode(
131
+ self,
132
+ status: Status,
133
+ message: Optional[str],
134
+ details: Optional[Sequence[Message]],
135
+ ) -> bytes:
136
+ details_proto = api_pb2.RPCStatus(code=status.value, message=message or "")
137
+ if details is not None:
138
+ for detail in details:
139
+ detail_container = details_proto.details.add()
140
+ detail_container.Pack(detail)
141
+ return details_proto.SerializeToString()
142
+
143
+ def decode(
144
+ self,
145
+ status: Status,
146
+ message: Optional[str],
147
+ data: bytes,
148
+ ) -> Any:
149
+ sym_db = _sym_db()
150
+ details_proto = api_pb2.RPCStatus.FromString(data)
151
+
152
+ details = []
153
+ for detail_container in details_proto.details:
154
+ # If we do not know how to decode an emssage, we'll ignore it.
155
+ with contextlib.suppress(Exception):
156
+ msg_type = sym_db.GetSymbol(detail_container.TypeName())
157
+ detail = msg_type()
158
+ detail_container.Unpack(detail)
159
+ details.append(detail)
160
+ return details
161
+
162
+
163
+ custom_detail_codec = CustomProtoStatusDetailsCodec()
164
+
165
+
110
166
  def create_channel(
111
167
  server_url: str,
112
168
  metadata: dict[str, str] = {},
@@ -125,7 +181,7 @@ def create_channel(
125
181
  )
126
182
 
127
183
  if o.scheme == "unix":
128
- channel = grpclib.client.Channel(path=o.path, config=config) # probably pointless to use a pool ever
184
+ channel = grpclib.client.Channel(path=o.path, config=config, status_details_codec=custom_detail_codec)
129
185
  elif o.scheme in ("http", "https"):
130
186
  target = o.netloc
131
187
  parts = target.split(":")
@@ -133,7 +189,7 @@ def create_channel(
133
189
  ssl = o.scheme.endswith("s")
134
190
  host = parts[0]
135
191
  port = int(parts[1]) if len(parts) == 2 else 443 if ssl else 80
136
- channel = grpclib.client.Channel(host, port, ssl=ssl, config=config)
192
+ channel = grpclib.client.Channel(host, port, ssl=ssl, config=config, status_details_codec=custom_detail_codec)
137
193
  else:
138
194
  raise Exception(f"Unknown scheme: {o.scheme}")
139
195
 
@@ -197,6 +253,50 @@ async def retry_transient_errors(
197
253
  return await _retry_transient_errors(fn, req, retry=Retry(max_retries=max_retries))
198
254
 
199
255
 
256
+ def get_server_retry_policy(exc: Exception) -> Optional[api_pb2.RPCRetryPolicy]:
257
+ """Get server retry policy."""
258
+ if not isinstance(exc, GRPCError) or not exc.details:
259
+ return None
260
+
261
+ # Server should not set multiple retry instructions, but if there is more than one, pick the first one
262
+ for entry in exc.details:
263
+ if isinstance(entry, api_pb2.RPCRetryPolicy):
264
+ return entry
265
+ return None
266
+
267
+
268
+ def process_exception_before_retry(
269
+ exc: Exception,
270
+ final_attempt: bool,
271
+ fn_name: str,
272
+ n_retries: int,
273
+ delay: float,
274
+ idempotency_key: str,
275
+ ):
276
+ """Process exception before retry, used by `_retry_transient_errors`."""
277
+ with suppress_tb_frames(1):
278
+ if final_attempt:
279
+ logger.debug(
280
+ f"Final attempt failed with {repr(exc)} {n_retries=} {delay=} for {fn_name} ({idempotency_key[:8]})"
281
+ )
282
+ if isinstance(exc, OSError):
283
+ raise ConnectionError(str(exc))
284
+ elif isinstance(exc, asyncio.TimeoutError):
285
+ raise ConnectionError(str(exc))
286
+ else:
287
+ raise exc
288
+
289
+ if isinstance(exc, AttributeError) and "_write_appdata" not in str(exc):
290
+ # StreamTerminatedError are not properly raised in grpclib<=0.4.7
291
+ # fixed in https://github.com/vmagamedov/grpclib/issues/185
292
+ # TODO: update to newer version (>=0.4.8) once stable
293
+ # Also be sure to remove the AttributeError from the set of exceptions
294
+ # we handle in the retry logic once we drop this check!
295
+ raise exc
296
+
297
+ logger.debug(f"Retryable failure {repr(exc)} {n_retries=} {delay=} for {fn_name} ({idempotency_key[:8]})")
298
+
299
+
200
300
  async def _retry_transient_errors(
201
301
  fn: typing.Union[
202
302
  "modal._grpc_client.UnaryUnaryWrapper[RequestType, ResponseType]",
@@ -219,12 +319,15 @@ async def _retry_transient_errors(
219
319
 
220
320
  delay = retry.base_delay
221
321
  n_retries = 0
322
+ n_throttled_retries = 0
222
323
 
223
324
  status_codes = [*RETRYABLE_GRPC_STATUS_CODES, *retry.additional_status_codes]
224
325
 
225
326
  idempotency_key = str(uuid.uuid4())
226
327
 
227
328
  t0 = time.time()
329
+ last_server_retry_warning_time = None
330
+
228
331
  if retry.total_timeout is not None:
229
332
  total_deadline = t0 + retry.total_timeout
230
333
  else:
@@ -236,29 +339,77 @@ async def _retry_transient_errors(
236
339
  attempt_metadata = [
237
340
  ("x-idempotency-key", idempotency_key),
238
341
  ("x-retry-attempt", str(n_retries)),
342
+ ("x-throttle-retry-attempt", str(n_throttled_retries)),
239
343
  *metadata,
240
344
  ]
241
345
  if n_retries > 0:
242
346
  attempt_metadata.append(("x-retry-delay", str(time.time() - t0)))
347
+ if n_throttled_retries > 0:
348
+ attempt_metadata.append(("x-throttle-retry-delay", str(time.time() - t0)))
349
+
243
350
  timeouts = []
244
351
  if retry.attempt_timeout is not None:
245
352
  timeouts.append(retry.attempt_timeout)
246
- if retry.total_timeout is not None and total_deadline is not None:
353
+ if total_deadline is not None:
247
354
  timeouts.append(max(total_deadline - time.time(), retry.attempt_timeout_floor))
248
355
  if timeouts:
249
356
  timeout = min(timeouts) # In case the function provided both types of timeouts
250
357
  else:
251
358
  timeout = None
359
+
252
360
  try:
253
361
  with suppress_tb_frames(1):
254
362
  return await fn_callable(req, metadata=attempt_metadata, timeout=timeout)
255
363
  except (StreamTerminatedError, GRPCError, OSError, asyncio.TimeoutError, AttributeError) as exc:
364
+ # Note that we only catch AttributeError to handle a specific case that works around a bug
365
+ # in grpclib<=0.4.7. See above (search for `write_appdata`).
366
+
367
+ # Server side instruction for retries
368
+ max_throttle_wait: Optional[int] = config.get("max_throttle_wait")
369
+ if (
370
+ max_throttle_wait != 0
371
+ and isinstance(exc, GRPCError)
372
+ and (server_retry_policy := get_server_retry_policy(exc))
373
+ ):
374
+ server_delay = server_retry_policy.retry_after_secs
375
+
376
+ now = time.time()
377
+
378
+ # We check if the timeout will be reached **after** the sleep, so we can raise an error early
379
+ # without needing to actually sleep.
380
+ total_timeout_will_be_reached = (
381
+ retry.total_timeout is not None and (now + server_delay - t0) >= retry.total_timeout
382
+ )
383
+ max_throttle_will_be_reached = (
384
+ max_throttle_wait is not None and (now + server_delay - t0) >= max_throttle_wait
385
+ )
386
+ final_attempt = total_timeout_will_be_reached or max_throttle_will_be_reached
387
+
388
+ with suppress_tb_frames(1):
389
+ process_exception_before_retry(
390
+ exc, final_attempt, fn.name, n_retries, server_delay, idempotency_key
391
+ )
392
+
393
+ now = time.time()
394
+ if last_server_retry_warning_time is None or (
395
+ now - last_server_retry_warning_time >= SERVER_RETRY_WARNING_TIME_INTERVAL
396
+ ):
397
+ last_server_retry_warning_time = now
398
+ logger.warning(
399
+ f"Warning: Received {exc.status} status: {exc.message}. "
400
+ f"Will retry in {server_delay:0.2f} seconds."
401
+ )
402
+
403
+ n_throttled_retries += 1
404
+ await asyncio.sleep(server_delay)
405
+ continue
406
+
407
+ # Client handles retry
256
408
  if isinstance(exc, GRPCError) and exc.status not in status_codes:
257
409
  if exc.status == Status.UNAUTHENTICATED:
258
410
  raise AuthError(exc.message)
259
411
  else:
260
412
  raise exc
261
-
262
413
  if retry.max_retries is not None and n_retries >= retry.max_retries:
263
414
  final_attempt = True
264
415
  elif total_deadline is not None and time.time() + delay + retry.attempt_timeout_floor >= total_deadline:
@@ -267,25 +418,7 @@ async def _retry_transient_errors(
267
418
  final_attempt = False
268
419
 
269
420
  with suppress_tb_frames(1):
270
- if final_attempt:
271
- logger.debug(
272
- f"Final attempt failed with {repr(exc)} {n_retries=} {delay=} "
273
- f"{total_deadline=} for {fn.name} ({idempotency_key[:8]})"
274
- )
275
- if isinstance(exc, OSError):
276
- raise ConnectionError(str(exc))
277
- elif isinstance(exc, asyncio.TimeoutError):
278
- raise ConnectionError(str(exc))
279
- else:
280
- raise exc
281
-
282
- if isinstance(exc, AttributeError) and "_write_appdata" not in str(exc):
283
- # StreamTerminatedError are not properly raised in grpclib<=0.4.7
284
- # fixed in https://github.com/vmagamedov/grpclib/issues/185
285
- # TODO: update to newer version (>=0.4.8) once stable
286
- raise exc
287
-
288
- logger.debug(f"Retryable failure {repr(exc)} {n_retries=} {delay=} for {fn.name} ({idempotency_key[:8]})")
421
+ process_exception_before_retry(exc, final_attempt, fn.name, n_retries, delay, idempotency_key)
289
422
 
290
423
  n_retries += 1
291
424
 
@@ -719,6 +719,7 @@ class _App:
719
719
  ] = None, # Set this to True if it's a non-generator function returning a [sync/async] generator object
720
720
  cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
721
721
  region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
722
+ nonpreemptible: bool = False, # Whether to run the function on a nonpreemptible instance.
722
723
  enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
723
724
  block_network: bool = False, # Whether to block network access
724
725
  restrict_modal_access: bool = False, # Whether to allow this function access to other Modal resources
@@ -731,9 +732,6 @@ class _App:
731
732
  include_source: Optional[bool] = None,
732
733
  experimental_options: Optional[dict[str, Any]] = None,
733
734
  # Parameters below here are experimental. Use with caution!
734
- _experimental_scheduler_placement: Optional[
735
- SchedulerPlacement
736
- ] = None, # Experimental controls over fine-grained scheduling (alpha).
737
735
  _experimental_proxy_ip: Optional[str] = None, # IP address of proxy
738
736
  _experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
739
737
  _experimental_restrict_output: bool = False, # Don't use pickle for return values
@@ -743,6 +741,8 @@ class _App:
743
741
  container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
744
742
  allow_concurrent_inputs: Optional[int] = None, # Replaced with the `@modal.concurrent` decorator
745
743
  _experimental_buffer_containers: Optional[int] = None, # Now stable API with `buffer_containers`
744
+ _experimental_scheduler_placement: Optional[SchedulerPlacement] = None, # Replaced in favor of
745
+ # using `region` and `nonpreemptible`
746
746
  ) -> _FunctionDecoratorType:
747
747
  """Decorator to register a new Modal Function with this App."""
748
748
  if isinstance(_warn_parentheses_missing, _Image):
@@ -762,6 +762,24 @@ class _App:
762
762
  "\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
763
763
  )
764
764
 
765
+ if _experimental_scheduler_placement is not None:
766
+ deprecation_warning(
767
+ (2025, 11, 17),
768
+ "The `_experimental_scheduler_placement` parameter is deprecated."
769
+ " Please use the `region` and `nonpreemptible` parameters instead.",
770
+ )
771
+ if region is not None or nonpreemptible:
772
+ raise InvalidError(
773
+ "Cannot use `_experimental_scheduler_placement` together with "
774
+ "`region` or `nonpreemptible` parameters."
775
+ )
776
+ # Extract regions and lifecycle from scheduler placement
777
+ if _experimental_scheduler_placement.proto.regions:
778
+ region = list(_experimental_scheduler_placement.proto.regions)
779
+ if _experimental_scheduler_placement.proto._lifecycle:
780
+ # Convert lifecycle to nonpreemptible: "on-demand" -> True, "spot" -> False
781
+ nonpreemptible = _experimental_scheduler_placement.proto._lifecycle == "on-demand"
782
+
765
783
  secrets = secrets or []
766
784
  if env:
767
785
  secrets = [*secrets, _Secret.from_dict(env)]
@@ -771,7 +789,7 @@ class _App:
771
789
  def wrapped(
772
790
  f: Union[_PartialFunction, Callable[..., Any], None],
773
791
  ) -> _Function:
774
- nonlocal is_generator, cloud, serialized
792
+ nonlocal is_generator, cloud, serialized, region, nonpreemptible
775
793
 
776
794
  # Check if the decorated object is a class
777
795
  if inspect.isclass(f):
@@ -860,12 +878,6 @@ class _App:
860
878
  if is_generator is None:
861
879
  is_generator = inspect.isgeneratorfunction(raw_f) or inspect.isasyncgenfunction(raw_f)
862
880
 
863
- scheduler_placement: Optional[SchedulerPlacement] = _experimental_scheduler_placement
864
- if region:
865
- if scheduler_placement:
866
- raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
867
- scheduler_placement = SchedulerPlacement(region=region)
868
-
869
881
  function = _Function.from_local(
870
882
  info,
871
883
  app=self,
@@ -892,12 +904,13 @@ class _App:
892
904
  timeout=timeout,
893
905
  startup_timeout=startup_timeout or timeout,
894
906
  cloud=cloud,
907
+ region=region,
908
+ nonpreemptible=nonpreemptible,
895
909
  webhook_config=webhook_config,
896
910
  enable_memory_snapshot=enable_memory_snapshot,
897
911
  block_network=block_network,
898
912
  restrict_modal_access=restrict_modal_access,
899
913
  max_inputs=max_inputs,
900
- scheduler_placement=scheduler_placement,
901
914
  i6pn_enabled=i6pn_enabled,
902
915
  cluster_size=cluster_size, # Experimental: Clustered functions
903
916
  rdma=rdma,
@@ -950,6 +963,7 @@ class _App:
950
963
  startup_timeout: Optional[int] = None, # Maximum startup time in seconds with higher precedence than `timeout`.
951
964
  cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
952
965
  region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
966
+ nonpreemptible: bool = False, # Whether to run the function on a non-preemptible instance.
953
967
  enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
954
968
  block_network: bool = False, # Whether to block network access
955
969
  restrict_modal_access: bool = False, # Whether to allow this class access to other Modal resources
@@ -960,9 +974,6 @@ class _App:
960
974
  include_source: Optional[bool] = None, # When `False`, don't automatically add the App source to the container.
961
975
  experimental_options: Optional[dict[str, Any]] = None,
962
976
  # Parameters below here are experimental. Use with caution!
963
- _experimental_scheduler_placement: Optional[
964
- SchedulerPlacement
965
- ] = None, # Experimental controls over fine-grained scheduling (alpha).
966
977
  _experimental_proxy_ip: Optional[str] = None, # IP address of proxy
967
978
  _experimental_custom_scaling_factor: Optional[float] = None, # Custom scaling factor
968
979
  _experimental_restrict_output: bool = False, # Don't use pickle for return values
@@ -972,6 +983,8 @@ class _App:
972
983
  container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
973
984
  allow_concurrent_inputs: Optional[int] = None, # Replaced with the `@modal.concurrent` decorator
974
985
  _experimental_buffer_containers: Optional[int] = None, # Now stable API with `buffer_containers`
986
+ _experimental_scheduler_placement: Optional[SchedulerPlacement] = None, # Replaced in favor of
987
+ # using `region` and `nonpreemptible`
975
988
  ) -> Callable[[Union[CLS_T, _PartialFunction]], CLS_T]:
976
989
  """
977
990
  Decorator to register a new Modal [Cls](https://modal.com/docs/reference/modal.Cls) with this App.
@@ -979,12 +992,6 @@ class _App:
979
992
  if _warn_parentheses_missing:
980
993
  raise InvalidError("Did you forget parentheses? Suggestion: `@app.cls()`.")
981
994
 
982
- scheduler_placement = _experimental_scheduler_placement
983
- if region:
984
- if scheduler_placement:
985
- raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
986
- scheduler_placement = SchedulerPlacement(region=region)
987
-
988
995
  if allow_concurrent_inputs is not None:
989
996
  deprecation_warning(
990
997
  (2025, 4, 9),
@@ -993,6 +1000,24 @@ class _App:
993
1000
  "\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
994
1001
  )
995
1002
 
1003
+ if _experimental_scheduler_placement is not None:
1004
+ deprecation_warning(
1005
+ (2025, 11, 17),
1006
+ "The `_experimental_scheduler_placement` parameter is deprecated."
1007
+ " Please use the `region` and `nonpreemptible` parameters instead.",
1008
+ )
1009
+ if region is not None or nonpreemptible:
1010
+ raise InvalidError(
1011
+ "Cannot use `_experimental_scheduler_placement` together with "
1012
+ "`region` or `nonpreemptible` parameters."
1013
+ )
1014
+ # Extract regions and lifecycle from scheduler placement
1015
+ if _experimental_scheduler_placement.proto.regions:
1016
+ region = list(_experimental_scheduler_placement.proto.regions)
1017
+ if _experimental_scheduler_placement.proto._lifecycle:
1018
+ # Convert lifecycle to nonpreemptible: "on-demand" -> True, "spot" -> False
1019
+ nonpreemptible = _experimental_scheduler_placement.proto._lifecycle == "on-demand"
1020
+
996
1021
  secrets = secrets or []
997
1022
  if env:
998
1023
  secrets = [*secrets, _Secret.from_dict(env)]
@@ -1086,11 +1111,12 @@ class _App:
1086
1111
  timeout=timeout,
1087
1112
  startup_timeout=startup_timeout or timeout,
1088
1113
  cloud=cloud,
1114
+ region=region,
1115
+ nonpreemptible=nonpreemptible,
1089
1116
  enable_memory_snapshot=enable_memory_snapshot,
1090
1117
  block_network=block_network,
1091
1118
  restrict_modal_access=restrict_modal_access,
1092
1119
  max_inputs=max_inputs,
1093
- scheduler_placement=scheduler_placement,
1094
1120
  i6pn_enabled=i6pn_enabled,
1095
1121
  cluster_size=cluster_size,
1096
1122
  rdma=rdma,