modAL 1.5.2__tar.gz → 1.5.2.dev0__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.5.2 → modal-1.5.2.dev0}/PKG-INFO +1 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/__init__.py +1 -2
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_billing.py +53 -2
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_environments.py +10 -7
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_function_variants.py +0 -4
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_functions.py +11 -3
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_image.py +9 -38
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/container_io_manager.py +1 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/blob_utils.py +0 -7
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/grpc_utils.py +17 -80
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/name_utils.py +2 -8
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/task_command_router_client.py +8 -24
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_workspace.py +35 -81
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/app.py +3 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/app.pyi +2 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/billing.py +1 -2
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/billing.pyi +28 -27
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/call_graph.py +30 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/_download.py +1 -2
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/_help.py +1 -7
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/changelog.py +7 -7
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/cluster.py +1 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/config.py +2 -2
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/container.py +14 -33
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/curl.py +5 -12
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/dict.py +5 -5
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/environment.py +14 -14
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/image.py +0 -104
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/network_file_system.py +6 -12
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/profile.py +1 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/queues.py +5 -5
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/secret.py +2 -2
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/shell.py +2 -6
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/volume.py +9 -17
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/workspace.py +0 -46
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/client.pyi +10 -2
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cls.py +4 -5
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cls.pyi +0 -4
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/config.py +1 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/dict.py +13 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/dict.pyi +24 -4
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/environments.pyi +17 -9
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/file_io.py +1 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/file_io.pyi +9 -9
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/functions.pyi +11 -12
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/network_file_system.pyi +7 -7
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/queue.py +13 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/queue.pyi +24 -4
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/sandbox.py +55 -215
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/sandbox.pyi +66 -313
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/sandbox_fs.py +64 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/sandbox_fs.pyi +117 -14
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/secret.py +13 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/secret.pyi +24 -4
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/volume.py +39 -22
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/volume.pyi +73 -34
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/workspace.py +0 -5
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/workspace.pyi +12 -140
- {modal-1.5.2 → modal-1.5.2.dev0}/modal.egg-info/PKG-INFO +1 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal.egg-info/SOURCES.txt +0 -1
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_docs/gen_cli_docs.py +7 -22
- modal-1.5.2.dev0/modal_docs/gen_reference_docs.py +177 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_docs/mdmd/mdmd.py +22 -129
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_docs/mdmd/signatures.py +0 -28
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/api_grpc.py +0 -160
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/api_pb2.py +970 -1133
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/api_pb2.pyi +42 -394
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/api_pb2_grpc.py +3 -333
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/api_pb2_grpc.pyi +8 -108
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/modal_api_grpc.py +0 -10
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/task_command_router_grpc.py +0 -16
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/task_command_router_pb2.py +41 -71
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/task_command_router_pb2.pyi +3 -40
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/task_command_router_pb2_grpc.py +0 -34
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/task_command_router_pb2_grpc.pyi +0 -12
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_version/__init__.py +1 -1
- modal-1.5.2/modal/types.py +0 -289
- modal-1.5.2/modal_docs/gen_reference_docs.py +0 -361
- {modal-1.5.2 → modal-1.5.2.dev0}/LICENSE +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/README.md +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/__main__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_clustered_functions.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_clustered_functions.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_container_entrypoint.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_grpc_client.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_ipython.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_load_context.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_location.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_logs.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_object.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_output/__init__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_output/manager.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_output/pty.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_output/rich.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_output/status.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_partial_function.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_resolver.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_resources.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/__init__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/asgi.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/container_io_manager.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/execution_context.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/execution_context.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/gpu_memory_snapshot.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/task_lifecycle_manager.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/task_lifecycle_manager.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/telemetry.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/user_code_event_loop.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_runtime/user_code_imports.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_serialization.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_server.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_traceback.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_tunnel.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_tunnel.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_type_manager.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/__init__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/app_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/async_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/auth_token_manager.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/browser_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/bytes_io_segment_payload.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/curl_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/deprecation.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/docker_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/function_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/git_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/grpc_testing.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/hash_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/http_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/jwt_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/logger.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/mount_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/package_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/pattern_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/proxy_support.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/rand_pb_testing.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/sandbox_fs_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/shell_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_utils/time_utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_vendor/__init__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_vendor/a2wsgi_wsgi.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_vendor/cloudpickle.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_vendor/tblib.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_vendor/version.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/_watcher.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/builder/2023.12.312.txt +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/builder/2023.12.txt +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/builder/2024.04.txt +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/builder/2024.10.txt +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/builder/2025.06.txt +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/builder/PREVIEW.txt +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/builder/README.md +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/builder/base-images.json +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/__init__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/_traceback.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/app.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/billing.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/bootstrap.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/dashboard.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/endpoint.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/entry_point.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/import_refs.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/launch.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/logo.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/programs/__init__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/programs/run_jupyter.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/programs/vscode.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/run.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/selector.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/skills.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/token.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cli/utils.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/client.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cloud_bucket_mount.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/cloud_bucket_mount.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/container_process.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/container_process.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/environments.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/exception.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/experimental/__init__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/experimental/flash.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/experimental/flash.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/experimental/ipython.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/file_pattern_matcher.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/functions.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/image.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/image.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/io_streams.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/io_streams.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/mount.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/mount.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/network_file_system.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/object.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/object.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/output.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/parallel_map.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/parallel_map.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/partial_function.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/partial_function.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/proxy.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/proxy.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/py.typed +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/retries.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/runner.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/runner.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/running_app.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/schedule.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/scheduler_placement.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/server.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/server.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/serving.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/serving.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/skills/modal/SKILL.md +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/snapshot.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/snapshot.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/stream_type.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/token_flow.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal/token_flow.pyi +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal.egg-info/dependency_links.txt +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal.egg-info/entry_points.txt +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal.egg-info/requires.txt +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal.egg-info/top_level.txt +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_docs/__init__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_docs/gen_cli_docs_main.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_docs/gen_reference_docs_main.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_docs/mdmd/__init__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_docs/mdmd/types.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/__init__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_proto/py.typed +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/modal_version/__main__.py +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/pyproject.toml +0 -0
- {modal-1.5.2 → modal-1.5.2.dev0}/setup.cfg +0 -0
|
@@ -9,7 +9,7 @@ if sys.version_info[:2] >= (3, 15):
|
|
|
9
9
|
from modal_version import __version__
|
|
10
10
|
|
|
11
11
|
try:
|
|
12
|
-
from . import billing
|
|
12
|
+
from . import billing
|
|
13
13
|
from ._runtime.execution_context import current_function_call_id, current_input_id, interact, is_local
|
|
14
14
|
from ._tunnel import Tunnel, forward
|
|
15
15
|
from .app import App
|
|
@@ -100,7 +100,6 @@ __all__ = [
|
|
|
100
100
|
"interact",
|
|
101
101
|
"method",
|
|
102
102
|
"parameter",
|
|
103
|
-
"types",
|
|
104
103
|
"web_endpoint",
|
|
105
104
|
"web_server",
|
|
106
105
|
"wsgi_app",
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# Copyright Modal Labs 2025
|
|
2
|
-
from
|
|
2
|
+
from dataclasses import FrozenInstanceError, dataclass
|
|
3
|
+
from datetime import datetime, timezone
|
|
3
4
|
from decimal import Decimal
|
|
4
|
-
from typing import TypedDict
|
|
5
|
+
from typing import Any, Iterable, TypedDict
|
|
6
|
+
|
|
7
|
+
from modal_proto import api_pb2
|
|
5
8
|
|
|
6
9
|
from ._utils.deprecation import deprecation_warning
|
|
7
10
|
from .client import _Client
|
|
@@ -16,6 +19,54 @@ class WorkspaceBillingReportItem(TypedDict):
|
|
|
16
19
|
tags: dict[str, str]
|
|
17
20
|
|
|
18
21
|
|
|
22
|
+
@dataclass(slots=True, frozen=True)
|
|
23
|
+
class BillingReportItem:
|
|
24
|
+
object_id: str
|
|
25
|
+
description: str
|
|
26
|
+
environment_name: str
|
|
27
|
+
interval_start: datetime
|
|
28
|
+
cost: Decimal
|
|
29
|
+
cost_by_resource: dict[str, Decimal]
|
|
30
|
+
tags: dict[str, str]
|
|
31
|
+
|
|
32
|
+
def __getitem__(self, key: str) -> Any:
|
|
33
|
+
"""mdmd:ignore"""
|
|
34
|
+
if key not in self.__slots__:
|
|
35
|
+
raise KeyError(key)
|
|
36
|
+
|
|
37
|
+
return getattr(self, key)
|
|
38
|
+
|
|
39
|
+
def __setitem__(self, key: str, _: Any):
|
|
40
|
+
"""mdmd:ignore"""
|
|
41
|
+
raise FrozenInstanceError(f"cannot assign to field {key!r}")
|
|
42
|
+
|
|
43
|
+
def keys(self) -> Iterable[str]:
|
|
44
|
+
"""mdmd:ignore"""
|
|
45
|
+
yield from self.__slots__
|
|
46
|
+
|
|
47
|
+
def values(self) -> Iterable[Any]:
|
|
48
|
+
"""mdmd:ignore"""
|
|
49
|
+
for k in self.__slots__:
|
|
50
|
+
yield getattr(self, k)
|
|
51
|
+
|
|
52
|
+
def items(self) -> Iterable[tuple[str, Any]]:
|
|
53
|
+
"""mdmd:ignore"""
|
|
54
|
+
for k in self.__slots__:
|
|
55
|
+
yield k, getattr(self, k)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def _from_proto(cls, pb_item: api_pb2.WorkspaceBillingReportItem) -> "BillingReportItem":
|
|
59
|
+
return cls(
|
|
60
|
+
object_id=pb_item.object_id,
|
|
61
|
+
description=pb_item.description,
|
|
62
|
+
environment_name=pb_item.environment_name,
|
|
63
|
+
interval_start=pb_item.interval.ToDatetime().replace(tzinfo=timezone.utc),
|
|
64
|
+
cost=Decimal(pb_item.cost),
|
|
65
|
+
cost_by_resource={k: Decimal(v) for k, v in pb_item.cost_by_resource.items()},
|
|
66
|
+
tags=dict(pb_item.tags),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
19
70
|
async def _workspace_billing_report(
|
|
20
71
|
*,
|
|
21
72
|
start: datetime, # Start of the report, inclusive
|
|
@@ -13,6 +13,7 @@ from synchronicity import classproperty
|
|
|
13
13
|
|
|
14
14
|
from modal_proto import api_pb2
|
|
15
15
|
|
|
16
|
+
from ._billing import BillingReportItem
|
|
16
17
|
from ._load_context import LoadContext
|
|
17
18
|
from ._object import _Object
|
|
18
19
|
from ._resolver import Resolver
|
|
@@ -20,7 +21,6 @@ from ._utils.name_utils import check_environment_name
|
|
|
20
21
|
from .client import _Client
|
|
21
22
|
from .config import config, logger
|
|
22
23
|
from .exception import InvalidError, WorkspaceManagementError
|
|
23
|
-
from .types import BillingReportItem
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
@dataclass(frozen=True)
|
|
@@ -103,6 +103,9 @@ class _EnvironmentManager:
|
|
|
103
103
|
await client.stub.EnvironmentDelete(api_pb2.EnvironmentDeleteRequest(name=name))
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
MemberRole = Literal["viewer", "contributor"]
|
|
107
|
+
|
|
108
|
+
|
|
106
109
|
def _role_to_proto(role: str) -> "api_pb2.EnvironmentRole.ValueType":
|
|
107
110
|
match role:
|
|
108
111
|
case "viewer":
|
|
@@ -113,7 +116,7 @@ def _role_to_proto(role: str) -> "api_pb2.EnvironmentRole.ValueType":
|
|
|
113
116
|
raise InvalidError(f"Invalid Environment role: {role!r} (expected 'viewer' or 'contributor')")
|
|
114
117
|
|
|
115
118
|
|
|
116
|
-
def _role_from_proto(proto_value: int) ->
|
|
119
|
+
def _role_from_proto(proto_value: int) -> MemberRole:
|
|
117
120
|
match proto_value:
|
|
118
121
|
case api_pb2.ENVIRONMENT_ROLE_VIEWER:
|
|
119
122
|
return "viewer"
|
|
@@ -134,7 +137,7 @@ class _EnvironmentMembersManager:
|
|
|
134
137
|
"""mdmd:hidden"""
|
|
135
138
|
self._environment = environment
|
|
136
139
|
|
|
137
|
-
async def list(self) -> dict[Literal["users", "service_users"], dict[str,
|
|
140
|
+
async def list(self) -> dict[Literal["users", "service_users"], dict[str, MemberRole]]:
|
|
138
141
|
"""Return the members of a restricted Environment with their roles.
|
|
139
142
|
|
|
140
143
|
**Examples:**
|
|
@@ -152,8 +155,8 @@ class _EnvironmentMembersManager:
|
|
|
152
155
|
req = api_pb2.EnvironmentGetManagedRequest(environment_id=self._environment.object_id)
|
|
153
156
|
resp = await self._environment.client.stub.EnvironmentGetManaged(req)
|
|
154
157
|
|
|
155
|
-
users: dict[str,
|
|
156
|
-
service_users: dict[str,
|
|
158
|
+
users: dict[str, MemberRole] = {}
|
|
159
|
+
service_users: dict[str, MemberRole] = {}
|
|
157
160
|
for principal in resp.principal_roles:
|
|
158
161
|
role = _role_from_proto(principal.role)
|
|
159
162
|
if principal.user_id:
|
|
@@ -166,8 +169,8 @@ class _EnvironmentMembersManager:
|
|
|
166
169
|
async def update(
|
|
167
170
|
self,
|
|
168
171
|
*,
|
|
169
|
-
users: Mapping[str,
|
|
170
|
-
service_users: Mapping[str,
|
|
172
|
+
users: Mapping[str, MemberRole] | None = None,
|
|
173
|
+
service_users: Mapping[str, MemberRole] | None = None,
|
|
171
174
|
) -> None:
|
|
172
175
|
"""Add or modify roles for members of a restricted Environment.
|
|
173
176
|
|
|
@@ -51,7 +51,6 @@ class _FunctionOptions:
|
|
|
51
51
|
target_concurrent_inputs: int | None = None
|
|
52
52
|
batch_max_size: int | None = None
|
|
53
53
|
batch_wait_ms: int | None = None
|
|
54
|
-
routing_region: str | None = None
|
|
55
54
|
|
|
56
55
|
@classmethod
|
|
57
56
|
def new(
|
|
@@ -74,7 +73,6 @@ class _FunctionOptions:
|
|
|
74
73
|
target_concurrent_inputs: int | None = None,
|
|
75
74
|
batch_max_size: int | None = None,
|
|
76
75
|
batch_wait_ms: int | None = None,
|
|
77
|
-
routing_region: str | None = None,
|
|
78
76
|
) -> "_FunctionOptions":
|
|
79
77
|
"""Internal constructor that validates and normalizes public parameters."""
|
|
80
78
|
retry_policy = _parse_retries(retries)
|
|
@@ -121,7 +119,6 @@ class _FunctionOptions:
|
|
|
121
119
|
target_concurrent_inputs=target_concurrent_inputs,
|
|
122
120
|
batch_max_size=batch_max_size,
|
|
123
121
|
batch_wait_ms=batch_wait_ms,
|
|
124
|
-
routing_region=routing_region,
|
|
125
122
|
)
|
|
126
123
|
|
|
127
124
|
def merge_options(self, new_options: "_FunctionOptions") -> "_FunctionOptions":
|
|
@@ -185,7 +182,6 @@ class _FunctionOptions:
|
|
|
185
182
|
batch_linger_ms=self.batch_wait_ms,
|
|
186
183
|
scheduler_placement=self.scheduler_placement,
|
|
187
184
|
cloud_provider_str=self.cloud,
|
|
188
|
-
routing_region=self.routing_region,
|
|
189
185
|
)
|
|
190
186
|
|
|
191
187
|
|
|
@@ -90,7 +90,6 @@ from .proxy import _Proxy
|
|
|
90
90
|
from .retries import Retries, RetryManager
|
|
91
91
|
from .schedule import Schedule
|
|
92
92
|
from .secret import _Secret
|
|
93
|
-
from .types import FunctionStats
|
|
94
93
|
from .volume import _Volume, _volume_to_mount_proto
|
|
95
94
|
|
|
96
95
|
if TYPE_CHECKING:
|
|
@@ -542,6 +541,17 @@ class _InputPlaneInvocation:
|
|
|
542
541
|
return [("x-modal-input-plane-region", input_plane_region), ("x-modal-auth-token", token)]
|
|
543
542
|
|
|
544
543
|
|
|
544
|
+
# Wrapper type for api_pb2.FunctionStats
|
|
545
|
+
@dataclass(frozen=True)
|
|
546
|
+
class FunctionStats:
|
|
547
|
+
"""Simple data structure storing stats for a running function."""
|
|
548
|
+
|
|
549
|
+
backlog: int
|
|
550
|
+
num_total_runners: int
|
|
551
|
+
num_running_inputs: int
|
|
552
|
+
input_headroom: int
|
|
553
|
+
|
|
554
|
+
|
|
545
555
|
@dataclass
|
|
546
556
|
class _FunctionSpec:
|
|
547
557
|
"""
|
|
@@ -1509,7 +1519,6 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
|
|
|
1509
1519
|
timeout: int | None = None,
|
|
1510
1520
|
region: str | Sequence[str] | None = None,
|
|
1511
1521
|
cloud: str | None = None,
|
|
1512
|
-
routing_region: str | None = None,
|
|
1513
1522
|
) -> "_Function[P, ReturnType, OriginalReturnType]":
|
|
1514
1523
|
"""Dynamically override the static Function configuration with invocation-specific values.
|
|
1515
1524
|
|
|
@@ -1558,7 +1567,6 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
|
|
|
1558
1567
|
timeout=timeout,
|
|
1559
1568
|
region=region,
|
|
1560
1569
|
cloud=cloud,
|
|
1561
|
-
routing_region=routing_region,
|
|
1562
1570
|
)
|
|
1563
1571
|
|
|
1564
1572
|
return self._apply_dynamic_config(options, "with_options")
|
|
@@ -117,34 +117,13 @@ def _validate_image_tag(tag: str) -> None:
|
|
|
117
117
|
check_object_name(tag, "Image tag")
|
|
118
118
|
|
|
119
119
|
|
|
120
|
-
def _parse_named_image_ref(name: str) ->
|
|
121
|
-
"""Parse an image reference, returning (namespace_prefix, name_tag).
|
|
122
|
-
|
|
123
|
-
If the name contains a '/', the part before the last '/' is extracted as
|
|
124
|
-
a namespace prefix (intended for environment/name or workspace/env/name
|
|
125
|
-
syntax). The actual image name (after the last '/') is validated as a
|
|
126
|
-
standard object name.
|
|
127
|
-
|
|
128
|
-
Returns a tuple of (prefix, "full_name:tag") where prefix is empty string
|
|
129
|
-
if no '/' is present.
|
|
130
|
-
"""
|
|
120
|
+
def _parse_named_image_ref(name: str) -> str:
|
|
131
121
|
image_name, sep, tag = name.partition(":")
|
|
132
122
|
if not sep:
|
|
133
123
|
tag = "latest"
|
|
134
|
-
|
|
135
|
-
prefix = ""
|
|
136
|
-
if "/" in image_name:
|
|
137
|
-
prefix, image_name = image_name.rsplit("/", 1)
|
|
138
|
-
if not prefix:
|
|
139
|
-
raise InvalidError("Invalid Image name: '/' prefix must be non-empty.")
|
|
140
|
-
if not image_name:
|
|
141
|
-
raise InvalidError("Invalid Image name: name after '/' must be non-empty.")
|
|
142
|
-
|
|
143
124
|
_validate_image_name(image_name)
|
|
144
125
|
_validate_image_tag(tag)
|
|
145
|
-
|
|
146
|
-
full_name = f"{prefix}/{image_name}" if prefix else image_name
|
|
147
|
-
return prefix, f"{full_name}:{tag}"
|
|
126
|
+
return f"{image_name}:{tag}"
|
|
148
127
|
|
|
149
128
|
|
|
150
129
|
def _validate_python_version(
|
|
@@ -1043,7 +1022,7 @@ class _Image(_Object, type_prefix="im"):
|
|
|
1043
1022
|
```
|
|
1044
1023
|
"""
|
|
1045
1024
|
if app.app_id is None:
|
|
1046
|
-
raise InvalidError("App has not been initialized yet. Use the
|
|
1025
|
+
raise InvalidError("App has not been initialized yet. Use the content manager `app.run()` or `App.lookup`")
|
|
1047
1026
|
|
|
1048
1027
|
resolver = Resolver()
|
|
1049
1028
|
async with TaskContext() as tc:
|
|
@@ -2944,15 +2923,12 @@ class _Image(_Object, type_prefix="im"):
|
|
|
2944
2923
|
...
|
|
2945
2924
|
```
|
|
2946
2925
|
"""
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
if namespace_prefix and environment_name:
|
|
2950
|
-
raise InvalidError("Cannot specify 'environment_name' when the image name contains a '/'.")
|
|
2926
|
+
tag = _parse_named_image_ref(name)
|
|
2951
2927
|
|
|
2952
2928
|
async def _load(self: _Image, resolver: Resolver, load_context: LoadContext, existing_object_id: str | None):
|
|
2953
2929
|
req = api_pb2.ImageGetByTagRequest(
|
|
2954
2930
|
tag=tag,
|
|
2955
|
-
environment_name=
|
|
2931
|
+
environment_name=load_context.environment_name,
|
|
2956
2932
|
)
|
|
2957
2933
|
response = await load_context.client.stub.ImageGetByTag(req)
|
|
2958
2934
|
self._hydrate(response.image_id, load_context.client, None)
|
|
@@ -2987,25 +2963,20 @@ class _Image(_Object, type_prefix="im"):
|
|
|
2987
2963
|
image.publish("my-image-with-numpy:v1")
|
|
2988
2964
|
```
|
|
2989
2965
|
"""
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
if namespace_prefix:
|
|
2993
|
-
if environment_name:
|
|
2994
|
-
raise InvalidError("Cannot specify 'environment_name' when the image name contains a '/'.")
|
|
2995
|
-
resolved_env = ""
|
|
2996
|
-
else:
|
|
2997
|
-
resolved_env = environment_name or config.get("environment") or ""
|
|
2966
|
+
tag = _parse_named_image_ref(name)
|
|
2998
2967
|
|
|
2999
2968
|
if self._object_id is None:
|
|
3000
2969
|
raise InvalidError("Cannot publish an image that has not been created yet. Call `.build()` first.")
|
|
3001
2970
|
|
|
3002
2971
|
_client = client or await _Client.from_env()
|
|
3003
2972
|
|
|
2973
|
+
resolved_env = environment_name or config.get("environment") or ""
|
|
2974
|
+
|
|
3004
2975
|
await _client.stub.ImagePublish(
|
|
3005
2976
|
api_pb2.ImagePublishRequest(
|
|
3006
2977
|
image_id=self._object_id,
|
|
3007
2978
|
environment_name=resolved_env,
|
|
3008
|
-
|
|
2979
|
+
is_public=False,
|
|
3009
2980
|
tag=tag,
|
|
3010
2981
|
)
|
|
3011
2982
|
)
|
|
@@ -264,7 +264,7 @@ class IOContext:
|
|
|
264
264
|
if not inspect.isasyncgen(expected_async_gen):
|
|
265
265
|
raise InvalidError(f"Async generator function returned value of type {type(expected_async_gen)}")
|
|
266
266
|
|
|
267
|
-
async with aclosing(
|
|
267
|
+
async with aclosing(expected_async_gen) as gen:
|
|
268
268
|
async for result in gen:
|
|
269
269
|
yield result
|
|
270
270
|
logger.debug(f"Finished generator input {self.input_ids}")
|
|
@@ -138,13 +138,6 @@ async def _upload_to_s3_url(
|
|
|
138
138
|
text = await resp.text()
|
|
139
139
|
except Exception:
|
|
140
140
|
text = "<no body>"
|
|
141
|
-
|
|
142
|
-
if "The Content-MD5 you specified did not match what we received." in text:
|
|
143
|
-
raise ExecutionError(
|
|
144
|
-
"Hash mismatch during upload. This may be caused by files changing while an upload is in "
|
|
145
|
-
"progress. Please try again."
|
|
146
|
-
)
|
|
147
|
-
|
|
148
141
|
raise ExecutionError(f"Put to url {upload_url} failed with status {resp.status}: {text}")
|
|
149
142
|
|
|
150
143
|
# client side ETag checksum verification
|
|
@@ -31,7 +31,7 @@ from modal_version import __version__
|
|
|
31
31
|
|
|
32
32
|
from .._traceback import suppress_tb_frame
|
|
33
33
|
from ..config import config
|
|
34
|
-
from .async_utils import
|
|
34
|
+
from .async_utils import retry
|
|
35
35
|
from .logger import logger
|
|
36
36
|
|
|
37
37
|
RequestType = TypeVar("RequestType", bound=Message)
|
|
@@ -163,10 +163,8 @@ RETRYABLE_GRPC_STATUS_CODES = [
|
|
|
163
163
|
Status.UNKNOWN,
|
|
164
164
|
]
|
|
165
165
|
SERVER_RETRY_WARNING_TIME_INTERVAL = 30.0
|
|
166
|
+
# Initial per-attempt timeout for TCP/TLS handshake in connect_channel.
|
|
166
167
|
DEFAULT_MAX_RETRIES = 3
|
|
167
|
-
GRPC_KEEPALIVE_TIME_SECS = 30.0
|
|
168
|
-
GRPC_KEEPALIVE_TIMEOUT_SECS = 10.0
|
|
169
|
-
GRPC_WINDOW_SIZE = 64 * 1024 * 1024 # 64 MiB
|
|
170
168
|
|
|
171
169
|
|
|
172
170
|
@dataclass
|
|
@@ -192,7 +190,14 @@ class ConnectionManager:
|
|
|
192
190
|
|
|
193
191
|
async def get_or_create_channel(self, server_url: str) -> grpclib.client.Channel:
|
|
194
192
|
if server_url not in self._channels:
|
|
195
|
-
self._channels[server_url] =
|
|
193
|
+
self._channels[server_url] = create_channel(
|
|
194
|
+
server_url,
|
|
195
|
+
self._metadata,
|
|
196
|
+
)
|
|
197
|
+
try:
|
|
198
|
+
await connect_channel(self._channels[server_url])
|
|
199
|
+
except (OSError, asyncio.TimeoutError) as exc:
|
|
200
|
+
raise ConnectionError("Could not connect to the Modal server.") from exc
|
|
196
201
|
return self._channels[server_url]
|
|
197
202
|
|
|
198
203
|
def close(self):
|
|
@@ -254,21 +259,6 @@ class CustomProtoStatusDetailsCodec(StatusDetailsCodecBase):
|
|
|
254
259
|
custom_detail_codec = CustomProtoStatusDetailsCodec()
|
|
255
260
|
|
|
256
261
|
|
|
257
|
-
def create_channel_config() -> grpclib.config.Configuration:
|
|
258
|
-
"""Shared grpclib channel settings for Modal client connections.
|
|
259
|
-
|
|
260
|
-
HTTP/2 keepalive probes keep idle transports warm through stateful
|
|
261
|
-
middleboxes and surface dead connections before retries reuse them.
|
|
262
|
-
"""
|
|
263
|
-
return grpclib.config.Configuration(
|
|
264
|
-
_keepalive_time=GRPC_KEEPALIVE_TIME_SECS,
|
|
265
|
-
_keepalive_timeout=GRPC_KEEPALIVE_TIMEOUT_SECS,
|
|
266
|
-
_keepalive_permit_without_calls=True,
|
|
267
|
-
http2_connection_window_size=GRPC_WINDOW_SIZE,
|
|
268
|
-
http2_stream_window_size=GRPC_WINDOW_SIZE,
|
|
269
|
-
)
|
|
270
|
-
|
|
271
|
-
|
|
272
262
|
def create_channel(
|
|
273
263
|
server_url: str,
|
|
274
264
|
metadata: dict[str, str] = {},
|
|
@@ -280,7 +270,10 @@ def create_channel(
|
|
|
280
270
|
o = urllib.parse.urlparse(server_url)
|
|
281
271
|
|
|
282
272
|
channel: grpclib.client.Channel
|
|
283
|
-
config =
|
|
273
|
+
config = grpclib.config.Configuration(
|
|
274
|
+
http2_connection_window_size=64 * 1024 * 1024, # 64 MiB
|
|
275
|
+
http2_stream_window_size=64 * 1024 * 1024, # 64 MiB
|
|
276
|
+
)
|
|
284
277
|
|
|
285
278
|
if o.scheme == "unix":
|
|
286
279
|
channel = grpclib.client.Channel(path=o.path, config=config, status_details_codec=custom_detail_codec)
|
|
@@ -303,12 +296,6 @@ def create_channel(
|
|
|
303
296
|
for k, v in metadata.items():
|
|
304
297
|
event.metadata[k] = v
|
|
305
298
|
|
|
306
|
-
if o.hostname is not None:
|
|
307
|
-
# GRPC actually injects a :authority pseudo-header containing the hostname.
|
|
308
|
-
# But this gets stripped out by the C/C++ extension server-side and is hard to fix.
|
|
309
|
-
# So let's just add a separate header we can parse easily.
|
|
310
|
-
event.metadata["x-modal-host"] = o.hostname
|
|
311
|
-
|
|
312
299
|
idempotency_key = typing.cast(Optional[str], event.metadata.get("x-idempotency-key"))
|
|
313
300
|
if idempotency_key is None:
|
|
314
301
|
logger.debug(f"Sending request to {event.method_name}")
|
|
@@ -322,59 +309,9 @@ def create_channel(
|
|
|
322
309
|
|
|
323
310
|
# With 18 attempts, the max delays between calls is: 0.1 + 0.2 + 0.4 + 0.8 + 1.6 + 3.2 + 5*11 ~ 62
|
|
324
311
|
@retry(n_attempts=18, base_delay=0.1, attempt_timeout=10.0, max_delay=5.0, total_timeout=63.0)
|
|
325
|
-
async def
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
stagger_delay: float = 1.0,
|
|
329
|
-
) -> grpclib.client.Channel:
|
|
330
|
-
"""Create a connected channel, supporting comma-separated fallback URLs.
|
|
331
|
-
|
|
332
|
-
`server_url` may be a single URL or a comma-separated list of URLs. The connection attempts
|
|
333
|
-
are raced against each other and the first one to connect wins, which lets the client fail
|
|
334
|
-
over to a secondary endpoint when the primary is unreachable (e.g. a DNS outage on the
|
|
335
|
-
primary's domain).
|
|
336
|
-
|
|
337
|
-
Attempts are staggered so earlier URLs (e.g. the primary) get a head start and are preferred
|
|
338
|
-
when reachable: the candidate at index `i` waits up to `i * stagger_delay` seconds before
|
|
339
|
-
starting. That wait is cut short as soon as the previous candidate *fails*, so a fast failure
|
|
340
|
-
falls over to the next URL immediately instead of waiting out the full stagger. Losing attempts
|
|
341
|
-
are cancelled and their channels closed.
|
|
342
|
-
"""
|
|
343
|
-
urls = [url.strip() for url in server_url.split(",")]
|
|
344
|
-
|
|
345
|
-
# Set when a candidate's connection attempt fails, letting the next candidate start immediately
|
|
346
|
-
# instead of waiting out its stagger delay.
|
|
347
|
-
failed = [asyncio.Event() for _ in urls]
|
|
348
|
-
|
|
349
|
-
async def connect(index: int, url: str) -> grpclib.client.Channel:
|
|
350
|
-
if index > 0:
|
|
351
|
-
# Stagger the start so earlier URLs are preferred, but start early if the previous
|
|
352
|
-
# candidate has already failed.
|
|
353
|
-
try:
|
|
354
|
-
await asyncio.wait_for(failed[index - 1].wait(), timeout=index * stagger_delay)
|
|
355
|
-
except asyncio.TimeoutError:
|
|
356
|
-
pass
|
|
357
|
-
channel = create_channel(url, metadata)
|
|
358
|
-
try:
|
|
359
|
-
await channel.__connect__()
|
|
360
|
-
except BaseException:
|
|
361
|
-
# Close the channel of a losing/cancelled attempt so its socket doesn't leak.
|
|
362
|
-
channel.close()
|
|
363
|
-
failed[index].set()
|
|
364
|
-
raise
|
|
365
|
-
return channel
|
|
366
|
-
|
|
367
|
-
first_exc: BaseException | None = None
|
|
368
|
-
async with TaskContext() as tc:
|
|
369
|
-
tasks = [tc.create_task(connect(i, url)) for i, url in enumerate(urls)]
|
|
370
|
-
for attempt in asyncio.as_completed(tasks):
|
|
371
|
-
try:
|
|
372
|
-
return await attempt
|
|
373
|
-
except Exception as exc:
|
|
374
|
-
if first_exc is None:
|
|
375
|
-
first_exc = exc
|
|
376
|
-
|
|
377
|
-
raise ConnectionError("Could not connect to the Modal server.") from first_exc
|
|
312
|
+
async def connect_channel(channel: grpclib.client.Channel):
|
|
313
|
+
"""Connect to socket and raise exceptions when there is a connection issue."""
|
|
314
|
+
await channel.__connect__()
|
|
378
315
|
|
|
379
316
|
|
|
380
317
|
if typing.TYPE_CHECKING:
|
|
@@ -29,13 +29,7 @@ def is_valid_object_name(name: str) -> bool:
|
|
|
29
29
|
|
|
30
30
|
def is_valid_environment_name(name: str) -> bool:
|
|
31
31
|
# first char is alnum, the rest allows other chars
|
|
32
|
-
|
|
33
|
-
# to environments can be resolved by either name or ID unambiguously.
|
|
34
|
-
return (
|
|
35
|
-
len(name) <= 64
|
|
36
|
-
and re.match(r"^[a-zA-Z0-9][a-zA-Z0-9-_.]+$", name) is not None
|
|
37
|
-
and not name.lower().startswith("en-")
|
|
38
|
-
)
|
|
32
|
+
return len(name) <= 64 and re.match(r"^[a-zA-Z0-9][a-zA-Z0-9-_.]+$", name) is not None
|
|
39
33
|
|
|
40
34
|
|
|
41
35
|
def is_valid_tag(tag: str, max_length: int = 50) -> bool:
|
|
@@ -72,7 +66,7 @@ def check_environment_name(name: str) -> None:
|
|
|
72
66
|
f"Invalid Environment name: '{name}'."
|
|
73
67
|
"\n\nEnvironment names can only start with alphanumeric characters,"
|
|
74
68
|
" may contain only alphanumeric characters, dashes, periods, and underscores,"
|
|
75
|
-
" must be shorter than 64 characters
|
|
69
|
+
" and must be shorter than 64 characters."
|
|
76
70
|
)
|
|
77
71
|
if not is_valid_environment_name(name):
|
|
78
72
|
raise InvalidError(message)
|
|
@@ -13,6 +13,7 @@ from contextlib import suppress
|
|
|
13
13
|
from typing import TypeVar
|
|
14
14
|
|
|
15
15
|
import grpclib.client
|
|
16
|
+
import grpclib.config
|
|
16
17
|
from grpclib import GRPCError, Status
|
|
17
18
|
from grpclib.exceptions import StreamTerminatedError
|
|
18
19
|
|
|
@@ -22,7 +23,7 @@ from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
|
|
|
22
23
|
from modal_proto.task_command_router_grpc import TaskCommandRouterStub
|
|
23
24
|
|
|
24
25
|
from .._grpc_client import grpc_error_converter
|
|
25
|
-
from .._utils.grpc_utils import ModalChannel
|
|
26
|
+
from .._utils.grpc_utils import ModalChannel
|
|
26
27
|
from .async_utils import aclosing, retry
|
|
27
28
|
from .grpc_utils import RETRYABLE_GRPC_STATUS_CODES
|
|
28
29
|
|
|
@@ -31,8 +32,8 @@ from .grpc_utils import RETRYABLE_GRPC_STATUS_CODES
|
|
|
31
32
|
async def _connect_channel(channel: grpclib.client.Channel):
|
|
32
33
|
"""Connect to the command router channel.
|
|
33
34
|
|
|
34
|
-
Uses a longer retry budget than grpc_utils.
|
|
35
|
-
|
|
35
|
+
Uses a longer retry budget than grpc_utils.connect_channel. In rare cases the sandbox
|
|
36
|
+
may take a long time to start on the worker after scheduling.
|
|
36
37
|
|
|
37
38
|
Retries with exponential backoff (1, 2, 4, 8, 10, 10, ...) capped at 10s per delay.
|
|
38
39
|
Total sleep between attempts: 1 + 2 + 4 + 8 + 10*29 = 305s (~5 min).
|
|
@@ -221,7 +222,10 @@ class TaskCommandRouterClient:
|
|
|
221
222
|
host,
|
|
222
223
|
port,
|
|
223
224
|
ssl=ssl_context,
|
|
224
|
-
config=
|
|
225
|
+
config=grpclib.config.Configuration(
|
|
226
|
+
http2_connection_window_size=64 * 1024 * 1024, # 64 MiB
|
|
227
|
+
http2_stream_window_size=64 * 1024 * 1024, # 64 MiB
|
|
228
|
+
),
|
|
225
229
|
closed_error_message="Unable to perform operation on a detached sandbox",
|
|
226
230
|
)
|
|
227
231
|
|
|
@@ -807,26 +811,6 @@ class TaskCommandRouterClient:
|
|
|
807
811
|
lambda: self._call_with_auth_retry(self._stub.TaskSetNetworkAccess, request)
|
|
808
812
|
)
|
|
809
813
|
|
|
810
|
-
async def reload_volumes(self, task_id: str, timeout: float) -> sr_pb2.TaskReloadVolumesResponse:
|
|
811
|
-
"""Reload all Volumes mounted in the task to reflect their latest committed state.
|
|
812
|
-
|
|
813
|
-
Args:
|
|
814
|
-
task_id: The task whose mounted Volumes should be reloaded.
|
|
815
|
-
timeout: Client-side deadline in seconds. If the reload does not complete within this
|
|
816
|
-
window, the call is cancelled and a `modal.exception.TimeoutError` is raised.
|
|
817
|
-
"""
|
|
818
|
-
request = sr_pb2.TaskReloadVolumesRequest(task_id=task_id)
|
|
819
|
-
with grpc_error_converter():
|
|
820
|
-
try:
|
|
821
|
-
return await asyncio.wait_for(
|
|
822
|
-
call_with_retries_on_transient_errors(
|
|
823
|
-
lambda: self._call_with_auth_retry(self._stub.TaskReloadVolumes, request, timeout=timeout),
|
|
824
|
-
),
|
|
825
|
-
timeout=timeout,
|
|
826
|
-
)
|
|
827
|
-
except asyncio.TimeoutError:
|
|
828
|
-
raise ModalTimeoutError("Timeout expired")
|
|
829
|
-
|
|
830
814
|
async def _snapshot_with_deadline(self, rpc, request, *, timeout: float, **kwargs):
|
|
831
815
|
# helper method for snapshot_directory and snapshot_filesystem to handle grpc
|
|
832
816
|
# deadlines in a consistent way, converting any error to TimeoutError after passing
|