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.
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/PKG-INFO +1 -2
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/README.md +0 -1
- modal-1.3.6.dev9/modal/_logs.py +498 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_output/manager.py +25 -4
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_output/pty.py +41 -2
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_output/rich.py +111 -25
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/async_utils.py +17 -2
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/blob_utils.py +2 -2
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/grpc_utils.py +15 -5
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/app.py +183 -8
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/utils.py +69 -1
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/client.pyi +2 -2
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/exception.py +4 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/image.py +1 -1
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/volume.py +3 -3
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/PKG-INFO +1 -2
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/SOURCES.txt +1 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_version/__init__.py +1 -1
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/pyproject.toml +47 -1
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/LICENSE +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/__main__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_billing.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_clustered_functions.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_clustered_functions.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_container_entrypoint.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_functions.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_grpc_client.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_ipython.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_load_context.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_location.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_object.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_output/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_output/status.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_partial_function.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_resolver.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_resources.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/asgi.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/container_io_manager.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/container_io_manager.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/execution_context.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/execution_context.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/gpu_memory_snapshot.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/telemetry.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/user_code_event_loop.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_runtime/user_code_imports.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_serialization.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_server.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_traceback.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_tunnel.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_tunnel.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_type_manager.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/app_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/auth_token_manager.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/browser_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/bytes_io_segment_payload.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/deprecation.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/docker_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/function_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/git_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/grpc_testing.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/hash_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/http_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/jwt_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/logger.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/mount_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/name_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/package_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/pattern_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/rand_pb_testing.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/sandbox_fs_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/shell_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/task_command_router_client.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_utils/time_utils.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_vendor/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_vendor/a2wsgi_wsgi.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_vendor/cloudpickle.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_vendor/tblib.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_vendor/version.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/_watcher.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/app.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/app.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/billing.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/2023.12.312.txt +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/2023.12.txt +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/2024.04.txt +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/2024.10.txt +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/2025.06.txt +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/PREVIEW.txt +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/README.md +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/builder/base-images.json +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/call_graph.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/_download.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/_traceback.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/billing.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/changelog.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/cluster.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/config.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/container.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/dashboard.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/dict.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/entry_point.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/environment.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/import_refs.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/launch.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/network_file_system.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/profile.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/programs/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/programs/run_jupyter.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/programs/vscode.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/queues.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/run.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/secret.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/selector.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/shell.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/token.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cli/volume.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/client.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cloud_bucket_mount.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cloud_bucket_mount.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cls.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/cls.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/config.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/container_process.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/container_process.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/dict.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/dict.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/environments.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/environments.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/experimental/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/experimental/flash.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/experimental/flash.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/experimental/ipython.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/file_io.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/file_io.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/file_pattern_matcher.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/functions.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/functions.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/gpu.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/image.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/io_streams.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/io_streams.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/mount.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/mount.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/network_file_system.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/network_file_system.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/object.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/object.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/output.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/parallel_map.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/parallel_map.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/partial_function.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/partial_function.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/proxy.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/proxy.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/py.typed +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/queue.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/queue.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/retries.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/runner.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/runner.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/running_app.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/sandbox.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/sandbox.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/sandbox_fs.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/sandbox_fs.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/schedule.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/scheduler_placement.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/secret.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/secret.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/server.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/server.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/serving.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/serving.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/snapshot.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/snapshot.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/stream_type.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/token_flow.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/token_flow.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal/volume.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/dependency_links.txt +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/entry_points.txt +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/requires.txt +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal.egg-info/top_level.txt +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/gen_cli_docs.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/gen_cli_docs_main.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/gen_reference_docs.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/gen_reference_docs_main.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/mdmd/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/mdmd/mdmd.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_docs/mdmd/signatures.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/__init__.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/api_grpc.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/api_pb2.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/api_pb2.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/api_pb2_grpc.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/api_pb2_grpc.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/modal_api_grpc.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/py.typed +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/task_command_router_grpc.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/task_command_router_pb2.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/task_command_router_pb2.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/task_command_router_pb2_grpc.py +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_proto/task_command_router_pb2_grpc.pyi +0 -0
- {modal-1.3.6.dev7 → modal-1.3.6.dev9}/modal_version/__main__.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/modal/)
|
|
38
38
|
[](https://github.com/modal-labs/modal-client/blob/master/LICENSE)
|
|
39
|
-
[](https://github.com/modal-labs/modal-client/actions/workflows/ci-cd.yml)
|
|
40
39
|
[](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
|
[](https://pypi.org/project/modal/)
|
|
4
4
|
[](https://github.com/modal-labs/modal-client/blob/master/LICENSE)
|
|
5
|
-
[](https://github.com/modal-labs/modal-client/actions/workflows/ci-cd.yml)
|
|
6
5
|
[](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
|
|
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
|
|
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
|
|
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
|
-
|
|
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):
|