modal 1.5.6.dev11__tar.gz → 1.5.6.dev12__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 (238) hide show
  1. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/PKG-INFO +1 -1
  2. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_functions.py +8 -1
  3. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/execution_context.py +1 -1
  4. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/image.py +4 -2
  5. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/import_refs.py +5 -2
  6. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/profile.py +21 -6
  7. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/token.py +9 -4
  8. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/client.pyi +2 -2
  9. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/config.py +46 -6
  10. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/functions.pyi +4 -2
  11. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/runner.py +13 -3
  12. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/token_flow.py +8 -1
  13. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/volume.py +138 -8
  14. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/volume.pyi +28 -0
  15. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal.egg-info/PKG-INFO +1 -1
  16. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/api_pb2.py +624 -603
  17. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/api_pb2.pyi +67 -3
  18. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/task_command_router_grpc.py +16 -0
  19. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/task_command_router_pb2.py +45 -25
  20. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/task_command_router_pb2.pyi +29 -0
  21. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/task_command_router_pb2_grpc.py +34 -0
  22. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/task_command_router_pb2_grpc.pyi +12 -0
  23. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_version/__init__.py +1 -1
  24. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/LICENSE +0 -0
  25. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/README.md +0 -0
  26. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/__init__.py +0 -0
  27. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/__main__.py +0 -0
  28. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_billing.py +0 -0
  29. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_clustered_functions.py +0 -0
  30. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_clustered_functions.pyi +0 -0
  31. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_container_entrypoint.py +0 -0
  32. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_environments.py +0 -0
  33. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_function_variants.py +0 -0
  34. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_grpc_client.py +0 -0
  35. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_image.py +0 -0
  36. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_ipython.py +0 -0
  37. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_load_context.py +0 -0
  38. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_location.py +0 -0
  39. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_logs.py +0 -0
  40. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_logs_manager.py +0 -0
  41. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_logs_manager.pyi +0 -0
  42. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_object.py +0 -0
  43. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_output/__init__.py +0 -0
  44. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_output/manager.py +0 -0
  45. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_output/pty.py +0 -0
  46. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_output/rich.py +0 -0
  47. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_output/status.py +0 -0
  48. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_partial_function.py +0 -0
  49. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_resolver.py +0 -0
  50. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_resources.py +0 -0
  51. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/__init__.py +0 -0
  52. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/asgi.py +0 -0
  53. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/container_io_manager.py +0 -0
  54. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/container_io_manager.pyi +0 -0
  55. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/execution_context.pyi +0 -0
  56. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/gpu_memory_snapshot.py +0 -0
  57. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/task_lifecycle_manager.py +0 -0
  58. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/task_lifecycle_manager.pyi +0 -0
  59. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/telemetry.py +0 -0
  60. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/user_code_event_loop.py +0 -0
  61. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_runtime/user_code_imports.py +0 -0
  62. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_serialization.py +0 -0
  63. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_server.py +0 -0
  64. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_supports_logs.py +0 -0
  65. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_traceback.py +0 -0
  66. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_tunnel.py +0 -0
  67. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_tunnel.pyi +0 -0
  68. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_type_manager.py +0 -0
  69. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/__init__.py +0 -0
  70. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/app_utils.py +0 -0
  71. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/async_utils.py +0 -0
  72. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/auth_token_manager.py +0 -0
  73. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/blob_utils.py +0 -0
  74. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/browser_utils.py +0 -0
  75. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/bytes_io_segment_payload.py +0 -0
  76. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/curl_utils.py +0 -0
  77. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/deprecation.py +0 -0
  78. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/display_utils.py +0 -0
  79. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/docker_utils.py +0 -0
  80. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/function_utils.py +0 -0
  81. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/git_utils.py +0 -0
  82. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/grpc_testing.py +0 -0
  83. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/grpc_utils.py +0 -0
  84. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/hash_utils.py +0 -0
  85. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/http_utils.py +0 -0
  86. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/jwt_utils.py +0 -0
  87. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/logger.py +0 -0
  88. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/mount_utils.py +0 -0
  89. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/name_utils.py +0 -0
  90. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/package_utils.py +0 -0
  91. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/pattern_utils.py +0 -0
  92. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/proxy_support.py +0 -0
  93. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/rand_pb_testing.py +0 -0
  94. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/sandbox_fs_utils.py +0 -0
  95. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/shell_utils.py +0 -0
  96. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/task_command_router_client.py +0 -0
  97. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_utils/time_utils.py +0 -0
  98. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_vendor/__init__.py +0 -0
  99. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  100. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_vendor/cloudpickle.py +0 -0
  101. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_vendor/tblib.py +0 -0
  102. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_vendor/version.py +0 -0
  103. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_watcher.py +0 -0
  104. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/_workspace.py +0 -0
  105. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/app.py +0 -0
  106. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/app.pyi +0 -0
  107. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/billing.py +0 -0
  108. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/billing.pyi +0 -0
  109. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/builder/2023.12.312.txt +0 -0
  110. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/builder/2023.12.txt +0 -0
  111. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/builder/2024.04.txt +0 -0
  112. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/builder/2024.10.txt +0 -0
  113. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/builder/2025.06.txt +0 -0
  114. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/builder/PREVIEW.txt +0 -0
  115. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/builder/README.md +0 -0
  116. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/builder/base-images.json +0 -0
  117. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/call_graph.py +0 -0
  118. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/__init__.py +0 -0
  119. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/_download.py +0 -0
  120. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/_help.py +0 -0
  121. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/_stats.py +0 -0
  122. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/_traceback.py +0 -0
  123. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/app.py +0 -0
  124. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/billing.py +0 -0
  125. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/bootstrap.py +0 -0
  126. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/changelog.py +0 -0
  127. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/cluster.py +0 -0
  128. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/config.py +0 -0
  129. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/container.py +0 -0
  130. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/curl.py +0 -0
  131. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/dashboard.py +0 -0
  132. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/dict.py +0 -0
  133. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/endpoint.py +0 -0
  134. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/entry_point.py +0 -0
  135. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/environment.py +0 -0
  136. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/function.py +0 -0
  137. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/launch.py +0 -0
  138. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/logo.py +0 -0
  139. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/network_file_system.py +0 -0
  140. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/programs/__init__.py +0 -0
  141. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/programs/run_jupyter.py +0 -0
  142. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/programs/vscode.py +0 -0
  143. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/queues.py +0 -0
  144. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/run.py +0 -0
  145. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/secret.py +0 -0
  146. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/selector.py +0 -0
  147. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/shell.py +0 -0
  148. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/skills.py +0 -0
  149. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/utils.py +0 -0
  150. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/volume.py +0 -0
  151. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cli/workspace.py +0 -0
  152. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/client.py +0 -0
  153. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cloud_bucket_mount.py +0 -0
  154. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cloud_bucket_mount.pyi +0 -0
  155. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cls.py +0 -0
  156. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/cls.pyi +0 -0
  157. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/container_process.py +0 -0
  158. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/container_process.pyi +0 -0
  159. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/dict.py +0 -0
  160. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/dict.pyi +0 -0
  161. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/environments.py +0 -0
  162. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/environments.pyi +0 -0
  163. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/exception.py +0 -0
  164. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/experimental/__init__.py +0 -0
  165. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/experimental/flash.py +0 -0
  166. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/experimental/flash.pyi +0 -0
  167. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/experimental/ipython.py +0 -0
  168. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/file_io.py +0 -0
  169. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/file_io.pyi +0 -0
  170. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/file_pattern_matcher.py +0 -0
  171. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/functions.py +0 -0
  172. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/image.py +0 -0
  173. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/image.pyi +0 -0
  174. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/io_streams.py +0 -0
  175. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/io_streams.pyi +0 -0
  176. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/mount.py +0 -0
  177. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/mount.pyi +0 -0
  178. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/network_file_system.py +0 -0
  179. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/network_file_system.pyi +0 -0
  180. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/object.py +0 -0
  181. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/object.pyi +0 -0
  182. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/output.py +0 -0
  183. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/parallel_map.py +0 -0
  184. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/parallel_map.pyi +0 -0
  185. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/partial_function.py +0 -0
  186. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/partial_function.pyi +0 -0
  187. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/proxy.py +0 -0
  188. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/proxy.pyi +0 -0
  189. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/py.typed +0 -0
  190. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/queue.py +0 -0
  191. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/queue.pyi +0 -0
  192. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/retries.py +0 -0
  193. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/runner.pyi +0 -0
  194. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/running_app.py +0 -0
  195. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/sandbox.py +0 -0
  196. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/sandbox.pyi +0 -0
  197. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/sandbox_fs.py +0 -0
  198. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/sandbox_fs.pyi +0 -0
  199. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/schedule.py +0 -0
  200. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/scheduler_placement.py +0 -0
  201. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/secret.py +0 -0
  202. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/secret.pyi +0 -0
  203. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/server.py +0 -0
  204. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/server.pyi +0 -0
  205. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/serving.py +0 -0
  206. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/serving.pyi +0 -0
  207. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/skills/modal/SKILL.md +0 -0
  208. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/snapshot.py +0 -0
  209. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/snapshot.pyi +0 -0
  210. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/stream_type.py +0 -0
  211. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/token_flow.pyi +0 -0
  212. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/types.py +0 -0
  213. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/workspace.py +0 -0
  214. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal/workspace.pyi +0 -0
  215. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal.egg-info/SOURCES.txt +0 -0
  216. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal.egg-info/dependency_links.txt +0 -0
  217. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal.egg-info/entry_points.txt +0 -0
  218. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal.egg-info/requires.txt +0 -0
  219. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal.egg-info/top_level.txt +0 -0
  220. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_docs/__init__.py +0 -0
  221. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_docs/gen_cli_docs.py +0 -0
  222. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_docs/gen_cli_docs_main.py +0 -0
  223. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_docs/gen_reference_docs.py +0 -0
  224. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_docs/gen_reference_docs_main.py +0 -0
  225. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_docs/mdmd/__init__.py +0 -0
  226. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_docs/mdmd/mdmd.py +0 -0
  227. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_docs/mdmd/signatures.py +0 -0
  228. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_docs/mdmd/types.py +0 -0
  229. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_docs/reference_intro.md +0 -0
  230. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/__init__.py +0 -0
  231. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/api_grpc.py +0 -0
  232. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/api_pb2_grpc.py +0 -0
  233. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/api_pb2_grpc.pyi +0 -0
  234. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/modal_api_grpc.py +0 -0
  235. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_proto/py.typed +0 -0
  236. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/modal_version/__main__.py +0 -0
  237. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/pyproject.toml +0 -0
  238. {modal-1.5.6.dev11 → modal-1.5.6.dev12}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.5.6.dev11
3
+ Version: 1.5.6.dev12
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -16,6 +16,7 @@ from google.protobuf.message import Message
16
16
  from grpclib import Status
17
17
  from synchronicity.combined_types import MethodWithAio
18
18
 
19
+ from modal._utils.logger import logger
19
20
  from modal.types import CloudBucketMountInfo, FunctionInfo, HttpInfo, VolumeMountInfo
20
21
  from modal_proto import api_pb2
21
22
  from modal_proto.modal_api_grpc import ModalClientModal
@@ -2306,7 +2307,8 @@ class _FunctionCall(typing.Generic[ReturnType], _Object, type_prefix="fc"):
2306
2307
  """Fetch information about the graph of Inputs this FunctionCall is part of.
2307
2308
 
2308
2309
  Note: the call graph data is not populated in real-time, and its capture is best-effort.
2309
- We do not recommend relying on this method for critical use cases.
2310
+ Large call graphs may be truncated. We do not recommend relying on this method
2311
+ for critical use cases.
2310
2312
 
2311
2313
  See the [`modal.types`](/docs/sdk/py/latest/types) reference for information
2312
2314
  on the return values.
@@ -2317,6 +2319,11 @@ class _FunctionCall(typing.Generic[ReturnType], _Object, type_prefix="fc"):
2317
2319
  assert self._client and self._client.stub
2318
2320
  request = api_pb2.FunctionGetCallGraphRequest(function_call_id=self.object_id)
2319
2321
  response = await self._client.stub.FunctionGetCallGraph(request)
2322
+ if response.truncated:
2323
+ logger.warning(
2324
+ f"Call graph for {self.object_id} was truncated; "
2325
+ f"returning the first {len(response.inputs)} inputs. The graph may be incomplete."
2326
+ )
2320
2327
  return _reconstruct_call_graph(response)
2321
2328
 
2322
2329
  @live_method
@@ -109,7 +109,7 @@ _current_input_id: ContextVar = ContextVar("_current_input_id")
109
109
  _current_function_call_id: ContextVar = ContextVar("_current_function_call_id")
110
110
  _current_attempt_token: ContextVar = ContextVar("_current_attempt_token")
111
111
 
112
- _is_currently_importing = False # we set this to True while a container is importing user code
112
+ _is_currently_importing = False
113
113
 
114
114
 
115
115
  @contextmanager
@@ -58,7 +58,7 @@ async def logs(image_id: str, layers: int | None, all_layers: bool) -> None:
58
58
  effective_layers = None if all_layers else layers or 1
59
59
  image = Image.from_id(image_id)
60
60
  output_mgr = OutputManager.get()
61
- last_message = ""
61
+ last_message: str | None = None
62
62
  async for entry in image.logs.fetch.aio(layers=effective_layers):
63
63
  last_message = entry.message
64
64
  await output_mgr.put_fetched_log(
@@ -69,7 +69,9 @@ async def logs(image_id: str, layers: int | None, all_layers: bool) -> None:
69
69
  )
70
70
  )
71
71
  output_mgr.flush_lines()
72
- if last_message and not last_message.endswith("\n"):
72
+ if last_message is None:
73
+ output_mgr.print(f"No build logs found for Image {image_id}.")
74
+ elif not last_message.endswith("\n"):
73
75
  output_mgr.print("")
74
76
 
75
77
 
@@ -27,6 +27,7 @@ from modal.exception import InvalidError, _CliUserExecutionError
27
27
  from modal.functions import Function
28
28
  from modal.output import OutputManager
29
29
 
30
+ from .._runtime import execution_context
30
31
  from .._utils.async_utils import synchronizer
31
32
 
32
33
 
@@ -74,7 +75,8 @@ def import_file_or_module(import_ref: ImportRef, base_cmd: str = ""):
74
75
  f"Use `{base_cmd} -m {import_ref.file_or_module}` instead.",
75
76
  )
76
77
  try:
77
- module = importlib.import_module(import_ref.file_or_module)
78
+ with execution_context._import_context():
79
+ module = importlib.import_module(import_ref.file_or_module)
78
80
  except Exception as exc:
79
81
  raise _CliUserExecutionError(import_ref.file_or_module) from exc
80
82
  else:
@@ -97,7 +99,8 @@ def import_file_or_module(import_ref: ImportRef, base_cmd: str = ""):
97
99
  sys.modules[module_name] = module
98
100
  try:
99
101
  assert spec.loader
100
- spec.loader.exec_module(module)
102
+ with execution_context._import_context():
103
+ spec.loader.exec_module(module)
101
104
  except Exception as exc:
102
105
  if isinstance(exc, ImportError) and "attempted relative import with no known parent package" in str(exc):
103
106
  if import_ref.file_or_module and import_ref.command and not import_ref.use_module_mode:
@@ -17,7 +17,7 @@ from modal.config import (
17
17
  config_set_active_profile,
18
18
  )
19
19
  from modal.environments import Environment
20
- from modal.exception import AuthError
20
+ from modal.exception import AuthError, InvalidError
21
21
  from modal.output import OutputManager
22
22
 
23
23
  from ._help import ModalGroup
@@ -53,8 +53,11 @@ async def list_(json: bool | None = False):
53
53
  lookup_coros = [
54
54
  _lookup_workspace(
55
55
  config.get("server_url", profile=profile),
56
- config.get("token_id", profile=profile, use_env=False),
57
- config.get("token_secret", profile=profile, use_env=False),
56
+ token_id=config.get("token_id", profile=profile, use_env=False),
57
+ token_secret=config.get("token_secret", profile=profile, use_env=False),
58
+ oauth_refresh_token=config.get("oauth_refresh_token", profile=profile, use_env=False),
59
+ oauth_client_id=config.get("oauth_client_id", profile=profile, use_env=False),
60
+ oauth_client_secret=config.get("oauth_client_secret", profile=profile, use_env=False),
58
61
  )
59
62
  for profile in profiles
60
63
  ]
@@ -77,16 +80,28 @@ async def list_(json: bool | None = False):
77
80
  rows.append((active, content))
78
81
 
79
82
  env_based_workspace: str | None = None
80
- if "MODAL_TOKEN_ID" in os.environ:
83
+ credential_env_vars = (
84
+ "MODAL_TOKEN_ID",
85
+ "MODAL_TOKEN_SECRET",
86
+ "MODAL_OAUTH_REFRESH_TOKEN",
87
+ "MODAL_OAUTH_CLIENT_ID",
88
+ "MODAL_OAUTH_CLIENT_SECRET",
89
+ )
90
+ if any(env_var in os.environ for env_var in credential_env_vars):
81
91
  try:
82
92
  env_based_resp = await _lookup_workspace(
83
93
  config.get("server_url", profile=config_module._profile),
84
- os.environ["MODAL_TOKEN_ID"],
85
- os.environ.get("MODAL_TOKEN_SECRET", ""),
94
+ token_id=config.get("token_id", profile=config_module._profile),
95
+ token_secret=config.get("token_secret", profile=config_module._profile),
96
+ oauth_refresh_token=config.get("oauth_refresh_token", profile=config_module._profile),
97
+ oauth_client_id=config.get("oauth_client_id", profile=config_module._profile),
98
+ oauth_client_secret=config.get("oauth_client_secret", profile=config_module._profile),
86
99
  )
87
100
  env_based_workspace = env_based_resp.username
88
101
  except AuthError:
89
102
  env_based_workspace = "Unknown (authentication failure)"
103
+ except InvalidError:
104
+ env_based_workspace = "Unknown (invalid credential configuration)"
90
105
 
91
106
  output = OutputManager.get()
92
107
  highlight = "bold green" if env_based_workspace is None else "yellow"
@@ -62,10 +62,15 @@ async def info():
62
62
 
63
63
  output = OutputManager.get()
64
64
  env_vars = []
65
- if os.environ.get("MODAL_TOKEN_ID"):
66
- env_vars.append("MODAL_TOKEN_ID")
67
- if os.environ.get("MODAL_TOKEN_SECRET"):
68
- env_vars.append("MODAL_TOKEN_SECRET")
65
+ for env_var in (
66
+ "MODAL_TOKEN_ID",
67
+ "MODAL_TOKEN_SECRET",
68
+ "MODAL_OAUTH_REFRESH_TOKEN",
69
+ "MODAL_OAUTH_CLIENT_ID",
70
+ "MODAL_OAUTH_CLIENT_SECRET",
71
+ ):
72
+ if os.environ.get(env_var):
73
+ env_vars.append(env_var)
69
74
 
70
75
  if env_vars:
71
76
  env_vars_str = " and ".join(env_vars)
@@ -77,7 +77,7 @@ class _Client:
77
77
  server_url: str,
78
78
  client_type: int,
79
79
  credentials: typing.Optional[tuple[str, str]],
80
- version: str = "1.5.6.dev11",
80
+ version: str = "1.5.6.dev12",
81
81
  *,
82
82
  oauth_credentials: typing.Optional[_OAuthCredentials] = None,
83
83
  ):
@@ -277,7 +277,7 @@ class Client:
277
277
  server_url: str,
278
278
  client_type: int,
279
279
  credentials: typing.Optional[tuple[str, str]],
280
- version: str = "1.5.6.dev11",
280
+ version: str = "1.5.6.dev12",
281
281
  *,
282
282
  oauth_credentials: typing.Optional[_OAuthCredentials] = None,
283
283
  ):
@@ -109,7 +109,7 @@ from google.protobuf.empty_pb2 import Empty
109
109
  from modal_proto import api_pb2
110
110
 
111
111
  from ._utils.logger import configure_logger
112
- from .exception import InvalidError, NotFoundError
112
+ from .exception import AuthError, InvalidError, NotFoundError
113
113
 
114
114
  # By default, try api.modal.com and fail over to api.modal2.com
115
115
  DEFAULT_SERVER_URL = "https://api.modal.com,https://api.modal2.com"
@@ -184,11 +184,51 @@ def _read_user_config():
184
184
  _user_config = _read_user_config()
185
185
 
186
186
 
187
- async def _lookup_workspace(server_url: str, token_id: str, token_secret: str) -> api_pb2.WorkspaceNameLookupResponse:
188
- from .client import _Client
189
-
190
- credentials = (token_id, token_secret)
191
- async with _Client(server_url, api_pb2.CLIENT_TYPE_CLIENT, credentials) as client:
187
+ async def _lookup_workspace(
188
+ server_url: str,
189
+ token_id: str | None = None,
190
+ token_secret: str | None = None,
191
+ *,
192
+ oauth_refresh_token: str | None = None,
193
+ oauth_client_id: str | None = None,
194
+ oauth_client_secret: str | None = None,
195
+ ) -> api_pb2.WorkspaceNameLookupResponse:
196
+ from .client import _Client, _OAuthCredentials
197
+
198
+ has_token_config = bool(token_id or token_secret)
199
+ has_oauth_config = bool(oauth_refresh_token or oauth_client_id or oauth_client_secret)
200
+ if has_token_config and has_oauth_config:
201
+ raise InvalidError("Modal token credentials and OAuth credentials cannot both be configured.")
202
+ if has_oauth_config:
203
+ missing_oauth_credentials = [
204
+ name
205
+ for name, value in (
206
+ ("refresh token", oauth_refresh_token),
207
+ ("client ID", oauth_client_id),
208
+ ("client secret", oauth_client_secret),
209
+ )
210
+ if not value
211
+ ]
212
+ if missing_oauth_credentials:
213
+ raise InvalidError(f"OAuth credentials are incomplete; missing {', '.join(missing_oauth_credentials)}.")
214
+ assert oauth_refresh_token and oauth_client_id and oauth_client_secret
215
+ credentials = None
216
+ oauth_credentials = _OAuthCredentials(
217
+ refresh_token=oauth_refresh_token,
218
+ client_id=oauth_client_id,
219
+ client_secret=oauth_client_secret,
220
+ )
221
+ else:
222
+ if not token_id or not token_secret:
223
+ raise AuthError("Token ID and secret must both be configured.")
224
+ credentials = (token_id, token_secret)
225
+ oauth_credentials = None
226
+ async with _Client(
227
+ server_url,
228
+ api_pb2.CLIENT_TYPE_CLIENT,
229
+ credentials,
230
+ oauth_credentials=oauth_credentials,
231
+ ) as client:
192
232
  return await client.stub.WorkspaceNameLookup(Empty(), retry=None, timeout=3)
193
233
 
194
234
 
@@ -1231,7 +1231,8 @@ class FunctionCall(typing.Generic[modal._functions.ReturnType], modal.object.Obj
1231
1231
  """Fetch information about the graph of Inputs this FunctionCall is part of.
1232
1232
 
1233
1233
  Note: the call graph data is not populated in real-time, and its capture is best-effort.
1234
- We do not recommend relying on this method for critical use cases.
1234
+ Large call graphs may be truncated. We do not recommend relying on this method
1235
+ for critical use cases.
1235
1236
 
1236
1237
  See the [`modal.types`](/docs/sdk/py/latest/types) reference for information
1237
1238
  on the return values.
@@ -1245,7 +1246,8 @@ class FunctionCall(typing.Generic[modal._functions.ReturnType], modal.object.Obj
1245
1246
  """Fetch information about the graph of Inputs this FunctionCall is part of.
1246
1247
 
1247
1248
  Note: the call graph data is not populated in real-time, and its capture is best-effort.
1248
- We do not recommend relying on this method for critical use cases.
1249
+ Large call graphs may be truncated. We do not recommend relying on this method
1250
+ for critical use cases.
1249
1251
 
1250
1252
  See the [`modal.types`](/docs/sdk/py/latest/types) reference for information
1251
1253
  on the return values.
@@ -377,13 +377,17 @@ async def _run_app(
377
377
  deployment_strategy: str = "rolling",
378
378
  ) -> AsyncGenerator["modal.app._App", None]:
379
379
  """mdmd:hidden"""
380
+ if modal._runtime.execution_context._in_import_context():
381
+ raise InvalidError(
382
+ "`App.run()` cannot be called in global scope. "
383
+ 'Use `if __name__ == "__main__"` to run Apps in a script, '
384
+ "or convert the block to a local entrypoint and use the `modal run` CLI."
385
+ )
386
+
380
387
  load_context = await app._root_load_context.reset().in_place_upgrade(
381
388
  client=client, environment_name=environment_name
382
389
  )
383
390
 
384
- if modal._runtime.execution_context._in_import_context():
385
- raise InvalidError("Can not run an app in global scope within a container")
386
-
387
391
  if app._running_app:
388
392
  raise InvalidError(
389
393
  "App is already running and can't be started again.\n"
@@ -601,6 +605,12 @@ async def _deploy_app(
601
605
 
602
606
  Users should prefer the `modal deploy` CLI or the `App.deploy` method.
603
607
  """
608
+ if modal._runtime.execution_context._in_import_context():
609
+ raise InvalidError(
610
+ "`App.deploy()` should not be called in global scope. "
611
+ 'Use `if __name__ == "__main__"` to deploy Apps in a script, '
612
+ "or use the `modal deploy` CLI."
613
+ )
604
614
 
605
615
  name = name or app.name or ""
606
616
  if not name:
@@ -166,7 +166,14 @@ async def _set_token(
166
166
  )
167
167
 
168
168
  # Warn the user if their token will be ignored
169
- env_vars = [var if os.environ.get(var) else None for var in ["MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"]]
169
+ credential_env_vars = [
170
+ "MODAL_TOKEN_ID",
171
+ "MODAL_TOKEN_SECRET",
172
+ "MODAL_OAUTH_REFRESH_TOKEN",
173
+ "MODAL_OAUTH_CLIENT_ID",
174
+ "MODAL_OAUTH_CLIENT_SECRET",
175
+ ]
176
+ env_vars = [var if os.environ.get(var) else None for var in credential_env_vars]
170
177
  env_vars_used = [var for var in env_vars if var is not None]
171
178
  env_vars_str = " / ".join(env_vars_used)
172
179
  if env_vars_used:
@@ -1,8 +1,10 @@
1
1
  # Copyright Modal Labs 2023
2
2
  import asyncio
3
+ import base64
3
4
  import builtins
4
5
  import concurrent.futures
5
6
  import functools
7
+ import hashlib
6
8
  import multiprocessing
7
9
  import os
8
10
  import platform
@@ -79,6 +81,21 @@ from .types import FileEntry, FileEntryType as FileEntryType, VolumeCreateOption
79
81
  VOLUME_PUT_FILE_CLIENT_TIMEOUT = 60 * 60
80
82
 
81
83
 
84
+ def _expected_block_lengths(start: int, length: int, num_blocks: int) -> list[int]:
85
+ """Number of bytes of a byte range that fall into each of its `BLOCK_SIZE`-aligned blocks.
86
+
87
+ A downloaded block may be shorter than this (trailing zero bytes are left out),
88
+ so the caller extends it with zeros to the expected length.
89
+ """
90
+ lengths = []
91
+ pos = start
92
+ for idx in range(num_blocks):
93
+ block_end = min(start + length, (start // BLOCK_SIZE + idx + 1) * BLOCK_SIZE)
94
+ lengths.append(max(0, block_end - pos))
95
+ pos = block_end
96
+ return lengths
97
+
98
+
82
99
  async def _raise_on_block_response_error(response) -> None:
83
100
  """Raise a picklable Modal exception on error.
84
101
 
@@ -99,6 +116,91 @@ async def _raise_on_block_response_error(response) -> None:
99
116
  raise ExecutionError(f"Request failed with status {response.status} {response.reason}: {body}")
100
117
 
101
118
 
119
+ # A block download may carry an RFC 9530 `Repr-Digest`. Only the `sha-256`
120
+ # member is understood, and it covers the whole body; every other algorithm
121
+ # key is ignored, as the RFC prescribes, and a body carrying only unknown keys
122
+ # is left unverified. The field is a Structured Fields Dictionary (RFC 8941):
123
+ # parameters on a member carry no meaning here and are dropped, and when a key
124
+ # repeats the last occurrence wins.
125
+ _REPR_DIGEST_MEMBER_RE = re.compile(r"^sha-256=:([A-Za-z0-9+/]+={0,2}):(;.*)?$")
126
+
127
+
128
+ def _split_sf_members(header: str) -> list[str]:
129
+ # Splits a Structured Fields List/Dictionary on commas, except commas inside
130
+ # quoted (possibly backslash-escaped) parameter strings.
131
+ members: list[str] = []
132
+ start = 0
133
+ in_quote = False
134
+ escaped = False
135
+ for i, ch in enumerate(header):
136
+ if escaped:
137
+ escaped = False
138
+ elif in_quote:
139
+ if ch == "\\":
140
+ escaped = True
141
+ elif ch == '"':
142
+ in_quote = False
143
+ elif ch == '"':
144
+ in_quote = True
145
+ elif ch == ",":
146
+ members.append(header[start:i])
147
+ start = i + 1
148
+ members.append(header[start:])
149
+ return members
150
+
151
+
152
+ def _parse_repr_digest(header: str | None) -> bytes | None:
153
+ if not header:
154
+ return None
155
+ sha256: bytes | None = None
156
+ for member in _split_sf_members(header):
157
+ match = _REPR_DIGEST_MEMBER_RE.match(member.strip())
158
+ if match is None:
159
+ continue
160
+ try:
161
+ decoded = base64.b64decode(match.group(1), validate=True)
162
+ except ValueError:
163
+ continue
164
+ if len(decoded) != 32:
165
+ continue
166
+ sha256 = decoded
167
+ if sha256 is None:
168
+ logger.debug(f"ignoring unrecognized Repr-Digest: {header!r}")
169
+ return sha256
170
+
171
+
172
+ class _BlockDigestVerifier:
173
+ """Checks a streamed block body against the digest its response advertised.
174
+
175
+ Feed the body through `update` in order, then call `finish`. Without a
176
+ digest both are no-ops, so callers can use one unconditionally.
177
+ """
178
+
179
+ def __init__(self, sha256: bytes | None):
180
+ self._sha256 = sha256
181
+ self._hasher = hashlib.sha256() if sha256 is not None else None
182
+
183
+ def update(self, chunk: bytes) -> None:
184
+ if self._hasher is not None:
185
+ self._hasher.update(chunk)
186
+
187
+ def finish(self) -> None:
188
+ if self._sha256 is None or self._hasher is None:
189
+ return
190
+ actual = self._hasher.digest()
191
+ if actual != self._sha256:
192
+ raise ExecutionError(f"Block download corrupted: expected sha256 {self._sha256.hex()}, got {actual.hex()}")
193
+
194
+
195
+ async def _read_block_body(response) -> bytes:
196
+ """Read a block response body in full, verifying any digest it advertises."""
197
+ verifier = _BlockDigestVerifier(_parse_repr_digest(response.headers.get("Repr-Digest")))
198
+ body = await response.content.read()
199
+ verifier.update(body)
200
+ verifier.finish()
201
+ return body
202
+
203
+
102
204
  def _validate_volume_version(
103
205
  requested_version: int,
104
206
  actual_version: int,
@@ -854,7 +956,7 @@ class _Volume(_Object, type_prefix="vo"):
854
956
  print(len(data)) # == 1024 * 1024
855
957
  ```
856
958
  """
857
- req = api_pb2.VolumeGetFile2Request(volume_id=self.object_id, path=path)
959
+ req = api_pb2.VolumeGetFile2Request(volume_id=self.object_id, path=path, client_pads_blocks=True)
858
960
 
859
961
  try:
860
962
  response = await self.client.stub.VolumeGetFile2(req)
@@ -862,14 +964,20 @@ class _Volume(_Object, type_prefix="vo"):
862
964
  raise FileNotFoundError(exc.args[0])
863
965
 
864
966
  @retry(n_attempts=5, base_delay=0.1, attempt_timeout=None)
865
- async def read_block(block_url: str) -> bytes:
967
+ async def read_block(block: tuple[str, int]) -> bytes:
968
+ block_url, expected_len = block
866
969
  async with ClientSessionRegistry.get_session().get(block_url) as get_response:
867
970
  await _raise_on_block_response_error(get_response)
868
- return await get_response.content.read()
971
+ body = await _read_block_body(get_response)
972
+ if len(body) > expected_len:
973
+ raise ExecutionError(f"Block body is {len(body)} bytes, expected at most {expected_len}")
974
+ return body.ljust(expected_len, b"\0")
869
975
 
870
- async def iter_urls() -> AsyncGenerator[str]:
871
- for url in response.get_urls:
872
- yield url
976
+ block_lengths = _expected_block_lengths(response.start, response.len, len(response.get_urls))
977
+
978
+ async def iter_urls() -> AsyncGenerator[tuple[str, int]]:
979
+ for url, expected_len in zip(response.get_urls, block_lengths):
980
+ yield url, expected_len
873
981
 
874
982
  # TODO(dflemstr): Reasonable default? Make configurable?
875
983
  prefetch_num_blocks = multiprocessing.cpu_count()
@@ -910,7 +1018,7 @@ class _Volume(_Object, type_prefix="vo"):
910
1018
  if concurrency is None:
911
1019
  concurrency = multiprocessing.cpu_count()
912
1020
 
913
- req = api_pb2.VolumeGetFile2Request(volume_id=self.object_id, path=path)
1021
+ req = api_pb2.VolumeGetFile2Request(volume_id=self.object_id, path=path, client_pads_blocks=True)
914
1022
 
915
1023
  # Acquire RPC semaphore if provided to limit concurrent VolumeGetFile2 RPCs.
916
1024
  # This is used by CLI downloads to prevent overwhelming the server.
@@ -929,14 +1037,23 @@ class _Volume(_Object, type_prefix="vo"):
929
1037
  write_lock = asyncio.Lock()
930
1038
  start_pos = fileobj.tell()
931
1039
 
1040
+ block_lengths = _expected_block_lengths(response.start, response.len, len(response.get_urls))
1041
+
932
1042
  @retry(n_attempts=5, base_delay=0.1, attempt_timeout=None)
933
1043
  async def download_block(idx, url) -> int:
934
1044
  block_start_pos = start_pos + idx * BLOCK_SIZE
1045
+ # Every attempt writes into exactly this slice, so a retry after a rejected
1046
+ # response overwrites everything the rejected attempt wrote.
1047
+ expected_len = block_lengths[idx]
935
1048
  num_bytes_written = 0
936
1049
 
937
1050
  async with download_semaphore, ClientSessionRegistry.get_session().get(url) as get_response:
938
1051
  await _raise_on_block_response_error(get_response)
1052
+ verifier = _BlockDigestVerifier(_parse_repr_digest(get_response.headers.get("Repr-Digest")))
939
1053
  async for chunk in get_response.content.iter_any():
1054
+ if num_bytes_written + len(chunk) > expected_len:
1055
+ raise ExecutionError(f"Block {idx} response is longer than the expected {expected_len} bytes")
1056
+ verifier.update(chunk)
940
1057
  num_chunk_bytes_written = 0
941
1058
 
942
1059
  while num_chunk_bytes_written < len(chunk):
@@ -944,13 +1061,26 @@ class _Volume(_Object, type_prefix="vo"):
944
1061
  fileobj.seek(block_start_pos + num_bytes_written + num_chunk_bytes_written)
945
1062
  # TODO(dflemstr): this is a small write, but nonetheless might block the event loop for some
946
1063
  # time:
947
- n = fileobj.write(chunk)
1064
+ n = fileobj.write(chunk[num_chunk_bytes_written:])
1065
+ if not n:
1066
+ raise OSError("File object write made no progress")
948
1067
 
949
1068
  num_chunk_bytes_written += n
950
1069
  progress_cb(advance=n)
951
1070
 
952
1071
  num_bytes_written += len(chunk)
953
1072
 
1073
+ # Trailing zero bytes may be omitted from the body; restore them.
1074
+ while num_bytes_written < expected_len:
1075
+ padding = b"\0" * min(expected_len - num_bytes_written, 1024 * 1024)
1076
+ async with write_lock:
1077
+ fileobj.seek(block_start_pos + num_bytes_written)
1078
+ n = fileobj.write(padding)
1079
+ num_bytes_written += n
1080
+ progress_cb(advance=n)
1081
+
1082
+ verifier.finish()
1083
+
954
1084
  return num_bytes_written
955
1085
 
956
1086
  coros = [download_block(idx, url) for idx, url in enumerate(response.get_urls)]
@@ -15,6 +15,14 @@ import synchronicity.combined_types
15
15
  import typing
16
16
  import typing_extensions
17
17
 
18
+ def _expected_block_lengths(start: int, length: int, num_blocks: int) -> list[int]:
19
+ """Number of bytes of a byte range that fall into each of its `BLOCK_SIZE`-aligned blocks.
20
+
21
+ A downloaded block may be shorter than this (trailing zero bytes are left out),
22
+ so the caller extends it with zeros to the expected length.
23
+ """
24
+ ...
25
+
18
26
  async def _raise_on_block_response_error(response) -> None:
19
27
  """Raise a picklable Modal exception on error.
20
28
 
@@ -24,6 +32,26 @@ async def _raise_on_block_response_error(response) -> None:
24
32
  """
25
33
  ...
26
34
 
35
+ def _split_sf_members(header: str) -> list[str]: ...
36
+ def _parse_repr_digest(header: typing.Optional[str]) -> typing.Optional[bytes]: ...
37
+
38
+ class _BlockDigestVerifier:
39
+ """Checks a streamed block body against the digest its response advertised.
40
+
41
+ Feed the body through `update` in order, then call `finish`. Without a
42
+ digest both are no-ops, so callers can use one unconditionally.
43
+ """
44
+ def __init__(self, sha256: typing.Optional[bytes]):
45
+ """Initialize self. See help(type(self)) for accurate signature."""
46
+ ...
47
+
48
+ def update(self, chunk: bytes) -> None: ...
49
+ def finish(self) -> None: ...
50
+
51
+ async def _read_block_body(response) -> bytes:
52
+ """Read a block response body in full, verifying any digest it advertises."""
53
+ ...
54
+
27
55
  def _validate_volume_version(requested_version: int, actual_version: int, volume_name: str) -> None:
28
56
  """Validate that the returned volume version matches the requested version."""
29
57
  ...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.5.6.dev11
3
+ Version: 1.5.6.dev12
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0