modal 1.6.1.dev1__tar.gz → 1.6.2__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 (250) hide show
  1. {modal-1.6.1.dev1 → modal-1.6.2}/PKG-INFO +6 -5
  2. {modal-1.6.1.dev1 → modal-1.6.2}/README.md +1 -1
  3. {modal-1.6.1.dev1 → modal-1.6.2}/modal/__init__.py +3 -3
  4. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_environments.py +4 -3
  5. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_functions.py +148 -2
  6. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_image.py +2 -2
  7. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_logs.py +82 -54
  8. modal-1.6.2/modal/_metrics.py +212 -0
  9. modal-1.6.2/modal/_metrics.pyi +312 -0
  10. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_outbound_policy.py +3 -3
  11. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_outbound_policy.pyi +2 -2
  12. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_output/status.py +11 -10
  13. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_server.py +219 -9
  14. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_server.pyi +107 -1
  15. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/async_utils.py +7 -1
  16. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/blob_utils.py +2 -0
  17. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/name_utils.py +9 -7
  18. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/time_utils.py +22 -39
  19. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_workspace.py +183 -3
  20. {modal-1.6.1.dev1 → modal-1.6.2}/modal/app.py +1 -1
  21. {modal-1.6.1.dev1 → modal-1.6.2}/modal/app.pyi +2 -2
  22. {modal-1.6.1.dev1 → modal-1.6.2}/modal/builder/2025.06.txt +8 -4
  23. {modal-1.6.1.dev1 → modal-1.6.2}/modal/builder/PREVIEW.txt +4 -2
  24. {modal-1.6.1.dev1 → modal-1.6.2}/modal/builder/base-images.json +4 -2
  25. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/_logs.py +33 -5
  26. modal-1.6.2/modal/cli/_stats.py +334 -0
  27. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/app.py +5 -5
  28. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/container.py +5 -3
  29. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/dict.py +3 -7
  30. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/endpoint.py +6 -1
  31. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/environment.py +3 -3
  32. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/function.py +482 -37
  33. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/import_refs.py +93 -13
  34. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/network_file_system.py +2 -4
  35. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/queues.py +3 -7
  36. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/run.py +55 -2
  37. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/secret.py +2 -4
  38. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/server.py +498 -31
  39. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/shell.py +8 -2
  40. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/utils.py +29 -1
  41. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/volume.py +3 -7
  42. modal-1.6.2/modal/cli/workspace.py +641 -0
  43. {modal-1.6.1.dev1 → modal-1.6.2}/modal/client.pyi +2 -2
  44. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cls.py +1 -1
  45. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cls.pyi +1 -1
  46. {modal-1.6.1.dev1 → modal-1.6.2}/modal/config.py +18 -6
  47. {modal-1.6.1.dev1 → modal-1.6.2}/modal/container_process.py +1 -1
  48. {modal-1.6.1.dev1 → modal-1.6.2}/modal/container_process.pyi +3 -3
  49. {modal-1.6.1.dev1 → modal-1.6.2}/modal/experimental/__init__.py +66 -0
  50. {modal-1.6.1.dev1 → modal-1.6.2}/modal/experimental/_flash_container_entry.py +1 -0
  51. {modal-1.6.1.dev1 → modal-1.6.2}/modal/functions.py +2 -1
  52. {modal-1.6.1.dev1 → modal-1.6.2}/modal/functions.pyi +161 -6
  53. {modal-1.6.1.dev1 → modal-1.6.2}/modal/io_streams.py +1 -1
  54. {modal-1.6.1.dev1 → modal-1.6.2}/modal/io_streams.pyi +9 -9
  55. {modal-1.6.1.dev1 → modal-1.6.2}/modal/mount.py +14 -1
  56. {modal-1.6.1.dev1 → modal-1.6.2}/modal/mount.pyi +3 -3
  57. {modal-1.6.1.dev1 → modal-1.6.2}/modal/object.pyi +0 -2
  58. {modal-1.6.1.dev1 → modal-1.6.2}/modal/queue.py +1 -1
  59. {modal-1.6.1.dev1 → modal-1.6.2}/modal/queue.pyi +2 -2
  60. {modal-1.6.1.dev1 → modal-1.6.2}/modal/runner.py +2 -2
  61. modal-1.6.2/modal/sandbox/__init__.py +9 -0
  62. modal-1.6.2/modal/sandbox/_common.py +248 -0
  63. modal-1.6.1.dev1/modal/sandbox_fs.py → modal-1.6.2/modal/sandbox/_filesystem.py +35 -28
  64. modal-1.6.1.dev1/modal/sandbox.py → modal-1.6.2/modal/sandbox/_sandbox.py +117 -919
  65. modal-1.6.2/modal/sandbox/_sidecar.py +646 -0
  66. modal-1.6.1.dev1/modal/snapshot.py → modal-1.6.2/modal/sandbox/_snapshot.py +5 -8
  67. modal-1.6.1.dev1/modal/_utils/task_command_router_client.py → modal-1.6.2/modal/sandbox/_task_command_router_client.py +132 -54
  68. modal-1.6.2/modal/sandbox/sandbox.py +11 -0
  69. {modal-1.6.1.dev1/modal → modal-1.6.2/modal/sandbox}/sandbox.pyi +705 -1236
  70. {modal-1.6.1.dev1 → modal-1.6.2}/modal/secret.py +13 -4
  71. {modal-1.6.1.dev1 → modal-1.6.2}/modal/secret.pyi +4 -0
  72. modal-1.6.2/modal/server.py +6 -0
  73. {modal-1.6.1.dev1 → modal-1.6.2}/modal/server.pyi +170 -0
  74. {modal-1.6.1.dev1 → modal-1.6.2}/modal/skills/modal/SKILL.md +19 -24
  75. {modal-1.6.1.dev1 → modal-1.6.2}/modal/types.py +94 -1
  76. {modal-1.6.1.dev1 → modal-1.6.2}/modal/volume.py +14 -0
  77. {modal-1.6.1.dev1 → modal-1.6.2}/modal/volume.pyi +15 -0
  78. {modal-1.6.1.dev1 → modal-1.6.2}/modal/workspace.py +3 -0
  79. {modal-1.6.1.dev1 → modal-1.6.2}/modal/workspace.pyi +239 -1
  80. {modal-1.6.1.dev1 → modal-1.6.2}/modal.egg-info/PKG-INFO +6 -5
  81. {modal-1.6.1.dev1 → modal-1.6.2}/modal.egg-info/SOURCES.txt +11 -7
  82. {modal-1.6.1.dev1 → modal-1.6.2}/modal.egg-info/requires.txt +5 -2
  83. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/api_grpc.py +224 -16
  84. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/api_pb2.py +1924 -1413
  85. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/api_pb2.pyi +1428 -218
  86. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/api_pb2_grpc.py +466 -35
  87. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/api_pb2_grpc.pyi +150 -16
  88. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/modal_api_grpc.py +14 -1
  89. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/task_command_router_grpc.py +5 -5
  90. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/task_command_router_pb2.py +112 -112
  91. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/task_command_router_pb2.pyi +34 -24
  92. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/task_command_router_pb2_grpc.py +8 -7
  93. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/task_command_router_pb2_grpc.pyi +10 -6
  94. {modal-1.6.1.dev1 → modal-1.6.2}/modal_version/__init__.py +1 -1
  95. {modal-1.6.1.dev1 → modal-1.6.2}/pyproject.toml +15 -7
  96. modal-1.6.1.dev1/modal/cli/_stats.py +0 -141
  97. modal-1.6.1.dev1/modal/cli/workspace.py +0 -269
  98. modal-1.6.1.dev1/modal/sandbox_fs.pyi +0 -1087
  99. modal-1.6.1.dev1/modal/server.py +0 -5
  100. modal-1.6.1.dev1/modal/snapshot.pyi +0 -85
  101. {modal-1.6.1.dev1 → modal-1.6.2}/LICENSE +0 -0
  102. {modal-1.6.1.dev1 → modal-1.6.2}/modal/__main__.py +0 -0
  103. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_billing.py +0 -0
  104. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_cluster.py +0 -0
  105. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_cluster.pyi +0 -0
  106. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_container_entrypoint.py +0 -0
  107. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_function_variants.py +0 -0
  108. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_grpc_client.py +0 -0
  109. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_ipython.py +0 -0
  110. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_load_context.py +0 -0
  111. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_location.py +0 -0
  112. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_logs_manager.py +0 -0
  113. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_logs_manager.pyi +0 -0
  114. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_object.py +0 -0
  115. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_output/__init__.py +0 -0
  116. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_output/manager.py +0 -0
  117. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_output/pty.py +0 -0
  118. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_output/rich.py +0 -0
  119. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_partial_function.py +0 -0
  120. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_resolver.py +0 -0
  121. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_resources.py +0 -0
  122. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/__init__.py +0 -0
  123. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/asgi.py +0 -0
  124. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/container_io_manager.py +0 -0
  125. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/container_io_manager.pyi +0 -0
  126. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/execution_context.py +0 -0
  127. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/execution_context.pyi +0 -0
  128. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/gpu_memory_snapshot.py +0 -0
  129. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/task_lifecycle_manager.py +0 -0
  130. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/task_lifecycle_manager.pyi +0 -0
  131. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/telemetry.py +0 -0
  132. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/user_code_event_loop.py +0 -0
  133. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_runtime/user_code_imports.py +0 -0
  134. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_serialization.py +0 -0
  135. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_supports_logs.py +0 -0
  136. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_traceback.py +0 -0
  137. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_tunnel.py +0 -0
  138. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_tunnel.pyi +0 -0
  139. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_type_manager.py +0 -0
  140. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/__init__.py +0 -0
  141. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/app_utils.py +0 -0
  142. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/auth_token_manager.py +0 -0
  143. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/browser_utils.py +0 -0
  144. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/bytes_io_segment_payload.py +0 -0
  145. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/curl_utils.py +0 -0
  146. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/deprecation.py +0 -0
  147. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/display_utils.py +0 -0
  148. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/docker_utils.py +0 -0
  149. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/function_utils.py +0 -0
  150. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/git_utils.py +0 -0
  151. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/grpc_testing.py +0 -0
  152. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/grpc_utils.py +0 -0
  153. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/hash_utils.py +0 -0
  154. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/http_utils.py +0 -0
  155. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/idle_countdown.py +0 -0
  156. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/jwt_utils.py +0 -0
  157. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/logger.py +0 -0
  158. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/mount_utils.py +0 -0
  159. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/package_utils.py +0 -0
  160. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/pattern_utils.py +0 -0
  161. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/proxy_support.py +0 -0
  162. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/rand_pb_testing.py +0 -0
  163. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/sandbox_fs_utils.py +0 -0
  164. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_utils/shell_utils.py +0 -0
  165. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_vendor/__init__.py +0 -0
  166. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  167. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_vendor/cloudpickle.py +0 -0
  168. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_vendor/tblib.py +0 -0
  169. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_vendor/version.py +0 -0
  170. {modal-1.6.1.dev1 → modal-1.6.2}/modal/_watcher.py +0 -0
  171. {modal-1.6.1.dev1 → modal-1.6.2}/modal/billing.py +0 -0
  172. {modal-1.6.1.dev1 → modal-1.6.2}/modal/billing.pyi +0 -0
  173. {modal-1.6.1.dev1 → modal-1.6.2}/modal/builder/2023.12.312.txt +0 -0
  174. {modal-1.6.1.dev1 → modal-1.6.2}/modal/builder/2023.12.txt +0 -0
  175. {modal-1.6.1.dev1 → modal-1.6.2}/modal/builder/2024.04.txt +0 -0
  176. {modal-1.6.1.dev1 → modal-1.6.2}/modal/builder/2024.10.txt +0 -0
  177. {modal-1.6.1.dev1 → modal-1.6.2}/modal/builder/README.md +0 -0
  178. {modal-1.6.1.dev1 → modal-1.6.2}/modal/call_graph.py +0 -0
  179. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/__init__.py +0 -0
  180. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/_download.py +0 -0
  181. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/_help.py +0 -0
  182. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/_traceback.py +0 -0
  183. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/billing.py +0 -0
  184. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/changelog.py +0 -0
  185. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/config.py +0 -0
  186. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/curl.py +0 -0
  187. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/dashboard.py +0 -0
  188. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/entry_point.py +0 -0
  189. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/image.py +0 -0
  190. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/logo.py +0 -0
  191. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/profile.py +0 -0
  192. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/selector.py +0 -0
  193. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/skills.py +0 -0
  194. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cli/token.py +0 -0
  195. {modal-1.6.1.dev1 → modal-1.6.2}/modal/client.py +0 -0
  196. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cloud_bucket_mount.py +0 -0
  197. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cloud_bucket_mount.pyi +0 -0
  198. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cluster.py +0 -0
  199. {modal-1.6.1.dev1 → modal-1.6.2}/modal/cluster.pyi +0 -0
  200. {modal-1.6.1.dev1 → modal-1.6.2}/modal/dict.py +0 -0
  201. {modal-1.6.1.dev1 → modal-1.6.2}/modal/dict.pyi +0 -0
  202. {modal-1.6.1.dev1 → modal-1.6.2}/modal/environments.py +0 -0
  203. {modal-1.6.1.dev1 → modal-1.6.2}/modal/environments.pyi +0 -0
  204. {modal-1.6.1.dev1 → modal-1.6.2}/modal/exception.py +0 -0
  205. {modal-1.6.1.dev1 → modal-1.6.2}/modal/experimental/flash.py +0 -0
  206. {modal-1.6.1.dev1 → modal-1.6.2}/modal/experimental/flash.pyi +0 -0
  207. {modal-1.6.1.dev1 → modal-1.6.2}/modal/experimental/flash_server.py +0 -0
  208. {modal-1.6.1.dev1 → modal-1.6.2}/modal/experimental/flash_server.pyi +0 -0
  209. {modal-1.6.1.dev1 → modal-1.6.2}/modal/experimental/ipython.py +0 -0
  210. {modal-1.6.1.dev1 → modal-1.6.2}/modal/file_pattern_matcher.py +0 -0
  211. {modal-1.6.1.dev1 → modal-1.6.2}/modal/image.py +0 -0
  212. {modal-1.6.1.dev1 → modal-1.6.2}/modal/image.pyi +0 -0
  213. {modal-1.6.1.dev1 → modal-1.6.2}/modal/network_file_system.py +0 -0
  214. {modal-1.6.1.dev1 → modal-1.6.2}/modal/network_file_system.pyi +0 -0
  215. {modal-1.6.1.dev1 → modal-1.6.2}/modal/object.py +0 -0
  216. {modal-1.6.1.dev1 → modal-1.6.2}/modal/output.py +0 -0
  217. {modal-1.6.1.dev1 → modal-1.6.2}/modal/parallel_map.py +0 -0
  218. {modal-1.6.1.dev1 → modal-1.6.2}/modal/parallel_map.pyi +0 -0
  219. {modal-1.6.1.dev1 → modal-1.6.2}/modal/partial_function.py +0 -0
  220. {modal-1.6.1.dev1 → modal-1.6.2}/modal/partial_function.pyi +0 -0
  221. {modal-1.6.1.dev1 → modal-1.6.2}/modal/proxy.py +0 -0
  222. {modal-1.6.1.dev1 → modal-1.6.2}/modal/proxy.pyi +0 -0
  223. {modal-1.6.1.dev1 → modal-1.6.2}/modal/py.typed +0 -0
  224. {modal-1.6.1.dev1 → modal-1.6.2}/modal/retries.py +0 -0
  225. {modal-1.6.1.dev1 → modal-1.6.2}/modal/runner.pyi +0 -0
  226. {modal-1.6.1.dev1 → modal-1.6.2}/modal/running_app.py +0 -0
  227. {modal-1.6.1.dev1 → modal-1.6.2}/modal/schedule.py +0 -0
  228. {modal-1.6.1.dev1 → modal-1.6.2}/modal/scheduler_placement.py +0 -0
  229. {modal-1.6.1.dev1 → modal-1.6.2}/modal/serving.py +0 -0
  230. {modal-1.6.1.dev1 → modal-1.6.2}/modal/serving.pyi +0 -0
  231. {modal-1.6.1.dev1 → modal-1.6.2}/modal/stream_type.py +0 -0
  232. {modal-1.6.1.dev1 → modal-1.6.2}/modal/token_flow.py +0 -0
  233. {modal-1.6.1.dev1 → modal-1.6.2}/modal/token_flow.pyi +0 -0
  234. {modal-1.6.1.dev1 → modal-1.6.2}/modal.egg-info/dependency_links.txt +0 -0
  235. {modal-1.6.1.dev1 → modal-1.6.2}/modal.egg-info/entry_points.txt +0 -0
  236. {modal-1.6.1.dev1 → modal-1.6.2}/modal.egg-info/top_level.txt +0 -0
  237. {modal-1.6.1.dev1 → modal-1.6.2}/modal_docs/__init__.py +0 -0
  238. {modal-1.6.1.dev1 → modal-1.6.2}/modal_docs/gen_cli_docs.py +0 -0
  239. {modal-1.6.1.dev1 → modal-1.6.2}/modal_docs/gen_cli_docs_main.py +0 -0
  240. {modal-1.6.1.dev1 → modal-1.6.2}/modal_docs/gen_reference_docs.py +0 -0
  241. {modal-1.6.1.dev1 → modal-1.6.2}/modal_docs/gen_reference_docs_main.py +0 -0
  242. {modal-1.6.1.dev1 → modal-1.6.2}/modal_docs/mdmd/__init__.py +0 -0
  243. {modal-1.6.1.dev1 → modal-1.6.2}/modal_docs/mdmd/mdmd.py +0 -0
  244. {modal-1.6.1.dev1 → modal-1.6.2}/modal_docs/mdmd/signatures.py +0 -0
  245. {modal-1.6.1.dev1 → modal-1.6.2}/modal_docs/mdmd/types.py +0 -0
  246. {modal-1.6.1.dev1 → modal-1.6.2}/modal_docs/reference_intro.md +0 -0
  247. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/__init__.py +0 -0
  248. {modal-1.6.1.dev1 → modal-1.6.2}/modal_proto/py.typed +0 -0
  249. {modal-1.6.1.dev1 → modal-1.6.2}/modal_version/__main__.py +0 -0
  250. {modal-1.6.1.dev1 → modal-1.6.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.6.1.dev1
3
+ Version: 1.6.2
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -12,7 +12,7 @@ Keywords: modal,client,cloud,serverless,infrastructure
12
12
  Classifier: Topic :: System :: Distributed Computing
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3
15
- Requires-Python: <3.15,>=3.10
15
+ Requires-Python: <3.16,>=3.10
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: aiohttp
@@ -21,14 +21,15 @@ Requires-Dist: certifi
21
21
  Requires-Dist: click~=8.1
22
22
  Requires-Dist: grpclib<0.4.10,>=0.4.7; python_version < "3.14"
23
23
  Requires-Dist: grpclib<0.4.10,>=0.4.9; python_version >= "3.14"
24
- Requires-Dist: protobuf!=4.24.0,<7.0,>=3.19
24
+ Requires-Dist: protobuf!=4.24.0,<8.0,>=3.19
25
25
  Requires-Dist: rich>=12.0.0
26
- Requires-Dist: synchronicity~=0.12.5
26
+ Requires-Dist: synchronicity~=0.12.6
27
27
  Requires-Dist: toml
28
28
  Requires-Dist: types-certifi
29
29
  Requires-Dist: types-toml
30
30
  Requires-Dist: watchfiles
31
31
  Requires-Dist: typing_extensions~=4.6
32
+ Requires-Dist: typing_extensions>=4.14; python_version >= "3.15"
32
33
  Provides-Extra: api-proxy-support
33
34
  Requires-Dist: python-socks[asyncio]>=2.5; extra == "api-proxy-support"
34
35
  Requires-Dist: aiohttp-socks>=0.8; extra == "api-proxy-support"
@@ -52,7 +53,7 @@ a [user guide](https://modal.com/docs/guide), and the detailed
52
53
 
53
54
  ## Installation
54
55
 
55
- **This library requires Python 3.10 – 3.14.**
56
+ **This library requires Python 3.10 – 3.15.**
56
57
 
57
58
  Install the package with `uv` or `pip`:
58
59
 
@@ -16,7 +16,7 @@ a [user guide](https://modal.com/docs/guide), and the detailed
16
16
 
17
17
  ## Installation
18
18
 
19
- **This library requires Python 3.10 – 3.14.**
19
+ **This library requires Python 3.10 – 3.15.**
20
20
 
21
21
  Install the package with `uv` or `pip`:
22
22
 
@@ -3,8 +3,8 @@ import sys
3
3
 
4
4
  if sys.version_info[:2] < (3, 10):
5
5
  raise RuntimeError("This version of Modal requires at least Python 3.10")
6
- if sys.version_info[:2] >= (3, 15):
7
- raise RuntimeError("This version of Modal does not support Python 3.15+")
6
+ if sys.version_info[:2] >= (3, 16):
7
+ raise RuntimeError("This version of Modal does not support Python 3.16+")
8
8
 
9
9
  from modal_version import __version__
10
10
 
@@ -43,11 +43,11 @@ try:
43
43
  from .queue import Queue
44
44
  from .retries import Retries
45
45
  from .sandbox import Probe, Sandbox
46
+ from .sandbox.sandbox import SandboxSnapshot
46
47
  from .schedule import Cron, Period
47
48
  from .scheduler_placement import SchedulerPlacement
48
49
  from .secret import Secret
49
50
  from .server import Server
50
- from .snapshot import SandboxSnapshot
51
51
  from .volume import Volume
52
52
  from .workspace import Workspace
53
53
  except Exception:
@@ -22,7 +22,7 @@ from ._utils.time_utils import is_utc_month_aligned, parse_billing_cycle
22
22
  from .client import _Client
23
23
  from .config import config, logger
24
24
  from .exception import InvalidError, WorkspaceManagementError
25
- from .types import BillingReportItem, EnvironmentBillingSummary
25
+ from .types import AppInfo, BillingReportItem, EnvironmentBillingSummary
26
26
 
27
27
  if TYPE_CHECKING:
28
28
  import modal
@@ -54,7 +54,7 @@ class _EnvironmentManager:
54
54
  modal.Environment.objects.create("my-environment")
55
55
  ```
56
56
  """
57
- check_environment_name(name)
57
+ check_environment_name(name, allow_periods=False)
58
58
 
59
59
  client = await _Client.from_env() if client is None else client
60
60
  request = api_pb2.EnvironmentCreateRequest(name=name, is_managed=restricted)
@@ -346,6 +346,7 @@ class _EnvironmentAppsManager:
346
346
  self._environment.name,
347
347
  self._environment.client,
348
348
  description=item.description or None,
349
+ info=AppInfo._from_proto(item.metadata, item.app_id),
349
350
  )
350
351
  for item in response.apps
351
352
  if item.state not in stopped_app_states
@@ -463,7 +464,7 @@ class _Environment(_Object, type_prefix="en"):
463
464
  client: _Client | None = None,
464
465
  ) -> "_Environment":
465
466
  """Look up an Environment object using its name."""
466
- check_environment_name(name)
467
+ check_environment_name(name, allow_periods=True)
467
468
  return _Environment._get_or_create(
468
469
  name=name,
469
470
  repr=f"Environment.from_name({name!r})",
@@ -27,6 +27,7 @@ from ._image import _Image
27
27
  from ._load_context import LoadContext
28
28
  from ._logs import LogsFilters
29
29
  from ._logs_manager import _FunctionCallLogsManager, _FunctionLogsManager
30
+ from ._metrics import _FunctionMetricsManager
30
31
  from ._object import _Object, live_method, live_method_gen
31
32
  from ._output.pty import get_pty_info
32
33
  from ._output.status import FunctionCreationStatus
@@ -50,6 +51,7 @@ from ._utils.async_utils import (
50
51
  )
51
52
  from ._utils.auth_token_manager import _AuthTokenManager
52
53
  from ._utils.blob_utils import MAX_ASYNC_OBJECT_SIZE_BYTES, MAX_OBJECT_SIZE_BYTES
54
+ from ._utils.deprecation import deprecation_warning
53
55
  from ._utils.function_utils import (
54
56
  ATTEMPT_TIMEOUT_GRACE_PERIOD,
55
57
  OUTPUTS_TIMEOUT,
@@ -661,6 +663,11 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
661
663
  source_object_id=self.object_id,
662
664
  )
663
665
 
666
+ @property
667
+ def metrics(self) -> "_FunctionMetricsManager":
668
+ """Access metric samples with `export()` and series definitions with `schema()`."""
669
+ return _FunctionMetricsManager(self)
670
+
664
671
  @property
665
672
  def logs(self) -> _FunctionLogsManager:
666
673
  """Access logs for a `Function`.
@@ -819,8 +826,8 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
819
826
  raise InvalidError(
820
827
  f"`min_containers` ({min_containers}) cannot be greater than `max_containers` ({max_containers})"
821
828
  )
822
- if scaledown_window is not None and scaledown_window <= 0:
823
- raise InvalidError("`scaledown_window` must be > 0")
829
+ if scaledown_window is not None and not (2 <= scaledown_window <= 3600):
830
+ raise InvalidError("`scaledown_window` must be between 2 and 3600 (inclusive)")
824
831
 
825
832
  autoscaler_settings = api_pb2.AutoscalerSettings(
826
833
  min_containers=min_containers,
@@ -1390,6 +1397,13 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1390
1397
  ```
1391
1398
 
1392
1399
  """
1400
+
1401
+ deprecation_warning(
1402
+ (2026, 10, 16),
1403
+ "`Function.update_autoscaler()` is deprecated and will be removed in a future version "
1404
+ "of `modal`. Use `Function.autoscaler.update()` instead.",
1405
+ )
1406
+
1393
1407
  # Assert .update_autoscaler() is not called on a method as opposed to the Object. Applicable for Cls only.
1394
1408
  if self._is_method:
1395
1409
  raise InvalidError("Cannot call .update_autoscaler() on a method. Call it on the class instance instead.")
@@ -2306,6 +2320,11 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
2306
2320
  assert self._metadata
2307
2321
  return self._metadata.function_schema
2308
2322
 
2323
+ @property
2324
+ def autoscaler(self) -> "_FunctionAutoscalerManager":
2325
+ """Namespace for Function autoscaler APIs."""
2326
+ return _FunctionAutoscalerManager(self)
2327
+
2309
2328
  # A bit hacky - but the map-style functions need to not be synchronicity-wrapped
2310
2329
  # in order to not execute their input iterators on the synchronicity event loop.
2311
2330
  # We still need to wrap them using MethodWithAio to maintain a synchronicity-like
@@ -2559,3 +2578,130 @@ class _FunctionCall(typing.Generic[ReturnType], _Object, type_prefix="fc"):
2559
2578
  except Exception as exc:
2560
2579
  # TODO: kill all running function calls
2561
2580
  raise exc
2581
+
2582
+
2583
+ UNSET_PARAMETER = -1
2584
+
2585
+
2586
+ class _FunctionAutoscalerManager:
2587
+ """mdmd:namespace"""
2588
+
2589
+ def __init__(self, function: _Function):
2590
+ """mdmd:hidden"""
2591
+ self._function = function
2592
+
2593
+ async def settings(self) -> FunctionAutoscalerSettings:
2594
+ """View the current Autoscaler settings for this Function.
2595
+
2596
+ Returns:
2597
+ A `FunctionAutoscalerSettings` dataclass.
2598
+
2599
+ Examples:
2600
+
2601
+ ```python notest
2602
+ fn = modal.Function.from_name("my-app", "my_function")
2603
+ settings = fn.autoscaler.settings()
2604
+ ```
2605
+ """
2606
+
2607
+ if not self._function._is_hydrated:
2608
+ await self._function.hydrate()
2609
+
2610
+ response = await self._function.client._stub.FunctionGetSchedulingParams(
2611
+ api_pb2.FunctionGetSchedulingParamsRequest(function_id=self._function.object_id)
2612
+ )
2613
+
2614
+ ret = FunctionAutoscalerSettings._from_proto(response.autoscaler_configuration.settings)
2615
+ if ret.max_containers == 0:
2616
+ ret = dataclasses.replace(ret, max_containers=None)
2617
+
2618
+ return ret
2619
+
2620
+ async def update(
2621
+ self,
2622
+ *,
2623
+ min_containers: int | None = UNSET_PARAMETER,
2624
+ max_containers: int | None = UNSET_PARAMETER,
2625
+ buffer_containers: int | None = UNSET_PARAMETER,
2626
+ scaledown_window: int = UNSET_PARAMETER,
2627
+ ) -> FunctionAutoscalerSettings:
2628
+ """Override the current Autoscaler settings for this Function.
2629
+
2630
+ All parameters to this function are optional; not passing an argument for a given parameter
2631
+ will not update its value.
2632
+
2633
+ Note that re-deploying the App containing this Function will wipe any overrides and revert
2634
+ its Autoscaler settings back to those defined on the Function at creation time.
2635
+
2636
+ Args:
2637
+ min_containers: Minimum number of containers to keep running. Must be a nonnegative
2638
+ integer, or `None`. Passing `None` is equivalent to setting the minimum to 0.
2639
+ max_containers: Maximum concurrent containers. Must be a positive integer, or `None`.
2640
+ Passing `None` is equivalent to disabling the upper bound on container concurrency at
2641
+ the Function level (note that Environment and Workspace level container upper limits
2642
+ still apply.)
2643
+ buffer_containers: Extra containers to keep warm beyond current demand. Must be a
2644
+ nonnegative integer, or `None`. Passing `None` is equivalent to setting the buffer to
2645
+ 0.
2646
+ scaledown_window: Maximum duration (in seconds) idle containers wait before scaling down.
2647
+ Must be a positive integer between 2 and 3600, inclusive.
2648
+
2649
+ Returns:
2650
+ A `FunctionAutoscalerSettings` dataclass.
2651
+
2652
+ Examples:
2653
+
2654
+ Update `my_function` so that 2 containers are always active.
2655
+
2656
+ ```python notest
2657
+ fn = modal.Function.from_name("my-app", "my_function")
2658
+ fn.autoscaler.update(min_containers=2)
2659
+ ```
2660
+
2661
+ Update `my_function` so that there is no Function-level limit on `max_containers`:
2662
+
2663
+ ```python notest
2664
+ fn = modal.Function.from_name("my-app", "my_function")
2665
+ fn.autoscaler.update(max_containers=None)
2666
+ ```
2667
+ """
2668
+
2669
+ if not self._function._is_hydrated:
2670
+ await self._function.hydrate()
2671
+
2672
+ settings: api_pb2.AutoscalerSettings = api_pb2.AutoscalerSettings()
2673
+
2674
+ if min_containers != UNSET_PARAMETER:
2675
+ min_containers = min_containers if min_containers is not None else 0
2676
+ if min_containers < 0:
2677
+ raise InvalidError("Invalid argument: `min_containers` must be a nonnegative integer or `None`.")
2678
+
2679
+ settings.min_containers = min_containers
2680
+
2681
+ if buffer_containers != UNSET_PARAMETER:
2682
+ buffer_containers = buffer_containers if buffer_containers is not None else 0
2683
+ if buffer_containers < 0:
2684
+ raise InvalidError("Invalid argument: `buffer_containers` must be a nonnegative integer or `None`.")
2685
+
2686
+ settings.buffer_containers = buffer_containers
2687
+
2688
+ if max_containers != UNSET_PARAMETER:
2689
+ if max_containers is not None and max_containers <= 0:
2690
+ raise InvalidError("Invalid argument: `max_containers` must be a postive integer or `None`.")
2691
+
2692
+ settings.max_containers = max_containers if max_containers is not None else 0
2693
+
2694
+ if scaledown_window != UNSET_PARAMETER:
2695
+ if not (2 <= scaledown_window <= 3600):
2696
+ raise InvalidError("Invalid argument: `scaledown_window` must be between 2 and 3600 (inclusive)")
2697
+
2698
+ settings.scaledown_window = scaledown_window
2699
+
2700
+ request = api_pb2.FunctionUpdateSchedulingParamsRequest(function_id=self._function.object_id, settings=settings)
2701
+ response = await self._function.client._stub.FunctionUpdateSchedulingParams(request)
2702
+
2703
+ ret = FunctionAutoscalerSettings._from_proto(response.current_settings)
2704
+ if ret.max_containers == 0:
2705
+ ret = dataclasses.replace(ret, max_containers=None)
2706
+
2707
+ return ret
@@ -76,8 +76,8 @@ ImageBuilderVersion = Literal["2023.12", "2024.04", "2024.10", "2025.06", "PREVI
76
76
  # Python versions in mount.py where we specify the "standalone Python versions" we create mounts for.
77
77
  # Consider consolidating these multiple sources of truth?
78
78
  SUPPORTED_PYTHON_SERIES: dict[ImageBuilderVersion, list[str]] = {
79
- "PREVIEW": ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"],
80
- "2025.06": ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"],
79
+ "PREVIEW": ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t"],
80
+ "2025.06": ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t"],
81
81
  "2024.10": ["3.10", "3.11", "3.12", "3.13"],
82
82
  "2024.04": ["3.10", "3.11", "3.12"],
83
83
  "2023.12": ["3.10", "3.11", "3.12"],
@@ -3,9 +3,9 @@ from __future__ import annotations
3
3
 
4
4
  import asyncio
5
5
  import dataclasses
6
- from collections.abc import AsyncGenerator
6
+ from collections.abc import AsyncGenerator, Awaitable, Callable
7
7
  from datetime import datetime, timedelta, timezone
8
- from typing import TYPE_CHECKING
8
+ from typing import TYPE_CHECKING, Sequence, TypeVar
9
9
 
10
10
  from google.protobuf.timestamp_pb2 import Timestamp
11
11
 
@@ -13,6 +13,9 @@ from modal._utils.async_utils import TaskContext
13
13
  from modal.exception import LogsFetchError
14
14
  from modal_proto import api_pb2
15
15
 
16
+ _T = TypeVar("_T")
17
+ _CountRanges = Callable[[float, float, int], Awaitable[list[tuple[float, float, int]]]]
18
+
16
19
  if TYPE_CHECKING:
17
20
  from modal.client import _Client
18
21
 
@@ -194,6 +197,32 @@ def _next_smaller_bucket_secs(current_bucket_secs: int) -> int | None:
194
197
  return None
195
198
 
196
199
 
200
+ async def _count_log_ranges(
201
+ client: _Client,
202
+ app_id: str,
203
+ since: float,
204
+ until: float,
205
+ bucket_secs: int,
206
+ filters: LogsFilters,
207
+ ) -> list[tuple[float, float, int]]:
208
+ count_req = api_pb2.AppCountLogsRequest(
209
+ app_id=app_id,
210
+ since=_seconds_to_timestamp(since),
211
+ until=_seconds_to_timestamp(until),
212
+ bucket_secs=bucket_secs,
213
+ source=filters.source,
214
+ function_id=filters.function_id,
215
+ parametrized_function_id=filters.parametrized_function_id,
216
+ function_call_id=filters.function_call_id,
217
+ task_id=filters.task_id,
218
+ sandbox_id=filters.sandbox_id,
219
+ search_text=filters.search_text,
220
+ )
221
+ count_resp: api_pb2.AppCountLogsResponse = await client._stub.AppCountLogs(count_req)
222
+
223
+ return _buckets_to_ranges(list(count_resp.buckets), bucket_secs)
224
+
225
+
197
226
  async def _refine_dense_ranges(
198
227
  client: _Client,
199
228
  app_id: str,
@@ -201,6 +230,19 @@ async def _refine_dense_ranges(
201
230
  filters: LogsFilters,
202
231
  max_ranges: int,
203
232
  max_iterations: int,
233
+ ) -> list[tuple[float, float, int]]:
234
+ async def count_ranges(start: float, end: float, bucket_secs: int) -> list[tuple[float, float, int]]:
235
+ return await _count_log_ranges(client, app_id, start, end, bucket_secs, filters)
236
+
237
+ return await _refine_ranges(ranges, count_ranges, max_ranges, max_iterations)
238
+
239
+
240
+ async def _refine_ranges(
241
+ ranges: list[tuple[float, float, int]],
242
+ count_ranges: _CountRanges,
243
+ max_ranges: int,
244
+ max_iterations: int,
245
+ max_concurrent_counts: int = _MAX_CONCURRENT_COUNTS,
204
246
  ) -> list[tuple[float, float, int]]:
205
247
  """Refine ranges exceeding _FETCH_LIMIT by subdividing with smaller buckets.
206
248
 
@@ -239,25 +281,11 @@ async def _refine_dense_ranges(
239
281
  break
240
282
 
241
283
  # Fire off all re-count RPCs in parallel with bounded concurrency
242
- semaphore = asyncio.Semaphore(_MAX_CONCURRENT_COUNTS)
284
+ semaphore = asyncio.Semaphore(max_concurrent_counts)
243
285
 
244
286
  async def _recount(start: float, end: float, smaller_secs: int) -> list[tuple[float, float, int]]:
245
287
  async with semaphore:
246
- sub_req = api_pb2.AppCountLogsRequest(
247
- app_id=app_id,
248
- since=_seconds_to_timestamp(start),
249
- until=_seconds_to_timestamp(end),
250
- bucket_secs=smaller_secs,
251
- source=filters.source,
252
- function_id=filters.function_id,
253
- parametrized_function_id=filters.parametrized_function_id,
254
- function_call_id=filters.function_call_id,
255
- task_id=filters.task_id,
256
- sandbox_id=filters.sandbox_id,
257
- search_text=filters.search_text,
258
- )
259
- sub_resp = await client._stub.AppCountLogs(sub_req)
260
- sub_ranges = _buckets_to_ranges(list(sub_resp.buckets), smaller_secs)
288
+ sub_ranges = await count_ranges(start, end, smaller_secs)
261
289
  # Clamp the edge sub-ranges to the parent boundaries. Bucket
262
290
  # alignment can make the first bucket begin before the parent
263
291
  # (e.g. parent (6,12) with 4s buckets produces (4,8)) or make
@@ -418,26 +446,34 @@ async def fetch_logs(
418
446
 
419
447
  if until - since > _MAX_FETCH_RANGE:
420
448
  raise LogsFetchError(f"Time range cannot exceed {_MAX_FETCH_RANGE.days} days.")
421
- # Phase 1: Count logs per bucket
422
- bucket_secs = _pick_bucket_secs(since, until)
423
- count_req = api_pb2.AppCountLogsRequest(
424
- app_id=app_id,
425
- since=_datetime_to_timestamp(since),
426
- until=_datetime_to_timestamp(until),
427
- bucket_secs=bucket_secs,
428
- source=filters.source,
429
- function_id=filters.function_id,
430
- parametrized_function_id=filters.parametrized_function_id,
431
- function_call_id=filters.function_call_id,
432
- task_id=filters.task_id,
433
- sandbox_id=filters.sandbox_id,
434
- search_text=filters.search_text,
435
- )
436
- count_resp: api_pb2.AppCountLogsResponse = await client._stub.AppCountLogs(count_req)
437
449
 
438
- ranges = _buckets_to_ranges(list(count_resp.buckets), bucket_secs)
439
- total_logs = sum(count for _, _, count in ranges)
440
- if total_logs == 0:
450
+ async def count_ranges(start: float, end: float, bucket_secs: int) -> list[tuple[float, float, int]]:
451
+ return await _count_log_ranges(client, app_id, start, end, bucket_secs, filters)
452
+
453
+ async def fetch_interval(start: float, end: float, limit: int) -> list[api_pb2.TaskLogsBatch]:
454
+ return await _fetch_interval(client, app_id, start, end, limit, filters)
455
+
456
+ async for batch in _fetch_time_range(
457
+ since,
458
+ until,
459
+ count_ranges,
460
+ fetch_interval,
461
+ ):
462
+ yield batch
463
+
464
+
465
+ async def _fetch_time_range(
466
+ since: datetime,
467
+ until: datetime,
468
+ count_ranges: _CountRanges,
469
+ fetch_interval: Callable[[float, float, int], Awaitable[Sequence[_T]]],
470
+ *,
471
+ max_concurrent_counts: int = _MAX_CONCURRENT_COUNTS,
472
+ ) -> AsyncGenerator[_T]:
473
+ """Count, refine, and fetch time intervals with bounded concurrency and ordered results."""
474
+ ranges = await count_ranges(since.timestamp(), until.timestamp(), _pick_bucket_secs(since, until))
475
+ total_records = sum(count for _, _, count in ranges)
476
+ if total_records == 0:
441
477
  return
442
478
 
443
479
  # Trim leading/trailing empty buckets so they don't consume refinement
@@ -449,19 +485,17 @@ async def fetch_logs(
449
485
 
450
486
  # Phase 1b: Refine any ranges that exceed the per-fetch limit.
451
487
  # Budget: at most _MAX_FETCHES intervals, each fetching up to _FETCH_LIMIT entries.
452
- ranges = await _refine_dense_ranges(
453
- client,
454
- app_id,
488
+ ranges = await _refine_ranges(
455
489
  ranges,
456
- filters,
490
+ count_ranges,
457
491
  max_ranges=_MAX_FETCHES,
458
492
  max_iterations=_MAX_REFINE_ITERATIONS,
493
+ max_concurrent_counts=max_concurrent_counts,
459
494
  )
460
- fetch_error_message = "Too many logs to fetch in time range. Consider narrowing the range or adding filters."
461
495
  # Check that all ranges fit within the per-fetch limit.
462
496
  over_limit = [r for r in ranges if r[2] > _FETCH_LIMIT]
463
497
  if over_limit:
464
- raise LogsFetchError(fetch_error_message)
498
+ raise LogsFetchError("Too many logs to fetch in time range. Consider narrowing the range or adding filters.")
465
499
 
466
500
  # Phase 2: Build intervals from non-empty ranges and fetch in parallel
467
501
  since_secs = since.timestamp()
@@ -476,24 +510,17 @@ async def fetch_logs(
476
510
  return
477
511
 
478
512
  if len(intervals) > _MAX_FETCHES:
479
- raise LogsFetchError(fetch_error_message)
513
+ raise LogsFetchError("Too many logs to fetch in time range. Consider narrowing the range or adding filters.")
480
514
 
481
515
  # Fetch all intervals concurrently (bounded by semaphore), but yield
482
- # results in interval order so logs stay chronological. Awaiting each
516
+ # results in interval order so records stay chronological. Awaiting each
483
517
  # task in sequence lets us stream batches to the caller as soon as all
484
518
  # preceding intervals have completed.
485
519
  semaphore = asyncio.Semaphore(_MAX_CONCURRENT_FETCHES)
486
520
 
487
- async def _bounded_fetch(since_s: float, until_s: float) -> list[api_pb2.TaskLogsBatch]:
521
+ async def _bounded_fetch(since_s: float, until_s: float) -> Sequence[_T]:
488
522
  async with semaphore:
489
- return await _fetch_interval(
490
- client,
491
- app_id,
492
- since_s,
493
- until_s,
494
- _FETCH_LIMIT,
495
- filters,
496
- )
523
+ return await fetch_interval(since_s, until_s, _FETCH_LIMIT)
497
524
 
498
525
  tasks = [asyncio.create_task(_bounded_fetch(s, u)) for s, u in intervals]
499
526
 
@@ -504,3 +531,4 @@ async def fetch_logs(
504
531
  finally:
505
532
  for task in tasks:
506
533
  task.cancel()
534
+ await asyncio.gather(*tasks, return_exceptions=True)