modAL 1.4.3.dev30__py3-none-any.whl → 1.4.4.dev0__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.
@@ -18,7 +18,7 @@ from ._utils.mount_utils import validate_volumes, validate_volumes_by_object_id
18
18
  from .cloud_bucket_mount import _CloudBucketMount, cloud_bucket_mounts_to_proto
19
19
  from .retries import Retries
20
20
  from .secret import _Secret
21
- from .volume import _Volume
21
+ from .volume import _Volume, _volume_to_mount_proto
22
22
 
23
23
  if TYPE_CHECKING:
24
24
  from ._functions import _Function
@@ -162,15 +162,7 @@ class _FunctionOptions:
162
162
  # Needs to be called late so that volumes are hydrated
163
163
  validate_volumes_by_object_id(self.validated_volumes)
164
164
 
165
- volume_mounts = [
166
- api_pb2.VolumeMount(
167
- mount_path=path,
168
- volume_id=volume.object_id,
169
- allow_background_commits=True,
170
- read_only=volume._read_only,
171
- )
172
- for path, volume in self.validated_volumes
173
- ]
165
+ volume_mounts = [_volume_to_mount_proto(path, volume) for path, volume in self.validated_volumes]
174
166
  return api_pb2.FunctionOptions(
175
167
  secret_ids=[secret.object_id for secret in self.secrets],
176
168
  replace_secret_ids=bool(self.secrets),
modal/_functions.py CHANGED
@@ -89,7 +89,7 @@ from .proxy import _Proxy
89
89
  from .retries import Retries, RetryManager
90
90
  from .schedule import Schedule
91
91
  from .secret import _Secret
92
- from .volume import _Volume
92
+ from .volume import _Volume, _volume_to_mount_proto
93
93
 
94
94
  if TYPE_CHECKING:
95
95
  import modal.app
@@ -667,6 +667,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
667
667
  batch_wait_ms: Optional[int] = None,
668
668
  cloud: Optional[str] = None,
669
669
  region: Optional[Union[str, Sequence[str]]] = None,
670
+ routing_region: Optional[str] = None,
670
671
  nonpreemptible: bool = False,
671
672
  is_builder_function: bool = False,
672
673
  is_auto_snapshot: bool = False,
@@ -946,13 +947,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
946
947
 
947
948
  # Relies on dicts being ordered (true as of Python 3.6).
948
949
  volume_mounts = [
949
- api_pb2.VolumeMount(
950
- mount_path=path,
951
- volume_id=volume.object_id,
952
- allow_background_commits=True,
953
- read_only=volume._read_only,
954
- )
955
- for path, volume in validated_volumes_no_cloud_buckets
950
+ _volume_to_mount_proto(path, volume) for path, volume in validated_volumes_no_cloud_buckets
956
951
  ]
957
952
  loaded_mount_ids = {m.object_id for m in all_mounts} | {m.object_id for m in image._mount_layers}
958
953
 
@@ -1033,6 +1028,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1033
1028
  supported_output_formats=supported_output_formats,
1034
1029
  http_config=http_config,
1035
1030
  is_server=is_server,
1031
+ routing_region=routing_region or "",
1036
1032
  )
1037
1033
 
1038
1034
  if isinstance(gpu, list):
@@ -1072,6 +1068,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1072
1068
  supported_output_formats=supported_output_formats,
1073
1069
  http_config=http_config,
1074
1070
  is_server=function_definition.is_server,
1071
+ routing_region=function_definition.routing_region,
1075
1072
  )
1076
1073
 
1077
1074
  ranked_functions = []
modal/_grpc_client.py CHANGED
@@ -44,10 +44,6 @@ _STATUS_TO_EXCEPTION: dict[Status, type[exception._GRPCErrorWrapper]] = {
44
44
 
45
45
 
46
46
  class grpc_error_converter:
47
- def __init__(self, *, expect_timeouts: bool = False):
48
- """expect_timeouts: if this is set we convert cancelled/deadline_exceeded statuses to exception.TimeoutError"""
49
- self._expect_timeouts = expect_timeouts
50
-
51
47
  def __enter__(self):
52
48
  pass
53
49
 
@@ -56,9 +52,6 @@ class grpc_error_converter:
56
52
  use_full_traceback = config.get("traceback")
57
53
  with suppress_tb_frame():
58
54
  if isinstance(exc, GRPCError):
59
- if self._expect_timeouts and exc.status in (Status.CANCELLED, Status.DEADLINE_EXCEEDED):
60
- raise exception.TimeoutError("Timeout expired")
61
-
62
55
  modal_exc = _STATUS_TO_EXCEPTION[exc.status](exc.message)
63
56
  modal_exc._grpc_message = exc.message
64
57
  modal_exc._grpc_status = exc.status
@@ -68,7 +68,7 @@ def validate_volumes(
68
68
  for path, volume in validated_volumes:
69
69
  if not isinstance(volume, (_Volume, _CloudBucketMount)):
70
70
  raise InvalidError(f"Object of type {type(volume)} mounted at '{path}' is not usable as a volume.")
71
- elif isinstance(volume, (_Volume)):
71
+ elif isinstance(volume, _Volume):
72
72
  volume_to_paths.setdefault(volume, []).append(path)
73
73
  for paths in volume_to_paths.values():
74
74
  if len(paths) > 1:
@@ -74,7 +74,7 @@ async def call_with_retries_on_transient_errors(
74
74
  delay_factor: float = 2,
75
75
  max_retries: Optional[int] = 10,
76
76
  exclude_status_codes: Optional[list[Status]] = None,
77
- expect_timeouts: bool = False, # when True we convert TimeoutError to exception.TimeoutError and don't retry"""
77
+ timeout_deadline: Optional[float] = None,
78
78
  ):
79
79
  """Call func() with transient error retries and exponential backoff.
80
80
 
@@ -84,6 +84,13 @@ async def call_with_retries_on_transient_errors(
84
84
  exclude_status_codes: gRPC status codes to exclude from retry logic even if
85
85
  they are in RETRYABLE_GRPC_STATUS_CODES. Use this to let certain errors
86
86
  (e.g. DEADLINE_EXCEEDED) propagate immediately rather than being retried.
87
+ timeout_deadline: Optional monotonic deadline (`time.monotonic()` value).
88
+ When set, retries are not attempted once the deadline is reached and
89
+ the backoff sleep is clamped so we don't sleep past it. It's up to
90
+ the caller to decide whether to further translate the surfaced
91
+ exception (e.g., into a TimeoutError) based on the deadline check.
92
+ The caller is also responsible for propagating the remaining budget
93
+ into `func()` (typically as the per-call gRPC timeout).
87
94
  """
88
95
  delay_secs = base_delay_secs
89
96
  num_retries = 0
@@ -92,10 +99,22 @@ async def call_with_retries_on_transient_errors(
92
99
  def is_retryable_status(status: Status) -> bool:
93
100
  return status in RETRYABLE_GRPC_STATUS_CODES and status not in exclude_status_codes
94
101
 
95
- async def sleep_and_update_delay_and_num_retries_remaining(e: Exception):
102
+ def can_retry() -> bool:
103
+ if max_retries is not None and num_retries >= max_retries:
104
+ return False
105
+ if timeout_deadline is not None and time.monotonic() >= timeout_deadline:
106
+ return False
107
+ return True
108
+
109
+ async def sleep_and_advance(e: Exception):
96
110
  nonlocal delay_secs, num_retries
97
- logger.debug(f"Retrying RPC with delay {delay_secs}s due to error: {e}")
98
- await asyncio.sleep(delay_secs)
111
+ # Clamp the backoff sleep to the remaining deadline so we don't sleep
112
+ # past it just to fail on the next iteration's deadline check.
113
+ sleep_for = delay_secs
114
+ if timeout_deadline is not None:
115
+ sleep_for = min(sleep_for, max(0.0, timeout_deadline - time.monotonic()))
116
+ logger.debug(f"Retrying RPC with delay {sleep_for}s due to error: {e}")
117
+ await asyncio.sleep(sleep_for)
99
118
  delay_secs *= delay_factor
100
119
  num_retries += 1
101
120
 
@@ -103,37 +122,28 @@ async def call_with_retries_on_transient_errors(
103
122
  try:
104
123
  return await func()
105
124
  except GRPCError as e:
106
- if (max_retries is None or num_retries < max_retries) and is_retryable_status(e.status):
107
- await sleep_and_update_delay_and_num_retries_remaining(e)
108
- else:
109
- raise e
125
+ if not is_retryable_status(e.status) or not can_retry():
126
+ raise
127
+ await sleep_and_advance(e)
110
128
  except AttributeError as e:
111
129
  # StreamTerminatedError are not properly raised in grpclib<=0.4.7
112
130
  # fixed in https://github.com/vmagamedov/grpclib/issues/185
113
131
  # TODO: update to newer version (>=0.4.8) once stable
114
- if (max_retries is None or num_retries < max_retries) and "_write_appdata" in str(e):
115
- await sleep_and_update_delay_and_num_retries_remaining(e)
116
- else:
117
- raise e
132
+ if "_write_appdata" not in str(e) or not can_retry():
133
+ raise
134
+ await sleep_and_advance(e)
118
135
  except StreamTerminatedError as e:
119
- if max_retries is None or num_retries < max_retries:
120
- await sleep_and_update_delay_and_num_retries_remaining(e)
121
- else:
122
- raise e
123
- except asyncio.TimeoutError as e:
124
- if expect_timeouts:
125
- # grpclib raises TimeoutError for the client-side timeout/deadlines
126
- raise ModalTimeoutError("Timeout expired")
127
-
128
- if max_retries is None or num_retries < max_retries:
129
- await sleep_and_update_delay_and_num_retries_remaining(e)
130
- else:
131
- raise ConnectionError(str(e))
132
- except OSError as e:
133
- if max_retries is None or num_retries < max_retries:
134
- await sleep_and_update_delay_and_num_retries_remaining(e)
135
- else:
136
+ if not can_retry():
137
+ raise
138
+ await sleep_and_advance(e)
139
+ except (asyncio.TimeoutError, OSError) as e:
140
+ if not can_retry():
141
+ # Client-side timeout / network OSError surfaces as a generic
142
+ # ConnectionError once we stop retrying. Callers that pass
143
+ # `timeout_deadline` can further translate this based on
144
+ # whether the deadline has elapsed.
136
145
  raise ConnectionError(str(e))
146
+ await sleep_and_advance(e)
137
147
 
138
148
 
139
149
  async def fetch_command_router_access(server_client, task_id: str) -> api_pb2.TaskGetCommandRouterAccessResponse:
@@ -656,20 +666,34 @@ class TaskCommandRouterClient:
656
666
  )
657
667
 
658
668
  async def snapshot_filesystem(
659
- self, request: sr_pb2.TaskSnapshotFilesystemRequest, *, timeout: Optional[int] = None, **kwargs
669
+ self, request: sr_pb2.TaskSnapshotFilesystemRequest, *, timeout: float, **kwargs
660
670
  ) -> sr_pb2.TaskSnapshotFilesystemResponse:
661
- expect_timeouts = timeout is not None
662
- with grpc_error_converter(expect_timeouts=expect_timeouts):
663
- # note: TaskSnapshotFilesystem has a timeout concept with multiple variants:
664
- # * Normally it would time out on the client side inside of grpclib - this causes an asyncio.TimeoutError
665
- # * It also propagates the timeout to the server however, which could potentially trigger, in particular
666
- # if an in-progress requests is idempotently rejoined as a retry. These errors appear to be propagated
667
- # as Status.CANCELLED at the time of writing this, but we may change the backend to return
668
- # DEADLINE_EXCEEDED in the future, so we want to make the code compatible with both here:
669
- return await call_with_retries_on_transient_errors(
670
- lambda: self._call_with_auth_retry(
671
- self._stub.TaskSnapshotFilesystem, request, timeout=timeout, **kwargs
672
- ),
673
- exclude_status_codes=[Status.DEADLINE_EXCEEDED, Status.CANCELLED],
674
- expect_timeouts=expect_timeouts, # client wanted a timeout - raise as such immediately
671
+ # Compute the overall deadline once; each retry attempt passes the
672
+ # remaining budget as the per-call gRPC timeout so we honor the
673
+ # caller-specified `timeout` across retries instead of giving each
674
+ # attempt a fresh full window.
675
+ timeout_deadline = time.monotonic() + timeout
676
+
677
+ def call():
678
+ call_timeout = timeout_deadline - time.monotonic()
679
+ if call_timeout <= 0.0:
680
+ # doesn't matter which exception type this is
681
+ # as it will be caught by the catch all below
682
+ raise ModalTimeoutError("Timeout expired")
683
+
684
+ return self._call_with_auth_retry(
685
+ self._stub.TaskSnapshotFilesystem, request, timeout=call_timeout, **kwargs
675
686
  )
687
+
688
+ # Any failure observed at or after the deadline is treated as a timeout
689
+ try:
690
+ with grpc_error_converter():
691
+ return await call_with_retries_on_transient_errors(
692
+ call,
693
+ exclude_status_codes=[Status.DEADLINE_EXCEEDED, Status.CANCELLED],
694
+ timeout_deadline=timeout_deadline,
695
+ )
696
+ except Exception:
697
+ if time.monotonic() >= timeout_deadline:
698
+ raise ModalTimeoutError("Timeout expired")
699
+ raise
modal/app.py CHANGED
@@ -743,6 +743,7 @@ class _App:
743
743
  ] = None, # Set this to True if it's a non-generator function returning a [sync/async] generator object
744
744
  cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
745
745
  region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
746
+ routing_region: Optional[str] = None, # Region to route inputs to the function through.
746
747
  nonpreemptible: bool = False, # Whether to run the function on a nonpreemptible instance.
747
748
  enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
748
749
  block_network: bool = False, # Whether to block network access
@@ -902,6 +903,7 @@ class _App:
902
903
  startup_timeout=startup_timeout or timeout,
903
904
  cloud=cloud,
904
905
  region=region,
906
+ routing_region=routing_region,
905
907
  nonpreemptible=nonpreemptible,
906
908
  webhook_config=webhook_config,
907
909
  enable_memory_snapshot=enable_memory_snapshot,
@@ -957,6 +959,7 @@ class _App:
957
959
  startup_timeout: Optional[int] = None, # Maximum startup time in seconds with higher precedence than `timeout`.
958
960
  cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
959
961
  region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
962
+ routing_region: Optional[str] = None, # Region to route inputs to the function through.
960
963
  nonpreemptible: bool = False, # Whether to run the function on a non-preemptible instance.
961
964
  enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
962
965
  block_network: bool = False, # Whether to block network access
@@ -1100,6 +1103,7 @@ class _App:
1100
1103
  startup_timeout=startup_timeout or timeout,
1101
1104
  cloud=cloud,
1102
1105
  region=region,
1106
+ routing_region=routing_region,
1103
1107
  nonpreemptible=nonpreemptible,
1104
1108
  enable_memory_snapshot=enable_memory_snapshot,
1105
1109
  block_network=block_network,
modal/app.pyi CHANGED
@@ -490,6 +490,7 @@ class _App:
490
490
  is_generator: typing.Optional[bool] = None,
491
491
  cloud: typing.Optional[str] = None,
492
492
  region: typing.Union[str, collections.abc.Sequence[str], None] = None,
493
+ routing_region: typing.Optional[str] = None,
493
494
  nonpreemptible: bool = False,
494
495
  enable_memory_snapshot: bool = False,
495
496
  block_network: bool = False,
@@ -537,6 +538,7 @@ class _App:
537
538
  startup_timeout: typing.Optional[int] = None,
538
539
  cloud: typing.Optional[str] = None,
539
540
  region: typing.Union[str, collections.abc.Sequence[str], None] = None,
541
+ routing_region: typing.Optional[str] = None,
540
542
  nonpreemptible: bool = False,
541
543
  enable_memory_snapshot: bool = False,
542
544
  block_network: bool = False,
@@ -1208,6 +1210,7 @@ class App:
1208
1210
  is_generator: typing.Optional[bool] = None,
1209
1211
  cloud: typing.Optional[str] = None,
1210
1212
  region: typing.Union[str, collections.abc.Sequence[str], None] = None,
1213
+ routing_region: typing.Optional[str] = None,
1211
1214
  nonpreemptible: bool = False,
1212
1215
  enable_memory_snapshot: bool = False,
1213
1216
  block_network: bool = False,
@@ -1255,6 +1258,7 @@ class App:
1255
1258
  startup_timeout: typing.Optional[int] = None,
1256
1259
  cloud: typing.Optional[str] = None,
1257
1260
  region: typing.Union[str, collections.abc.Sequence[str], None] = None,
1261
+ routing_region: typing.Optional[str] = None,
1258
1262
  nonpreemptible: bool = False,
1259
1263
  enable_memory_snapshot: bool = False,
1260
1264
  block_network: bool = False,
modal/cli/shell.py CHANGED
@@ -58,6 +58,23 @@ def _passed_forbidden_args(
58
58
  return passed_forbidden
59
59
 
60
60
 
61
+ def _parse_experimental_options(options: Iterable[str]) -> dict[str, bool]:
62
+ parsed: dict[str, bool] = {}
63
+ for option in options:
64
+ key, sep, raw_value = option.partition("=")
65
+ if not key or not sep:
66
+ raise click.BadParameter("must use KEY=VALUE")
67
+
68
+ value = raw_value.lower()
69
+ if value in {"1", "true"}:
70
+ parsed[key] = True
71
+ elif value in {"0", "false"}:
72
+ parsed[key] = False
73
+ else:
74
+ raise click.BadParameter("BOOL must be one of true, false, 1, or 0")
75
+ return parsed
76
+
77
+
61
78
  def _is_valid_modal_id(ref: str, prefix: str) -> bool:
62
79
  assert prefix.endswith("-")
63
80
  return ref.startswith(prefix) and len(ref[len(prefix) :]) > 0 and ref[len(prefix) :].isalnum()
@@ -180,6 +197,7 @@ def _start_shell_from_function_spec(
180
197
  timeout: int,
181
198
  function_spec: _FunctionSpec,
182
199
  pty: bool,
200
+ experimental_options: dict[str, bool],
183
201
  ) -> None:
184
202
  interactive_shell(
185
203
  app,
@@ -198,6 +216,7 @@ def _start_shell_from_function_spec(
198
216
  region=function_spec.scheduler_placement.regions if function_spec.scheduler_placement else None,
199
217
  pty=pty,
200
218
  proxy=function_spec.proxy,
219
+ experimental_options=experimental_options,
201
220
  )
202
221
 
203
222
 
@@ -216,6 +235,7 @@ def _start_shell_from_image(
216
235
  cloud: Optional[str],
217
236
  region: Optional[str],
218
237
  pty: bool,
238
+ experimental_options: dict[str, bool],
219
239
  ) -> None:
220
240
  volumes = {f"/mnt/{vol}": Volume.from_name(vol) for vol in volume}
221
241
  secrets = [Secret.from_name(s) for s in secret]
@@ -246,6 +266,7 @@ def _start_shell_from_image(
246
266
  secrets=secrets,
247
267
  region=region.split(",") if region else [],
248
268
  pty=pty,
269
+ experimental_options=experimental_options,
249
270
  )
250
271
 
251
272
 
@@ -298,6 +319,14 @@ def _start_shell_from_image(
298
319
  default=False,
299
320
  help="Interpret argument as a Python module path instead of a file/script path",
300
321
  )
322
+ @click.option(
323
+ "--experimental-option",
324
+ "experimental_options",
325
+ multiple=True,
326
+ default=(),
327
+ hidden=True,
328
+ metavar="KEY=VALUE",
329
+ )
301
330
  def shell(
302
331
  ref: Optional[str] = None,
303
332
  cmd: str = "/bin/bash",
@@ -314,6 +343,7 @@ def shell(
314
343
  region: Optional[str] = None,
315
344
  pty: Optional[bool] = None,
316
345
  use_module_mode: bool = False,
346
+ experimental_options: tuple[str, ...] = (),
317
347
  ):
318
348
  """Run a command or interactive shell inside a Modal container.
319
349
 
@@ -384,12 +414,16 @@ def shell(
384
414
  # NB: invoking under bash makes --cmd a lot more flexible.
385
415
  cmds = shlex.split(f'/bin/bash -c "{cmd}"')
386
416
  timeout = 3600
417
+ parsed_experimental_options = _parse_experimental_options(experimental_options)
387
418
 
388
419
  if ref is not None and not _is_valid_modal_id(ref, "im-"):
389
420
  # If ref it not a Modal Image ID, then it's a function reference, and we'll start a new container from its spec.
390
421
  ctx = click.get_current_context()
391
422
  if passed_forbidden := _passed_forbidden_args(
392
- shell.params, ctx, locals(), allowed=lambda p: p in {"cmd", "env", "pty", "ref", "use_module_mode"}
423
+ shell.params,
424
+ ctx,
425
+ locals(),
426
+ allowed=lambda p: p in {"cmd", "env", "pty", "ref", "use_module_mode", "experimental_options"},
393
427
  ):
394
428
  raise ClickException(
395
429
  f"Cannot specify container configuration arguments ({', '.join(passed_forbidden)}) "
@@ -397,7 +431,7 @@ def shell(
397
431
  )
398
432
 
399
433
  function_spec = _function_spec_from_ref(ref, use_module_mode)
400
- _start_shell_from_function_spec(app, cmds, env, timeout, function_spec, pty)
434
+ _start_shell_from_function_spec(app, cmds, env, timeout, function_spec, pty, parsed_experimental_options)
401
435
  return
402
436
 
403
437
  if ref is not None and _is_valid_modal_id(ref, "im-"):
@@ -428,4 +462,5 @@ def shell(
428
462
  cloud,
429
463
  region,
430
464
  pty,
465
+ parsed_experimental_options,
431
466
  )
modal/client.pyi CHANGED
@@ -35,7 +35,7 @@ class _Client:
35
35
  server_url: str,
36
36
  client_type: int,
37
37
  credentials: typing.Optional[tuple[str, str]],
38
- version: str = "1.4.3.dev30",
38
+ version: str = "1.4.4.dev0",
39
39
  ):
40
40
  """mdmd:hidden
41
41
  The Modal client object is not intended to be instantiated directly by users.
@@ -175,7 +175,7 @@ class Client:
175
175
  server_url: str,
176
176
  client_type: int,
177
177
  credentials: typing.Optional[tuple[str, str]],
178
- version: str = "1.4.3.dev30",
178
+ version: str = "1.4.4.dev0",
179
179
  ):
180
180
  """mdmd:hidden
181
181
  The Modal client object is not intended to be instantiated directly by users.
modal/functions.pyi CHANGED
@@ -97,6 +97,7 @@ class Function(
97
97
  batch_wait_ms: typing.Optional[int] = None,
98
98
  cloud: typing.Optional[str] = None,
99
99
  region: typing.Union[str, collections.abc.Sequence[str], None] = None,
100
+ routing_region: typing.Optional[str] = None,
100
101
  nonpreemptible: bool = False,
101
102
  is_builder_function: bool = False,
102
103
  is_auto_snapshot: bool = False,
modal/image.py CHANGED
@@ -59,7 +59,7 @@ from .mount import _Mount, python_standalone_mount_name
59
59
  from .network_file_system import _NetworkFileSystem
60
60
  from .output import OutputManager
61
61
  from .secret import _Secret
62
- from .volume import _Volume
62
+ from .volume import _Volume, _volume_to_mount_proto
63
63
 
64
64
  if typing.TYPE_CHECKING:
65
65
  import modal._functions
@@ -631,15 +631,7 @@ class _Image(_Object, type_prefix="im"):
631
631
  validate_volumes_by_object_id(validated_volumes)
632
632
 
633
633
  # Relies on dicts being ordered (true as of Python 3.6).
634
- volume_mounts = [
635
- api_pb2.VolumeMount(
636
- mount_path=path,
637
- volume_id=volume.object_id,
638
- allow_background_commits=True,
639
- read_only=volume._read_only,
640
- )
641
- for path, volume in validated_volumes
642
- ]
634
+ volume_mounts = [_volume_to_mount_proto(path, volume) for path, volume in validated_volumes]
643
635
 
644
636
  image_definition = api_pb2.Image(
645
637
  base_images=base_images_pb2s,
modal/sandbox.py CHANGED
@@ -21,7 +21,7 @@ from google.protobuf.message import Message
21
21
  from modal._tunnel import Tunnel
22
22
  from modal.cloud_bucket_mount import _CloudBucketMount, cloud_bucket_mounts_to_proto
23
23
  from modal.mount import _Mount
24
- from modal.volume import _Volume
24
+ from modal.volume import _Volume, _volume_to_mount_proto
25
25
  from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
26
26
 
27
27
  from ._load_context import LoadContext
@@ -301,15 +301,7 @@ class _Sandbox(_Object, type_prefix="sb"):
301
301
  validate_volumes_by_object_id(validated_volumes)
302
302
 
303
303
  # Relies on dicts being ordered (true as of Python 3.6).
304
- volume_mounts = [
305
- api_pb2.VolumeMount(
306
- mount_path=path,
307
- volume_id=volume.object_id,
308
- allow_background_commits=True,
309
- read_only=volume._read_only,
310
- )
311
- for path, volume in validated_volumes
312
- ]
304
+ volume_mounts = [_volume_to_mount_proto(path, volume) for path, volume in validated_volumes]
313
305
 
314
306
  open_ports = [api_pb2.PortSpec(port=port, unencrypted=False) for port in encrypted_ports]
315
307
  open_ports.extend([api_pb2.PortSpec(port=port, unencrypted=True) for port in unencrypted_ports])
modal/volume.py CHANGED
@@ -340,6 +340,24 @@ class _VolumeManager:
340
340
  VolumeManager = synchronize_api(_VolumeManager)
341
341
 
342
342
 
343
+ @dataclass(frozen=True)
344
+ class _VolumeMountOptions:
345
+ read_only: bool = False
346
+ sub_path: Optional[str] = None
347
+
348
+
349
+ def _volume_to_mount_proto(path: str, volume: "_Volume") -> api_pb2.VolumeMount:
350
+ """Convert a Volume to a VolumeMount proto for use in Function/Sandbox definitions."""
351
+ mount_options = volume._mount_options or _VolumeMountOptions()
352
+ return api_pb2.VolumeMount(
353
+ mount_path=path,
354
+ volume_id=volume.object_id,
355
+ allow_background_commits=True,
356
+ read_only=mount_options.read_only,
357
+ sub_path=mount_options.sub_path,
358
+ )
359
+
360
+
343
361
  class _Volume(_Object, type_prefix="vo"):
344
362
  """A writeable volume that can be used to share files between one or more Modal functions.
345
363
 
@@ -384,8 +402,24 @@ class _Volume(_Object, type_prefix="vo"):
384
402
 
385
403
  _lock: Optional[asyncio.Lock] = None
386
404
  _metadata: "typing.Optional[api_pb2.VolumeMetadata]"
405
+ _mount_options: Optional[_VolumeMountOptions] = None
406
+ # Client-side read-only flag from the deprecated .read_only() method. Unlike
407
+ # _mount_options.read_only (which only configures the server-side mount), this
408
+ # flag triggers client-side InvalidError on mutating calls like batch_upload().
387
409
  _read_only: bool = False
388
410
 
411
+ def _initialize_from_empty(self):
412
+ self._metadata = None
413
+ self._mount_options = None
414
+ self._read_only = False
415
+
416
+ def _initialize_from_other(self, other):
417
+ super()._initialize_from_other(other)
418
+ self._metadata = other._metadata
419
+ self._mount_options = other._mount_options
420
+ self._read_only = other._read_only
421
+ self._name = other._name
422
+
389
423
  @classproperty
390
424
  @classmethod
391
425
  def objects(cls) -> _VolumeManager:
@@ -396,41 +430,105 @@ class _Volume(_Object, type_prefix="vo"):
396
430
  return self._name
397
431
 
398
432
  def read_only(self) -> "_Volume":
399
- """Configure Volume to mount as read-only.
433
+ """mdmd:hidden"""
434
+
435
+ # Silently deprecated in favor of `with_mount_options(read_only=True)`.
436
+ if self._mount_options is not None:
437
+ raise InvalidError(
438
+ "Cannot call read_only() on a Volume that has with_mount_options() applied. "
439
+ "Use with_mount_options(read_only=True, ...) instead. Note that with_mount_options(read_only=True) "
440
+ "only configures the server-side mount and does not block client-side Volume SDK methods."
441
+ )
442
+ mount_options = _VolumeMountOptions(read_only=True)
400
443
 
401
- **Example**
444
+ async def _load(
445
+ new_volume: _Volume, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
446
+ ):
447
+ new_volume._initialize_from_other(self)
448
+ new_volume._mount_options = mount_options
449
+ new_volume._read_only = True
402
450
 
451
+ new_volume = _Volume._from_loader(
452
+ _load,
453
+ "Volume()",
454
+ hydrate_lazily=True,
455
+ deps=lambda: [self],
456
+ load_context_overrides=self._load_context_overrides,
457
+ )
458
+ new_volume._initialize_from_other(self)
459
+ new_volume._mount_options = mount_options
460
+ new_volume._read_only = True
461
+ return new_volume
462
+
463
+ def with_mount_options(
464
+ self,
465
+ *,
466
+ read_only: Optional[bool] = None,
467
+ sub_path: Optional[Union[str, PurePosixPath]] = None,
468
+ ) -> "_Volume":
469
+ """Configure options used when mounting this Volume.
470
+
471
+ Note that these options are not properties stored with the Volume itself - they can be individually configured
472
+ for each Volume - container association.
473
+
474
+ read_only: bool (optional) - set this to True to make the Volume read only from within containers
475
+ sub_path: str | PurePosixPath (optional) - only mount this sub_path directory from the Volume.
476
+ If the directory doesn't exist in the Volume, it will be created when the container starts up
477
+
478
+
479
+ **Mount Volume in read-only mode**
403
480
  ```python
404
481
  import modal
405
482
 
406
- volume = modal.Volume.from_name("my-volume", create_if_missing=True)
483
+ volume = modal.Volume.from_name("my-volume")
407
484
 
408
- @app.function(volumes={"/mnt/items": volume.read_only()})
485
+ @app.function(volumes={"/mnt": volume.with_mount_options(read_only=True)})
409
486
  def f():
410
- with open("/mnt/items/my-file.txt") as f:
411
- return f.read()
487
+ return os.mkdir("/mnt/foo") # not possible!
412
488
  ```
413
489
 
414
- The Volume is mounted as a read-only volume in a function. Any file system write operation into the
415
- mounted volume will result in an error.
490
+ **Mount only part of a Volume using sub_path**
491
+ ```python
492
+ import modal
493
+
494
+ volume = modal.Volume.from_name("my-volume")
416
495
 
417
- Added in v1.0.5.
496
+ @app.function(volumes={"/user_data": volume.with_mount_options(sub_path="/users/my_user")})
497
+ def f():
498
+ return os.listdir("/user_data") # lists data from /users/my_user
499
+ ```
418
500
  """
501
+ if self._read_only:
502
+ raise InvalidError(
503
+ "Cannot call with_mount_options() on a Volume that has .read_only() applied. "
504
+ "Use with_mount_options(read_only=True, ...) instead. Note that with_mount_options(read_only=True) "
505
+ "only configures the server-side mount and does not block client-side Volume SDK methods."
506
+ )
507
+ current_mount_options = self._mount_options or _VolumeMountOptions()
508
+ normalized_sub_path = current_mount_options.sub_path
509
+ if sub_path is not None:
510
+ # Normalize sub_path: "/" means whole volume (same as None)
511
+ path_str = PurePosixPath(sub_path).as_posix()
512
+ normalized_sub_path = None if path_str == "/" else path_str
513
+ read_only = current_mount_options.read_only if read_only is None else read_only
514
+ mount_options = _VolumeMountOptions(read_only=read_only, sub_path=normalized_sub_path)
419
515
 
420
516
  async def _load(
421
517
  new_volume: _Volume, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
422
518
  ):
423
519
  new_volume._initialize_from_other(self)
424
- new_volume._read_only = True
520
+ new_volume._mount_options = mount_options
425
521
 
426
- obj = _Volume._from_loader(
522
+ new_volume = _Volume._from_loader(
427
523
  _load,
428
524
  "Volume()",
429
525
  hydrate_lazily=True,
430
526
  deps=lambda: [self],
431
527
  load_context_overrides=self._load_context_overrides,
432
528
  )
433
- return obj
529
+ new_volume._initialize_from_other(self)
530
+ new_volume._mount_options = mount_options
531
+ return new_volume
434
532
 
435
533
  def _hydrate_metadata(self, metadata: Optional[Message]):
436
534
  if metadata:
modal/volume.pyi CHANGED
@@ -416,6 +416,40 @@ class VolumeManager:
416
416
 
417
417
  delete: __delete_spec
418
418
 
419
+ class _VolumeMountOptions:
420
+ """_VolumeMountOptions(read_only: bool = False, sub_path: Optional[str] = None)"""
421
+
422
+ read_only: bool
423
+ sub_path: typing.Optional[str]
424
+
425
+ def __init__(self, read_only: bool = False, sub_path: typing.Optional[str] = None) -> None:
426
+ """Initialize self. See help(type(self)) for accurate signature."""
427
+ ...
428
+
429
+ def __repr__(self):
430
+ """Return repr(self)."""
431
+ ...
432
+
433
+ def __eq__(self, other):
434
+ """Return self==value."""
435
+ ...
436
+
437
+ def __setattr__(self, name, value):
438
+ """Implement setattr(self, name, value)."""
439
+ ...
440
+
441
+ def __delattr__(self, name):
442
+ """Implement delattr(self, name)."""
443
+ ...
444
+
445
+ def __hash__(self):
446
+ """Return hash(self)."""
447
+ ...
448
+
449
+ def _volume_to_mount_proto(path: str, volume: _Volume) -> modal_proto.api_pb2.VolumeMount:
450
+ """Convert a Volume to a VolumeMount proto for use in Function/Sandbox definitions."""
451
+ ...
452
+
419
453
  class _Volume(modal._object._Object):
420
454
  """A writeable volume that can be used to share files between one or more Modal functions.
421
455
 
@@ -460,33 +494,57 @@ class _Volume(modal._object._Object):
460
494
 
461
495
  _lock: typing.Optional[asyncio.locks.Lock]
462
496
  _metadata: typing.Optional[modal_proto.api_pb2.VolumeMetadata]
497
+ _mount_options: typing.Optional[_VolumeMountOptions]
463
498
  _read_only: bool
464
499
 
500
+ def _initialize_from_empty(self): ...
501
+ def _initialize_from_other(self, other): ...
465
502
  @synchronicity.classproperty
466
503
  @classmethod
467
504
  def objects(cls) -> _VolumeManager: ...
468
505
  @property
469
506
  def name(self) -> typing.Optional[str]: ...
470
507
  def read_only(self) -> _Volume:
471
- """Configure Volume to mount as read-only.
508
+ """mdmd:hidden"""
509
+ ...
510
+
511
+ def with_mount_options(
512
+ self,
513
+ *,
514
+ read_only: typing.Optional[bool] = None,
515
+ sub_path: typing.Union[str, pathlib.PurePosixPath, None] = None,
516
+ ) -> _Volume:
517
+ """Configure options used when mounting this Volume.
518
+
519
+ Note that these options are not properties stored with the Volume itself - they can be individually configured
520
+ for each Volume - container association.
472
521
 
473
- **Example**
522
+ read_only: bool (optional) - set this to True to make the Volume read only from within containers
523
+ sub_path: str | PurePosixPath (optional) - only mount this sub_path directory from the Volume.
524
+ If the directory doesn't exist in the Volume, it will be created when the container starts up
474
525
 
526
+
527
+ **Mount Volume in read-only mode**
475
528
  ```python
476
529
  import modal
477
530
 
478
- volume = modal.Volume.from_name("my-volume", create_if_missing=True)
531
+ volume = modal.Volume.from_name("my-volume")
479
532
 
480
- @app.function(volumes={"/mnt/items": volume.read_only()})
533
+ @app.function(volumes={"/mnt": volume.with_mount_options(read_only=True)})
481
534
  def f():
482
- with open("/mnt/items/my-file.txt") as f:
483
- return f.read()
535
+ return os.mkdir("/mnt/foo") # not possible!
484
536
  ```
485
537
 
486
- The Volume is mounted as a read-only volume in a function. Any file system write operation into the
487
- mounted volume will result in an error.
538
+ **Mount only part of a Volume using sub_path**
539
+ ```python
540
+ import modal
541
+
542
+ volume = modal.Volume.from_name("my-volume")
488
543
 
489
- Added in v1.0.5.
544
+ @app.function(volumes={"/user_data": volume.with_mount_options(sub_path="/users/my_user")})
545
+ def f():
546
+ return os.listdir("/user_data") # lists data from /users/my_user
547
+ ```
490
548
  """
491
549
  ...
492
550
 
@@ -792,37 +850,61 @@ class Volume(modal.object.Object):
792
850
 
793
851
  _lock: typing.Optional[asyncio.locks.Lock]
794
852
  _metadata: typing.Optional[modal_proto.api_pb2.VolumeMetadata]
853
+ _mount_options: typing.Optional[_VolumeMountOptions]
795
854
  _read_only: bool
796
855
 
797
856
  def __init__(self, *args, **kwargs):
798
857
  """mdmd:hidden"""
799
858
  ...
800
859
 
860
+ def _initialize_from_empty(self): ...
861
+ def _initialize_from_other(self, other): ...
801
862
  @synchronicity.classproperty
802
863
  @classmethod
803
864
  def objects(cls) -> VolumeManager: ...
804
865
  @property
805
866
  def name(self) -> typing.Optional[str]: ...
806
867
  def read_only(self) -> Volume:
807
- """Configure Volume to mount as read-only.
868
+ """mdmd:hidden"""
869
+ ...
870
+
871
+ def with_mount_options(
872
+ self,
873
+ *,
874
+ read_only: typing.Optional[bool] = None,
875
+ sub_path: typing.Union[str, pathlib.PurePosixPath, None] = None,
876
+ ) -> Volume:
877
+ """Configure options used when mounting this Volume.
878
+
879
+ Note that these options are not properties stored with the Volume itself - they can be individually configured
880
+ for each Volume - container association.
808
881
 
809
- **Example**
882
+ read_only: bool (optional) - set this to True to make the Volume read only from within containers
883
+ sub_path: str | PurePosixPath (optional) - only mount this sub_path directory from the Volume.
884
+ If the directory doesn't exist in the Volume, it will be created when the container starts up
810
885
 
886
+
887
+ **Mount Volume in read-only mode**
811
888
  ```python
812
889
  import modal
813
890
 
814
- volume = modal.Volume.from_name("my-volume", create_if_missing=True)
891
+ volume = modal.Volume.from_name("my-volume")
815
892
 
816
- @app.function(volumes={"/mnt/items": volume.read_only()})
893
+ @app.function(volumes={"/mnt": volume.with_mount_options(read_only=True)})
817
894
  def f():
818
- with open("/mnt/items/my-file.txt") as f:
819
- return f.read()
895
+ return os.mkdir("/mnt/foo") # not possible!
820
896
  ```
821
897
 
822
- The Volume is mounted as a read-only volume in a function. Any file system write operation into the
823
- mounted volume will result in an error.
898
+ **Mount only part of a Volume using sub_path**
899
+ ```python
900
+ import modal
901
+
902
+ volume = modal.Volume.from_name("my-volume")
824
903
 
825
- Added in v1.0.5.
904
+ @app.function(volumes={"/user_data": volume.with_mount_options(sub_path="/users/my_user")})
905
+ def f():
906
+ return os.listdir("/user_data") # lists data from /users/my_user
907
+ ```
826
908
  """
827
909
  ...
828
910
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.4.3.dev30
3
+ Version: 1.4.4.dev0
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -5,9 +5,9 @@ modal/_clustered_functions.py,sha256=7amiOxErtlciWeBJBL2KvaxerUomJpTAJ3qElflFHQA
5
5
  modal/_clustered_functions.pyi,sha256=JmYwAGOLEnD5AF-gYF9O5tu-SgGjeoJz-X1j48b1Ijg,1157
6
6
  modal/_container_entrypoint.py,sha256=_dyBqh_3KFn1M8S2Z4upvHT1rAYvxggz8-uCb3N6w5Q,20994
7
7
  modal/_environments.py,sha256=uEjww31OATuKS-AYvxRjvbkSy_zRnraN9H5GkJJ6Xno,17625
8
- modal/_function_variants.py,sha256=lvmF5a6pTNPHMkEipHBjjvId0jtxB3iTiNTwSwzp38I,11166
9
- modal/_functions.py,sha256=yVHaFEb9_el0XrbLKA-hoHI1xUtc1O639-GM4b1RUsY,89061
10
- modal/_grpc_client.py,sha256=TQ5LYcdMIVybijv8xCHPjsm3BDa7OT9EfHU1cUJcj4w,7325
8
+ modal/_function_variants.py,sha256=Xmet85lFEv2rT_nTPvlbfw8T09GzeN5Rx5JDdMXbhCU,10989
9
+ modal/_functions.py,sha256=qhi23PoA8JgH0XGfxZL5b6adUcSS7s8FmuzshlcCor8,89036
10
+ modal/_grpc_client.py,sha256=G8gWQXChtsuRgyXK9kcPiPNYXOYbl_m9Bz2AVBJb-zs,6924
11
11
  modal/_ipython.py,sha256=Bu9fc3hm3xt2O47qLe_d-Mu2jS81LwMsmXNJ1sawBA0,575
12
12
  modal/_load_context.py,sha256=DvfOWNTevEmHqdlu0-AeO_03JChrmAjoupgppPVW2Pc,4867
13
13
  modal/_location.py,sha256=joiX-0ZeutEUDTrrqLF1GHXCdVLF-rHzstocbMcd_-k,366
@@ -23,12 +23,12 @@ modal/_tunnel.py,sha256=ixQGklCn6eV2TekgET8o0ZD9dEo36Iw_grUsg-S5Bhg,7084
23
23
  modal/_tunnel.pyi,sha256=FoedJzHymbPk4Bmnbszm6uhay2rMq1JS9mIiHELYf3k,13804
24
24
  modal/_type_manager.py,sha256=DWjgmjYJuOagw2erin506UUbG2H5UzZCFEekS-7hmfA,9087
25
25
  modal/_watcher.py,sha256=LerzeKqyGKOlPw9caRGnIyjcZTFXe5YvmeZ2grT2_q8,3539
26
- modal/app.py,sha256=WYfJySrzxSoV2yThLTBMTBar6aNFJRuYIeYyUb4_TK8,62548
27
- modal/app.pyi,sha256=1Ghrrc5suME_WUXYjdBTYDlkv41u3BKD9778KAtGWbc,53864
26
+ modal/app.py,sha256=s9AZNpis2NwTFnmb1InK-Y4qPHCYXrgEoB69EsM3J-s,62836
27
+ modal/app.pyi,sha256=NP1ni_ljQ8uxgywJFuNnTTLvAUPz1Cij1gVSrtptVX0,54076
28
28
  modal/billing.py,sha256=B2zXa7KeMnpqi2AQINycEk-KSXVYaLTTTAQTtuf2Gfs,257
29
29
  modal/call_graph.py,sha256=1g2DGcMIJvRy-xKicuf63IVE98gJSnQsr8R_NVMptNc,2581
30
30
  modal/client.py,sha256=jjT4eLFHGcvIF6Xw4kPptIx-VmExtphRN8XJ1JA7fWI,15502
31
- modal/client.pyi,sha256=edFNUAAMiVsVXuNwqPHveI90oh9dO6R2nBWJEHdG2Jw,15703
31
+ modal/client.pyi,sha256=3QdGRynAOZ7EI_54u3Ic0oSNaoK2Q3x1WhJ7ElEf88Y,15701
32
32
  modal/cloud_bucket_mount.py,sha256=KkaJVy5vpO_u2NtDYtv1JM-M3y4J0UtWBapTDKpQRcA,6034
33
33
  modal/cloud_bucket_mount.pyi,sha256=mGDeEd1XvxGpCE6O5jRBSfIvMXvZ5AtIybea5k-0w9w,7560
34
34
  modal/cls.py,sha256=UTtp9W3SuFIbkYEE2D94SUzKiMXM5BYgfHGzBQD6CEk,38304
@@ -45,8 +45,8 @@ modal/file_io.py,sha256=DYQXrPUNkKG-0lyzc81E3rh7g56Fg5y6eIQkTqhfqbU,22015
45
45
  modal/file_io.pyi,sha256=DPaq0BhySaiCX3g56QHhyIMdU1izbfWsjpSp30JoJUU,15745
46
46
  modal/file_pattern_matcher.py,sha256=A_Kdkej6q7YQyhM_2-BvpFmPqJ0oHb54B6yf9VqvPVE,8116
47
47
  modal/functions.py,sha256=EZRcHFfnoYbvawUJnAHF5nIvoo9GX2Djy7mUmOCXmXc,258
48
- modal/functions.pyi,sha256=olNUSsi5dgrvbQAWQzJeuUziI5wSFp7r0P_46WLdcZ0,37887
49
- modal/image.py,sha256=K-105RcY39Kq8uC4toG6WOMTnAVlPn_6P8J_DiNxiyQ,113430
48
+ modal/functions.pyi,sha256=oQbuXhpMhPmOvDK5YvDaC19iGqa_gbtPG8yTe8MOhz4,37940
49
+ modal/image.py,sha256=1iHT8KmdWtvIfusupTf_AJGBMUI4PQuAciBp-f_uYL0,113221
50
50
  modal/image.pyi,sha256=FUAY23rnaUmpRBVazrzh3Z3b9tYTMikbyk2SbuIpihI,81004
51
51
  modal/io_streams.py,sha256=15dSqTktIFvqANRDO8pjFjuwgybGAdRBorEM-3ag-fw,25000
52
52
  modal/io_streams.pyi,sha256=xCXvb8MxXwytsh2cKXQ3ZUizOOYlXJbEc_2qRiB8DZw,19232
@@ -70,7 +70,7 @@ modal/retries.py,sha256=vPWWKP-FRolJwTBuLPXdWqHV7aJfSQFgEW7vhlBBXRQ,5067
70
70
  modal/runner.py,sha256=GLx_JwZ2hpA5yuHMY2b2Gt8Ri-KxJZHF2Zp1N_qVLD4,30144
71
71
  modal/runner.pyi,sha256=7QHbZawq1P5N9DiSrrEYtXtBCDAWlRibNonBO0q6MdM,9570
72
72
  modal/running_app.py,sha256=v61mapYNV1-O-Uaho5EfJlryMLvIT9We0amUOSvSGx8,1188
73
- modal/sandbox.py,sha256=DpnLovZwNgluZgjhCYy5ImbjfwEOeWsSFA4UIk4u6vc,83630
73
+ modal/sandbox.py,sha256=QLksqBwaKO3Qbon0B9teIS1Ssns16reQIuXecR-vojY,83421
74
74
  modal/sandbox.pyi,sha256=aetTQ_YcMh4YF3NIdJs76gaY7LL72SVeQYN_ge-yDmU,85909
75
75
  modal/sandbox_fs.py,sha256=3EGten2nvZ7H451FnH0gbFXcdLvQtQNVX-7qI0TtT3c,22472
76
76
  modal/sandbox_fs.pyi,sha256=01VyLN0AIplD45qh8F6NJXYj5YxGPaqRETMp4rDLZcQ,35036
@@ -87,8 +87,8 @@ modal/snapshot.pyi,sha256=vMYDx1s9Ds4qtH-PXgB_9RzVuS5wsTchVcclR5Q-mmM,2014
87
87
  modal/stream_type.py,sha256=A6320qoAAWhEfwOCZfGtymQTu5AfLfJXXgARqooTPvY,417
88
88
  modal/token_flow.py,sha256=muvMsYAcLB2X1s61K-ChGNnu-vPpQUW1N8IYOrA8eho,7036
89
89
  modal/token_flow.pyi,sha256=5zdXJ5SLLHM4j6xSPuEvnpSeEJBbzE6NkSX-MSyE5wM,2282
90
- modal/volume.py,sha256=YFfzcGRA41pZk6gJSd7VYqVxNWJWubjVv2cJpBHcLOc,56414
91
- modal/volume.pyi,sha256=nOTgO1HThjldwxcUSw8S534BMNYooDGK1sI4ySnHQ6o,56189
90
+ modal/volume.py,sha256=d8nQVfSJqW7d3NFjJ6aJyXrrH71j6fKJjN5KFLwpWBM,60864
91
+ modal/volume.pyi,sha256=jymbcDonHUk2W4phnP9bF-fzyQpdaPGR02rdeYk3oEk,59220
92
92
  modal/_output/__init__.py,sha256=xtGN6SPv2zvByrJ32JPao0yJKpxyCIZ-2JMTScrisuQ,28
93
93
  modal/_output/manager.py,sha256=8JGUm8S1PfiibvyI1t_pcniktZbe8KAV1TvLtoZ80rk,18161
94
94
  modal/_output/pty.py,sha256=wniYe_MW9_pEhCjDgRyrruYJNNtZoAJ0MuOOl3UrGI0,10174
@@ -121,14 +121,14 @@ modal/_utils/hash_utils.py,sha256=zg3J6OGxTFGSFri1qQ12giDz90lWk8bzaxCTUCRtiX4,30
121
121
  modal/_utils/http_utils.py,sha256=yeTFsXYr0rYMEhB7vBP7audG9Uc7OLhzKBANFDZWVt0,2451
122
122
  modal/_utils/jwt_utils.py,sha256=fxH9plyrbAemTbjSsQtzIdDXE9QXxvMC4DiUZ16G0aA,1360
123
123
  modal/_utils/logger.py,sha256=NgbMKFT9chYYt_TU01DdIior5ByYr2gZtrWIk1SFRLc,1782
124
- modal/_utils/mount_utils.py,sha256=YlWhO_aaQjl_yDK-Fo0dzbGQav5-tHL1H5FDEIT9bxo,5082
124
+ modal/_utils/mount_utils.py,sha256=9Cl2JHJ_nxKt4t0W7UC8lidAwD486q78wnVY8-OaqWs,5080
125
125
  modal/_utils/name_utils.py,sha256=Im2A4zeberAAfTxPRSx46ILE_pIWIE1f-GxIYulFOSE,2517
126
126
  modal/_utils/package_utils.py,sha256=jYE7WFVzYWIfO0ZJhno_BYHb6Skp48uupOiw1a3yWzw,2056
127
127
  modal/_utils/pattern_utils.py,sha256=ZUffaECfe2iYBhH6cvCB-0-UWhmEBTZEl_TwG_So3ag,6714
128
128
  modal/_utils/rand_pb_testing.py,sha256=OYfbSeEXNMgwy-nAi4hHbgZdb08Ss3BV6TyJp6-gwNs,4360
129
129
  modal/_utils/sandbox_fs_utils.py,sha256=lfYcYA8wQgnUWtSC0UxRzrEThFWOY5ppuA0GJeYzhrg,13914
130
130
  modal/_utils/shell_utils.py,sha256=GjMrO1YxnBr4_UlpA2ZYQKCHxV78QTg0mqxAMHWGTE4,3427
131
- modal/_utils/task_command_router_client.py,sha256=s1Ni3cyke1LHCIAsXONA_mQZXvjGU-dSbWd08csatE8,30870
131
+ modal/_utils/task_command_router_client.py,sha256=O116m39h569JgpF8w5zA3g9UzWJcsRKYKNG08Wcyaac,31510
132
132
  modal/_utils/time_utils.py,sha256=PBMHCGzysBVaEnsjc0BSf0bPdSOSXeV11rtTAu5vvus,8451
133
133
  modal/_vendor/__init__.py,sha256=MIEP8jhXUeGq_eCjYFcqN5b1bxBM4fdk0VESpjWR0fc,28
134
134
  modal/_vendor/a2wsgi_wsgi.py,sha256=Q1AsjpV_Q_vzQsz_cSqmP9jWzsGsB-ARFU6vpQYml8k,21878
@@ -167,7 +167,7 @@ modal/cli/queues.py,sha256=Nz_gkmlz8c2DqbL3GZnIcdmaHjC8Sj5wl55qq17kty4,6565
167
167
  modal/cli/run.py,sha256=aTfa5P44_W2LbUVdjYND4kvr8-G1qLHCSYUNilHkLwA,22656
168
168
  modal/cli/secret.py,sha256=_YEeQ0HSRXXZgf7Bx990D4B7kzRamNDLlEd92uqK6O4,8252
169
169
  modal/cli/selector.py,sha256=OJr9Ua3K_l-qu3N3-I8z8oVq8lBakws2-XgfheR5p-c,4741
170
- modal/cli/shell.py,sha256=tzcNuCCZk_0nhUT-jQK824BzDQYuWc_N73nhmwwmROU,14970
170
+ modal/cli/shell.py,sha256=2oD_MtbkazowubdryGWfhHqO44Sa3Lv7bpENmF68ghc,16132
171
171
  modal/cli/token.py,sha256=Km7V7pHXsNKyjOCEdioQT8-o04Rh2-PzmNpiL1_i8tw,4352
172
172
  modal/cli/utils.py,sha256=CUNFXLcV8EJymZCTXettHDJTDBJi73uHMywayr0ytow,6305
173
173
  modal/cli/volume.py,sha256=pP1yqWCwzy1IP-_BbUKZKysdx7sMI5RT5jZUkj1DPsc,11514
@@ -178,7 +178,7 @@ modal/experimental/__init__.py,sha256=Wb1YoAKAnNxH_0c2wA0T12I7bSu9PSO2nS_LuLska7
178
178
  modal/experimental/flash.py,sha256=M-mDFR02V57EzXMuetG_DIkeqe-FATZbh29JAIcLxwU,33361
179
179
  modal/experimental/flash.pyi,sha256=RiJXdAUnKmLWpdDzeFhXzClMc5mIr_inbn_cptm_wOI,17003
180
180
  modal/experimental/ipython.py,sha256=TrCfmol9LGsRZMeDoeMPx3Hv3BFqQhYnmD_iH0pqdhk,2904
181
- modal-1.4.3.dev30.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
181
+ modal-1.4.4.dev0.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
182
182
  modal_docs/__init__.py,sha256=svYKtV8HDwDCN86zbdWqyq5T8sMdGDj0PVlzc2tIxDM,28
183
183
  modal_docs/gen_cli_docs.py,sha256=bfUSLscHfoQoKkKjIqJvQMe3fURzqcQK7gF1h9WQLtM,3742
184
184
  modal_docs/gen_cli_docs_main.py,sha256=UPGr7O1DYTIRLwfINe2rbdh89J3cPVTU1OWL-SjxnOQ,198
@@ -190,7 +190,7 @@ modal_docs/mdmd/signatures.py,sha256=XJaZrK7Mdepk5fdX51A8uENiLFNil85Ud0d4MH8H5f0
190
190
  modal_proto/__init__.py,sha256=MIEP8jhXUeGq_eCjYFcqN5b1bxBM4fdk0VESpjWR0fc,28
191
191
  modal_proto/api_grpc.py,sha256=m0qewo8OuWxTAQkTN0-_mBX9V3DJTWJ0Gw-jo4Ug_x8,153908
192
192
  modal_proto/api_pb2.py,sha256=X5MKLTgGelUZOUY0tfiCT9MbKIHOFcHZOeAANHDY8Ko,432024
193
- modal_proto/api_pb2.pyi,sha256=5ooPDCEwivBFNVBGdj5yAIiNcgSp8OZdf77o5Qa3i5U,606061
193
+ modal_proto/api_pb2.pyi,sha256=h4Gnl57ovADVWo9keEnSmE8O44py3RBKlnCEjZ_YXhw,606063
194
194
  modal_proto/api_pb2_grpc.py,sha256=-ulRQTC92M00MDa7O2OKABgwgq3w5MYKbQAem9QLBTg,331488
195
195
  modal_proto/api_pb2_grpc.pyi,sha256=R3HeGMYv8cTBuzohYodGZf63g_1EILXUj9IfuWdQJMQ,77441
196
196
  modal_proto/modal_api_grpc.py,sha256=uLaaPRcnDBe_MQkShJ2Zpw1MtcbF10yjMEF5YNFtjmw,24293
@@ -200,10 +200,10 @@ modal_proto/task_command_router_pb2.py,sha256=-K4H-C7N-ILpdvFyM4PQMZcGZvXV1J89mf
200
200
  modal_proto/task_command_router_pb2.pyi,sha256=KNM7lPtngiv0_Ni_1zRg1rl65ynyjN8vpzd3XISxMGo,37281
201
201
  modal_proto/task_command_router_pb2_grpc.py,sha256=368j8b_h19L7bidFTRGSdSG4bK0zU9Y_Oq46_B2ACDg,34666
202
202
  modal_proto/task_command_router_pb2_grpc.pyi,sha256=PbzkCj-uSlN1inYSdhncPQ1-04vhBnj3Wzvb-D2pLHY,11226
203
- modal_version/__init__.py,sha256=GWSnPzlxpsx5SoO7zuyk-a4fyTGrrv4G9Twri6HwLFs,121
203
+ modal_version/__init__.py,sha256=PDlj-Uj0cww12-YJ8DVmqiHyARKWhHEhET6YgIsmqXc,120
204
204
  modal_version/__main__.py,sha256=nLUbubxKx7GXNzJIkTB7M7_u8080LyLM0IS0rzFkC_c,119
205
- modal-1.4.3.dev30.dist-info/METADATA,sha256=z4YKSLlNvO7RDpVrBD_3m9mWp_zfYYhOCn16_i0xGVc,2339
206
- modal-1.4.3.dev30.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
207
- modal-1.4.3.dev30.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
208
- modal-1.4.3.dev30.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
209
- modal-1.4.3.dev30.dist-info/RECORD,,
205
+ modal-1.4.4.dev0.dist-info/METADATA,sha256=ebODN8NcflHVOrVzI6sqwPMyhjW_9Sio_9FP0rdWdZc,2338
206
+ modal-1.4.4.dev0.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
207
+ modal-1.4.4.dev0.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
208
+ modal-1.4.4.dev0.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
209
+ modal-1.4.4.dev0.dist-info/RECORD,,
modal_proto/api_pb2.pyi CHANGED
@@ -5614,7 +5614,7 @@ class Function(google.protobuf.message.Message):
5614
5614
  is_server: builtins.bool
5615
5615
  """When True, the function is a server function"""
5616
5616
  routing_region: builtins.str
5617
- """Where calls to a function should be routed through."""
5617
+ """Where inputs to a function should be routed through."""
5618
5618
  def __init__(
5619
5619
  self,
5620
5620
  *,
@@ -6247,7 +6247,7 @@ class FunctionData(google.protobuf.message.Message):
6247
6247
  is_server: builtins.bool
6248
6248
  """When True, the function is a server function"""
6249
6249
  routing_region: builtins.str
6250
- """Where calls to a function should be routed through."""
6250
+ """Where inputs to a function should be routed through."""
6251
6251
  def __init__(
6252
6252
  self,
6253
6253
  *,
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.4.3.dev30"
4
+ __version__ = "1.4.4.dev0"