modAL 1.6.3.dev5__py3-none-any.whl → 1.6.3.dev7__py3-none-any.whl

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/_image.py CHANGED
@@ -57,7 +57,7 @@ from .exception import (
57
57
  VersionError,
58
58
  )
59
59
  from .file_pattern_matcher import NON_PYTHON_FILES, FilePatternMatcher, _ignore_fn
60
- from .mount import _Mount, python_standalone_mount_name
60
+ from .mount import PYTHON_STANDALONE_VERSIONS, _Mount, python_standalone_mount_name
61
61
  from .network_file_system import _NetworkFileSystem
62
62
  from .output import OutputManager
63
63
  from .secret import _Secret
@@ -70,6 +70,7 @@ if typing.TYPE_CHECKING:
70
70
 
71
71
  # This is used for both type checking and runtime validation
72
72
  ImageBuilderVersion = Literal["2023.12", "2024.04", "2024.10", "2025.06", "PREVIEW"]
73
+ LATEST_IMAGE_BUILDER_VERSION: ImageBuilderVersion = "2025.06"
73
74
 
74
75
  # Note: we also define supported Python versions via logic at the top of the package __init__.py
75
76
  # so that we fail fast / clearly in unsupported containers. Additionally, we enumerate the supported
@@ -402,7 +403,7 @@ def _create_context_mount_function(
402
403
 
403
404
  if ignore is AUTO_DOCKERIGNORE:
404
405
 
405
- def auto_created_context_mount_fn() -> _Mount | None:
406
+ def auto_created_context_mount_fn(builder_version: ImageBuilderVersion) -> _Mount | None:
406
407
  nonlocal context_dir
407
408
  context_dir = Path.cwd() if context_dir is None else Path(context_dir).absolute()
408
409
  dockerignore_file = find_dockerignore_file(context_dir, dockerfile_path)
@@ -419,7 +420,7 @@ def _create_context_mount_function(
419
420
 
420
421
  else:
421
422
 
422
- def auto_created_context_mount_fn() -> _Mount | None:
423
+ def auto_created_context_mount_fn(builder_version: ImageBuilderVersion) -> _Mount | None:
423
424
  # use COPY commands and ignore patterns to construct implicit context mount
424
425
  nonlocal context_dir
425
426
  context_dir = Path.cwd() if context_dir is None else Path(context_dir).absolute()
@@ -622,7 +623,7 @@ class _Image(_Object, type_prefix="im"):
622
623
  build_function: "modal._functions._Function | None" = None,
623
624
  build_function_input: api_pb2.FunctionInput | None = None,
624
625
  image_registry_config: _ImageRegistryConfig | None = None,
625
- context_mount_function: Callable[[], _Mount | None] | None = None,
626
+ context_mount_function: Callable[[ImageBuilderVersion], _Mount | None] | None = None,
626
627
  force_build: bool = False,
627
628
  build_args: dict[str, str] = {},
628
629
  validated_volumes: Sequence[tuple[str, _Volume]] | None = None,
@@ -661,7 +662,13 @@ class _Image(_Object, type_prefix="im"):
661
662
  return deps
662
663
 
663
664
  async def _load(self: _Image, resolver: Resolver, load_context: LoadContext, existing_object_id: str | None):
664
- context_mount = context_mount_function() if context_mount_function else None
665
+ assert load_context.app_id # type narrowing
666
+ environment = await _get_environment_cached(load_context.environment_name or "", load_context.client)
667
+ # A bit hacky,but assume that the environment provides a valid builder version
668
+ image_builder_version = cast(ImageBuilderVersion, environment._settings.image_builder_version)
669
+ builder_version = _get_image_builder_version(image_builder_version)
670
+
671
+ context_mount = context_mount_function(builder_version) if context_mount_function else None
665
672
  if context_mount:
666
673
  await resolver.load(context_mount, load_context)
667
674
 
@@ -670,12 +677,6 @@ class _Image(_Object, type_prefix="im"):
670
677
  # base images can't have
671
678
  image._assert_no_mount_layers()
672
679
 
673
- assert load_context.app_id # type narrowing
674
- environment = await _get_environment_cached(load_context.environment_name or "", load_context.client)
675
- # A bit hacky,but assume that the environment provides a valid builder version
676
- image_builder_version = cast(ImageBuilderVersion, environment._settings.image_builder_version)
677
- builder_version = _get_image_builder_version(image_builder_version)
678
-
679
680
  if dockerfile_function is None:
680
681
  dockerfile = DockerfileSpec(commands=[], context_files={})
681
682
  else:
@@ -847,7 +848,7 @@ class _Image(_Object, type_prefix="im"):
847
848
  return _Image._from_args(
848
849
  base_images={"base": self},
849
850
  dockerfile_function=build_dockerfile,
850
- context_mount_function=lambda: mount,
851
+ context_mount_function=lambda builder_version: mount,
851
852
  )
852
853
 
853
854
  @_requires_image_instance
@@ -2191,12 +2192,9 @@ class _Image(_Object, type_prefix="im"):
2191
2192
  "COPY /python/. /usr/local",
2192
2193
  "ENV TERMINFO_DIRS=/etc/terminfo:/lib/terminfo:/usr/share/terminfo:/usr/lib/terminfo",
2193
2194
  ]
2194
- python_minor = add_python.split(".")[1]
2195
- if python_minor.endswith("t"):
2196
- python_minor = python_minor[:-1]
2197
- if int(python_minor) < 13:
2198
- # Previous versions did not include the `python` binary, but later ones do.
2199
- # (The important factor is not the Python version itself, but the standalone dist version.)
2195
+ release, _ = PYTHON_STANDALONE_VERSIONS[builder_version][add_python]
2196
+ if release < "20241008":
2197
+ # Older standalone releases do not include the `python` binary.
2200
2198
  # We insert the command in the list at the position it was previously always added
2201
2199
  # for backwards compatibility with existing images.
2202
2200
  add_python_commands.insert(1, "RUN ln -s /usr/local/bin/python3 /usr/local/bin/python")
@@ -2271,10 +2269,10 @@ class _Image(_Object, type_prefix="im"):
2271
2269
  ```
2272
2270
  """
2273
2271
 
2274
- def context_mount_function() -> _Mount | None:
2272
+ def context_mount_function(builder_version: ImageBuilderVersion) -> _Mount | None:
2275
2273
  return (
2276
2274
  _Mount.from_name(
2277
- python_standalone_mount_name(add_python),
2275
+ python_standalone_mount_name(add_python, builder_version),
2278
2276
  namespace=api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
2279
2277
  )
2280
2278
  if add_python
@@ -2516,16 +2514,17 @@ class _Image(_Object, type_prefix="im"):
2516
2514
  gpu_config=gpu_config,
2517
2515
  secrets=secrets,
2518
2516
  build_args=build_args,
2517
+ force_build=force_build,
2519
2518
  )
2520
2519
 
2521
2520
  # --- Now add in the modal dependencies, and, optionally a Python distribution
2522
2521
  # This happening in two steps is probably a vestigial consequence of previous limitations,
2523
2522
  # but it will be difficult to merge them without forcing rebuilds of images.
2524
2523
 
2525
- def add_python_mount():
2524
+ def add_python_mount(builder_version: ImageBuilderVersion) -> _Mount | None:
2526
2525
  return (
2527
2526
  _Mount.from_name(
2528
- python_standalone_mount_name(add_python),
2527
+ python_standalone_mount_name(add_python, builder_version),
2529
2528
  namespace=api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
2530
2529
  )
2531
2530
  if add_python
@@ -116,7 +116,7 @@ class InterceptionContext:
116
116
  """Adds one response payload to an expected queue of responses for a method.
117
117
 
118
118
  These responses will be used once each instead of calling the MockServicer's
119
- implementation of the method.
119
+ implementation of the method. An exception payload is raised instead of being sent.
120
120
 
121
121
  The interception context will throw an exception on exit if not all of the added
122
122
  responses have been consumed.
@@ -184,6 +184,8 @@ class InterceptionContext:
184
184
  async def responder(servicer_self, stream):
185
185
  await stream.recv_message() # get the input message so we can track that
186
186
  for msg in next_response_messages:
187
+ if isinstance(msg, Exception):
188
+ raise msg
187
189
  await stream.send_message(msg)
188
190
 
189
191
  return responder
modal/builder/PREVIEW.txt CHANGED
@@ -1,21 +1,18 @@
1
- aiohappyeyeballs==2.6.1
2
- aiohttp==3.12.7
3
- aiosignal==1.3.2
1
+ aiohappyeyeballs==2.7.1
2
+ aiohttp==3.14.5
3
+ aiosignal==1.4.0
4
4
  async-timeout==5.0.1 ; python_version < "3.11"
5
- attrs==25.3.0
6
- cbor2==5.7.0
7
- certifi==2025.4.26
8
- frozenlist==1.6.0
9
- grpclib==0.4.8 ; python_version < "3.14"
10
- grpclib==0.4.9 ; python_version >= "3.14"
11
- h2==4.2.0
12
- hpack==4.1.0
5
+ attrs==26.1.0
6
+ cbor2==5.9.0
7
+ certifi==2026.10.19
8
+ frozenlist==1.8.0
9
+ grpclib==0.4.9
10
+ h2==4.4.1
11
+ hpack==4.2.0
13
12
  hyperframe==6.1.0
14
- idna==3.10
15
- multidict==6.4.4
16
- propcache==0.3.1
17
- protobuf==6.31.1 ; python_version <= "3.14"
18
- protobuf==7.35.1 ; python_version >= "3.15"
19
- typing_extensions==4.13.2 ; python_version <= "3.14"
20
- typing_extensions==4.16.0 ; python_version >= "3.15"
21
- yarl==1.20.0
13
+ idna==3.20
14
+ multidict==7.1.0
15
+ propcache==0.5.4
16
+ protobuf==7.36.2
17
+ typing_extensions==4.16.0
18
+ yarl==1.25.1
modal/client.pyi CHANGED
@@ -80,7 +80,7 @@ class _Client:
80
80
  server_url: str,
81
81
  client_type: int,
82
82
  credentials: typing.Optional[tuple[str, str]],
83
- version: str = "1.6.3.dev5",
83
+ version: str = "1.6.3.dev7",
84
84
  *,
85
85
  oauth_credentials: typing.Optional[_OAuthCredentials] = None,
86
86
  ):
@@ -290,7 +290,7 @@ class Client:
290
290
  server_url: str,
291
291
  client_type: int,
292
292
  credentials: typing.Optional[tuple[str, str]],
293
- version: str = "1.6.3.dev5",
293
+ version: str = "1.6.3.dev7",
294
294
  *,
295
295
  oauth_credentials: typing.Optional[_OAuthCredentials] = None,
296
296
  ):
modal/image.pyi CHANGED
@@ -74,7 +74,10 @@ class Image(modal.object.Object):
74
74
  build_function_input: typing.Optional[modal_proto.api_pb2.FunctionInput] = None,
75
75
  image_registry_config: typing.Optional[modal._image._ImageRegistryConfig] = None,
76
76
  context_mount_function: typing.Optional[
77
- collections.abc.Callable[[], typing.Optional[modal.mount.Mount]]
77
+ collections.abc.Callable[
78
+ [typing.Literal["2023.12", "2024.04", "2024.10", "2025.06", "PREVIEW"]],
79
+ typing.Optional[modal.mount.Mount],
80
+ ]
78
81
  ] = None,
79
82
  force_build: bool = False,
80
83
  build_args: dict[str, str] = {},
modal/mount.py CHANGED
@@ -39,11 +39,11 @@ from .file_pattern_matcher import FilePatternMatcher
39
39
  ROOT_DIR: PurePosixPath = PurePosixPath("/root")
40
40
  MOUNT_PUT_FILE_CLIENT_TIMEOUT = 10 * 60 # 10 min max for transferring files
41
41
 
42
- # Supported releases and versions for python-build-standalone.
42
+ # Supported releases and versions for python-build-standalone, keyed by image builder version.
43
43
  #
44
- # These can be updated safely, but changes will trigger a rebuild for all images
45
- # that rely on `add_python()` in their constructor.
46
- PYTHON_STANDALONE_VERSIONS: dict[str, tuple[str, str]] = {
44
+ # Changing the versions for a builder version triggers a rebuild for all images on that builder
45
+ # version that rely on `add_python()` in their constructor, so only "PREVIEW" should be updated.
46
+ _PYTHON_STANDALONE_VERSIONS_2025_06: dict[str, tuple[str, str]] = {
47
47
  "3.10": ("20230826", "3.10.13"),
48
48
  "3.11": ("20230826", "3.11.5"),
49
49
  "3.12": ("20240107", "3.12.1"),
@@ -53,6 +53,22 @@ PYTHON_STANDALONE_VERSIONS: dict[str, tuple[str, str]] = {
53
53
  "3.15": ("20261009", "3.15.0"),
54
54
  "3.15t": ("20261009", "3.15.0t"),
55
55
  }
56
+ PYTHON_STANDALONE_VERSIONS: dict[str, dict[str, tuple[str, str]]] = {
57
+ "PREVIEW": {
58
+ "3.10": ("20261009", "3.10.22"),
59
+ "3.11": ("20261021", "3.11.17"),
60
+ "3.12": ("20261021", "3.12.15"),
61
+ "3.13": ("20261021", "3.13.17"),
62
+ "3.14": ("20261021", "3.14.9"),
63
+ "3.14t": ("20261021", "3.14.9t"),
64
+ "3.15": ("20261021", "3.15.1"),
65
+ "3.15t": ("20261021", "3.15.1t"),
66
+ },
67
+ "2025.06": _PYTHON_STANDALONE_VERSIONS_2025_06,
68
+ "2024.10": _PYTHON_STANDALONE_VERSIONS_2025_06,
69
+ "2024.04": _PYTHON_STANDALONE_VERSIONS_2025_06,
70
+ "2023.12": _PYTHON_STANDALONE_VERSIONS_2025_06,
71
+ }
56
72
 
57
73
  MOUNT_DEPRECATION_MESSAGE_PATTERN = """modal.Mount usage will soon be deprecated.
58
74
 
@@ -72,20 +88,20 @@ def client_mount_name() -> str:
72
88
  return f"modal-client-mount-{version}"
73
89
 
74
90
 
75
- def python_standalone_mount_name(version: str) -> str:
91
+ def python_standalone_mount_name(version: str, builder_version: str) -> str:
76
92
  """Get the deployed name of the python-build-standalone mount."""
77
93
  if "-" in version: # default to glibc
78
94
  version, libc = version.split("-")
79
95
  else:
80
96
  libc = "gnu"
81
- if version not in PYTHON_STANDALONE_VERSIONS:
97
+ standalone_versions = PYTHON_STANDALONE_VERSIONS[builder_version]
98
+ if version not in standalone_versions:
82
99
  raise modal.exception.InvalidError(
83
- f"Unsupported standalone python version: {version!r}, supported values are "
84
- f"{list(PYTHON_STANDALONE_VERSIONS)}"
100
+ f"Unsupported standalone python version: {version!r}, supported values are {list(standalone_versions)}"
85
101
  )
86
102
  if libc != "gnu":
87
103
  raise modal.exception.InvalidError(f"Unsupported libc identifier: {libc}")
88
- release, full_version = PYTHON_STANDALONE_VERSIONS[version]
104
+ release, full_version = standalone_versions[version]
89
105
  return f"python-build-standalone.{release}.{full_version}-{libc}"
90
106
 
91
107
 
@@ -950,19 +966,25 @@ async def _create_single_client_dependency_mount(
950
966
 
951
967
  async def _create_client_dependency_mounts(
952
968
  client=None,
953
- python_versions: list[str] = list(PYTHON_STANDALONE_VERSIONS),
954
- builder_versions: list[str] = ["2025.06"], # Reenable "PREVIEW" during testing
969
+ python_versions: list[str] | None = None,
970
+ builder_versions: list[str] | None = None, # Pass "PREVIEW" during testing
955
971
  check_if_exists=True,
956
972
  dry_run=False,
957
973
  ):
974
+ from ._image import LATEST_IMAGE_BUILDER_VERSION, SUPPORTED_PYTHON_SERIES
975
+
958
976
  arch = "x86_64"
959
977
  platform_tags = [
960
978
  ("manylinux_2_17", f"{arch}-manylinux_2_17"), # glibc >= 2.17
961
979
  ("musllinux_1_2", f"{arch}-unknown-linux-musl"), # musl >= 1.2
962
980
  ]
963
981
  coros = []
964
- for python_version in python_versions:
965
- for builder_version in builder_versions:
982
+ for builder_version in builder_versions or [LATEST_IMAGE_BUILDER_VERSION]:
983
+ if python_versions is None:
984
+ builder_python_versions = SUPPORTED_PYTHON_SERIES[builder_version]
985
+ else:
986
+ builder_python_versions = python_versions
987
+ for python_version in builder_python_versions:
966
988
  for platform, uv_python_platform in platform_tags:
967
989
  coros.append(
968
990
  _create_single_client_dependency_mount(
modal/mount.pyi CHANGED
@@ -16,7 +16,7 @@ def client_mount_name() -> str:
16
16
  """Get the deployed name of the client package mount."""
17
17
  ...
18
18
 
19
- def python_standalone_mount_name(version: str) -> str:
19
+ def python_standalone_mount_name(version: str, builder_version: str) -> str:
20
20
  """Get the deployed name of the python-build-standalone mount."""
21
21
  ...
22
22
 
@@ -444,8 +444,8 @@ async def _create_single_client_dependency_mount(
444
444
  ): ...
445
445
  async def _create_client_dependency_mounts(
446
446
  client=None,
447
- python_versions: list[str] = ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t"],
448
- builder_versions: list[str] = ["2025.06"],
447
+ python_versions: typing.Optional[list[str]] = None,
448
+ builder_versions: typing.Optional[list[str]] = None,
449
449
  check_if_exists=True,
450
450
  dry_run=False,
451
451
  ): ...
@@ -455,8 +455,8 @@ class __create_client_dependency_mounts_spec(typing_extensions.Protocol):
455
455
  self,
456
456
  /,
457
457
  client=None,
458
- python_versions: list[str] = ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t"],
459
- builder_versions: list[str] = ["2025.06"],
458
+ python_versions: typing.Optional[list[str]] = None,
459
+ builder_versions: typing.Optional[list[str]] = None,
460
460
  check_if_exists=True,
461
461
  dry_run=False,
462
462
  ): ...
@@ -464,8 +464,8 @@ class __create_client_dependency_mounts_spec(typing_extensions.Protocol):
464
464
  self,
465
465
  /,
466
466
  client=None,
467
- python_versions: list[str] = ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t"],
468
- builder_versions: list[str] = ["2025.06"],
467
+ python_versions: typing.Optional[list[str]] = None,
468
+ builder_versions: typing.Optional[list[str]] = None,
469
469
  check_if_exists=True,
470
470
  dry_run=False,
471
471
  ): ...
@@ -474,4 +474,6 @@ create_client_dependency_mounts: __create_client_dependency_mounts_spec
474
474
 
475
475
  ROOT_DIR: pathlib.PurePosixPath
476
476
 
477
- PYTHON_STANDALONE_VERSIONS: dict[str, tuple[str, str]]
477
+ _PYTHON_STANDALONE_VERSIONS_2025_06: dict[str, tuple[str, str]]
478
+
479
+ PYTHON_STANDALONE_VERSIONS: dict[str, dict[str, tuple[str, str]]]
modal/sandbox/_common.py CHANGED
@@ -386,7 +386,7 @@ class SandboxCreateArgs:
386
386
  secrets: Collection[_Secret] | None = None
387
387
  env: dict[str, str | None] | None = None
388
388
  name: str | None = None
389
- timeout: int = 300
389
+ timeout: int | None = 300
390
390
  idle_timeout: int | None = None
391
391
  workdir: str | None = None
392
392
  gpu: str | None = None
@@ -417,6 +417,7 @@ class SandboxCreateArgs:
417
417
  custom_domain: str | None = None
418
418
  include_oidc_identity_token: bool = False
419
419
  runtime: SandboxRuntime | None = None
420
+ persistent: bool = False
420
421
 
421
422
 
422
423
  class SandboxCreateCommon:
@@ -428,6 +429,8 @@ class SandboxCreateCommon:
428
429
 
429
430
  def __init__(self, create_args: SandboxCreateArgs, *, secrets: Sequence[_Secret]) -> None:
430
431
  a = create_args
432
+ if a.timeout is None:
433
+ raise InvalidError("`timeout=None` is only supported for experimental persistent Sandboxes.")
431
434
  _validate_exec_args(a.args)
432
435
  if a.name is not None:
433
436
  check_object_name(a.name, "Sandbox")
@@ -452,6 +455,7 @@ class SandboxCreateCommon:
452
455
  raise InvalidError("`inbound_cidr_allowlist` cannot be used when `block_network` is enabled")
453
456
 
454
457
  self.create_args = a
458
+ self.timeout = a.timeout
455
459
  self.secrets = secrets
456
460
  self.image = a.image or _default_image
457
461
  self.network_file_systems = validate_network_file_systems(a.network_file_systems)
@@ -503,7 +507,7 @@ class SandboxCreateCommon:
503
507
  secret_ids=[secret.object_id for secret in self.secrets],
504
508
  # Ephemeral disk requests are not supported on Sandboxes.
505
509
  resources=convert_fn_config_to_resources_config(cpu=a.cpu, memory=a.memory, gpu=a.gpu, ephemeral_disk=None),
506
- timeout_secs=a.timeout,
510
+ timeout_secs=self.timeout,
507
511
  idle_timeout_secs=a.idle_timeout,
508
512
  workdir=a.workdir,
509
513
  cloud_provider_str=a.cloud if a.cloud else None, # pyright: ignore[reportArgumentType]
@@ -637,6 +641,10 @@ class SandboxImpl(Protocol):
637
641
  """
638
642
  ...
639
643
 
644
+ def clone(self) -> "SandboxImpl":
645
+ """Create an implementation of the same type for another handle to the same Sandbox."""
646
+ ...
647
+
640
648
  @classmethod
641
649
  async def lookup_by_name(
642
650
  cls, client: _Client, req: api_pb2.SandboxGetFromNameRequest
@@ -674,6 +682,10 @@ class SandboxImpl(Protocol):
674
682
 
675
683
  async def terminate(self) -> None: ...
676
684
 
685
+ async def pause(self) -> None: ...
686
+
687
+ async def resume(self) -> None: ...
688
+
677
689
  async def close(self) -> None:
678
690
  """Release connections held for the Sandbox once in-flight operations complete."""
679
691
  ...
@@ -907,6 +919,9 @@ async def snapshot_filesystem_via_tcr(
907
919
  return _Image._new_hydrated(res.image_id, client, None)
908
920
 
909
921
 
922
+ _PERSISTENT_ONLY_MESSAGE = "`{}` requires a persistent Sandbox, created with `_experimental_persistent=True`."
923
+
924
+
910
925
  async def mount_image_via_tcr(
911
926
  impl: SandboxImpl,
912
927
  path: PurePosixPath | str,
@@ -0,0 +1,470 @@
1
+ # Copyright Modal Labs 2026
2
+ """Persistent Sandboxes, managed by the `PersistentSandbox*` control-plane RPCs.
3
+
4
+ A persistent Sandbox runs as a sequence of regular V2 Sandboxes, its sessions. The control plane starts each
5
+ session from the exit snapshot of the previous one.
6
+ """
7
+
8
+ import asyncio
9
+ import copy
10
+ import dataclasses
11
+ import time
12
+ import weakref
13
+ from collections.abc import Awaitable, Callable, Collection, Sequence
14
+ from pathlib import PurePosixPath
15
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar
16
+
17
+ from grpclib import GRPCError, Status
18
+
19
+ from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
20
+
21
+ from .._image import _Image
22
+ from .._outbound_policy import _OutboundPolicy
23
+ from .._resolver import Resolver
24
+ from .._tunnel import Tunnel
25
+ from ..client import _Client
26
+ from ..cloud_bucket_mount import _CloudBucketMount, cloud_bucket_mounts_to_proto
27
+ from ..config import logger
28
+ from ..container_process import _ContainerProcess
29
+ from ..exception import ClientClosed, InvalidError, SandboxTerminatedError
30
+ from ..io_streams import (
31
+ StreamReader,
32
+ StreamWriter,
33
+ _StreamReaderThroughSandboxCommandRouterParams,
34
+ _StreamWriterThroughCommandRouterSandboxParams,
35
+ )
36
+ from ..secret import _resolvable_secrets, _Secret, _secret_sources
37
+ from ..stream_type import StreamType
38
+ from ._common import SandboxCreateArgs, SandboxCreateCommon, SandboxImpl, command_router, sandbox_create_context
39
+ from ._impl_v2 import SandboxImplV2, auth_metadata
40
+ from ._snapshot import _SandboxSnapshot
41
+ from ._task_command_router_client import TaskCommandRouterClient
42
+
43
+ if TYPE_CHECKING:
44
+ import modal.app
45
+
46
+ _T = TypeVar("_T")
47
+
48
+ # Errors caused by the caller rather than by the session, which a new session can't fix.
49
+ _NON_RECOVERABLE_ERRORS = (InvalidError, ClientClosed)
50
+
51
+ # Lifetime limits of each session. A session that hits one is paused, and resumed on next use.
52
+ PERSISTENT_SANDBOX_SESSION_TIMEOUT = 60 * 60
53
+ PERSISTENT_SANDBOX_SESSION_IDLE_TIMEOUT = 5 * 60
54
+
55
+ # How long to retry resuming while the previous session is still saving its exit snapshot.
56
+ _RESUME_RETRY_TIMEOUT = 120.0
57
+ _RESUME_RETRY_INTERVAL = 1.0
58
+
59
+ # How often `wait()` checks whether the persistent Sandbox has been terminated.
60
+ _WAIT_POLL_INTERVAL = 1.0
61
+
62
+ _TERMINATED_RESULT = api_pb2.GenericResult(status=api_pb2.GenericResult.GENERIC_STATUS_TERMINATED)
63
+
64
+
65
+ class SandboxImplPersistent:
66
+ """Backs a persistent Sandbox, whose object ID is the ID of its first session.
67
+
68
+ Operations are forwarded to the current session. When one fails and the Sandbox hasn't been terminated
69
+ through this handle, the control plane is asked for the running session, resuming the Sandbox from its
70
+ latest exit snapshot if there is none. The operation is then retried once on the new session.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ client: _Client,
76
+ sandbox_id: str,
77
+ *,
78
+ result: api_pb2.GenericResult | None = None,
79
+ persistent_sandbox_id: str = "",
80
+ session: SandboxImplV2 | None = None,
81
+ ) -> None:
82
+ self.client = client
83
+ self.sandbox_id = sandbox_id
84
+ self.result = result
85
+ self._persistent_sandbox_id = persistent_sandbox_id
86
+ self._session = session if session is not None else SandboxImplV2(client, sandbox_id)
87
+ self._replace_lock: asyncio.Lock | None = None
88
+ self._terminated = result is not None
89
+
90
+ @property
91
+ def _delegate(self) -> SandboxImplV2:
92
+ return self._session
93
+
94
+ @property
95
+ def _client(self) -> _Client:
96
+ return self.client
97
+
98
+ @classmethod
99
+ async def create(
100
+ cls, create_args: SandboxCreateArgs, *, app: "modal.app._App | None", client: _Client | None
101
+ ) -> "tuple[SandboxImpl, api_pb2.SandboxHandleMetadata]":
102
+ if create_args.timeout is not None or create_args.idle_timeout is not None:
103
+ raise InvalidError("Persistent Sandboxes require `timeout=None` and don't support `idle_timeout`.")
104
+ create_args = dataclasses.replace(
105
+ create_args,
106
+ timeout=PERSISTENT_SANDBOX_SESSION_TIMEOUT,
107
+ idle_timeout=PERSISTENT_SANDBOX_SESSION_IDLE_TIMEOUT,
108
+ )
109
+ if create_args.name is not None:
110
+ raise InvalidError("Named persistent Sandboxes are not supported yet.")
111
+ if create_args.gpu is not None or create_args.network_file_systems or create_args.pty_info is not None:
112
+ raise InvalidError("GPUs, network file systems and PTYs are not supported by persistent Sandboxes yet.")
113
+ if create_args.enable_snapshot:
114
+ raise InvalidError("Memory snapshots are not supported by persistent Sandboxes.")
115
+ if create_args.env or create_args.tags:
116
+ raise InvalidError("`env` and `tags` are not supported by persistent Sandboxes yet.")
117
+
118
+ secrets = create_args.secrets or []
119
+ common = SandboxCreateCommon(create_args, secrets=_resolvable_secrets(secrets))
120
+ async with sandbox_create_context(app, client) as (client, load_context):
121
+ await common.load_dependencies(Resolver(), load_context, load_ephemeral_bucket_secrets=False)
122
+ cloud_bucket_mount_protos, cloud_bucket_credentials = cloud_bucket_mounts_to_proto(
123
+ common.cloud_bucket_mounts, split_ephemeral_credentials=True
124
+ )
125
+ if cloud_bucket_credentials or any(source.HasField("env") for source in _secret_sources(secrets)):
126
+ raise InvalidError("Ephemeral credentials are not supported by persistent Sandboxes yet.")
127
+ req = api_pb2.PersistentSandboxCreateRequest(
128
+ app_id=load_context.app_id or "",
129
+ definition=common.definition(cloud_bucket_mounts=cloud_bucket_mount_protos),
130
+ )
131
+ resp = await client._stub.PersistentSandboxCreate(req, metadata=await auth_metadata(client))
132
+ app_id = load_context.app_id or ""
133
+ impl = cls(client, resp.sandbox_id, persistent_sandbox_id=resp.persistent_sandbox_id)
134
+ return impl, api_pb2.SandboxHandleMetadata(app_id=app_id)
135
+
136
+ # Persistent Sandboxes are only created through `create`, and can't be looked up yet.
137
+
138
+ @classmethod
139
+ async def lookup_by_name(cls, *args: Any, **kwargs: Any) -> api_pb2.SandboxGetFromNameResponse:
140
+ raise NotImplementedError
141
+
142
+ @classmethod
143
+ async def lookup_by_id(cls, *args: Any, **kwargs: Any) -> api_pb2.SandboxWaitResponse:
144
+ raise NotImplementedError
145
+
146
+ @classmethod
147
+ async def list_page(cls, *args: Any, **kwargs: Any) -> api_pb2.SandboxListResponse:
148
+ raise NotImplementedError
149
+
150
+ @classmethod
151
+ async def restore_from_memory_snapshot(
152
+ cls, *args: Any, **kwargs: Any
153
+ ) -> "tuple[SandboxImpl, api_pb2.SandboxHandleMetadata]":
154
+ raise NotImplementedError
155
+
156
+ def clone(self) -> "SandboxImplPersistent":
157
+ return copy.copy(self)
158
+
159
+ def make_stdio(self, check_open: Callable[[], None]) -> tuple[StreamReader[str], StreamReader[str], StreamWriter]:
160
+ """Create stdio streams of the entrypoint of whichever session is current when they connect."""
161
+ weak_self = weakref.ref(self)
162
+
163
+ async def resolve_router() -> tuple[str, TaskCommandRouterClient]:
164
+ this = weak_self()
165
+ if this is None:
166
+ raise RuntimeError("Sandbox was garbage collected before its stdio reader connected")
167
+ return await command_router(this)
168
+
169
+ def reader(file_descriptor: "api_pb2.FileDescriptor.ValueType") -> StreamReader[str]:
170
+ return StreamReader(
171
+ _StreamReaderThroughSandboxCommandRouterParams(
172
+ file_descriptor=file_descriptor, sandbox_id=self.sandbox_id, resolve_router=resolve_router
173
+ ),
174
+ by_line=True,
175
+ )
176
+
177
+ stdin = StreamWriter(
178
+ _StreamWriterThroughCommandRouterSandboxParams(resolve_router=resolve_router, check_open=check_open)
179
+ )
180
+ return reader(api_pb2.FILE_DESCRIPTOR_STDOUT), reader(api_pb2.FILE_DESCRIPTOR_STDERR), stdin
181
+
182
+ # Session replacement
183
+
184
+ async def _run(self, op: Callable[[SandboxImplV2], Awaitable[_T]]) -> _T:
185
+ """Run `op` on the session, switching to the running session and retrying once if it fails."""
186
+ session = self._delegate
187
+ try:
188
+ return await op(session)
189
+ except _NON_RECOVERABLE_ERRORS:
190
+ raise
191
+ except Exception as exc:
192
+ if self._terminated:
193
+ # A terminated persistent Sandbox is stopped for good, so it isn't resumed on use.
194
+ raise
195
+ replacement = await self._replace_session(session)
196
+ if replacement is None:
197
+ raise
198
+ logger.debug(f"Retrying operation on persistent Sandbox {self._persistent_sandbox_id} after: {exc!r}")
199
+ return await op(replacement)
200
+
201
+ async def _get_running_session_id(self) -> str:
202
+ req = api_pb2.PersistentSandboxGetRequest(persistent_sandbox_id=self._persistent_sandbox_id)
203
+ resp = await self._client._stub.PersistentSandboxGet(req, metadata=await auth_metadata(self._client))
204
+ return resp.sandbox_id
205
+
206
+ async def _resume_session(self) -> str:
207
+ """Resume the persistent Sandbox, returning the ID of its running session."""
208
+ req = api_pb2.PersistentSandboxResumeRequest(persistent_sandbox_id=self._persistent_sandbox_id)
209
+ deadline = time.monotonic() + _RESUME_RETRY_TIMEOUT
210
+ while True:
211
+ try:
212
+ resp = await self._client._stub.PersistentSandboxResume(req, metadata=await auth_metadata(self._client))
213
+ return resp.sandbox_id
214
+ except GRPCError as exc:
215
+ # UNAVAILABLE means the previous session is still pausing or another resume is in progress.
216
+ if exc.status != Status.UNAVAILABLE or time.monotonic() > deadline:
217
+ raise
218
+ await asyncio.sleep(_RESUME_RETRY_INTERVAL)
219
+
220
+ async def _replace_session(self, failed: SandboxImplV2 | None, *, resume: bool = True) -> SandboxImplV2 | None:
221
+ """Switch from the `failed` session, or return None if it is still the running session."""
222
+ # Lazily create the lock on the event loop that first uses the Sandbox.
223
+ if self._replace_lock is None:
224
+ self._replace_lock = asyncio.Lock()
225
+ async with self._replace_lock:
226
+ if self._session is not failed:
227
+ # A concurrent operation has already replaced the session.
228
+ return self._session
229
+
230
+ session_id = await self._get_running_session_id()
231
+ if failed is not None and session_id == failed.sandbox_id:
232
+ return None
233
+ if not session_id:
234
+ if not resume:
235
+ return None
236
+ session_id = await self._resume_session()
237
+ logger.debug(f"Switching persistent Sandbox {self._persistent_sandbox_id} to session {session_id}")
238
+ replacement = SandboxImplV2(self.client, session_id)
239
+ self._session = replacement
240
+ if failed is not None:
241
+ await failed.close()
242
+ return replacement
243
+
244
+ # Operations
245
+
246
+ async def close(self) -> None:
247
+ await self._session.close()
248
+
249
+ def _set_terminated_result(self) -> None:
250
+ self.result = _TERMINATED_RESULT
251
+
252
+ async def wait(self, raise_on_termination: bool) -> None:
253
+ # A persistent Sandbox keeps running across sessions, until it is terminated.
254
+ while not self._terminated:
255
+ await asyncio.sleep(_WAIT_POLL_INTERVAL)
256
+ self._set_terminated_result()
257
+ if raise_on_termination:
258
+ raise SandboxTerminatedError()
259
+
260
+ async def poll(self) -> None:
261
+ if self._terminated:
262
+ self._set_terminated_result()
263
+ else:
264
+ self.result = None
265
+
266
+ async def terminate(self) -> None:
267
+ req = api_pb2.PersistentSandboxTerminateRequest(persistent_sandbox_id=self._persistent_sandbox_id)
268
+ await self._client._stub.PersistentSandboxTerminate(req, metadata=await auth_metadata(self._client))
269
+ self._terminated = True
270
+ self._set_terminated_result()
271
+
272
+ async def pause(self) -> None:
273
+ """Stop the current session, which saves its exit snapshot for the next `resume`."""
274
+ self._check_not_terminated()
275
+ session = self._delegate
276
+ await session.terminate()
277
+ await session.wait(raise_on_termination=False)
278
+
279
+ async def resume(self) -> None:
280
+ """Start a session from the latest exit snapshot, unless one is already running."""
281
+ self._check_not_terminated()
282
+ session = self._delegate
283
+ session_id = await self._resume_session()
284
+ if session_id != session.sandbox_id:
285
+ self._session = SandboxImplV2(self.client, session_id)
286
+ await session.close()
287
+
288
+ def _check_not_terminated(self) -> None:
289
+ if self._terminated:
290
+ raise InvalidError(f"Persistent Sandbox {self._persistent_sandbox_id} has been terminated.")
291
+
292
+ async def tunnels(self, timeout: int) -> dict[int, Tunnel]:
293
+ return await self._run(lambda d: d.tunnels(timeout))
294
+
295
+ async def get_task_id(
296
+ self, raise_if_task_complete: bool = False, timeout: float | None = None, retry_transient: bool = False
297
+ ) -> str:
298
+ return await self._run(
299
+ lambda d: d.get_task_id(
300
+ raise_if_task_complete=raise_if_task_complete, timeout=timeout, retry_transient=retry_transient
301
+ )
302
+ )
303
+
304
+ async def resolve_task_id_for_logs(self) -> str:
305
+ return await self._delegate.resolve_task_id_for_logs()
306
+
307
+ async def get_exit_snapshot(self, timeout: float | None) -> _Image:
308
+ return await self._delegate.get_exit_snapshot(timeout)
309
+
310
+ async def snapshot_memory(self) -> _SandboxSnapshot:
311
+ raise NotImplementedError("Memory snapshots aren't supported for persistent Sandboxes.")
312
+
313
+ async def get_command_router_client(self, task_id: str) -> TaskCommandRouterClient:
314
+ # The task ID belongs to the current delegate, so this can't be retried on a replacement.
315
+ return await self._delegate.get_command_router_client(task_id)
316
+
317
+ async def get_tags(self) -> dict[str, str]:
318
+ raise NotImplementedError("Tags aren't supported for persistent Sandboxes yet.")
319
+
320
+ async def set_tags(self, tags: dict[str, str]) -> None:
321
+ raise NotImplementedError("Tags aren't supported for persistent Sandboxes yet.")
322
+
323
+ async def set_name(self, name: str) -> None:
324
+ raise InvalidError("The name of a persistent Sandbox can't be changed.")
325
+
326
+ async def create_connect_token(
327
+ self, req: api_pb2.SandboxCreateConnectTokenRequest
328
+ ) -> api_pb2.SandboxCreateConnectTokenResponse:
329
+ def create(d: SandboxImplV2) -> Awaitable[api_pb2.SandboxCreateConnectTokenResponse]:
330
+ delegate_req = api_pb2.SandboxCreateConnectTokenRequest()
331
+ delegate_req.CopyFrom(req)
332
+ delegate_req.sandbox_id = d.sandbox_id
333
+ return d.create_connect_token(delegate_req)
334
+
335
+ return await self._run(create)
336
+
337
+ async def wait_until_ready(self, timeout: int) -> None:
338
+ await self._run(lambda d: d.wait_until_ready(timeout))
339
+
340
+ async def set_network_access(self, network_access: api_pb2.NetworkAccess) -> None:
341
+ raise NotImplementedError("Updating network access isn't supported for persistent Sandboxes yet.")
342
+
343
+ async def update_outbound_policy(self, outbound_policy: _OutboundPolicy) -> None:
344
+ raise NotImplementedError("Updating the outbound policy isn't supported for persistent Sandboxes yet.")
345
+
346
+ async def snapshot_filesystem(self, timeout: int, *, ttl: int | None, container_id: str = "") -> _Image:
347
+ return await self._run(lambda d: d.snapshot_filesystem(timeout, ttl=ttl, container_id=container_id))
348
+
349
+ async def mount_image(
350
+ self,
351
+ path: PurePosixPath | str,
352
+ image_id: str,
353
+ *,
354
+ encryption_key: bytes | None,
355
+ container_id: str = "",
356
+ ) -> None:
357
+ raise NotImplementedError("Mounting images isn't supported for persistent Sandboxes yet.")
358
+
359
+ async def unmount_image(self, path: PurePosixPath | str, *, container_id: str = "") -> None:
360
+ raise NotImplementedError("Mounting images isn't supported for persistent Sandboxes yet.")
361
+
362
+ async def snapshot_directory(
363
+ self,
364
+ path: PurePosixPath | str,
365
+ *,
366
+ timeout: int,
367
+ ttl: int | None,
368
+ encryption_key: bytes | None,
369
+ container_id: str = "",
370
+ ) -> _Image:
371
+ return await self._run(
372
+ lambda d: d.snapshot_directory(
373
+ path, timeout=timeout, ttl=ttl, encryption_key=encryption_key, container_id=container_id
374
+ )
375
+ )
376
+
377
+ async def reload_volumes(self, *, timeout: int, container_id: str = "") -> None:
378
+ await self._run(lambda d: d.reload_volumes(timeout=timeout, container_id=container_id))
379
+
380
+ async def exec(
381
+ self,
382
+ *args: str,
383
+ pty_info: api_pb2.PTYInfo | None,
384
+ stdout: StreamType,
385
+ stderr: StreamType,
386
+ timeout: int | None,
387
+ workdir: str | None,
388
+ env: dict[str, str | None] | None,
389
+ secrets: Collection[_Secret] | None,
390
+ text: bool,
391
+ bufsize: Literal[-1, 1],
392
+ container_id: str | None,
393
+ ) -> _ContainerProcess[bytes] | _ContainerProcess[str]:
394
+ return await self._run(
395
+ lambda d: d.exec(
396
+ *args,
397
+ pty_info=pty_info,
398
+ stdout=stdout,
399
+ stderr=stderr,
400
+ timeout=timeout,
401
+ workdir=workdir,
402
+ env=env,
403
+ secrets=secrets,
404
+ text=text,
405
+ bufsize=bufsize,
406
+ container_id=container_id,
407
+ )
408
+ )
409
+
410
+ # Sidecar containers
411
+
412
+ def validate_sidecar_create(
413
+ self,
414
+ *,
415
+ cloud_bucket_mounts: Sequence[tuple[str, _CloudBucketMount]],
416
+ has_proxy: bool,
417
+ include_oidc_identity_token: bool,
418
+ ) -> None:
419
+ self._delegate.validate_sidecar_create(
420
+ cloud_bucket_mounts=cloud_bucket_mounts,
421
+ has_proxy=has_proxy,
422
+ include_oidc_identity_token=include_oidc_identity_token,
423
+ )
424
+
425
+ async def create_sidecar(
426
+ self,
427
+ *args: str,
428
+ name: str,
429
+ image_id: str,
430
+ secret_ids: Sequence[str],
431
+ secret_sources: Sequence[api_pb2.SecretSource],
432
+ workdir: str | None,
433
+ volume_mounts: Sequence[api_pb2.VolumeMount],
434
+ cloud_bucket_mounts: Sequence[tuple[str, _CloudBucketMount]],
435
+ network_access: api_pb2.NetworkAccess,
436
+ include_oidc_identity_token: bool,
437
+ proxy_id: str | None,
438
+ pty_info: api_pb2.PTYInfo | None,
439
+ memory_reserve_consume_mib: int | None,
440
+ ) -> tuple[str, str]:
441
+ return await self._run(
442
+ lambda d: d.create_sidecar(
443
+ *args,
444
+ name=name,
445
+ image_id=image_id,
446
+ secret_ids=secret_ids,
447
+ secret_sources=secret_sources,
448
+ workdir=workdir,
449
+ volume_mounts=volume_mounts,
450
+ cloud_bucket_mounts=cloud_bucket_mounts,
451
+ network_access=network_access,
452
+ include_oidc_identity_token=include_oidc_identity_token,
453
+ proxy_id=proxy_id,
454
+ pty_info=pty_info,
455
+ memory_reserve_consume_mib=memory_reserve_consume_mib,
456
+ )
457
+ )
458
+
459
+ async def get_sidecar(self, name: str, include_terminated: bool) -> sr_pb2.TaskContainerInfo:
460
+ return await self._run(lambda d: d.get_sidecar(name, include_terminated))
461
+
462
+ async def list_sidecars(self, include_terminated: bool) -> list[sr_pb2.TaskContainerInfo]:
463
+ return await self._run(lambda d: d.list_sidecars(include_terminated))
464
+
465
+ async def wait_sidecar(self, container_id: str, timeout: float) -> api_pb2.GenericResult | None:
466
+ # Sidecar containers don't carry over to a replacement delegate.
467
+ return await self._delegate.wait_sidecar(container_id, timeout)
468
+
469
+ async def terminate_sidecar(self, container_id: str) -> None:
470
+ await self._delegate.terminate_sidecar(container_id)
modal/sandbox/_impl_v1.py CHANGED
@@ -40,6 +40,7 @@ from ..io_streams import (
40
40
  from ..secret import _Secret
41
41
  from ..stream_type import StreamType
42
42
  from ._common import (
43
+ _PERSISTENT_ONLY_MESSAGE,
43
44
  _TASK_ID_POLL_INTERVAL,
44
45
  ExitSnapshotPoll,
45
46
  SandboxCreateArgs,
@@ -124,6 +125,9 @@ class SandboxImplV1:
124
125
  def _client(self) -> _Client:
125
126
  return self.client
126
127
 
128
+ def clone(self) -> "SandboxImplV1":
129
+ return type(self)(self.client, self.sandbox_id, result=self.result)
130
+
127
131
  @classmethod
128
132
  async def create(
129
133
  cls, create_args: SandboxCreateArgs, *, app: "modal.app._App | None", client: _Client | None
@@ -289,6 +293,12 @@ class SandboxImplV1:
289
293
  req = api_pb2.SandboxTerminateRequest(sandbox_id=self.sandbox_id)
290
294
  await self.client._stub.SandboxTerminate(req)
291
295
 
296
+ async def pause(self) -> None:
297
+ raise NotImplementedError(_PERSISTENT_ONLY_MESSAGE.format("_experimental_pause()"))
298
+
299
+ async def resume(self) -> None:
300
+ raise NotImplementedError(_PERSISTENT_ONLY_MESSAGE.format("_experimental_resume()"))
301
+
292
302
  async def snapshot_filesystem(self, timeout: int, *, ttl: int | None, container_id: str = "") -> _Image:
293
303
  if not container_id and os.environ.get("MODAL_USE_LEGACY_FILESYSTEM_SNAPSHOT") == "1":
294
304
  if ttl != DEFAULT_SNAPSHOT_TTL:
modal/sandbox/_impl_v2.py CHANGED
@@ -32,6 +32,7 @@ from ..io_streams import (
32
32
  from ..secret import _resolvable_secrets, _Secret, _secret_sources
33
33
  from ..stream_type import StreamType
34
34
  from ._common import (
35
+ _PERSISTENT_ONLY_MESSAGE,
35
36
  _TASK_ID_POLL_INTERVAL,
36
37
  ExitSnapshotPoll,
37
38
  SandboxCreateArgs,
@@ -147,6 +148,9 @@ class SandboxImplV2:
147
148
  def _client(self) -> _Client:
148
149
  return self.client
149
150
 
151
+ def clone(self) -> "SandboxImplV2":
152
+ return type(self)(self.client, self.sandbox_id, result=self.result)
153
+
150
154
  @classmethod
151
155
  async def create(
152
156
  cls, create_args: SandboxCreateArgs, *, app: "modal.app._App | None", client: _Client | None
@@ -351,6 +355,12 @@ class SandboxImplV2:
351
355
  req = api_pb2.SandboxTerminateRequest(sandbox_id=self.sandbox_id)
352
356
  await self.client._stub.SandboxTerminateV2(req, metadata=await self._metadata())
353
357
 
358
+ async def pause(self) -> None:
359
+ raise NotImplementedError(_PERSISTENT_ONLY_MESSAGE.format("_experimental_pause()"))
360
+
361
+ async def resume(self) -> None:
362
+ raise NotImplementedError(_PERSISTENT_ONLY_MESSAGE.format("_experimental_resume()"))
363
+
354
364
  def _create_sidecar_via_control_plane(self) -> bool:
355
365
  # Opt out via the `use_control_plane_sidecar_create` config setting to create
356
366
  # sidecars over the task command router instead.
modal/sandbox/_sandbox.py CHANGED
@@ -46,6 +46,7 @@ from ._common import (
46
46
  default_pty_info,
47
47
  )
48
48
  from ._filesystem import _SandboxFilesystem
49
+ from ._impl_persistent import SandboxImplPersistent
49
50
  from ._impl_v1 import SandboxImplV1, command_router_access_fetcher as v1_command_router_access_fetcher
50
51
  from ._impl_v2 import SandboxImplV2, command_router_access_fetcher as v2_command_router_access_fetcher
51
52
  from ._sidecar import _SidecarManager
@@ -69,6 +70,8 @@ def impl_class_for_sandbox_id(sandbox_id: str) -> type[SandboxImpl]:
69
70
 
70
71
  def impl_class_for_create_args(create_args: SandboxCreateArgs) -> type[SandboxImpl]:
71
72
  """Select the implementation that creates the Sandbox described by `create_args`."""
73
+ if create_args.persistent:
74
+ return SandboxImplPersistent
72
75
  if create_args.gpu is not None or create_args.network_file_systems or create_args.pty_info is not None:
73
76
  # GPUs, network file systems, and PTYs are not supported on V2, so those calls use the V1
74
77
  # backend regardless of the setting.
@@ -183,7 +186,7 @@ class _Sandbox(_Object, type_prefix="sb"):
183
186
  env: dict[str, str | None] | None = None,
184
187
  secrets: Collection[_Secret] | None = None,
185
188
  network_file_systems: dict[str | os.PathLike, _NetworkFileSystem] = {},
186
- timeout: int = 300,
189
+ timeout: int | None = 300,
187
190
  idle_timeout: int | None = None,
188
191
  workdir: str | None = None,
189
192
  gpu: str | None = None,
@@ -209,6 +212,7 @@ class _Sandbox(_Object, type_prefix="sb"):
209
212
  verbose: bool = False,
210
213
  experimental_options: dict[str, Any] | None = None,
211
214
  _experimental_enable_snapshot: bool = False,
215
+ _experimental_persistent: bool = False,
212
216
  client: _Client | None = None,
213
217
  environment_name: str | None = None, # *DEPRECATED*
214
218
  pty_info: api_pb2.PTYInfo | None = None, # *DEPRECATED*
@@ -349,6 +353,7 @@ class _Sandbox(_Object, type_prefix="sb"):
349
353
  custom_domain=custom_domain,
350
354
  include_oidc_identity_token=include_oidc_identity_token,
351
355
  runtime=runtime,
356
+ persistent=_experimental_persistent,
352
357
  )
353
358
  impl, metadata = await impl_class_for_create_args(create_args).create(create_args, app=app, client=client)
354
359
  return _Sandbox._from_impl(impl, metadata)
@@ -495,8 +500,7 @@ class _Sandbox(_Object, type_prefix="sb"):
495
500
  super()._initialize_from_other(other)
496
501
  self._attached = other._attached
497
502
  self._app_id = other._app_id
498
- other_impl = other._impl
499
- self._impl = type(other_impl)(other_impl.client, other_impl.sandbox_id, result=other_impl.result)
503
+ self._impl = other._impl.clone()
500
504
 
501
505
  def _initialize_from_empty(self):
502
506
  super()._initialize_from_empty()
@@ -1029,6 +1033,20 @@ class _Sandbox(_Object, type_prefix="sb"):
1029
1033
  return self.returncode
1030
1034
  return None
1031
1035
 
1036
+ async def _experimental_pause(self) -> None:
1037
+ """Stop the Sandbox's current container, keeping its state so it can be resumed.
1038
+
1039
+ Only supported for experimental persistent Sandboxes.
1040
+ """
1041
+ await self._impl.pause()
1042
+
1043
+ async def _experimental_resume(self) -> None:
1044
+ """Resume a paused Sandbox from its saved state.
1045
+
1046
+ Only supported for experimental persistent Sandboxes.
1047
+ """
1048
+ await self._impl.resume()
1049
+
1032
1050
  async def poll(self) -> int | None:
1033
1051
  """Check if the Sandbox has finished running.
1034
1052
 
modal/sandbox/sandbox.pyi CHANGED
@@ -1333,7 +1333,7 @@ class Sandbox(modal.object.Object):
1333
1333
  network_file_systems: dict[
1334
1334
  typing.Union[str, os.PathLike], modal.network_file_system.NetworkFileSystem
1335
1335
  ] = {},
1336
- timeout: int = 300,
1336
+ timeout: typing.Optional[int] = 300,
1337
1337
  idle_timeout: typing.Optional[int] = None,
1338
1338
  workdir: typing.Optional[str] = None,
1339
1339
  gpu: typing.Optional[str] = None,
@@ -1362,6 +1362,7 @@ class Sandbox(modal.object.Object):
1362
1362
  verbose: bool = False,
1363
1363
  experimental_options: typing.Optional[dict[str, typing.Any]] = None,
1364
1364
  _experimental_enable_snapshot: bool = False,
1365
+ _experimental_persistent: bool = False,
1365
1366
  client: typing.Optional[modal.client.Client] = None,
1366
1367
  environment_name: typing.Optional[str] = None,
1367
1368
  pty_info: typing.Optional[modal_proto.api_pb2.PTYInfo] = None,
@@ -1457,7 +1458,7 @@ class Sandbox(modal.object.Object):
1457
1458
  network_file_systems: dict[
1458
1459
  typing.Union[str, os.PathLike], modal.network_file_system.NetworkFileSystem
1459
1460
  ] = {},
1460
- timeout: int = 300,
1461
+ timeout: typing.Optional[int] = 300,
1461
1462
  idle_timeout: typing.Optional[int] = None,
1462
1463
  workdir: typing.Optional[str] = None,
1463
1464
  gpu: typing.Optional[str] = None,
@@ -1486,6 +1487,7 @@ class Sandbox(modal.object.Object):
1486
1487
  verbose: bool = False,
1487
1488
  experimental_options: typing.Optional[dict[str, typing.Any]] = None,
1488
1489
  _experimental_enable_snapshot: bool = False,
1490
+ _experimental_persistent: bool = False,
1489
1491
  client: typing.Optional[modal.client.Client] = None,
1490
1492
  environment_name: typing.Optional[str] = None,
1491
1493
  pty_info: typing.Optional[modal_proto.api_pb2.PTYInfo] = None,
@@ -2582,6 +2584,40 @@ class Sandbox(modal.object.Object):
2582
2584
 
2583
2585
  terminate: __terminate_spec
2584
2586
 
2587
+ class ___experimental_pause_spec(typing_extensions.Protocol):
2588
+ def __call__(self, /) -> None:
2589
+ """Stop the Sandbox's current container, keeping its state so it can be resumed.
2590
+
2591
+ Only supported for experimental persistent Sandboxes.
2592
+ """
2593
+ ...
2594
+
2595
+ async def aio(self, /) -> None:
2596
+ """Stop the Sandbox's current container, keeping its state so it can be resumed.
2597
+
2598
+ Only supported for experimental persistent Sandboxes.
2599
+ """
2600
+ ...
2601
+
2602
+ _experimental_pause: ___experimental_pause_spec
2603
+
2604
+ class ___experimental_resume_spec(typing_extensions.Protocol):
2605
+ def __call__(self, /) -> None:
2606
+ """Resume a paused Sandbox from its saved state.
2607
+
2608
+ Only supported for experimental persistent Sandboxes.
2609
+ """
2610
+ ...
2611
+
2612
+ async def aio(self, /) -> None:
2613
+ """Resume a paused Sandbox from its saved state.
2614
+
2615
+ Only supported for experimental persistent Sandboxes.
2616
+ """
2617
+ ...
2618
+
2619
+ _experimental_resume: ___experimental_resume_spec
2620
+
2585
2621
  class __poll_spec(typing_extensions.Protocol):
2586
2622
  def __call__(self, /) -> typing.Optional[int]:
2587
2623
  """Check if the Sandbox has finished running.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.6.3.dev5
3
+ Version: 1.6.3.dev7
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -8,7 +8,7 @@ modal/_environments.py,sha256=O7YagqEUhmxEG5Q10Ch6FDSIsAKFbKXULLaeRw0uCio,27602
8
8
  modal/_function_variants.py,sha256=xF34OxGaZbmxe0chAJCQb9Uyc8TP78VG8MO55deBWLs,24398
9
9
  modal/_functions.py,sha256=-yGdaDkb2IPKDu4TWl93HpiWEU2Qv3bBGrSklEjiE3I,118392
10
10
  modal/_grpc_client.py,sha256=ajCxTqhDk-rpWuYmUScRXwYMPVmvujo8ckvvv4l0czk,7088
11
- modal/_image.py,sha256=xuNfn9b9W8IjtH2jQkLVRkqJaysp-vm06BhbpgbSIIk,132898
11
+ modal/_image.py,sha256=RrTSL4WBuaCpZwvimgK8jvQWzwIvBOd2crKM_QYonOM,133085
12
12
  modal/_ipython.py,sha256=Bu9fc3hm3xt2O47qLe_d-Mu2jS81LwMsmXNJ1sawBA0,575
13
13
  modal/_load_context.py,sha256=wrDqD3KehhFepp0vBhyz_PoLWoRVD0gZHP8dlLHCTiw,4818
14
14
  modal/_location.py,sha256=joiX-0ZeutEUDTrrqLF1GHXCdVLF-rHzstocbMcd_-k,366
@@ -39,7 +39,7 @@ modal/billing.py,sha256=E1-P_P8bJFLp-bTaggYd4BaaViImPB5UdtXiY8VeAZQ,390
39
39
  modal/billing.pyi,sha256=4m7VLYNS8IiJ2I3CJx0awvrOM6BILWRVLfdeqtAYr8M,7189
40
40
  modal/call_graph.py,sha256=XGALIDn1bLchWo_goyX23bcPjzCZcfqKUo8INIfDapo,1775
41
41
  modal/client.py,sha256=cstrL0wpXCZmcvRs7Sy3QRw9Vz8zkZy8NNk0tPe38sk,22852
42
- modal/client.pyi,sha256=wkF1jBOBV3bFWy8sI4U5HwBKIkFNyf1rjIFiI0E7sAA,23488
42
+ modal/client.pyi,sha256=Rezm_F0bqn00RhEQ0cCYxSqFdofGx4A61ZDQIBpDpFk,23488
43
43
  modal/cloud_bucket_mount.py,sha256=ZOg69dhIFcJcwim4JP-6s1tqwOYAfgS9806jRpspifw,7511
44
44
  modal/cloud_bucket_mount.pyi,sha256=HoPe_NG5jYdVfmTNY-ZJ3-C8Eo_FzJovl9orJRjkGbE,10252
45
45
  modal/cls.py,sha256=JfDAUMrkhasm26lK7kDUNarXZA_7AvZrymf7XNSGTDM,41777
@@ -58,11 +58,11 @@ modal/file_pattern_matcher.py,sha256=CpaNNj7oBlOSewX-AAUZCF8KDxH0_DmgwzfpVGbPYJ4
58
58
  modal/functions.py,sha256=-5aogviGxcNXX4qZ34GSBMKnLqH-WH0eb4cGbHpCYYw,382
59
59
  modal/functions.pyi,sha256=lnDLXYKS86J57g0gmNOWeY70KIyIho945CDSkPDN5lU,62671
60
60
  modal/image.py,sha256=c0lUSAoU-Ip3g5DGZ0uhdCzoWSQWh1g0i-7Rxo-Sz-I,160
61
- modal/image.pyi,sha256=R5rdY4P30IEm-F7A2S9CdXa6QYNk-pQwsEun9BKTyLw,61094
61
+ modal/image.pyi,sha256=L53g84DVKa94LvqSqqYZLpG2o9-vxcC5vp5pXKQB3f0,61210
62
62
  modal/io_streams.py,sha256=iWwhGCcTwhNtIo2t2f-qVV3FBEK1DIi40Tm_Ai01eYA,32992
63
63
  modal/io_streams.pyi,sha256=ciTHe1pyl6Wii7QEI5cPRmnHBLOw3sOh_AnWsQlpzTw,26345
64
- modal/mount.py,sha256=k-NsrptGC4sMdWPj3mzIBnC6YDUmFdtV909j-A_a9E0,37700
65
- modal/mount.pyi,sha256=aQM9hIJCIeKnooL0_z2APx6-0wW_WQ6v27vafRzKUvs,16646
64
+ modal/mount.py,sha256=Wmt6F0aUqTZHQycu_Rs-qrHlngdtNnSgLGLVv4ex4mY,38767
65
+ modal/mount.pyi,sha256=RjYRGZzFe0RYpnZT6aXkZRsIsFyPAa9F-3gz9uXkRfA,16639
66
66
  modal/network_file_system.py,sha256=2uNLvtvN-WnDJlriKqQE5e4Eh10dJHFGGIEQH7xlaKc,14617
67
67
  modal/network_file_system.pyi,sha256=Z1BFJJSd99g1IKZXydikE6fuYWu0vQ2uBsApG9WX4H8,18037
68
68
  modal/object.py,sha256=bTeskuY8JFrESjU4_UL_nTwYlBQdOLmVaOX3X6EMxsg,164
@@ -127,7 +127,7 @@ modal/_utils/display_utils.py,sha256=5XyCCjnMAWeLEEqF0hiLSA6XrrNp-gvXVsLcngN6vAI
127
127
  modal/_utils/docker_utils.py,sha256=k8fkYT6G-X_B3iamjxiSpwSE-k58xGVHtsk2_gR7i2Q,4197
128
128
  modal/_utils/function_utils.py,sha256=dVCCnovfSlYssRuQh0Woq5oqX4Kj_tAK4vJ3USFNUFw,27537
129
129
  modal/_utils/git_utils.py,sha256=Lat_l4O4hwOYgylEtWup3hyqWT8D7JoCIjCflXcX8BE,3148
130
- modal/_utils/grpc_testing.py,sha256=udyK_wRXX_yc9eLt8mIZZqx12XlaiUqORxaGNvfI_MM,8697
130
+ modal/_utils/grpc_testing.py,sha256=59FDtDsSeKrp9gmFI41dP_67yxxxydm06BhixpEnf2k,8828
131
131
  modal/_utils/grpc_utils.py,sha256=Gcl5EyEMEHMaMlGh90F63Oi1l9K6cnmeTCbGCouwJ-A,29998
132
132
  modal/_utils/hash_utils.py,sha256=WHuMRwmB4fnaM-qmyWXR87C2CinvLnmRoygSzsTEunA,3008
133
133
  modal/_utils/http_utils.py,sha256=TO9nnKR0Cc-iWCmkou56wJh9MHgNZwQMosK8WK-GD5Y,3133
@@ -153,7 +153,7 @@ modal/builder/2023.12.txt,sha256=OjsbXFkCSdkzzryZP82Q73osr5wxQ6EUzmGcK7twfkA,502
153
153
  modal/builder/2024.04.txt,sha256=6NnrbIE-mflwMyKyQ0tsWeY8XFE1kSW9oE8DVDoD8QU,544
154
154
  modal/builder/2024.10.txt,sha256=qD-5cVIVM9wXesJ6JC89Ew-3m2KjEElUz3jaw_MddRo,296
155
155
  modal/builder/2025.06.txt,sha256=90NzKtbJ32RrUtYiUv1XtJTDEaJ1SFcVpJXUE7Udle4,791
156
- modal/builder/PREVIEW.txt,sha256=9iN3AGZSovazQpxSHekDGDgjhroPz9Z-A6hZiZMJ7bs,544
156
+ modal/builder/PREVIEW.txt,sha256=bBLNruBc0v4ulzCraMBVfVm-6kPku3Ag1nFnTy1kr_o,326
157
157
  modal/builder/README.md,sha256=9tK76KP0Uph7O0M5oUgsSwEZDj5y-dcUPsnpR0Sc-Ik,854
158
158
  modal/builder/base-images.json,sha256=5wyWfI3fQ51PH_dHvmXeTJPrKQBdi5B3DihQIWEXC4Q,1202
159
159
  modal/cli/__init__.py,sha256=6FRleWQxBDT19y7OayO4lBOzuL6Bs9r0rLINYYYbHwQ,769
@@ -198,18 +198,19 @@ modal/experimental/flash_server.py,sha256=iqxVoQij6aM58_h0la6Y_Vy3CCQj8iYmjzt79r
198
198
  modal/experimental/flash_server.pyi,sha256=LuIBqeOJ1NAapKWnJMRzzrwIHlIK2GcOLOI2r-z_QN0,1025
199
199
  modal/experimental/ipython.py,sha256=kJ6o9wkAY_MiSb7paewI7f1MJAeqdi8TX5pBdhr_mUI,2925
200
200
  modal/sandbox/__init__.py,sha256=wHE5KCHUaMSK_nNGbn852PI423v3Q4UQEsFpoqNqMMI,203
201
- modal/sandbox/_common.py,sha256=z82O2XIrRaeanCzNY4CBnk1X1MuRROJjDcFDtSDtaV8,46549
201
+ modal/sandbox/_common.py,sha256=Kuibj5eS8upWLbSxtYdojPZtyCh0iLtdsAJ4RuK2Lk4,47098
202
202
  modal/sandbox/_filesystem.py,sha256=GdkMNVahPpUpfuY04HwWWwZyMID3bcut7PHXNWOIpYg,28299
203
- modal/sandbox/_impl_v1.py,sha256=AiFhTxEXb-B3TWRyDnIeO55UrMwEJAnDIhYf5h5oYrE,20513
204
- modal/sandbox/_impl_v2.py,sha256=O1NpY7c5qF-8RbypnWjdJxUfivxO-XAevDeluoLDtJs,25866
205
- modal/sandbox/_sandbox.py,sha256=K6GVvu4QW07gQWot8ZNX5wpsolYl-ZFVj3-RyfooopQ,61001
203
+ modal/sandbox/_impl_persistent.py,sha256=PwlzsxfQYvsKPHDgmK1LnYSx7CIZSFKCtm_87YBSrnc,20182
204
+ modal/sandbox/_impl_v1.py,sha256=Xy0Mp0_X9T7WAxY20R2NVaobXiC4ciPdsRLC0k4JnCM,20918
205
+ modal/sandbox/_impl_v2.py,sha256=AUCRYVv-qm-eLEOqmsAacwXyRK7VTJ1faPJdwk18q5g,26271
206
+ modal/sandbox/_sandbox.py,sha256=wvchpp_g1zotOMpMhzmiAovaAi6_q_f5_CF2aKzijQ8,61591
206
207
  modal/sandbox/_sidecar.py,sha256=k9SMe2V--gkj22fZRC3EN4GNPtdPFkrwJRxB7W4IFU4,21303
207
208
  modal/sandbox/_snapshot.py,sha256=8mB6FOecXEbgTC9dyogIEGCxTdjemuFsyfVFQyQ2Cp8,3105
208
209
  modal/sandbox/_task_command_router_client.py,sha256=ta_I2EsVruEkRE9YCaCHns4oCb_htz7aQIf0ZSp2wH0,55181
209
210
  modal/sandbox/sandbox.py,sha256=4ekghND9qUGf5wtIQH4mVa6PHGjHgY0UN5RKryOM05k,683
210
- modal/sandbox/sandbox.pyi,sha256=H3Itxk7z1hbrsRIF_zZP8mzAhMJ0pnrr1FvnWUIavZ4,137650
211
+ modal/sandbox/sandbox.pyi,sha256=iWxqScPKLa6oo9vJGukv2XhuCOK0E-bj52trqetBBh8,138895
211
212
  modal/skills/modal/SKILL.md,sha256=Bm-0BPTYppJoeQzA-N-Re8L6-nKnV6gspJXMl4cD0P0,5490
212
- modal-1.6.3.dev5.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
213
+ modal-1.6.3.dev7.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
213
214
  modal_docs/__init__.py,sha256=svYKtV8HDwDCN86zbdWqyq5T8sMdGDj0PVlzc2tIxDM,28
214
215
  modal_docs/gen_cli_docs.py,sha256=9-b8vl2EsLWD9zJqpCK8IUi5h44Bjn3i3rSdedZV4bA,6415
215
216
  modal_docs/gen_cli_docs_main.py,sha256=UPGr7O1DYTIRLwfINe2rbdh89J3cPVTU1OWL-SjxnOQ,198
@@ -233,10 +234,10 @@ modal_proto/task_command_router_pb2.py,sha256=ysC0d8p3YxwyOpP3B-qDlvE81Yo4ntQiwi
233
234
  modal_proto/task_command_router_pb2.pyi,sha256=FDcd5t3ZCmQHUjefOzg07iglrgKYcBrzFvdKIWsEMHI,50628
234
235
  modal_proto/task_command_router_pb2_grpc.py,sha256=8iGUGwEVbqOsZWPJ7OsQ9OKUGaSCf9Dr2tvb04Ww6d4,44620
235
236
  modal_proto/task_command_router_pb2_grpc.pyi,sha256=26Fq9RQo09B9KLHsgdpRuw-I6Z4-2jrXLQkuDzB-moI,15097
236
- modal_version/__init__.py,sha256=TWF4iwbBqC0_KrbqBk0D3qxFSr5QGvt9zcsCBxWyQ88,120
237
+ modal_version/__init__.py,sha256=_NyIMw3R_WktxIyqvxZFAsrP4rSI-AsLzcRB4crWyC4,120
237
238
  modal_version/__main__.py,sha256=nLUbubxKx7GXNzJIkTB7M7_u8080LyLM0IS0rzFkC_c,119
238
- modal-1.6.3.dev5.dist-info/METADATA,sha256=aOHUajhismM2QZ3trzMWEvzX3-X99wK7Yq0j8gN6S8A,2577
239
- modal-1.6.3.dev5.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
240
- modal-1.6.3.dev5.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
241
- modal-1.6.3.dev5.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
242
- modal-1.6.3.dev5.dist-info/RECORD,,
239
+ modal-1.6.3.dev7.dist-info/METADATA,sha256=YcuXMLRCPRbuK-tN6nj38HqhWH0YinACJZOjRI5NjbI,2577
240
+ modal-1.6.3.dev7.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
241
+ modal-1.6.3.dev7.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
242
+ modal-1.6.3.dev7.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
243
+ modal-1.6.3.dev7.dist-info/RECORD,,
modal_version/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # Copyright Modal Labs 2026
2
2
  """Supplies the current version of the modal client library."""
3
3
 
4
- __version__ = "1.6.3.dev5"
4
+ __version__ = "1.6.3.dev7"