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 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
- gpu_process_state = gpu_memory_snapshot.get_state()
885
- if gpu_process_state != gpu_memory_snapshot.CudaCheckpointState.CHECKPOINTED:
886
- raise ValueError(
887
- "Cannot restore GPU state if GPU isn't in a 'checkpointed' state. "
888
- f"Current GPU state: {gpu_process_state}"
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.toggle()
917
- gpu_memory_snapshot.wait_for_state(gpu_memory_snapshot.CudaCheckpointState.CHECKPOINTED)
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
- def toggle():
33
- """Toggle CUDA checkpoint state for current process, moving GPU memory to the
34
- CPU and back depending on the current process state when called."""
35
- pid = get_own_pid()
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
- try:
39
- subprocess.run(
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
- except subprocess.CalledProcessError as e:
53
- logger.debug(f"Failed to toggle CUDA checkpoint state: {e.stderr}")
54
- raise CudaCheckpointException(e.stderr)
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
- def get_state() -> CudaCheckpointState:
58
- """Get current CUDA checkpoint state for this process."""
59
- pid = get_own_pid()
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
- try:
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
- # Parse output to get state
67
- state_str = result.stdout.strip().lower()
68
- return CudaCheckpointState(state_str)
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
- except subprocess.CalledProcessError as e:
71
- logger.debug(f"Failed to get CUDA checkpoint state: {e.stderr}")
72
- raise CudaCheckpointException(e.stderr)
61
+ if self.state == target_state:
62
+ return False
73
63
 
74
-
75
- def wait_for_state(target_state: CudaCheckpointState, timeout_secs: float = 5.0):
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(f"Timeout after {elapsed:.2f}s waiting for state {target_state.value}")
93
-
94
- time.sleep(0.1)
95
-
96
-
97
- def get_own_pid():
98
- """Returns the Process ID (PID) of the current Python process
99
- using only the standard library.
100
- """
101
- return os.getpid()
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 BytesIOPayload
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(BytesIOPayload):
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
- chunk = await loop.run_in_executor(None, self._value.read, num_bytes)
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(..., help="Space-separated KEY=VALUE items"),
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
- for arg in keyvalues:
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
@@ -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 added in a subsequent
98
- layer. It _can_ be directly used with a modal Sandbox, which does not need the Modal client.
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 added in a subsequent
131
- layer. It _can_ be directly used with a modal Sandbox, which does not need the Modal client.
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
- raise RemoteError(f"Image build for {image_id} failed with the exception:\n{result.exception}")
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
- f"COPY {CONTAINER_REQUIREMENTS_PATH} {CONTAINER_REQUIREMENTS_PATH}",
1527
- f"RUN python -m pip install --upgrade {_base_image_config('package_tools', builder_version)}",
1528
- f"RUN {requirements_prefix}{_get_modal_requirements_command(builder_version)}",
1529
- ])
1530
- if "2024.10" >= builder_version > "2023.12":
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
- f"COPY {CONTAINER_REQUIREMENTS_PATH} {CONTAINER_REQUIREMENTS_PATH}",
1835
- ])
1836
- commands.extend([
1837
- "RUN apt-get update",
1838
- "RUN apt-get install -y gcc gfortran build-essential",
1839
- f"RUN pip install --upgrade {_base_image_config('package_tools', version)}",
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
- f"RUN {_get_modal_requirements_command(version)}",
1845
- ])
1846
- commands.extend([
1847
- # Set debian front-end to non-interactive to avoid users getting stuck with input prompts.
1848
- "RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections",
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":