modal 1.5.4.dev13__tar.gz → 1.5.4.dev15__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 (236) hide show
  1. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/PKG-INFO +1 -1
  2. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_image.py +71 -11
  3. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_logs.py +9 -7
  4. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_logs_manager.py +192 -71
  5. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_logs_manager.pyi +82 -14
  6. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_supports_logs.py +20 -0
  7. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/image.py +37 -0
  8. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/run.py +6 -3
  9. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/workspace.py +21 -4
  10. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/client.pyi +2 -2
  11. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/exception.py +8 -0
  12. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/functions.pyi +6 -6
  13. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/image.pyi +30 -0
  14. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/sandbox.py +1 -8
  15. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal.egg-info/PKG-INFO +1 -1
  16. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/api_grpc.py +16 -0
  17. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/api_pb2.py +715 -685
  18. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/api_pb2.pyi +58 -0
  19. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/api_pb2_grpc.py +34 -1
  20. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/api_pb2_grpc.pyi +13 -3
  21. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/modal_api_grpc.py +1 -0
  22. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_version/__init__.py +1 -1
  23. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/LICENSE +0 -0
  24. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/README.md +0 -0
  25. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/__init__.py +0 -0
  26. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/__main__.py +0 -0
  27. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_billing.py +0 -0
  28. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_clustered_functions.py +0 -0
  29. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_clustered_functions.pyi +0 -0
  30. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_container_entrypoint.py +0 -0
  31. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_environments.py +0 -0
  32. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_function_variants.py +0 -0
  33. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_functions.py +0 -0
  34. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_grpc_client.py +0 -0
  35. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_ipython.py +0 -0
  36. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_load_context.py +0 -0
  37. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_location.py +0 -0
  38. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_object.py +0 -0
  39. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_output/__init__.py +0 -0
  40. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_output/manager.py +0 -0
  41. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_output/pty.py +0 -0
  42. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_output/rich.py +0 -0
  43. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_output/status.py +0 -0
  44. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_partial_function.py +0 -0
  45. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_resolver.py +0 -0
  46. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_resources.py +0 -0
  47. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/__init__.py +0 -0
  48. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/asgi.py +0 -0
  49. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/container_io_manager.py +0 -0
  50. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/container_io_manager.pyi +0 -0
  51. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/execution_context.py +0 -0
  52. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/execution_context.pyi +0 -0
  53. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/gpu_memory_snapshot.py +0 -0
  54. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/task_lifecycle_manager.py +0 -0
  55. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/task_lifecycle_manager.pyi +0 -0
  56. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/telemetry.py +0 -0
  57. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/user_code_event_loop.py +0 -0
  58. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_runtime/user_code_imports.py +0 -0
  59. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_serialization.py +0 -0
  60. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_server.py +0 -0
  61. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_traceback.py +0 -0
  62. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_tunnel.py +0 -0
  63. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_tunnel.pyi +0 -0
  64. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_type_manager.py +0 -0
  65. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/__init__.py +0 -0
  66. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/app_utils.py +0 -0
  67. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/async_utils.py +0 -0
  68. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/auth_token_manager.py +0 -0
  69. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/blob_utils.py +0 -0
  70. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/browser_utils.py +0 -0
  71. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/bytes_io_segment_payload.py +0 -0
  72. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/curl_utils.py +0 -0
  73. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/deprecation.py +0 -0
  74. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/display_utils.py +0 -0
  75. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/docker_utils.py +0 -0
  76. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/function_utils.py +0 -0
  77. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/git_utils.py +0 -0
  78. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/grpc_testing.py +0 -0
  79. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/grpc_utils.py +0 -0
  80. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/hash_utils.py +0 -0
  81. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/http_utils.py +0 -0
  82. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/jwt_utils.py +0 -0
  83. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/logger.py +0 -0
  84. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/mount_utils.py +0 -0
  85. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/name_utils.py +0 -0
  86. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/package_utils.py +0 -0
  87. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/pattern_utils.py +0 -0
  88. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/proxy_support.py +0 -0
  89. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/rand_pb_testing.py +0 -0
  90. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/sandbox_fs_utils.py +0 -0
  91. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/shell_utils.py +0 -0
  92. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/task_command_router_client.py +0 -0
  93. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_utils/time_utils.py +0 -0
  94. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_vendor/__init__.py +0 -0
  95. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  96. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_vendor/cloudpickle.py +0 -0
  97. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_vendor/tblib.py +0 -0
  98. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_vendor/version.py +0 -0
  99. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_watcher.py +0 -0
  100. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/_workspace.py +0 -0
  101. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/app.py +0 -0
  102. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/app.pyi +0 -0
  103. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/billing.py +0 -0
  104. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/billing.pyi +0 -0
  105. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/builder/2023.12.312.txt +0 -0
  106. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/builder/2023.12.txt +0 -0
  107. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/builder/2024.04.txt +0 -0
  108. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/builder/2024.10.txt +0 -0
  109. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/builder/2025.06.txt +0 -0
  110. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/builder/PREVIEW.txt +0 -0
  111. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/builder/README.md +0 -0
  112. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/builder/base-images.json +0 -0
  113. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/call_graph.py +0 -0
  114. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/__init__.py +0 -0
  115. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/_download.py +0 -0
  116. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/_help.py +0 -0
  117. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/_traceback.py +0 -0
  118. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/app.py +0 -0
  119. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/billing.py +0 -0
  120. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/bootstrap.py +0 -0
  121. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/changelog.py +0 -0
  122. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/cluster.py +0 -0
  123. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/config.py +0 -0
  124. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/container.py +0 -0
  125. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/curl.py +0 -0
  126. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/dashboard.py +0 -0
  127. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/dict.py +0 -0
  128. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/endpoint.py +0 -0
  129. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/entry_point.py +0 -0
  130. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/environment.py +0 -0
  131. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/import_refs.py +0 -0
  132. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/launch.py +0 -0
  133. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/logo.py +0 -0
  134. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/network_file_system.py +0 -0
  135. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/profile.py +0 -0
  136. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/programs/__init__.py +0 -0
  137. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/programs/run_jupyter.py +0 -0
  138. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/programs/vscode.py +0 -0
  139. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/queues.py +0 -0
  140. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/secret.py +0 -0
  141. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/selector.py +0 -0
  142. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/shell.py +0 -0
  143. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/skills.py +0 -0
  144. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/token.py +0 -0
  145. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/utils.py +0 -0
  146. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cli/volume.py +0 -0
  147. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/client.py +0 -0
  148. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cloud_bucket_mount.py +0 -0
  149. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cloud_bucket_mount.pyi +0 -0
  150. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cls.py +0 -0
  151. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/cls.pyi +0 -0
  152. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/config.py +0 -0
  153. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/container_process.py +0 -0
  154. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/container_process.pyi +0 -0
  155. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/dict.py +0 -0
  156. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/dict.pyi +0 -0
  157. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/environments.py +0 -0
  158. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/environments.pyi +0 -0
  159. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/experimental/__init__.py +0 -0
  160. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/experimental/flash.py +0 -0
  161. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/experimental/flash.pyi +0 -0
  162. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/experimental/ipython.py +0 -0
  163. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/file_io.py +0 -0
  164. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/file_io.pyi +0 -0
  165. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/file_pattern_matcher.py +0 -0
  166. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/functions.py +0 -0
  167. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/image.py +0 -0
  168. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/io_streams.py +0 -0
  169. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/io_streams.pyi +0 -0
  170. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/mount.py +0 -0
  171. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/mount.pyi +0 -0
  172. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/network_file_system.py +0 -0
  173. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/network_file_system.pyi +0 -0
  174. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/object.py +0 -0
  175. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/object.pyi +0 -0
  176. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/output.py +0 -0
  177. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/parallel_map.py +0 -0
  178. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/parallel_map.pyi +0 -0
  179. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/partial_function.py +0 -0
  180. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/partial_function.pyi +0 -0
  181. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/proxy.py +0 -0
  182. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/proxy.pyi +0 -0
  183. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/py.typed +0 -0
  184. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/queue.py +0 -0
  185. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/queue.pyi +0 -0
  186. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/retries.py +0 -0
  187. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/runner.py +0 -0
  188. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/runner.pyi +0 -0
  189. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/running_app.py +0 -0
  190. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/sandbox.pyi +0 -0
  191. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/sandbox_fs.py +0 -0
  192. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/sandbox_fs.pyi +0 -0
  193. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/schedule.py +0 -0
  194. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/scheduler_placement.py +0 -0
  195. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/secret.py +0 -0
  196. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/secret.pyi +0 -0
  197. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/server.py +0 -0
  198. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/server.pyi +0 -0
  199. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/serving.py +0 -0
  200. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/serving.pyi +0 -0
  201. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/skills/modal/SKILL.md +0 -0
  202. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/snapshot.py +0 -0
  203. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/snapshot.pyi +0 -0
  204. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/stream_type.py +0 -0
  205. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/token_flow.py +0 -0
  206. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/token_flow.pyi +0 -0
  207. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/types.py +0 -0
  208. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/volume.py +0 -0
  209. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/volume.pyi +0 -0
  210. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/workspace.py +0 -0
  211. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal/workspace.pyi +0 -0
  212. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal.egg-info/SOURCES.txt +0 -0
  213. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal.egg-info/dependency_links.txt +0 -0
  214. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal.egg-info/entry_points.txt +0 -0
  215. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal.egg-info/requires.txt +0 -0
  216. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal.egg-info/top_level.txt +0 -0
  217. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_docs/__init__.py +0 -0
  218. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_docs/gen_cli_docs.py +0 -0
  219. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_docs/gen_cli_docs_main.py +0 -0
  220. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_docs/gen_reference_docs.py +0 -0
  221. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_docs/gen_reference_docs_main.py +0 -0
  222. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_docs/mdmd/__init__.py +0 -0
  223. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_docs/mdmd/mdmd.py +0 -0
  224. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_docs/mdmd/signatures.py +0 -0
  225. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_docs/mdmd/types.py +0 -0
  226. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_docs/reference_intro.md +0 -0
  227. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/__init__.py +0 -0
  228. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/py.typed +0 -0
  229. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/task_command_router_grpc.py +0 -0
  230. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/task_command_router_pb2.py +0 -0
  231. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/task_command_router_pb2.pyi +0 -0
  232. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/task_command_router_pb2_grpc.py +0 -0
  233. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_proto/task_command_router_pb2_grpc.pyi +0 -0
  234. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/modal_version/__main__.py +0 -0
  235. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/pyproject.toml +0 -0
  236. {modal-1.5.4.dev13 → modal-1.5.4.dev15}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.5.4.dev13
3
+ Version: 1.5.4.dev15
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -30,9 +30,11 @@ from modal_proto import api_pb2
30
30
 
31
31
  from ._environments import _get_environment_cached
32
32
  from ._load_context import LoadContext
33
+ from ._logs_manager import _ImageLogsManager
33
34
  from ._object import _Object, live_method_gen
34
35
  from ._resolver import Resolver
35
36
  from ._serialization import get_preferred_payload_format, serialize
37
+ from ._supports_logs import _ImageLogQueryData
36
38
  from ._utils.async_utils import TaskContext, deprecate_aio_usage, synchronizer
37
39
  from ._utils.blob_utils import MAX_OBJECT_SIZE_BYTES
38
40
  from ._utils.docker_utils import (
@@ -47,10 +49,10 @@ from .cloud_bucket_mount import _CloudBucketMount
47
49
  from .config import config, logger, user_config_path
48
50
  from .exception import (
49
51
  ExecutionError,
52
+ ImageBuildError,
50
53
  InternalError,
51
54
  InvalidError,
52
55
  NotFoundError,
53
- RemoteError,
54
56
  ServiceError,
55
57
  VersionError,
56
58
  )
@@ -477,6 +479,12 @@ def _requires_image_instance(method):
477
479
  return wrapper
478
480
 
479
481
 
482
+ @dataclass(frozen=True)
483
+ class _FailedBuildAttempt:
484
+ image_id: str
485
+ client: _Client
486
+
487
+
480
488
  class _Image(_Object, type_prefix="im"):
481
489
  """Base class for container images to run functions in.
482
490
 
@@ -493,6 +501,9 @@ class _Image(_Object, type_prefix="im"):
493
501
  _added_python_source_set: frozenset[str] # used to warn about missing mounts during auto-mount deprecation
494
502
  _metadata: api_pb2.ImageMetadata | None = None # set on hydration, private for now
495
503
  _is_empty: bool
504
+ _build_steps: list[api_pb2.ImageBuildStep] | None = None
505
+ # A failed image ID is only queryable through the client that performed the build.
506
+ _failed_build_attempt: _FailedBuildAttempt | None = None
496
507
 
497
508
  def _initialize_from_empty(self):
498
509
  self.inside_exceptions = []
@@ -524,6 +535,9 @@ class _Image(_Object, type_prefix="im"):
524
535
  if metadata:
525
536
  assert isinstance(metadata, api_pb2.ImageMetadata)
526
537
  self._metadata = metadata
538
+ # Successful hydration supersedes diagnostic state retained from a failed build.
539
+ self._failed_build_attempt = None
540
+ self._build_steps = None
527
541
 
528
542
  def _add_mount_layer_or_copy(self, mount: _Mount, copy: bool = False):
529
543
  if copy:
@@ -752,12 +766,12 @@ class _Image(_Object, type_prefix="im"):
752
766
 
753
767
  if result.status == api_pb2.GenericResult.GENERIC_STATUS_FAILURE:
754
768
  if result.exception:
755
- raise RemoteError(f"Image build for {image_id} failed with the exception:\n{result.exception}")
769
+ raise ImageBuildError(
770
+ f"Image build for {image_id} failed with the exception:\n{result.exception}", image_id
771
+ )
756
772
  else:
757
- msg = f"Image build for {image_id} failed. See build logs for more details."
758
- if not OutputManager.get().is_enabled:
759
- msg += " (Hint: Use `modal.enable_output()` to see logs from the process building the Image.)"
760
- raise RemoteError(msg)
773
+ msg = f"Image build for {image_id} failed.\nView the build logs:\n modal image logs {image_id}"
774
+ raise ImageBuildError(msg, image_id)
761
775
  elif result.status == api_pb2.GenericResult.GENERIC_STATUS_TERMINATED:
762
776
  msg = f"Image build for {image_id} terminated due to external shut-down. Please try again."
763
777
  if result.exception:
@@ -765,15 +779,16 @@ class _Image(_Object, type_prefix="im"):
765
779
  f"Image build for {image_id} terminated due to external shut-down with the exception:\n"
766
780
  f"{result.exception}"
767
781
  )
768
- raise RemoteError(msg)
782
+ raise ImageBuildError(msg, image_id)
769
783
  elif result.status == api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT:
770
- raise RemoteError(
771
- f"Image build for {image_id} timed out. Please try again with a larger `timeout` parameter."
784
+ raise ImageBuildError(
785
+ f"Image build for {image_id} timed out. Please try again with a larger `timeout` parameter.",
786
+ image_id,
772
787
  )
773
788
  elif result.status == api_pb2.GenericResult.GENERIC_STATUS_SUCCESS:
774
789
  pass
775
790
  else:
776
- raise RemoteError("Unknown status %s!" % result.status)
791
+ raise ImageBuildError("Unknown status %s!" % result.status, image_id)
777
792
 
778
793
  self._hydrate(image_id, load_context.client, metadata)
779
794
  local_mounts: set[_Mount] = set()
@@ -1064,8 +1079,18 @@ class _Image(_Object, type_prefix="im"):
1064
1079
 
1065
1080
  resolver = Resolver()
1066
1081
  async with TaskContext() as tc:
1082
+ self._build_steps = None
1083
+ self._failed_build_attempt = None
1067
1084
  load_context = LoadContext(task_context=tc).merged_with(app._root_load_context)
1068
- await resolver.load(self, load_context)
1085
+ try:
1086
+ await resolver.load(self, load_context)
1087
+ except ImageBuildError as e:
1088
+ self._failed_build_attempt = _FailedBuildAttempt(
1089
+ image_id=e.image_id,
1090
+ client=load_context.client,
1091
+ )
1092
+
1093
+ raise
1069
1094
  return self
1070
1095
 
1071
1096
  @_requires_image_instance
@@ -3028,3 +3053,38 @@ class _Image(_Object, type_prefix="im"):
3028
3053
  "Images cannot currently be hydrated on demand; you can build an Image by running an App that uses it."
3029
3054
  )
3030
3055
  return self
3056
+
3057
+ async def _resolve_image_id_client_for_logs(self) -> tuple[str, _Client]:
3058
+ if self._failed_build_attempt is not None:
3059
+ return self._failed_build_attempt.image_id, self._failed_build_attempt.client
3060
+ if self._object_id is None:
3061
+ raise InvalidError("Cannot fetch logs for an image that has not been built yet.")
3062
+ if self._is_hydrated:
3063
+ return self._object_id, self.client
3064
+ # Use self._load_context_overrides to handle case of explicitly passed
3065
+ # client in from_id
3066
+ client = (await self._load_context_overrides.apply_defaults()).client
3067
+ return self._object_id, client
3068
+
3069
+ async def _get_log_query_data(self) -> _ImageLogQueryData:
3070
+ image_id, client = await self._resolve_image_id_client_for_logs()
3071
+
3072
+ if self._build_steps is None:
3073
+ request = api_pb2.ImageBuildChainGetRequest(image_id=image_id)
3074
+ response = await client.stub.ImageBuildChainGet(request)
3075
+ self._build_steps = list(response.build_steps)
3076
+ return _ImageLogQueryData(client, self._build_steps or [], image_id)
3077
+
3078
+ @property
3079
+ def logs(self) -> _ImageLogsManager:
3080
+ """Access logs for an `Image`.
3081
+
3082
+ Use [`fetch()`](#logsfetch)
3083
+ to read logs for individual build layers and [`tail()`](#logstail)
3084
+ to read the most recent logs.
3085
+
3086
+ See also:
3087
+ - [`modal app logs`](https://modal.com/docs/cli/latest/app#modal-app-logs):
3088
+ CLI access to logs for an App.
3089
+ """
3090
+ return _ImageLogsManager(self)
@@ -256,13 +256,16 @@ async def _refine_dense_ranges(
256
256
  )
257
257
  sub_resp = await client.stub.AppCountLogs(sub_req)
258
258
  sub_ranges = _buckets_to_ranges(list(sub_resp.buckets), smaller_secs)
259
- # Clamp the last sub-range to the parent's end. When bucket_secs
260
- # doesn't evenly divide the parent duration, the last bucket
261
- # boundary overshoots (e.g. parent (0,30) with 20s buckets
262
- # produces (20,40)), causing overlaps with sibling ranges.
259
+ # Clamp the edge sub-ranges to the parent boundaries. Bucket
260
+ # alignment can make the first bucket begin before the parent
261
+ # (e.g. parent (6,12) with 4s buckets produces (4,8)) or make
262
+ # the last bucket end after it (e.g. parent (0,30) with 20s
263
+ # buckets produces (20,40)), causing overlaps with siblings.
263
264
  if sub_ranges:
264
- s, _, c = sub_ranges[-1]
265
- sub_ranges[-1] = (s, min(s + smaller_secs, end), c)
265
+ first_start, first_end, first_count = sub_ranges[0]
266
+ sub_ranges[0] = (max(first_start, start), first_end, first_count)
267
+ last_start, last_end, last_count = sub_ranges[-1]
268
+ sub_ranges[-1] = (last_start, min(last_end, end), last_count)
266
269
  return sub_ranges
267
270
 
268
271
  refine_set = {i for i, _ in to_refine}
@@ -410,7 +413,6 @@ async def fetch_logs(
410
413
 
411
414
  if until - since > _MAX_FETCH_RANGE:
412
415
  raise LogsFetchError(f"Time range cannot exceed {_MAX_FETCH_RANGE.days} days.")
413
-
414
416
  # Phase 1: Count logs per bucket
415
417
  bucket_secs = _pick_bucket_secs(since, until)
416
418
  count_req = api_pb2.AppCountLogsRequest(
@@ -11,8 +11,8 @@ from datetime import datetime, timezone
11
11
 
12
12
  from grpclib.exceptions import StreamTerminatedError
13
13
 
14
- from modal._logs import fetch_logs, tail_logs
15
- from modal._supports_logs import _LogQueryData, _SupportsLogs
14
+ from modal._logs import LogsFilters, fetch_logs, tail_logs
15
+ from modal._supports_logs import _LogQueryData, _SupportsImageLogs, _SupportsLogs
16
16
  from modal._utils.async_utils import synchronize_api
17
17
  from modal._utils.grpc_utils import RETRYABLE_GRPC_STATUS_CODES as _RETRYABLE_STREAM_STATUSES
18
18
  from modal._utils.time_utils import locale_tz
@@ -38,6 +38,70 @@ class _StreamStopReason(enum.Enum):
38
38
  STOP_STREAM = "stop_stream"
39
39
 
40
40
 
41
+ def _normalize_utc_datetime(value: datetime, name: str) -> datetime:
42
+ if not isinstance(value, datetime):
43
+ raise TypeError(f"{name} must be a datetime")
44
+ if value.tzinfo is None:
45
+ return value.replace(tzinfo=locale_tz()).astimezone(timezone.utc)
46
+ return value.astimezone(timezone.utc)
47
+
48
+
49
+ def _entry_source(file_descriptor: api_pb2.FileDescriptor.ValueType) -> LogSource:
50
+ match file_descriptor:
51
+ case api_pb2.FILE_DESCRIPTOR_STDOUT:
52
+ return "stdout"
53
+ case api_pb2.FILE_DESCRIPTOR_STDERR:
54
+ return "stderr"
55
+ case _:
56
+ return "system"
57
+
58
+
59
+ def _entry_timestamp(item: api_pb2.TaskLogs) -> datetime:
60
+ if item.timestamp_ns:
61
+ return datetime.fromtimestamp(item.timestamp_ns / 1_000_000_000, timezone.utc)
62
+ return datetime.fromtimestamp(item.timestamp, timezone.utc)
63
+
64
+
65
+ def _resolve_source(source: LogSource | None) -> api_pb2.FileDescriptor.ValueType:
66
+ match source:
67
+ case "stderr":
68
+ return api_pb2.FILE_DESCRIPTOR_STDERR
69
+ case "stdout":
70
+ return api_pb2.FILE_DESCRIPTOR_STDOUT
71
+ case "system":
72
+ return api_pb2.FILE_DESCRIPTOR_INFO
73
+ case None:
74
+ return api_pb2.FILE_DESCRIPTOR_UNSPECIFIED
75
+ case _:
76
+ raise ValueError("source must be one of 'stdout', 'stderr', 'system', or None")
77
+
78
+
79
+ def _entry_context_ids(object_id: str, item: api_pb2.TaskLogs, batch: api_pb2.TaskLogsBatch) -> list[str]:
80
+ match object_id[:2]:
81
+ case "fu":
82
+ context_ids = [
83
+ item.function_call_id,
84
+ item.input_id or batch.input_id,
85
+ item.container_id or batch.task_id,
86
+ ]
87
+ case "fc":
88
+ context_ids = [item.input_id or batch.input_id, item.container_id or batch.task_id]
89
+ case _:
90
+ context_ids = []
91
+ # filter for empty strings, can happen for Server which have function_ids but no input/container
92
+ return [c for c in context_ids if c]
93
+
94
+
95
+ def _entry_from_item(object_id: str, item: api_pb2.TaskLogs, batch: api_pb2.TaskLogsBatch) -> LogEntry:
96
+ return LogEntry(
97
+ message=item.data,
98
+ timestamp=_entry_timestamp(item),
99
+ source=_entry_source(item.file_descriptor),
100
+ object_id=object_id,
101
+ context_ids=_entry_context_ids(object_id, item, batch),
102
+ )
103
+
104
+
41
105
  class _LogsManager:
42
106
  """mdmd:namespace"""
43
107
 
@@ -52,68 +116,6 @@ class _LogsManager:
52
116
  self._query_params = await self._source._get_log_query_data()
53
117
  return self._query_params
54
118
 
55
- @staticmethod
56
- def _resolve_source(source: LogSource | None) -> api_pb2.FileDescriptor.ValueType:
57
- match source:
58
- case "stderr":
59
- return api_pb2.FILE_DESCRIPTOR_STDERR
60
- case "stdout":
61
- return api_pb2.FILE_DESCRIPTOR_STDOUT
62
- case "system":
63
- return api_pb2.FILE_DESCRIPTOR_INFO
64
- case None:
65
- return api_pb2.FILE_DESCRIPTOR_UNSPECIFIED
66
- case _:
67
- raise ValueError("source must be one of 'stdout', 'stderr', 'system', or None")
68
-
69
- @staticmethod
70
- def _normalize_utc_datetime(value: datetime, name: str) -> datetime:
71
- if not isinstance(value, datetime):
72
- raise TypeError(f"{name} must be a datetime")
73
- if value.tzinfo is None:
74
- return value.replace(tzinfo=locale_tz()).astimezone(timezone.utc)
75
- return value.astimezone(timezone.utc)
76
-
77
- @staticmethod
78
- def _entry_source(file_descriptor: api_pb2.FileDescriptor.ValueType) -> LogSource:
79
- match file_descriptor:
80
- case api_pb2.FILE_DESCRIPTOR_STDOUT:
81
- return "stdout"
82
- case api_pb2.FILE_DESCRIPTOR_STDERR:
83
- return "stderr"
84
- case _:
85
- return "system"
86
-
87
- @staticmethod
88
- def _entry_timestamp(item: api_pb2.TaskLogs) -> datetime:
89
- if item.timestamp_ns:
90
- return datetime.fromtimestamp(item.timestamp_ns / 1_000_000_000, timezone.utc)
91
- return datetime.fromtimestamp(item.timestamp, timezone.utc)
92
-
93
- def _entry_context_ids(self, item: api_pb2.TaskLogs, batch: api_pb2.TaskLogsBatch) -> list[str]:
94
- match self._source.object_id[:2]:
95
- case "fu":
96
- context_ids = [
97
- item.function_call_id,
98
- item.input_id or batch.input_id,
99
- item.container_id or batch.task_id,
100
- ]
101
- case "fc":
102
- context_ids = [item.input_id or batch.input_id, item.container_id or batch.task_id]
103
- case _:
104
- context_ids = []
105
- # filter for empty strings, can happen for Server which have function_ids but no input/container
106
- return [c for c in context_ids if c]
107
-
108
- def _entry_from_item(self, item: api_pb2.TaskLogs, batch: api_pb2.TaskLogsBatch) -> LogEntry:
109
- return LogEntry(
110
- message=item.data,
111
- timestamp=self._entry_timestamp(item),
112
- source=self._entry_source(item.file_descriptor),
113
- object_id=self._source.object_id,
114
- context_ids=self._entry_context_ids(item, batch),
115
- )
116
-
117
119
  async def _watch_stream_stop(self, deadline: _Deadline) -> _StreamStopReason | None:
118
120
  while True:
119
121
  if deadline.value is not None and time.monotonic() >= deadline.value:
@@ -135,13 +137,13 @@ class _LogsManager:
135
137
  search_text: str = "",
136
138
  ) -> AsyncGenerator[LogEntry, None]:
137
139
  """Fetch all associated logs corresponding to the date range and filters."""
138
- since = self._normalize_utc_datetime(since, "since")
139
- until = self._normalize_utc_datetime(until or datetime.now(timezone.utc), "until")
140
+ since = _normalize_utc_datetime(since, "since")
141
+ until = _normalize_utc_datetime(until or datetime.now(timezone.utc), "until")
140
142
  target = await self._params()
141
- filters = dataclasses.replace(target.filters, source=self._resolve_source(source), search_text=search_text)
143
+ filters = dataclasses.replace(target.filters, source=_resolve_source(source), search_text=search_text)
142
144
  async for batch in fetch_logs(target.client, target.app_id, since=since, until=until, filters=filters):
143
145
  for item in batch.items:
144
- yield self._entry_from_item(item, batch)
146
+ yield _entry_from_item(self._source.object_id, item, batch)
145
147
 
146
148
  async def tail(
147
149
  self,
@@ -151,7 +153,7 @@ class _LogsManager:
151
153
  ) -> AsyncGenerator[LogEntry, None]:
152
154
  """Fetch the most recent logs."""
153
155
  params = await self._params()
154
- filters = dataclasses.replace(params.filters, source=self._resolve_source(source))
156
+ filters = dataclasses.replace(params.filters, source=_resolve_source(source))
155
157
  async for batch in tail_logs(
156
158
  params.client,
157
159
  params.app_id,
@@ -159,7 +161,7 @@ class _LogsManager:
159
161
  filters=filters,
160
162
  ):
161
163
  for item in batch.items:
162
- yield self._entry_from_item(item, batch)
164
+ yield _entry_from_item(self._source.object_id, item, batch)
163
165
 
164
166
  def _create_log_stream(
165
167
  self, params: _LogQueryData, last_entry_id: str, timeout: float
@@ -180,7 +182,7 @@ class _LogsManager:
180
182
  def _stream_entries(self, batch: api_pb2.TaskLogsBatch):
181
183
  for item in batch.items:
182
184
  if item.data:
183
- yield self._entry_from_item(item, batch)
185
+ yield _entry_from_item(self._source.object_id, item, batch)
184
186
 
185
187
  @staticmethod
186
188
  def _advance_batch(batch: api_pb2.TaskLogsBatch, last_entry_id: str) -> tuple[str, bool]:
@@ -668,3 +670,122 @@ class _FunctionCallLogsManager:
668
670
 
669
671
 
670
672
  FunctionCallLogsManager = synchronize_api(_FunctionCallLogsManager, target_module=__name__)
673
+
674
+
675
+ class _ImageLogsManager:
676
+ """mdmd:namespace"""
677
+
678
+ _MAX_CONCURRENT_FETCHES = 10
679
+
680
+ def __init__(self, source: _SupportsImageLogs):
681
+ """mdmd:hidden"""
682
+ self._source = source
683
+
684
+ async def fetch(
685
+ self,
686
+ layers: int | None = 1,
687
+ ) -> AsyncGenerator[LogEntry, None]:
688
+ """Fetch logs for the most recent Image build steps.
689
+
690
+ Args:
691
+ layers: The number of build layers to fetch, counting backward
692
+ from the final Image. If None, logs are fetched for all build steps.
693
+ """
694
+ params = await self._source._get_log_query_data()
695
+ if layers is None:
696
+ layer_count = len(params.build_steps)
697
+ elif layers < 1:
698
+ raise ValueError("layers must be at least 1")
699
+ else:
700
+ layer_count = min(layers, len(params.build_steps))
701
+
702
+ selected_layers = params.build_steps[-layer_count:]
703
+
704
+ semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_FETCHES)
705
+ queues = [asyncio.Queue(maxsize=100) for _ in selected_layers]
706
+ DONE = object()
707
+
708
+ async def _bounded_fetch(step: api_pb2.ImageBuildStep, queue: asyncio.Queue[LogEntry | object]) -> None:
709
+ cancelled = False
710
+ # Awkward but intentional double try/except to track whether the task was cancelled.
711
+ # In Python 3.11+ we can use Task.cancelling().
712
+ try:
713
+ try:
714
+ async with semaphore:
715
+ async for batch in fetch_logs(
716
+ params.client,
717
+ step.builder_app_id,
718
+ # Protobuf timestamps represent UTC time.
719
+ since=step.started_at.ToDatetime(tzinfo=timezone.utc),
720
+ until=step.finished_at.ToDatetime(tzinfo=timezone.utc),
721
+ filters=LogsFilters(
722
+ task_id=step.builder_task_id,
723
+ ),
724
+ ):
725
+ for item in batch.items:
726
+ await queue.put(_entry_from_item(params.object_id, item, batch))
727
+ except Exception as e:
728
+ await queue.put(e)
729
+ except asyncio.CancelledError:
730
+ cancelled = True
731
+ raise
732
+ finally:
733
+ if not cancelled:
734
+ await queue.put(DONE)
735
+
736
+ tasks = [asyncio.create_task(_bounded_fetch(s, q)) for s, q in zip(selected_layers, queues)]
737
+
738
+ try:
739
+ for q in queues:
740
+ while (item := await q.get()) is not DONE:
741
+ if isinstance(item, Exception):
742
+ raise item
743
+ yield item
744
+ finally:
745
+ for task in tasks:
746
+ task.cancel()
747
+ await asyncio.gather(*tasks, return_exceptions=True)
748
+
749
+ async def tail(
750
+ self,
751
+ entries: int = 100,
752
+ ) -> AsyncGenerator[LogEntry, None]:
753
+ """Fetch the most recent Image logs.
754
+
755
+ Args:
756
+ entries: The number of log entries to return.
757
+ """
758
+ params = await self._source._get_log_query_data()
759
+ remaining_entries = entries
760
+ entries_by_step: list[list[LogEntry]] = []
761
+
762
+ for step in reversed(params.build_steps):
763
+ if remaining_entries <= 0:
764
+ break
765
+
766
+ step_entries: list[LogEntry] = []
767
+ async for batch in tail_logs(
768
+ params.client,
769
+ step.builder_app_id,
770
+ remaining_entries,
771
+ since=step.started_at.ToDatetime(tzinfo=timezone.utc),
772
+ until=step.finished_at.ToDatetime(tzinfo=timezone.utc),
773
+ filters=LogsFilters(task_id=step.builder_task_id),
774
+ ):
775
+ for item in batch.items:
776
+ if len(step_entries) >= remaining_entries:
777
+ break
778
+ step_entries.append(_entry_from_item(params.object_id, item, batch))
779
+
780
+ if step_entries:
781
+ entries_by_step.append(step_entries)
782
+ remaining_entries -= len(step_entries)
783
+
784
+ # Steps are queried newest-to-oldest so we can stop as soon as we have
785
+ # enough entries, but results are exposed in chronological build order.
786
+ for step_entries in reversed(entries_by_step):
787
+ for entry in step_entries:
788
+ yield entry
789
+
790
+
791
+ ImageLogsManager = synchronize_api(_ImageLogsManager, target_module=__name__)
@@ -32,6 +32,17 @@ class _StreamStopReason(enum.Enum):
32
32
  IDLE_TIMEOUT = "idle_timeout"
33
33
  STOP_STREAM = "stop_stream"
34
34
 
35
+ def _normalize_utc_datetime(value: datetime.datetime, name: str) -> datetime.datetime: ...
36
+ def _entry_source(file_descriptor: int) -> typing.Literal["stdout", "stderr", "system"]: ...
37
+ def _entry_timestamp(item: modal_proto.api_pb2.TaskLogs) -> datetime.datetime: ...
38
+ def _resolve_source(source: typing.Optional[typing.Literal["stdout", "stderr", "system"]]) -> int: ...
39
+ def _entry_context_ids(
40
+ object_id: str, item: modal_proto.api_pb2.TaskLogs, batch: modal_proto.api_pb2.TaskLogsBatch
41
+ ) -> list[str]: ...
42
+ def _entry_from_item(
43
+ object_id: str, item: modal_proto.api_pb2.TaskLogs, batch: modal_proto.api_pb2.TaskLogsBatch
44
+ ) -> modal.types.LogEntry: ...
45
+
35
46
  class _LogsManager:
36
47
  """mdmd:namespace"""
37
48
  def __init__(
@@ -43,20 +54,6 @@ class _LogsManager:
43
54
  ...
44
55
 
45
56
  async def _params(self) -> modal._supports_logs._LogQueryData: ...
46
- @staticmethod
47
- def _resolve_source(source: typing.Optional[typing.Literal["stdout", "stderr", "system"]]) -> int: ...
48
- @staticmethod
49
- def _normalize_utc_datetime(value: datetime.datetime, name: str) -> datetime.datetime: ...
50
- @staticmethod
51
- def _entry_source(file_descriptor: int) -> typing.Literal["stdout", "stderr", "system"]: ...
52
- @staticmethod
53
- def _entry_timestamp(item: modal_proto.api_pb2.TaskLogs) -> datetime.datetime: ...
54
- def _entry_context_ids(
55
- self, item: modal_proto.api_pb2.TaskLogs, batch: modal_proto.api_pb2.TaskLogsBatch
56
- ) -> list[str]: ...
57
- def _entry_from_item(
58
- self, item: modal_proto.api_pb2.TaskLogs, batch: modal_proto.api_pb2.TaskLogsBatch
59
- ) -> modal.types.LogEntry: ...
60
57
  async def _watch_stream_stop(self, deadline: _Deadline) -> typing.Optional[_StreamStopReason]: ...
61
58
  def fetch(
62
59
  self,
@@ -927,3 +924,74 @@ class FunctionCallLogsManager:
927
924
  ...
928
925
 
929
926
  fetch: __fetch_spec
927
+
928
+ class _ImageLogsManager:
929
+ """mdmd:namespace"""
930
+ def __init__(self, source: modal._supports_logs._SupportsImageLogs):
931
+ """mdmd:hidden"""
932
+ ...
933
+
934
+ def fetch(self, layers: typing.Optional[int] = 1) -> collections.abc.AsyncGenerator[modal.types.LogEntry, None]:
935
+ """Fetch logs for the most recent Image build steps.
936
+
937
+ Args:
938
+ layers: The number of build layers to fetch, counting backward
939
+ from the final Image. If None, logs are fetched for all build steps.
940
+ """
941
+ ...
942
+
943
+ def tail(self, entries: int = 100) -> collections.abc.AsyncGenerator[modal.types.LogEntry, None]:
944
+ """Fetch the most recent Image logs.
945
+
946
+ Args:
947
+ entries: The number of log entries to return.
948
+ """
949
+ ...
950
+
951
+ class ImageLogsManager:
952
+ """mdmd:namespace"""
953
+ def __init__(self, source: modal._supports_logs._SupportsImageLogs):
954
+ """mdmd:hidden"""
955
+ ...
956
+
957
+ class __fetch_spec(typing_extensions.Protocol):
958
+ def __call__(self, /, layers: typing.Optional[int] = 1) -> typing.Generator[modal.types.LogEntry, None, None]:
959
+ """Fetch logs for the most recent Image build steps.
960
+
961
+ Args:
962
+ layers: The number of build layers to fetch, counting backward
963
+ from the final Image. If None, logs are fetched for all build steps.
964
+ """
965
+ ...
966
+
967
+ def aio(
968
+ self, /, layers: typing.Optional[int] = 1
969
+ ) -> collections.abc.AsyncGenerator[modal.types.LogEntry, None]:
970
+ """Fetch logs for the most recent Image build steps.
971
+
972
+ Args:
973
+ layers: The number of build layers to fetch, counting backward
974
+ from the final Image. If None, logs are fetched for all build steps.
975
+ """
976
+ ...
977
+
978
+ fetch: __fetch_spec
979
+
980
+ class __tail_spec(typing_extensions.Protocol):
981
+ def __call__(self, /, entries: int = 100) -> typing.Generator[modal.types.LogEntry, None, None]:
982
+ """Fetch the most recent Image logs.
983
+
984
+ Args:
985
+ entries: The number of log entries to return.
986
+ """
987
+ ...
988
+
989
+ def aio(self, /, entries: int = 100) -> collections.abc.AsyncGenerator[modal.types.LogEntry, None]:
990
+ """Fetch the most recent Image logs.
991
+
992
+ Args:
993
+ entries: The number of log entries to return.
994
+ """
995
+ ...
996
+
997
+ tail: __tail_spec
@@ -5,6 +5,7 @@ from typing import Protocol
5
5
 
6
6
  from modal._logs import LogsFilters
7
7
  from modal.client import _Client
8
+ from modal_proto import api_pb2
8
9
 
9
10
 
10
11
  @dataclasses.dataclass(frozen=True)
@@ -30,3 +31,22 @@ class _SupportsLogs(Protocol):
30
31
 
31
32
  @property
32
33
  def object_id(self) -> str: ...
34
+
35
+
36
+ @dataclasses.dataclass(frozen=True)
37
+ class _ImageLogQueryData:
38
+ """Resolved data needed to query logs for a Modal object.
39
+
40
+ Log managers lazily ask their source object for this data before the first
41
+ query.
42
+ """
43
+
44
+ client: _Client
45
+ build_steps: list[api_pb2.ImageBuildStep]
46
+ object_id: str
47
+
48
+
49
+ class _SupportsImageLogs(Protocol):
50
+ """Protocol for objects that support log streaming and fetching and have build steps."""
51
+
52
+ async def _get_log_query_data(self) -> _ImageLogQueryData: ...