modAL 1.3.6.dev7__tar.gz → 1.3.6.dev9__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 (210) hide show
  1. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/PKG-INFO +1 -2
  2. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/README.md +0 -1
  3. modal-1.3.6.dev9/modal/_logs.py +498 -0
  4. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_output/manager.py +25 -4
  5. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_output/pty.py +41 -2
  6. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_output/rich.py +111 -25
  7. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/async_utils.py +17 -2
  8. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/blob_utils.py +2 -2
  9. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/grpc_utils.py +15 -5
  10. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/app.py +183 -8
  11. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/utils.py +69 -1
  12. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/client.pyi +2 -2
  13. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/exception.py +4 -0
  14. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/image.py +1 -1
  15. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/volume.py +3 -3
  16. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/PKG-INFO +1 -2
  17. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/SOURCES.txt +1 -0
  18. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_version/__init__.py +1 -1
  19. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/pyproject.toml +47 -1
  20. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/LICENSE +0 -0
  21. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/__init__.py +0 -0
  22. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/__main__.py +0 -0
  23. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_billing.py +0 -0
  24. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_clustered_functions.py +0 -0
  25. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_clustered_functions.pyi +0 -0
  26. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_container_entrypoint.py +0 -0
  27. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_functions.py +0 -0
  28. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_grpc_client.py +0 -0
  29. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_ipython.py +0 -0
  30. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_load_context.py +0 -0
  31. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_location.py +0 -0
  32. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_object.py +0 -0
  33. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_output/__init__.py +0 -0
  34. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_output/status.py +0 -0
  35. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_partial_function.py +0 -0
  36. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_resolver.py +0 -0
  37. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_resources.py +0 -0
  38. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/__init__.py +0 -0
  39. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/asgi.py +0 -0
  40. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/container_io_manager.py +0 -0
  41. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/container_io_manager.pyi +0 -0
  42. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/execution_context.py +0 -0
  43. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/execution_context.pyi +0 -0
  44. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/gpu_memory_snapshot.py +0 -0
  45. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/telemetry.py +0 -0
  46. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/user_code_event_loop.py +0 -0
  47. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/user_code_imports.py +0 -0
  48. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_serialization.py +0 -0
  49. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_server.py +0 -0
  50. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_traceback.py +0 -0
  51. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_tunnel.py +0 -0
  52. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_tunnel.pyi +0 -0
  53. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_type_manager.py +0 -0
  54. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/__init__.py +0 -0
  55. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/app_utils.py +0 -0
  56. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/auth_token_manager.py +0 -0
  57. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/browser_utils.py +0 -0
  58. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/bytes_io_segment_payload.py +0 -0
  59. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/deprecation.py +0 -0
  60. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/docker_utils.py +0 -0
  61. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/function_utils.py +0 -0
  62. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/git_utils.py +0 -0
  63. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/grpc_testing.py +0 -0
  64. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/hash_utils.py +0 -0
  65. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/http_utils.py +0 -0
  66. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/jwt_utils.py +0 -0
  67. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/logger.py +0 -0
  68. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/mount_utils.py +0 -0
  69. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/name_utils.py +0 -0
  70. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/package_utils.py +0 -0
  71. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/pattern_utils.py +0 -0
  72. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/rand_pb_testing.py +0 -0
  73. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/sandbox_fs_utils.py +0 -0
  74. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/shell_utils.py +0 -0
  75. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/task_command_router_client.py +0 -0
  76. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/time_utils.py +0 -0
  77. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_vendor/__init__.py +0 -0
  78. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  79. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_vendor/cloudpickle.py +0 -0
  80. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_vendor/tblib.py +0 -0
  81. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_vendor/version.py +0 -0
  82. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_watcher.py +0 -0
  83. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/app.py +0 -0
  84. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/app.pyi +0 -0
  85. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/billing.py +0 -0
  86. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/2023.12.312.txt +0 -0
  87. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/2023.12.txt +0 -0
  88. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/2024.04.txt +0 -0
  89. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/2024.10.txt +0 -0
  90. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/2025.06.txt +0 -0
  91. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/PREVIEW.txt +0 -0
  92. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/README.md +0 -0
  93. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/base-images.json +0 -0
  94. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/call_graph.py +0 -0
  95. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/__init__.py +0 -0
  96. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/_download.py +0 -0
  97. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/_traceback.py +0 -0
  98. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/billing.py +0 -0
  99. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/changelog.py +0 -0
  100. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/cluster.py +0 -0
  101. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/config.py +0 -0
  102. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/container.py +0 -0
  103. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/dashboard.py +0 -0
  104. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/dict.py +0 -0
  105. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/entry_point.py +0 -0
  106. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/environment.py +0 -0
  107. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/import_refs.py +0 -0
  108. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/launch.py +0 -0
  109. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/network_file_system.py +0 -0
  110. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/profile.py +0 -0
  111. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/programs/__init__.py +0 -0
  112. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/programs/run_jupyter.py +0 -0
  113. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/programs/vscode.py +0 -0
  114. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/queues.py +0 -0
  115. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/run.py +0 -0
  116. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/secret.py +0 -0
  117. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/selector.py +0 -0
  118. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/shell.py +0 -0
  119. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/token.py +0 -0
  120. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/volume.py +0 -0
  121. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/client.py +0 -0
  122. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cloud_bucket_mount.py +0 -0
  123. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cloud_bucket_mount.pyi +0 -0
  124. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cls.py +0 -0
  125. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cls.pyi +0 -0
  126. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/config.py +0 -0
  127. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/container_process.py +0 -0
  128. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/container_process.pyi +0 -0
  129. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/dict.py +0 -0
  130. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/dict.pyi +0 -0
  131. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/environments.py +0 -0
  132. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/environments.pyi +0 -0
  133. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/experimental/__init__.py +0 -0
  134. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/experimental/flash.py +0 -0
  135. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/experimental/flash.pyi +0 -0
  136. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/experimental/ipython.py +0 -0
  137. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/file_io.py +0 -0
  138. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/file_io.pyi +0 -0
  139. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/file_pattern_matcher.py +0 -0
  140. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/functions.py +0 -0
  141. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/functions.pyi +0 -0
  142. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/gpu.py +0 -0
  143. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/image.pyi +0 -0
  144. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/io_streams.py +0 -0
  145. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/io_streams.pyi +0 -0
  146. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/mount.py +0 -0
  147. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/mount.pyi +0 -0
  148. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/network_file_system.py +0 -0
  149. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/network_file_system.pyi +0 -0
  150. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/object.py +0 -0
  151. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/object.pyi +0 -0
  152. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/output.py +0 -0
  153. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/parallel_map.py +0 -0
  154. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/parallel_map.pyi +0 -0
  155. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/partial_function.py +0 -0
  156. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/partial_function.pyi +0 -0
  157. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/proxy.py +0 -0
  158. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/proxy.pyi +0 -0
  159. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/py.typed +0 -0
  160. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/queue.py +0 -0
  161. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/queue.pyi +0 -0
  162. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/retries.py +0 -0
  163. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/runner.py +0 -0
  164. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/runner.pyi +0 -0
  165. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/running_app.py +0 -0
  166. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/sandbox.py +0 -0
  167. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/sandbox.pyi +0 -0
  168. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/sandbox_fs.py +0 -0
  169. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/sandbox_fs.pyi +0 -0
  170. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/schedule.py +0 -0
  171. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/scheduler_placement.py +0 -0
  172. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/secret.py +0 -0
  173. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/secret.pyi +0 -0
  174. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/server.py +0 -0
  175. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/server.pyi +0 -0
  176. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/serving.py +0 -0
  177. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/serving.pyi +0 -0
  178. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/snapshot.py +0 -0
  179. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/snapshot.pyi +0 -0
  180. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/stream_type.py +0 -0
  181. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/token_flow.py +0 -0
  182. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/token_flow.pyi +0 -0
  183. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/volume.pyi +0 -0
  184. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/dependency_links.txt +0 -0
  185. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/entry_points.txt +0 -0
  186. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/requires.txt +0 -0
  187. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/top_level.txt +0 -0
  188. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/__init__.py +0 -0
  189. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/gen_cli_docs.py +0 -0
  190. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/gen_cli_docs_main.py +0 -0
  191. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/gen_reference_docs.py +0 -0
  192. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/gen_reference_docs_main.py +0 -0
  193. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/mdmd/__init__.py +0 -0
  194. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/mdmd/mdmd.py +0 -0
  195. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/mdmd/signatures.py +0 -0
  196. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/__init__.py +0 -0
  197. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/api_grpc.py +0 -0
  198. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/api_pb2.py +0 -0
  199. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/api_pb2.pyi +0 -0
  200. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/api_pb2_grpc.py +0 -0
  201. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/api_pb2_grpc.pyi +0 -0
  202. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/modal_api_grpc.py +0 -0
  203. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/py.typed +0 -0
  204. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/task_command_router_grpc.py +0 -0
  205. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/task_command_router_pb2.py +0 -0
  206. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/task_command_router_pb2.pyi +0 -0
  207. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/task_command_router_pb2_grpc.py +0 -0
  208. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/task_command_router_pb2_grpc.pyi +0 -0
  209. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_version/__main__.py +0 -0
  210. {modal-1.3.6.dev7 → modal-1.3.6.dev9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.3.6.dev7
3
+ Version: 1.3.6.dev9
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -36,7 +36,6 @@ Dynamic: license-file
36
36
 
37
37
  [![PyPI Version](https://img.shields.io/pypi/v/modal.svg)](https://pypi.org/project/modal/)
38
38
  [![License](https://img.shields.io/badge/license-apache_2.0-darkviolet.svg)](https://github.com/modal-labs/modal-client/blob/master/LICENSE)
39
- [![Tests](https://github.com/modal-labs/modal-client/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/modal-labs/modal-client/actions/workflows/ci-cd.yml)
40
39
  [![Slack](https://img.shields.io/badge/slack-join-blue.svg?logo=slack)](https://modal.com/slack)
41
40
 
42
41
  The [Modal](https://modal.com/) Python SDK provides convenient, on-demand
@@ -2,7 +2,6 @@
2
2
 
3
3
  [![PyPI Version](https://img.shields.io/pypi/v/modal.svg)](https://pypi.org/project/modal/)
4
4
  [![License](https://img.shields.io/badge/license-apache_2.0-darkviolet.svg)](https://github.com/modal-labs/modal-client/blob/master/LICENSE)
5
- [![Tests](https://github.com/modal-labs/modal-client/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/modal-labs/modal-client/actions/workflows/ci-cd.yml)
6
5
  [![Slack](https://img.shields.io/badge/slack-join-blue.svg?logo=slack)](https://modal.com/slack)
7
6
 
8
7
  The [Modal](https://modal.com/) Python SDK provides convenient, on-demand
@@ -0,0 +1,498 @@
1
+ # Copyright Modal Labs 2026
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import dataclasses
6
+ from collections.abc import AsyncGenerator
7
+ from datetime import datetime, timedelta, timezone
8
+ from typing import TYPE_CHECKING, Optional
9
+
10
+ from google.protobuf.timestamp_pb2 import Timestamp
11
+
12
+ from modal._utils.async_utils import TaskContext
13
+ from modal.exception import LogsFetchError
14
+ from modal_proto import api_pb2
15
+
16
+ if TYPE_CHECKING:
17
+ from modal.client import _Client
18
+
19
+
20
+ @dataclasses.dataclass(frozen=True)
21
+ class LogsFilters:
22
+ source: "api_pb2.FileDescriptor.ValueType" = api_pb2.FILE_DESCRIPTOR_UNSPECIFIED
23
+ function_id: str = ""
24
+ function_call_id: str = ""
25
+ task_id: str = ""
26
+ sandbox_id: str = ""
27
+ search_text: str = ""
28
+
29
+
30
+ # Maximum number of logs to request per fetch RPC call. This is a safety cap
31
+ # to avoid silently dropping logs from dense intervals, not a target size.
32
+ # Derived from a ~4 MiB target response size at ~200 bytes per log entry.
33
+ _FETCH_LIMIT = 20_000
34
+
35
+ # Minimum number of logs to accumulate per interval before splitting into
36
+ # a new one. Buckets are merged until this threshold is reached. Intervals
37
+ # with a single bucket exceeding this value are emitted as-is.
38
+ _INTERVAL_LOG_THRESHOLD = 2_000
39
+
40
+ # Maximum number of fetch RPCs per fetch_logs call. Together with _FETCH_LIMIT,
41
+ # this bounds total retrievable entries to _MAX_FETCHES * _FETCH_LIMIT = 10M.
42
+ _MAX_FETCHES = 500
43
+
44
+ # Maximum allowed range between `since` and `until`, enforced server-side.
45
+ _MAX_FETCH_RANGE = timedelta(days=35)
46
+
47
+ # Lookback windows for tailing logs, tried in order from shortest to longest.
48
+ _TAIL_LOOKBACKS = [
49
+ timedelta(hours=1),
50
+ timedelta(days=1),
51
+ timedelta(days=7),
52
+ timedelta(days=30),
53
+ ]
54
+
55
+ # Maximum number of concurrent AppFetchLogs requests.
56
+ _MAX_CONCURRENT_FETCHES = 10
57
+
58
+ # Maximum number of concurrent AppCountLogs requests (responses are small).
59
+ _MAX_CONCURRENT_COUNTS = 20
60
+
61
+ # Maximum number of refinement iterations (each fires a parallel batch of AppCountLogs RPCs).
62
+ _MAX_REFINE_ITERATIONS = 3
63
+
64
+ # Number of buckets for the initial count
65
+ _APPROX_INITIAL_BUCKETS = 100
66
+
67
+ # Predefined bucket sizes (in seconds)
68
+ # We pick the smallest size such that the total number of buckets stays <= _APPROX_INITIAL_BUCKETS.
69
+ _BUCKET_SIZES_SECS = [
70
+ 2,
71
+ 4,
72
+ 6,
73
+ 12,
74
+ 20,
75
+ 30,
76
+ 60,
77
+ 120,
78
+ 180,
79
+ 240,
80
+ 300,
81
+ 360,
82
+ 600,
83
+ 720,
84
+ 900,
85
+ 1200,
86
+ 1800,
87
+ 3600,
88
+ 7200,
89
+ 10800,
90
+ 14400,
91
+ 28800,
92
+ 43200,
93
+ 86400,
94
+ ]
95
+
96
+
97
+ def _datetime_to_timestamp(dt: datetime) -> Timestamp:
98
+ ts = Timestamp()
99
+ ts.FromDatetime(dt)
100
+ return ts
101
+
102
+
103
+ def _timestamp_to_seconds(ts: Timestamp) -> float:
104
+ return ts.seconds + ts.nanos / 1e9
105
+
106
+
107
+ def _seconds_to_timestamp(secs: float) -> Timestamp:
108
+ ts = Timestamp()
109
+ ts.seconds = int(secs)
110
+ ts.nanos = int((secs - int(secs)) * 1e9)
111
+ return ts
112
+
113
+
114
+ def _pick_bucket_secs(since: datetime, until: datetime) -> int:
115
+ """Pick the smallest bucket size that yields _APPROX_INITIAL_BUCKETS."""
116
+ duration_secs = (until - since).total_seconds()
117
+ for bucket_secs in _BUCKET_SIZES_SECS:
118
+ if duration_secs / bucket_secs <= _APPROX_INITIAL_BUCKETS:
119
+ return bucket_secs
120
+ return _BUCKET_SIZES_SECS[-1]
121
+
122
+
123
+ def _bucket_count(bucket: api_pb2.AppCountLogsResponse.LogBucket) -> int:
124
+ return bucket.stdout_logs + bucket.stderr_logs + bucket.system_logs
125
+
126
+
127
+ def _buckets_to_ranges(
128
+ buckets: list[api_pb2.AppCountLogsResponse.LogBucket],
129
+ bucket_secs: int,
130
+ ) -> list[tuple[float, float, int]]:
131
+ """Convert proto buckets to (start, end, count) tuples."""
132
+ return [
133
+ (
134
+ _timestamp_to_seconds(b.bucket_start_at),
135
+ _timestamp_to_seconds(b.bucket_start_at) + bucket_secs,
136
+ _bucket_count(b),
137
+ )
138
+ for b in buckets
139
+ ]
140
+
141
+
142
+ def _build_fetch_intervals(
143
+ ranges: list[tuple[float, float, int]],
144
+ ) -> list[tuple[float, float]]:
145
+ """Build time intervals for parallel fetching from (start, end, count) ranges.
146
+
147
+ Merges adjacent non-empty ranges into intervals, splitting when the
148
+ accumulated log count reaches _INTERVAL_LOG_THRESHOLD or when adding the
149
+ next range would push the interval over _FETCH_LIMIT.
150
+ """
151
+ intervals: list[tuple[float, float]] = []
152
+ current_start: Optional[float] = None
153
+ current_end: float = 0.0
154
+ current_count = 0
155
+
156
+ for range_start, range_end, total in ranges:
157
+ if total == 0:
158
+ if current_start is not None:
159
+ intervals.append((current_start, current_end))
160
+ current_start = None
161
+ current_count = 0
162
+ continue
163
+
164
+ if current_start is not None and current_count + total > _FETCH_LIMIT:
165
+ intervals.append((current_start, current_end))
166
+ current_start = None
167
+ current_count = 0
168
+
169
+ if current_start is None:
170
+ current_start = range_start
171
+ current_count = total
172
+ else:
173
+ current_count += total
174
+ current_end = range_end
175
+
176
+ if current_count >= _INTERVAL_LOG_THRESHOLD:
177
+ intervals.append((current_start, current_end))
178
+ current_start = None
179
+ current_count = 0
180
+
181
+ # Flush remaining interval
182
+ if current_start is not None:
183
+ intervals.append((current_start, current_end))
184
+
185
+ return intervals
186
+
187
+
188
+ def _next_smaller_bucket_secs(current_bucket_secs: int) -> Optional[int]:
189
+ """Return the next smaller bucket size, or None if already at the smallest."""
190
+ for size in reversed(_BUCKET_SIZES_SECS):
191
+ if size < current_bucket_secs:
192
+ return size
193
+ return None
194
+
195
+
196
+ async def _refine_dense_ranges(
197
+ client: _Client,
198
+ app_id: str,
199
+ ranges: list[tuple[float, float, int]],
200
+ filters: LogsFilters,
201
+ max_ranges: int,
202
+ max_iterations: int,
203
+ ) -> list[tuple[float, float, int]]:
204
+ """Refine ranges exceeding _FETCH_LIMIT by subdividing with smaller buckets.
205
+
206
+ Uses breadth-first refinement: each iteration splits ALL over-limit ranges
207
+ one level, ensuring fair treatment across dense regions. Stops when all
208
+ ranges fit within _FETCH_LIMIT, no further subdivision is possible,
209
+ the total range count would exceed max_ranges, or the iteration budget
210
+ is exhausted.
211
+ """
212
+ refined = list(ranges)
213
+ iterations = 0
214
+
215
+ while iterations < max_iterations:
216
+ # Identify dense ranges that can be subdivided
217
+ to_refine: list[tuple[int, int]] = [] # (index, smaller_secs)
218
+ for i, (start, end, count) in enumerate(refined):
219
+ if count <= _FETCH_LIMIT:
220
+ continue
221
+ duration = end - start
222
+ smaller_secs = _next_smaller_bucket_secs(int(duration))
223
+ if smaller_secs is not None and smaller_secs < duration:
224
+ to_refine.append((i, smaller_secs))
225
+
226
+ if not to_refine:
227
+ break
228
+
229
+ # Estimate new range count: each refined range of duration D
230
+ # with bucket size S produces ceil(D/S) sub-ranges, replacing 1.
231
+ estimated_new = len(refined)
232
+ for i, smaller_secs in to_refine:
233
+ start, end, _ = refined[i]
234
+ duration = end - start
235
+ sub_count = -(-int(duration) // smaller_secs) # ceil division
236
+ estimated_new += sub_count - 1 # replacing 1 range with sub_count
237
+ if estimated_new > max_ranges:
238
+ break
239
+
240
+ # Fire off all re-count RPCs in parallel with bounded concurrency
241
+ semaphore = asyncio.Semaphore(_MAX_CONCURRENT_COUNTS)
242
+
243
+ async def _recount(start: float, end: float, smaller_secs: int) -> list[tuple[float, float, int]]:
244
+ async with semaphore:
245
+ sub_req = api_pb2.AppCountLogsRequest(
246
+ app_id=app_id,
247
+ since=_seconds_to_timestamp(start),
248
+ until=_seconds_to_timestamp(end),
249
+ bucket_secs=smaller_secs,
250
+ source=filters.source,
251
+ function_id=filters.function_id,
252
+ function_call_id=filters.function_call_id,
253
+ task_id=filters.task_id,
254
+ sandbox_id=filters.sandbox_id,
255
+ search_text=filters.search_text,
256
+ )
257
+ sub_resp = await client.stub.AppCountLogs(sub_req)
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.
263
+ if sub_ranges:
264
+ s, _, c = sub_ranges[-1]
265
+ sub_ranges[-1] = (s, min(s + smaller_secs, end), c)
266
+ return sub_ranges
267
+
268
+ refine_set = {i for i, _ in to_refine}
269
+ tasks = [_recount(refined[i][0], refined[i][1], s) for i, s in to_refine]
270
+ iterations += 1
271
+ sub_results = await TaskContext.gather(*tasks)
272
+
273
+ # Rebuild the list, splicing in sub-ranges for refined entries
274
+ result_iter = iter(sub_results)
275
+ new_refined: list[tuple[float, float, int]] = []
276
+ for i, r in enumerate(refined):
277
+ if i in refine_set:
278
+ new_refined.extend(next(result_iter))
279
+ else:
280
+ new_refined.append(r)
281
+
282
+ refined = new_refined
283
+
284
+ return refined
285
+
286
+
287
+ async def _fetch_interval(
288
+ client: _Client,
289
+ app_id: str,
290
+ since: float,
291
+ until: float,
292
+ limit: int,
293
+ filters: LogsFilters,
294
+ ) -> list[api_pb2.TaskLogsBatch]:
295
+ """Fetch logs for a single time interval."""
296
+ req = api_pb2.AppFetchLogsRequest(
297
+ app_id=app_id,
298
+ since=_seconds_to_timestamp(since),
299
+ until=_seconds_to_timestamp(until),
300
+ limit=limit,
301
+ source=filters.source,
302
+ function_id=filters.function_id,
303
+ function_call_id=filters.function_call_id,
304
+ task_id=filters.task_id,
305
+ sandbox_id=filters.sandbox_id,
306
+ search_text=filters.search_text,
307
+ )
308
+ resp: api_pb2.AppFetchLogsResponse = await client.stub.AppFetchLogs(req)
309
+ return list(resp.batches)
310
+
311
+
312
+ async def tail_logs(
313
+ client: _Client,
314
+ app_id: str,
315
+ n: int,
316
+ *,
317
+ since: Optional[datetime] = None,
318
+ until: Optional[datetime] = None,
319
+ filters: Optional[LogsFilters] = None,
320
+ ) -> AsyncGenerator[api_pb2.TaskLogsBatch]:
321
+ """Fetch up to `n` of the most recent log entries for an app.
322
+
323
+ When `since` is provided, it is used as a hard floor (no progressive
324
+ widening). When omitted, starts with a short lookback window and widens
325
+ until `n` results are returned or all windows are exhausted. `until`
326
+ defaults to now.
327
+
328
+ `n` must not exceed _FETCH_LIMIT.
329
+ """
330
+ if filters is None:
331
+ filters = LogsFilters()
332
+ if n > _FETCH_LIMIT:
333
+ raise LogsFetchError(f"--tail value must not exceed {_FETCH_LIMIT}.")
334
+
335
+ now = datetime.now(timezone.utc)
336
+
337
+ if since is not None and since.tzinfo is None:
338
+ raise ValueError("since must be timezone-aware")
339
+ if until is not None and until.tzinfo is None:
340
+ raise ValueError("until must be timezone-aware")
341
+
342
+ until_ts = _datetime_to_timestamp(until) if until else _datetime_to_timestamp(now)
343
+
344
+ if since is not None:
345
+ # Explicit floor — single fetch, no widening.
346
+ effective_until = until or now
347
+ if effective_until - since > _MAX_FETCH_RANGE:
348
+ raise LogsFetchError(f"Time range cannot exceed {_MAX_FETCH_RANGE.days} days.")
349
+ req = api_pb2.AppFetchLogsRequest(
350
+ app_id=app_id,
351
+ since=_datetime_to_timestamp(since),
352
+ until=until_ts,
353
+ limit=n,
354
+ source=filters.source,
355
+ function_id=filters.function_id,
356
+ function_call_id=filters.function_call_id,
357
+ task_id=filters.task_id,
358
+ sandbox_id=filters.sandbox_id,
359
+ search_text=filters.search_text,
360
+ )
361
+ resp = await client.stub.AppFetchLogs(req)
362
+ for batch in resp.batches:
363
+ yield batch
364
+ return
365
+
366
+ # No explicit since — progressively widen the lookback.
367
+ anchor = until or now
368
+ for lookback in _TAIL_LOOKBACKS:
369
+ lookback_since = anchor - lookback
370
+ req = api_pb2.AppFetchLogsRequest(
371
+ app_id=app_id,
372
+ since=_datetime_to_timestamp(lookback_since),
373
+ until=until_ts,
374
+ limit=n,
375
+ source=filters.source,
376
+ function_id=filters.function_id,
377
+ function_call_id=filters.function_call_id,
378
+ task_id=filters.task_id,
379
+ sandbox_id=filters.sandbox_id,
380
+ search_text=filters.search_text,
381
+ )
382
+ resp = await client.stub.AppFetchLogs(req)
383
+
384
+ total_items = sum(len(b.items) for b in resp.batches)
385
+ if total_items >= n or lookback == _TAIL_LOOKBACKS[-1]:
386
+ for batch in resp.batches:
387
+ yield batch
388
+ return
389
+
390
+
391
+ async def fetch_logs(
392
+ client: _Client,
393
+ app_id: str,
394
+ since: datetime,
395
+ until: datetime,
396
+ *,
397
+ filters: Optional[LogsFilters] = None,
398
+ ) -> AsyncGenerator[api_pb2.TaskLogsBatch]:
399
+ """Fetch logs for an app over a time range using count-then-fetch strategy.
400
+
401
+ Yields TaskLogsBatch objects in chronological order.
402
+ """
403
+ if filters is None:
404
+ filters = LogsFilters()
405
+
406
+ if since.tzinfo is None:
407
+ raise ValueError("since must be timezone-aware")
408
+ if until.tzinfo is None:
409
+ raise ValueError("until must be timezone-aware")
410
+
411
+ if until - since > _MAX_FETCH_RANGE:
412
+ raise LogsFetchError(f"Time range cannot exceed {_MAX_FETCH_RANGE.days} days.")
413
+
414
+ # Phase 1: Count logs per bucket
415
+ bucket_secs = _pick_bucket_secs(since, until)
416
+ count_req = api_pb2.AppCountLogsRequest(
417
+ app_id=app_id,
418
+ since=_datetime_to_timestamp(since),
419
+ until=_datetime_to_timestamp(until),
420
+ bucket_secs=bucket_secs,
421
+ source=filters.source,
422
+ function_id=filters.function_id,
423
+ function_call_id=filters.function_call_id,
424
+ task_id=filters.task_id,
425
+ sandbox_id=filters.sandbox_id,
426
+ search_text=filters.search_text,
427
+ )
428
+ count_resp: api_pb2.AppCountLogsResponse = await client.stub.AppCountLogs(count_req)
429
+
430
+ ranges = _buckets_to_ranges(list(count_resp.buckets), bucket_secs)
431
+ total_logs = sum(count for _, _, count in ranges)
432
+ if total_logs == 0:
433
+ return
434
+
435
+ # Trim leading/trailing empty buckets so they don't consume refinement
436
+ # budget. Interior zeros are kept to prevent merging across gaps.
437
+ while ranges and ranges[0][2] == 0:
438
+ ranges.pop(0)
439
+ while ranges and ranges[-1][2] == 0:
440
+ ranges.pop()
441
+
442
+ # Phase 1b: Refine any ranges that exceed the per-fetch limit.
443
+ # Budget: at most _MAX_FETCHES intervals, each fetching up to _FETCH_LIMIT entries.
444
+ ranges = await _refine_dense_ranges(
445
+ client,
446
+ app_id,
447
+ ranges,
448
+ filters,
449
+ max_ranges=_MAX_FETCHES,
450
+ max_iterations=_MAX_REFINE_ITERATIONS,
451
+ )
452
+ fetch_error_message = "Too many logs to fetch in time range. Consider narrowing the range or adding filters."
453
+ # Check that all ranges fit within the per-fetch limit.
454
+ over_limit = [r for r in ranges if r[2] > _FETCH_LIMIT]
455
+ if over_limit:
456
+ raise LogsFetchError(fetch_error_message)
457
+
458
+ # Phase 2: Build intervals from non-empty ranges and fetch in parallel
459
+ since_secs = since.timestamp()
460
+ until_secs = until.timestamp()
461
+ intervals = _build_fetch_intervals(ranges)
462
+ # Clamp intervals to the requested time range. Bucket boundaries may
463
+ # extend beyond the user's since/until (e.g. an 86400s bucket could
464
+ # include up to a full extra day).
465
+ intervals = [(max(s, since_secs), min(u, until_secs)) for s, u in intervals]
466
+ intervals = [(s, u) for s, u in intervals if s < u]
467
+ if not intervals:
468
+ return
469
+
470
+ if len(intervals) > _MAX_FETCHES:
471
+ raise LogsFetchError(fetch_error_message)
472
+
473
+ # Fetch all intervals concurrently (bounded by semaphore), but yield
474
+ # results in interval order so logs stay chronological. Awaiting each
475
+ # task in sequence lets us stream batches to the caller as soon as all
476
+ # preceding intervals have completed.
477
+ semaphore = asyncio.Semaphore(_MAX_CONCURRENT_FETCHES)
478
+
479
+ async def _bounded_fetch(since_s: float, until_s: float) -> list[api_pb2.TaskLogsBatch]:
480
+ async with semaphore:
481
+ return await _fetch_interval(
482
+ client,
483
+ app_id,
484
+ since_s,
485
+ until_s,
486
+ _FETCH_LIMIT,
487
+ filters,
488
+ )
489
+
490
+ tasks = [asyncio.create_task(_bounded_fetch(s, u)) for s, u in intervals]
491
+
492
+ try:
493
+ for task in tasks:
494
+ for batch in await task:
495
+ yield batch
496
+ finally:
497
+ for task in tasks:
498
+ task.cancel()
@@ -298,13 +298,31 @@ class OutputManager(ABC):
298
298
  ...
299
299
 
300
300
  @abstractmethod
301
- async def put_log_content(self, log: "api_pb2.TaskLogs") -> None:
302
- """Process and display log content.
301
+ async def put_streaming_log(self, log: "api_pb2.TaskLogs", prefix: str = "") -> None:
302
+ """Process and display log content with immediate output.
303
+
304
+ Use this for streaming paths (--follow, image logs) where logs arrive
305
+ in real time and should be displayed immediately.
303
306
 
304
307
  Note: In RichOutputManager, log output is always displayed even when quiet mode
305
308
  is enabled. This is intentional - quiet mode suppresses progress indicators and
306
309
  status messages, but not actual log output from running functions/images.
307
310
  In contrast, DisabledOutputManager suppresses all output including logs.
311
+
312
+ prefix: optional string to prepend to each log line (e.g. object IDs).
313
+ """
314
+ ...
315
+
316
+ @abstractmethod
317
+ async def put_fetched_log(self, log: "api_pb2.TaskLogs", prefix: str = "") -> None:
318
+ """Process and display log content with batched output.
319
+
320
+ Use this for bulk historical log fetching where thousands of entries
321
+ are processed in a tight loop. Output is buffered internally and
322
+ flushed periodically to avoid per-line stdout overhead. Call
323
+ flush_lines() after the loop to drain any remaining buffered output.
324
+
325
+ prefix: optional string to prepend to each log line (e.g. object IDs).
308
326
  """
309
327
  ...
310
328
 
@@ -382,7 +400,7 @@ class OutputManager(ABC):
382
400
  - All print() output is suppressed, including stderr
383
401
  - Progress indicators (spinners, progress bars) are suppressed
384
402
  - Warnings (via show_warning) are still displayed
385
- - Log output (via put_log_content) is still displayed
403
+ - Log output (via put_streaming_log) is still displayed
386
404
  - Interactive/raw PTY content (via put_pty_content) is still forwarded to stdout
387
405
  - Error output (via print_error) is still displayed
388
406
 
@@ -516,7 +534,10 @@ class DisabledOutputManager(OutputManager):
516
534
  ) -> None:
517
535
  pass
518
536
 
519
- async def put_log_content(self, log: "api_pb2.TaskLogs") -> None:
537
+ async def put_streaming_log(self, log: "api_pb2.TaskLogs", prefix: str = "") -> None:
538
+ pass
539
+
540
+ async def put_fetched_log(self, log: "api_pb2.TaskLogs", prefix: str = "") -> None:
520
541
  pass
521
542
 
522
543
  def flush_lines(self) -> None:
@@ -64,6 +64,30 @@ async def stream_pty_shell_input(client: "_Client", exec_id: str, finish_event:
64
64
  await finish_event.wait()
65
65
 
66
66
 
67
+ _PREFIX_FIELD_GETTERS = {
68
+ "fu": lambda batch, log: batch.function_id,
69
+ "ta": lambda batch, log: batch.task_id,
70
+ "fc": lambda batch, log: log.function_call_id,
71
+ }
72
+
73
+
74
+ def _build_log_prefix(batch: api_pb2.TaskLogsBatch, log: api_pb2.TaskLogs, prefix_fields: list[str]) -> str:
75
+ """Build a prefix string from batch/log fields based on requested prefix types.
76
+
77
+ The order of prefix_fields controls the order of the output.
78
+ """
79
+ if not prefix_fields:
80
+ return ""
81
+ parts = []
82
+ for field in prefix_fields:
83
+ getter = _PREFIX_FIELD_GETTERS.get(field)
84
+ if getter:
85
+ value = getter(batch, log)
86
+ if value:
87
+ parts.append(value)
88
+ return " ".join(parts)
89
+
90
+
67
91
  async def get_app_logs_loop(
68
92
  client: "_Client",
69
93
  output_mgr: "OutputManager",
@@ -71,8 +95,15 @@ async def get_app_logs_loop(
71
95
  task_id: Optional[str] = None,
72
96
  sandbox_id: Optional[str] = None,
73
97
  follow: bool = True,
98
+ last_entry_id: str = "",
99
+ prefix_fields: Optional[list[str]] = None,
100
+ file_descriptor: "api_pb2.FileDescriptor.ValueType" = api_pb2.FILE_DESCRIPTOR_UNSPECIFIED,
101
+ function_id: str = "",
102
+ function_call_id: str = "",
103
+ search_text: str = "",
74
104
  ):
75
- last_log_batch_entry_id = ""
105
+ last_log_batch_entry_id = last_entry_id
106
+ _prefixes = prefix_fields or []
76
107
 
77
108
  pty_shell_finish_event: Optional[asyncio.Event] = None
78
109
  pty_shell_task_id: Optional[str] = None
@@ -112,10 +143,15 @@ async def get_app_logs_loop(
112
143
  else: # Ensure forward-compatible with new types.
113
144
  logger.debug(f"Received unrecognized progress type: {log.task_progress.progress_type}")
114
145
  elif log.data:
146
+ # Client-side search filter for Redis streaming (AppGetLogs
147
+ # doesn't support server-side search)
148
+ if search_text and search_text not in log.data:
149
+ return
115
150
  if pty_shell_finish_event:
116
151
  await output_mgr.put_pty_content(log)
117
152
  else:
118
- await output_mgr.put_log_content(log)
153
+ log_prefix = _build_log_prefix(log_batch, log, _prefixes)
154
+ await output_mgr.put_streaming_log(log, prefix=log_prefix)
119
155
 
120
156
  async def _get_logs():
121
157
  nonlocal last_log_batch_entry_id
@@ -127,6 +163,9 @@ async def get_app_logs_loop(
127
163
  sandbox_id=sandbox_id or "",
128
164
  timeout=55 if follow else 0,
129
165
  last_entry_id=last_log_batch_entry_id,
166
+ file_descriptor=file_descriptor,
167
+ function_id=function_id,
168
+ function_call_id=function_call_id,
130
169
  )
131
170
  log_batch: api_pb2.TaskLogsBatch
132
171
  async for log_batch in client.stub.AppGetLogs.unary_stream(request):