modal 1.0.2.dev6__py3-none-any.whl → 1.0.3__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/_functions.py +3 -2
- modal/_runtime/container_io_manager.py +8 -14
- modal/_runtime/gpu_memory_snapshot.py +158 -60
- modal/_utils/bytes_io_segment_payload.py +17 -3
- modal/cli/run.py +12 -0
- modal/cli/secret.py +43 -4
- modal/cli/volume.py +6 -1
- modal/client.pyi +2 -10
- modal/experimental/__init__.py +6 -4
- modal/image.py +38 -22
- modal/mount.py +128 -4
- modal/mount.pyi +22 -0
- modal/parallel_map.py +47 -23
- modal/runner.py +2 -7
- modal/sandbox.py +17 -4
- modal/sandbox.pyi +14 -6
- modal/schedule.py +17 -4
- modal/volume.py +17 -49
- modal/volume.pyi +11 -43
- {modal-1.0.2.dev6.dist-info → modal-1.0.3.dist-info}/METADATA +2 -2
- {modal-1.0.2.dev6.dist-info → modal-1.0.3.dist-info}/RECORD +29 -29
- modal_proto/api.proto +19 -4
- modal_proto/api_pb2.py +591 -585
- modal_proto/api_pb2.pyi +32 -6
- modal_version/__init__.py +1 -1
- {modal-1.0.2.dev6.dist-info → modal-1.0.3.dist-info}/WHEEL +0 -0
- {modal-1.0.2.dev6.dist-info → modal-1.0.3.dist-info}/entry_points.txt +0 -0
- {modal-1.0.2.dev6.dist-info → modal-1.0.3.dist-info}/licenses/LICENSE +0 -0
- {modal-1.0.2.dev6.dist-info → modal-1.0.3.dist-info}/top_level.txt +0 -0
modal/_functions.py
CHANGED
@@ -101,6 +101,7 @@ if TYPE_CHECKING:
|
|
101
101
|
|
102
102
|
MAX_INTERNAL_FAILURE_COUNT = 8
|
103
103
|
|
104
|
+
|
104
105
|
@dataclasses.dataclass
|
105
106
|
class _RetryContext:
|
106
107
|
function_call_invocation_type: "api_pb2.FunctionCallInvocationType.ValueType"
|
@@ -1661,8 +1662,8 @@ Use the `Function.get_web_url()` method instead.
|
|
1661
1662
|
async def spawn(self, *args: P.args, **kwargs: P.kwargs) -> "_FunctionCall[ReturnType]":
|
1662
1663
|
"""Calls the function with the given arguments, without waiting for the results.
|
1663
1664
|
|
1664
|
-
Returns a `modal.FunctionCall` object, that can later be polled or
|
1665
|
-
waited for using `.get(timeout=...)
|
1665
|
+
Returns a [`modal.FunctionCall`](/docs/reference/modal.FunctionCall) object, that can later be polled or
|
1666
|
+
waited for using [`.get(timeout=...)`](/docs/reference/modal.FunctionCall#get).
|
1666
1667
|
Conceptually similar to `multiprocessing.pool.apply_async`, or a Future/Promise in other contexts.
|
1667
1668
|
"""
|
1668
1669
|
self._check_no_web_url("spawn")
|
@@ -323,6 +323,7 @@ class _ContainerIOManager:
|
|
323
323
|
self._heartbeat_loop = None
|
324
324
|
self._heartbeat_condition = None
|
325
325
|
self._waiting_for_memory_snapshot = False
|
326
|
+
self._cuda_checkpoint_session = None
|
326
327
|
|
327
328
|
self._is_interactivity_enabled = False
|
328
329
|
self._fetching_inputs = True
|
@@ -881,13 +882,11 @@ class _ContainerIOManager:
|
|
881
882
|
# Restore GPU memory.
|
882
883
|
if self.function_def._experimental_enable_gpu_snapshot and self.function_def.resources.gpu_config.gpu_type:
|
883
884
|
logger.debug("GPU memory snapshot enabled. Attempting to restore GPU memory.")
|
884
|
-
|
885
|
-
|
886
|
-
|
887
|
-
|
888
|
-
|
889
|
-
)
|
890
|
-
gpu_memory_snapshot.toggle()
|
885
|
+
|
886
|
+
assert self._cuda_checkpoint_session, (
|
887
|
+
"CudaCheckpointSession not found when attempting to restore GPU memory"
|
888
|
+
)
|
889
|
+
self._cuda_checkpoint_session.restore()
|
891
890
|
|
892
891
|
# Restore input to default state.
|
893
892
|
self.current_input_id = None
|
@@ -907,14 +906,9 @@ class _ContainerIOManager:
|
|
907
906
|
# Snapshot GPU memory.
|
908
907
|
if self.function_def._experimental_enable_gpu_snapshot and self.function_def.resources.gpu_config.gpu_type:
|
909
908
|
logger.debug("GPU memory snapshot enabled. Attempting to snapshot GPU memory.")
|
910
|
-
gpu_process_state = gpu_memory_snapshot.get_state()
|
911
|
-
if gpu_process_state != gpu_memory_snapshot.CudaCheckpointState.RUNNING:
|
912
|
-
raise ValueError(
|
913
|
-
f"Cannot snapshot GPU state if it isn't running. Current GPU state: {gpu_process_state}"
|
914
|
-
)
|
915
909
|
|
916
|
-
gpu_memory_snapshot.
|
917
|
-
|
910
|
+
self._cuda_checkpoint_session = gpu_memory_snapshot.CudaCheckpointSession()
|
911
|
+
self._cuda_checkpoint_session.checkpoint()
|
918
912
|
|
919
913
|
# Notify the heartbeat loop that the snapshot phase has begun in order to
|
920
914
|
# prevent it from sending heartbeat RPCs
|
@@ -6,10 +6,12 @@
|
|
6
6
|
#
|
7
7
|
# [1] https://github.com/NVIDIA/cuda-checkpoint
|
8
8
|
|
9
|
-
import os
|
10
9
|
import subprocess
|
11
10
|
import time
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
12
|
+
from dataclasses import dataclass
|
12
13
|
from enum import Enum
|
14
|
+
from pathlib import Path
|
13
15
|
|
14
16
|
from modal.config import config, logger
|
15
17
|
|
@@ -29,73 +31,169 @@ class CudaCheckpointException(Exception):
|
|
29
31
|
pass
|
30
32
|
|
31
33
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
logger.debug(f"Toggling CUDA checkpoint state for PID {pid}")
|
34
|
+
@dataclass
|
35
|
+
class CudaCheckpointProcess:
|
36
|
+
"""Contains a reference to a PID with active CUDA session. This also provides
|
37
|
+
methods for checkpointing and restoring GPU memory."""
|
37
38
|
|
38
|
-
|
39
|
-
|
40
|
-
[
|
41
|
-
CUDA_CHECKPOINT_PATH,
|
42
|
-
"--toggle",
|
43
|
-
"--pid",
|
44
|
-
str(pid),
|
45
|
-
],
|
46
|
-
check=True,
|
47
|
-
capture_output=True,
|
48
|
-
text=True,
|
49
|
-
)
|
50
|
-
logger.debug("Successfully toggled CUDA checkpoint state")
|
39
|
+
pid: int
|
40
|
+
state: CudaCheckpointState
|
51
41
|
|
52
|
-
|
53
|
-
|
54
|
-
|
42
|
+
def toggle(self, target_state: CudaCheckpointState, timeout_secs: float = 5 * 60.0):
|
43
|
+
"""Toggle CUDA checkpoint state for current process, moving GPU memory to the
|
44
|
+
CPU and back depending on the current process state when called."""
|
45
|
+
logger.debug(f"PID: {self.pid} Toggling CUDA checkpoint state to {target_state.value}")
|
55
46
|
|
47
|
+
start_time = time.monotonic()
|
56
48
|
|
57
|
-
|
58
|
-
|
59
|
-
|
49
|
+
while self._should_continue_toggle(target_state, start_time, timeout_secs):
|
50
|
+
self._execute_toggle_command()
|
51
|
+
time.sleep(0.1)
|
60
52
|
|
61
|
-
|
62
|
-
result = subprocess.run(
|
63
|
-
[CUDA_CHECKPOINT_PATH, "--get-state", "--pid", str(pid)], check=True, capture_output=True, text=True
|
64
|
-
)
|
53
|
+
logger.debug(f"PID: {self.pid} Target state {target_state.value} reached")
|
65
54
|
|
66
|
-
|
67
|
-
|
68
|
-
|
55
|
+
def _should_continue_toggle(
|
56
|
+
self, target_state: CudaCheckpointState, start_time: float, timeout_secs: float
|
57
|
+
) -> bool:
|
58
|
+
"""Check if toggle operation should continue based on current state and timeout."""
|
59
|
+
self.refresh_state()
|
69
60
|
|
70
|
-
|
71
|
-
|
72
|
-
raise CudaCheckpointException(e.stderr)
|
61
|
+
if self.state == target_state:
|
62
|
+
return False
|
73
63
|
|
74
|
-
|
75
|
-
|
76
|
-
"""Wait for CUDA checkpoint to reach a specific state."""
|
77
|
-
logger.debug(f"Waiting for CUDA checkpoint state {target_state.value}")
|
78
|
-
start_time = time.monotonic()
|
79
|
-
|
80
|
-
while True:
|
81
|
-
current_state = get_state()
|
82
|
-
|
83
|
-
if current_state == target_state:
|
84
|
-
logger.debug(f"Target state {target_state.value} reached")
|
85
|
-
break
|
86
|
-
|
87
|
-
if current_state == CudaCheckpointState.FAILED:
|
88
|
-
raise CudaCheckpointException(f"CUDA process state is {current_state}")
|
64
|
+
if self.state == CudaCheckpointState.FAILED:
|
65
|
+
raise CudaCheckpointException(f"PID: {self.pid} CUDA process state is {self.state}")
|
89
66
|
|
90
67
|
elapsed = time.monotonic() - start_time
|
91
68
|
if elapsed >= timeout_secs:
|
92
|
-
raise CudaCheckpointException(
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
69
|
+
raise CudaCheckpointException(
|
70
|
+
f"PID: {self.pid} Timeout after {elapsed:.2f}s waiting for state {target_state.value}. "
|
71
|
+
f"Current state: {self.state}"
|
72
|
+
)
|
73
|
+
|
74
|
+
return True
|
75
|
+
|
76
|
+
def _execute_toggle_command(self):
|
77
|
+
"""Execute the cuda-checkpoint toggle command."""
|
78
|
+
try:
|
79
|
+
subprocess.run(
|
80
|
+
[CUDA_CHECKPOINT_PATH, "--toggle", "--pid", str(self.pid)],
|
81
|
+
check=True,
|
82
|
+
capture_output=True,
|
83
|
+
text=True,
|
84
|
+
)
|
85
|
+
logger.debug(f"PID: {self.pid} Successfully toggled CUDA checkpoint state")
|
86
|
+
except subprocess.CalledProcessError as e:
|
87
|
+
logger.debug(f"PID: {self.pid} Failed to toggle CUDA checkpoint state: {e.stderr}")
|
88
|
+
raise CudaCheckpointException(e.stderr)
|
89
|
+
|
90
|
+
def refresh_state(self) -> None:
|
91
|
+
"""Refreshes the current CUDA checkpoint state for this process."""
|
92
|
+
try:
|
93
|
+
result = subprocess.run(
|
94
|
+
[CUDA_CHECKPOINT_PATH, "--get-state", "--pid", str(self.pid)],
|
95
|
+
check=True,
|
96
|
+
capture_output=True,
|
97
|
+
text=True,
|
98
|
+
timeout=5,
|
99
|
+
)
|
100
|
+
|
101
|
+
state_str = result.stdout.strip().lower()
|
102
|
+
self.state = CudaCheckpointState(state_str)
|
103
|
+
|
104
|
+
except subprocess.CalledProcessError as e:
|
105
|
+
logger.debug(f"PID: {self.pid} Failed to get CUDA checkpoint state: {e.stderr}")
|
106
|
+
raise CudaCheckpointException(e.stderr)
|
107
|
+
|
108
|
+
|
109
|
+
class CudaCheckpointSession:
|
110
|
+
"""Manages the checkpointing state of processes with active CUDA sessions."""
|
111
|
+
|
112
|
+
def __init__(self):
|
113
|
+
self.cuda_processes = self._get_cuda_pids()
|
114
|
+
logger.debug(f"PIDs with CUDA sessions: {[c.pid for c in self.cuda_processes]}")
|
115
|
+
|
116
|
+
def _get_cuda_pids(self) -> list[CudaCheckpointProcess]:
|
117
|
+
"""Iterates over all PIDs and identifies the ones that have running
|
118
|
+
CUDA sessions."""
|
119
|
+
cuda_pids: list[CudaCheckpointProcess] = []
|
120
|
+
|
121
|
+
# Get all active process IDs from /proc directory
|
122
|
+
proc_dir = Path("/proc")
|
123
|
+
if not proc_dir.exists():
|
124
|
+
raise CudaCheckpointException(
|
125
|
+
"OS does not have /proc path rendering it incompatible with GPU memory snapshots."
|
126
|
+
)
|
127
|
+
|
128
|
+
for entry in proc_dir.iterdir():
|
129
|
+
if not entry.name.isdigit():
|
130
|
+
continue
|
131
|
+
|
132
|
+
pid = int(entry.name)
|
133
|
+
try:
|
134
|
+
# Call cuda-checkpoint to check if this PID has a CUDA session
|
135
|
+
result = subprocess.run(
|
136
|
+
[CUDA_CHECKPOINT_PATH, "--get-state", "--pid", str(pid)],
|
137
|
+
capture_output=True,
|
138
|
+
text=True,
|
139
|
+
timeout=10,
|
140
|
+
)
|
141
|
+
|
142
|
+
# If the command succeeds (return code 0), this PID has a CUDA session
|
143
|
+
if result.returncode == 0:
|
144
|
+
state_str = result.stdout.strip().lower()
|
145
|
+
state = CudaCheckpointState(state_str)
|
146
|
+
|
147
|
+
cuda_checkpoint_process = CudaCheckpointProcess(pid=pid, state=state)
|
148
|
+
cuda_pids.append(cuda_checkpoint_process)
|
149
|
+
|
150
|
+
# Command failed, which is expected for PIDs without CUDA sessions
|
151
|
+
except subprocess.CalledProcessError:
|
152
|
+
continue
|
153
|
+
|
154
|
+
# Raise other exceptions
|
155
|
+
except subprocess.TimeoutExpired:
|
156
|
+
raise CudaCheckpointException(f"Failed to get CUDA state for PID {pid}")
|
157
|
+
except Exception as e:
|
158
|
+
raise CudaCheckpointException(e)
|
159
|
+
|
160
|
+
# Sort PIDs for ordered checkpointing
|
161
|
+
cuda_pids.sort(key=lambda x: x.pid)
|
162
|
+
return cuda_pids
|
163
|
+
|
164
|
+
def checkpoint(self) -> None:
|
165
|
+
# Validate all states first
|
166
|
+
for proc in self.cuda_processes:
|
167
|
+
if proc.state != CudaCheckpointState.RUNNING:
|
168
|
+
raise CudaCheckpointException(f"CUDA session not in {CudaCheckpointState.RUNNING} state.")
|
169
|
+
|
170
|
+
# Moving state from GPU to CPU can take several seconds per CUDA session.
|
171
|
+
# Make a parallel call per CUDA session.
|
172
|
+
start = time.perf_counter()
|
173
|
+
|
174
|
+
def checkpoint_impl(proc: CudaCheckpointProcess):
|
175
|
+
proc.toggle(CudaCheckpointState.CHECKPOINTED)
|
176
|
+
|
177
|
+
with ThreadPoolExecutor() as executor:
|
178
|
+
list(executor.map(checkpoint_impl, self.cuda_processes))
|
179
|
+
|
180
|
+
elapsed = time.perf_counter() - start
|
181
|
+
logger.debug(f"Checkpointing CUDA sessions took => {elapsed:.3f}s")
|
182
|
+
|
183
|
+
def restore(self) -> None:
|
184
|
+
# Validate all states first
|
185
|
+
for proc in self.cuda_processes:
|
186
|
+
if proc.state != CudaCheckpointState.CHECKPOINTED:
|
187
|
+
raise CudaCheckpointException(f"CUDA session not in {CudaCheckpointState.CHECKPOINTED} state.")
|
188
|
+
|
189
|
+
# See checkpoint() for rationale about parallelism.
|
190
|
+
start = time.perf_counter()
|
191
|
+
|
192
|
+
def restore_process(proc: CudaCheckpointProcess):
|
193
|
+
proc.toggle(CudaCheckpointState.RUNNING)
|
194
|
+
|
195
|
+
with ThreadPoolExecutor() as executor:
|
196
|
+
list(executor.map(restore_process, self.cuda_processes))
|
197
|
+
|
198
|
+
elapsed = time.perf_counter() - start
|
199
|
+
logger.debug(f"Restoring CUDA sessions took => {elapsed:.3f}s")
|
@@ -8,14 +8,14 @@ from typing import BinaryIO, Callable, Optional
|
|
8
8
|
# Note: this module needs to import aiohttp in global scope
|
9
9
|
# This takes about 50ms and isn't needed in many cases for Modal execution
|
10
10
|
# To avoid this, we import it in local scope when needed (blob_utils.py)
|
11
|
-
from aiohttp import
|
11
|
+
from aiohttp import Payload
|
12
12
|
from aiohttp.abc import AbstractStreamWriter
|
13
13
|
|
14
14
|
# read ~16MiB chunks by default
|
15
15
|
DEFAULT_SEGMENT_CHUNK_SIZE = 2**24
|
16
16
|
|
17
17
|
|
18
|
-
class BytesIOSegmentPayload(
|
18
|
+
class BytesIOSegmentPayload(Payload):
|
19
19
|
"""Modified bytes payload for concurrent sends of chunks from the same file.
|
20
20
|
|
21
21
|
Adds:
|
@@ -26,6 +26,8 @@ class BytesIOSegmentPayload(BytesIOPayload):
|
|
26
26
|
Feels like this should be in some standard lib...
|
27
27
|
"""
|
28
28
|
|
29
|
+
_value: BinaryIO
|
30
|
+
|
29
31
|
def __init__(
|
30
32
|
self,
|
31
33
|
bytes_io: BinaryIO, # should *not* be shared as IO position modification is not locked
|
@@ -36,6 +38,7 @@ class BytesIOSegmentPayload(BytesIOPayload):
|
|
36
38
|
):
|
37
39
|
# not thread safe constructor!
|
38
40
|
super().__init__(bytes_io)
|
41
|
+
self._size = segment_length
|
39
42
|
self.initial_seek_pos = bytes_io.tell()
|
40
43
|
self.segment_start = segment_start
|
41
44
|
self.segment_length = segment_length
|
@@ -46,6 +49,10 @@ class BytesIOSegmentPayload(BytesIOPayload):
|
|
46
49
|
self.progress_report_cb = progress_report_cb or (lambda *_, **__: None)
|
47
50
|
self.reset_state()
|
48
51
|
|
52
|
+
def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str:
|
53
|
+
self._value.seek(self.initial_seek_pos)
|
54
|
+
return self._value.read().decode(encoding, errors)
|
55
|
+
|
49
56
|
def reset_state(self):
|
50
57
|
self._md5_checksum = hashlib.md5()
|
51
58
|
self.num_bytes_read = 0
|
@@ -76,14 +83,21 @@ class BytesIOSegmentPayload(BytesIOPayload):
|
|
76
83
|
return self._md5_checksum
|
77
84
|
|
78
85
|
async def write(self, writer: "AbstractStreamWriter"):
|
86
|
+
# On aiohttp < 3.12.0 - this is the method that's being called on a custom payload,
|
87
|
+
# but on aiohttp 3.12+ `write_with_length` is called directly.
|
88
|
+
await self.write_with_length(writer, None)
|
89
|
+
|
90
|
+
async def write_with_length(self, writer: AbstractStreamWriter, content_length: Optional[int]):
|
79
91
|
loop = asyncio.get_event_loop()
|
80
92
|
|
81
93
|
async def safe_read():
|
82
94
|
read_start = self.initial_seek_pos + self.segment_start + self.num_bytes_read
|
83
95
|
self._value.seek(read_start)
|
84
96
|
num_bytes = min(self.chunk_size, self.remaining_bytes())
|
85
|
-
|
97
|
+
if content_length is not None:
|
98
|
+
num_bytes = min(num_bytes, content_length)
|
86
99
|
|
100
|
+
chunk = await loop.run_in_executor(None, self._value.read, num_bytes)
|
87
101
|
await loop.run_in_executor(None, self._md5_checksum.update, chunk)
|
88
102
|
self.num_bytes_read += len(chunk)
|
89
103
|
return chunk
|
modal/cli/run.py
CHANGED
@@ -171,6 +171,14 @@ def _write_local_result(result_path: str, res: Any):
|
|
171
171
|
fid.write(res)
|
172
172
|
|
173
173
|
|
174
|
+
def _validate_interactive_quiet_params(ctx):
|
175
|
+
interactive = ctx.obj["interactive"]
|
176
|
+
show_progress = ctx.obj["show_progress"]
|
177
|
+
|
178
|
+
if not show_progress and interactive:
|
179
|
+
raise InvalidError("To use interactive mode, remove the --quiet flag")
|
180
|
+
|
181
|
+
|
174
182
|
def _make_click_function(app, signature: CliRunnableSignature, inner: Callable[[tuple[str, ...], dict[str, Any]], Any]):
|
175
183
|
@click.pass_context
|
176
184
|
def f(ctx, **kwargs):
|
@@ -180,6 +188,8 @@ def _make_click_function(app, signature: CliRunnableSignature, inner: Callable[[
|
|
180
188
|
else:
|
181
189
|
args = ()
|
182
190
|
|
191
|
+
_validate_interactive_quiet_params(ctx)
|
192
|
+
|
183
193
|
show_progress: bool = ctx.obj["show_progress"]
|
184
194
|
with enable_output(show_progress):
|
185
195
|
with run_app(
|
@@ -291,6 +301,8 @@ def _get_click_command_for_local_entrypoint(app: App, entrypoint: LocalEntrypoin
|
|
291
301
|
assert len(args) == 0 and len(kwargs) == 0
|
292
302
|
args = ctx.args
|
293
303
|
|
304
|
+
_validate_interactive_quiet_params(ctx)
|
305
|
+
|
294
306
|
show_progress: bool = ctx.obj["show_progress"]
|
295
307
|
with enable_output(show_progress):
|
296
308
|
with run_app(
|
modal/cli/secret.py
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
# Copyright Modal Labs 2022
|
2
|
+
import json
|
2
3
|
import os
|
3
4
|
import platform
|
4
5
|
import subprocess
|
6
|
+
from pathlib import Path
|
5
7
|
from tempfile import NamedTemporaryFile
|
6
8
|
from typing import Optional
|
7
9
|
|
@@ -48,14 +50,17 @@ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
|
|
48
50
|
@secret_cli.command("create", help="Create a new secret.")
|
49
51
|
@synchronizer.create_blocking
|
50
52
|
async def create(
|
51
|
-
secret_name,
|
52
|
-
keyvalues: list[str] = typer.Argument(
|
53
|
+
secret_name: str,
|
54
|
+
keyvalues: Optional[list[str]] = typer.Argument(default=None, help="Space-separated KEY=VALUE items."),
|
53
55
|
env: Optional[str] = ENV_OPTION,
|
56
|
+
from_dotenv: Optional[Path] = typer.Option(default=None, help="Path to a .env file to load secrets from."),
|
57
|
+
from_json: Optional[Path] = typer.Option(default=None, help="Path to a JSON file to load secrets from."),
|
54
58
|
force: bool = typer.Option(False, "--force", help="Overwrite the secret if it already exists."),
|
55
59
|
):
|
56
60
|
env = ensure_env(env)
|
57
61
|
env_dict = {}
|
58
|
-
|
62
|
+
|
63
|
+
for arg in keyvalues or []:
|
59
64
|
if "=" in arg:
|
60
65
|
key, value = arg.split("=", 1)
|
61
66
|
if value == "-":
|
@@ -63,17 +68,51 @@ async def create(
|
|
63
68
|
env_dict[key] = value
|
64
69
|
else:
|
65
70
|
raise click.UsageError(
|
66
|
-
"""Each item should be of the form <KEY>=VALUE. To enter secrets using your $EDITOR, use `<KEY>=-`.
|
71
|
+
"""Each item should be of the form <KEY>=VALUE. To enter secrets using your $EDITOR, use `<KEY>=-`. To
|
72
|
+
enter secrets from environment variables, use `<KEY>="$ENV_VAR"`.
|
67
73
|
|
68
74
|
E.g.
|
69
75
|
|
70
76
|
modal secret create my-credentials username=john password=-
|
77
|
+
modal secret create my-credentials username=john password="$PASSWORD"
|
71
78
|
"""
|
72
79
|
)
|
73
80
|
|
81
|
+
if from_dotenv:
|
82
|
+
if not from_dotenv.is_file():
|
83
|
+
raise click.UsageError(f"Could not read .env file at {from_dotenv}")
|
84
|
+
|
85
|
+
try:
|
86
|
+
from dotenv import dotenv_values
|
87
|
+
except ImportError:
|
88
|
+
raise ImportError(
|
89
|
+
"Need the `python-dotenv` package installed. You can install it by running `pip install python-dotenv`."
|
90
|
+
)
|
91
|
+
|
92
|
+
try:
|
93
|
+
env_dict.update(dotenv_values(from_dotenv))
|
94
|
+
except Exception as e:
|
95
|
+
raise click.UsageError(f"Could not parse .env file at {from_dotenv}: {e}")
|
96
|
+
|
97
|
+
if from_json:
|
98
|
+
if not from_json.is_file():
|
99
|
+
raise click.UsageError(f"Could not read JSON file at {from_json}")
|
100
|
+
|
101
|
+
try:
|
102
|
+
with from_json.open("r") as f:
|
103
|
+
env_dict.update(json.load(f))
|
104
|
+
except Exception as e:
|
105
|
+
raise click.UsageError(f"Could not parse JSON file at {from_json}: {e}")
|
106
|
+
|
74
107
|
if not env_dict:
|
75
108
|
raise click.UsageError("You need to specify at least one key for your secret")
|
76
109
|
|
110
|
+
for k, v in env_dict.items():
|
111
|
+
if not isinstance(k, str) or not k:
|
112
|
+
raise click.UsageError(f"Invalid key: '{k}'")
|
113
|
+
if not isinstance(v, str):
|
114
|
+
raise click.UsageError(f"Non-string value for secret '{k}'")
|
115
|
+
|
77
116
|
# Create secret
|
78
117
|
await _Secret.create_deployed(secret_name, env_dict, overwrite=force)
|
79
118
|
|
modal/cli/volume.py
CHANGED
@@ -154,6 +154,10 @@ async def ls(
|
|
154
154
|
filetype = "dir"
|
155
155
|
elif entry.type == api_pb2.FileEntry.FileType.SYMLINK:
|
156
156
|
filetype = "link"
|
157
|
+
elif entry.type == api_pb2.FileEntry.FileType.FIFO:
|
158
|
+
filetype = "fifo"
|
159
|
+
elif entry.type == api_pb2.FileEntry.FileType.SOCKET:
|
160
|
+
filetype = "socket"
|
157
161
|
else:
|
158
162
|
filetype = "file"
|
159
163
|
rows.append(
|
@@ -261,12 +265,13 @@ async def rm(
|
|
261
265
|
async def cp(
|
262
266
|
volume_name: str,
|
263
267
|
paths: list[str], # accepts multiple paths, last path is treated as destination path
|
268
|
+
recursive: bool = Option(False, "-r", "--recursive", help="Copy directories recursively"),
|
264
269
|
env: Optional[str] = ENV_OPTION,
|
265
270
|
):
|
266
271
|
ensure_env(env)
|
267
272
|
volume = _Volume.from_name(volume_name, environment_name=env)
|
268
273
|
*src_paths, dst_path = paths
|
269
|
-
await volume.copy_files(src_paths, dst_path)
|
274
|
+
await volume.copy_files(src_paths, dst_path, recursive)
|
270
275
|
|
271
276
|
|
272
277
|
@volume_cli.command(
|
modal/client.pyi
CHANGED
@@ -27,11 +27,7 @@ class _Client:
|
|
27
27
|
_snapshotted: bool
|
28
28
|
|
29
29
|
def __init__(
|
30
|
-
self,
|
31
|
-
server_url: str,
|
32
|
-
client_type: int,
|
33
|
-
credentials: typing.Optional[tuple[str, str]],
|
34
|
-
version: str = "1.0.2.dev6",
|
30
|
+
self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "1.0.3"
|
35
31
|
): ...
|
36
32
|
def is_closed(self) -> bool: ...
|
37
33
|
@property
|
@@ -90,11 +86,7 @@ class Client:
|
|
90
86
|
_snapshotted: bool
|
91
87
|
|
92
88
|
def __init__(
|
93
|
-
self,
|
94
|
-
server_url: str,
|
95
|
-
client_type: int,
|
96
|
-
credentials: typing.Optional[tuple[str, str]],
|
97
|
-
version: str = "1.0.2.dev6",
|
89
|
+
self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "1.0.3"
|
98
90
|
): ...
|
99
91
|
def is_closed(self) -> bool: ...
|
100
92
|
@property
|
modal/experimental/__init__.py
CHANGED
@@ -94,8 +94,9 @@ async def raw_dockerfile_image(
|
|
94
94
|
|
95
95
|
Unlike for `modal.Image.from_dockerfile`, the provided recipe will not be embellished with
|
96
96
|
steps to install dependencies for the Modal client package. As a consequence, the resulting
|
97
|
-
Image cannot be used with a modal Function unless those dependencies are
|
98
|
-
|
97
|
+
Image cannot be used with a modal Function unless those dependencies are already included
|
98
|
+
as part of the base Dockerfile recipe or are added in a subsequent layer. The Image _can_ be
|
99
|
+
directly used with a modal Sandbox, which does not need the Modal client.
|
99
100
|
|
100
101
|
We expect to support this experimental function until the `2025.04` Modal Image Builder is
|
101
102
|
stable, at which point Modal Image recipes will no longer install the client dependencies
|
@@ -127,8 +128,9 @@ async def raw_registry_image(
|
|
127
128
|
|
128
129
|
Unlike for `modal.Image.from_registry`, the provided recipe will not be embellished with
|
129
130
|
steps to install dependencies for the Modal client package. As a consequence, the resulting
|
130
|
-
Image cannot be used with a modal Function unless those dependencies are
|
131
|
-
|
131
|
+
Image cannot be used with a modal Function unless those dependencies are already included
|
132
|
+
as part of the registry Image or are added in a subsequent layer. The Image _can_ be
|
133
|
+
directly used with a modal Sandbox, which does not need the Modal client.
|
132
134
|
|
133
135
|
We expect to support this experimental function until the `2025.04` Modal Image Builder is
|
134
136
|
stable, at which point Modal Image recipes will no longer install the client dependencies
|
modal/image.py
CHANGED
@@ -647,7 +647,13 @@ class _Image(_Object, type_prefix="im"):
|
|
647
647
|
metadata = resp.metadata
|
648
648
|
|
649
649
|
if result.status == api_pb2.GenericResult.GENERIC_STATUS_FAILURE:
|
650
|
-
|
650
|
+
if result.exception:
|
651
|
+
raise RemoteError(f"Image build for {image_id} failed with the exception:\n{result.exception}")
|
652
|
+
else:
|
653
|
+
msg = f"Image build for {image_id} failed. See build logs for more details."
|
654
|
+
if not _get_output_manager():
|
655
|
+
msg += " (Hint: Use `modal.enable_output()` to see logs from the process building the Image.)"
|
656
|
+
raise RemoteError(msg)
|
651
657
|
elif result.status == api_pb2.GenericResult.GENERIC_STATUS_TERMINATED:
|
652
658
|
raise RemoteError(f"Image build for {image_id} terminated due to external shut-down. Please try again.")
|
653
659
|
elif result.status == api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT:
|
@@ -1522,12 +1528,14 @@ class _Image(_Object, type_prefix="im"):
|
|
1522
1528
|
modal_requirements_commands = []
|
1523
1529
|
if builder_version <= "2024.10":
|
1524
1530
|
# past 2024.10, client dependencies are mounted at runtime
|
1525
|
-
modal_requirements_commands.extend(
|
1526
|
-
|
1527
|
-
|
1528
|
-
|
1529
|
-
|
1530
|
-
|
1531
|
+
modal_requirements_commands.extend(
|
1532
|
+
[
|
1533
|
+
f"COPY {CONTAINER_REQUIREMENTS_PATH} {CONTAINER_REQUIREMENTS_PATH}",
|
1534
|
+
f"RUN python -m pip install --upgrade {_base_image_config('package_tools', builder_version)}",
|
1535
|
+
f"RUN {requirements_prefix}{_get_modal_requirements_command(builder_version)}",
|
1536
|
+
]
|
1537
|
+
)
|
1538
|
+
if "2024.10" >= builder_version > "2023.12":
|
1531
1539
|
modal_requirements_commands.append(f"RUN rm {CONTAINER_REQUIREMENTS_PATH}")
|
1532
1540
|
|
1533
1541
|
return [
|
@@ -1830,23 +1838,31 @@ class _Image(_Object, type_prefix="im"):
|
|
1830
1838
|
f"FROM python:{full_python_version}-slim-{debian_codename}",
|
1831
1839
|
]
|
1832
1840
|
if version <= "2024.10":
|
1833
|
-
commands.extend(
|
1834
|
-
|
1835
|
-
|
1836
|
-
|
1837
|
-
|
1838
|
-
|
1839
|
-
|
1840
|
-
|
1841
|
+
commands.extend(
|
1842
|
+
[
|
1843
|
+
f"COPY {CONTAINER_REQUIREMENTS_PATH} {CONTAINER_REQUIREMENTS_PATH}",
|
1844
|
+
]
|
1845
|
+
)
|
1846
|
+
commands.extend(
|
1847
|
+
[
|
1848
|
+
"RUN apt-get update",
|
1849
|
+
"RUN apt-get install -y gcc gfortran build-essential",
|
1850
|
+
f"RUN pip install --upgrade {_base_image_config('package_tools', version)}",
|
1851
|
+
]
|
1852
|
+
)
|
1841
1853
|
if version <= "2024.10":
|
1842
1854
|
# after 2024.10, modal requirements are mounted at runtime
|
1843
|
-
commands.extend(
|
1844
|
-
|
1845
|
-
|
1846
|
-
|
1847
|
-
|
1848
|
-
|
1849
|
-
|
1855
|
+
commands.extend(
|
1856
|
+
[
|
1857
|
+
f"RUN {_get_modal_requirements_command(version)}",
|
1858
|
+
]
|
1859
|
+
)
|
1860
|
+
commands.extend(
|
1861
|
+
[
|
1862
|
+
# Set debian front-end to non-interactive to avoid users getting stuck with input prompts.
|
1863
|
+
"RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections",
|
1864
|
+
]
|
1865
|
+
)
|
1850
1866
|
if "2024.10" >= version > "2023.12":
|
1851
1867
|
commands.append(f"RUN rm {CONTAINER_REQUIREMENTS_PATH}")
|
1852
1868
|
if version > "2024.10":
|