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.
- modal/_function_variants.py +2 -10
- modal/_functions.py +5 -8
- modal/_grpc_client.py +0 -7
- modal/_utils/mount_utils.py +1 -1
- modal/_utils/task_command_router_client.py +68 -44
- modal/app.py +4 -0
- modal/app.pyi +4 -0
- modal/cli/shell.py +37 -2
- modal/client.pyi +2 -2
- modal/functions.pyi +1 -0
- modal/image.py +2 -10
- modal/sandbox.py +2 -10
- modal/volume.py +110 -12
- modal/volume.pyi +100 -18
- {modal-1.4.3.dev30.dist-info → modal-1.4.4.dev0.dist-info}/METADATA +1 -1
- {modal-1.4.3.dev30.dist-info → modal-1.4.4.dev0.dist-info}/RECORD +22 -22
- modal_proto/api_pb2.pyi +2 -2
- modal_version/__init__.py +1 -1
- {modal-1.4.3.dev30.dist-info → modal-1.4.4.dev0.dist-info}/WHEEL +0 -0
- {modal-1.4.3.dev30.dist-info → modal-1.4.4.dev0.dist-info}/entry_points.txt +0 -0
- {modal-1.4.3.dev30.dist-info → modal-1.4.4.dev0.dist-info}/licenses/LICENSE +0 -0
- {modal-1.4.3.dev30.dist-info → modal-1.4.4.dev0.dist-info}/top_level.txt +0 -0
modal/_function_variants.py
CHANGED
|
@@ -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
|
-
|
|
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
|
modal/_utils/mount_utils.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
#
|
|
126
|
-
|
|
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:
|
|
669
|
+
self, request: sr_pb2.TaskSnapshotFilesystemRequest, *, timeout: float, **kwargs
|
|
660
670
|
) -> sr_pb2.TaskSnapshotFilesystemResponse:
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
)
|
|
673
|
-
|
|
674
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
"""
|
|
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
|
-
|
|
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"
|
|
483
|
+
volume = modal.Volume.from_name("my-volume")
|
|
407
484
|
|
|
408
|
-
@app.function(volumes={"/mnt
|
|
485
|
+
@app.function(volumes={"/mnt": volume.with_mount_options(read_only=True)})
|
|
409
486
|
def f():
|
|
410
|
-
|
|
411
|
-
return f.read()
|
|
487
|
+
return os.mkdir("/mnt/foo") # not possible!
|
|
412
488
|
```
|
|
413
489
|
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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.
|
|
520
|
+
new_volume._mount_options = mount_options
|
|
425
521
|
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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"
|
|
531
|
+
volume = modal.Volume.from_name("my-volume")
|
|
479
532
|
|
|
480
|
-
@app.function(volumes={"/mnt
|
|
533
|
+
@app.function(volumes={"/mnt": volume.with_mount_options(read_only=True)})
|
|
481
534
|
def f():
|
|
482
|
-
|
|
483
|
-
return f.read()
|
|
535
|
+
return os.mkdir("/mnt/foo") # not possible!
|
|
484
536
|
```
|
|
485
537
|
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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"
|
|
891
|
+
volume = modal.Volume.from_name("my-volume")
|
|
815
892
|
|
|
816
|
-
@app.function(volumes={"/mnt
|
|
893
|
+
@app.function(volumes={"/mnt": volume.with_mount_options(read_only=True)})
|
|
817
894
|
def f():
|
|
818
|
-
|
|
819
|
-
return f.read()
|
|
895
|
+
return os.mkdir("/mnt/foo") # not possible!
|
|
820
896
|
```
|
|
821
897
|
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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=
|
|
9
|
-
modal/_functions.py,sha256=
|
|
10
|
-
modal/_grpc_client.py,sha256=
|
|
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=
|
|
27
|
-
modal/app.pyi,sha256=
|
|
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=
|
|
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=
|
|
49
|
-
modal/image.py,sha256=
|
|
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=
|
|
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=
|
|
91
|
-
modal/volume.pyi,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
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=
|
|
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=
|
|
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.
|
|
206
|
-
modal-1.4.
|
|
207
|
-
modal-1.4.
|
|
208
|
-
modal-1.4.
|
|
209
|
-
modal-1.4.
|
|
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
|
|
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
|
|
6250
|
+
"""Where inputs to a function should be routed through."""
|
|
6251
6251
|
def __init__(
|
|
6252
6252
|
self,
|
|
6253
6253
|
*,
|
modal_version/__init__.py
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|