modAL 1.3.6.dev12__py3-none-any.whl → 1.3.6.dev13__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
@@ -56,7 +56,7 @@ from ._utils.function_utils import (
56
56
  is_async,
57
57
  )
58
58
  from ._utils.grpc_utils import Retry, RetryWarningMessage
59
- from ._utils.mount_utils import validate_network_file_systems, validate_volumes
59
+ from ._utils.mount_utils import validate_network_file_systems, validate_volumes, validate_volumes_by_object_id
60
60
  from .call_graph import InputInfo, _reconstruct_call_graph
61
61
  from .client import _Client
62
62
  from .cloud_bucket_mount import _CloudBucketMount, cloud_bucket_mounts_to_proto
@@ -966,6 +966,11 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
966
966
  if image._metadata is not None:
967
967
  mount_client_dependencies = image._metadata.image_builder_version > "2024.10"
968
968
 
969
+ # Validate that the same volume (by object_id) isn't mounted at multiple paths
970
+ # This validation happens here (at load time) because volumes need to be hydrated
971
+ # to have their object_id set.
972
+ validate_volumes_by_object_id(validated_volumes_no_cloud_buckets)
973
+
969
974
  # Relies on dicts being ordered (true as of Python 3.6).
970
975
  volume_mounts = [
971
976
  api_pb2.VolumeMount(
@@ -1235,6 +1240,9 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1235
1240
  assert parent is not None and parent.is_hydrated
1236
1241
 
1237
1242
  if options:
1243
+ # Validate that the same volume (by object_id) isn't mounted at multiple paths
1244
+ validate_volumes_by_object_id(options.validated_volumes)
1245
+
1238
1246
  volume_mounts = [
1239
1247
  api_pb2.VolumeMount(
1240
1248
  mount_path=path,
@@ -101,3 +101,23 @@ def validate_only_modal_volumes(
101
101
  raise InvalidError(f"{caller_name} only supports volumes that are modal.Volume")
102
102
 
103
103
  return validated_volumes
104
+
105
+
106
+ def validate_volumes_by_object_id(validated_volumes: Sequence[tuple[str, _Volume]]) -> None:
107
+ """Validate that the same volume (by object_id) is not mounted at multiple paths.
108
+
109
+ This validation happens at load time after volumes are hydrated and have object_ids.
110
+ """
111
+ object_id_to_paths: dict[str, list[str]] = {}
112
+ for path, volume in validated_volumes:
113
+ if not volume.is_hydrated:
114
+ # This should never happen since this function is only called at load time
115
+ raise RuntimeError(f"Internal error: Volume at '{path}' is not hydrated when validating mounts")
116
+ object_id_to_paths.setdefault(volume.object_id, []).append(path)
117
+
118
+ for object_id, paths in object_id_to_paths.items():
119
+ if len(paths) > 1:
120
+ conflicting = ", ".join(sorted(paths))
121
+ raise InvalidError(
122
+ f"The same Volume cannot be mounted in multiple locations for the same function: {conflicting}"
123
+ )
modal/cli/app.py CHANGED
@@ -137,13 +137,14 @@ def logs(
137
137
  "--until",
138
138
  help="End of time range; accepts same argument types as --since",
139
139
  ),
140
+ tail: Optional[int] = typer.Option(None, "--tail", "-n", help="Show only the last N log entries"),
140
141
  search: Optional[str] = typer.Option(None, "--search", help="Filter by search text"),
141
142
  function_id: Optional[str] = typer.Option("", "--function", help="Filter by Function ID (fu-*)"),
142
143
  function_call_id: Optional[str] = typer.Option("", "--function-call", help="Filter by FunctionCall ID (fc-*)"),
144
+ container_id: Optional[str] = typer.Option("", "--container", help="Filter by Container ID (ta-*)"),
143
145
  source: Optional[str] = typer.Option(
144
146
  None, "--source", "-s", help="Filter by source: 'stdout', 'stderr', or 'system'"
145
147
  ),
146
- tail: Optional[int] = typer.Option(None, "--tail", "-n", help="Show only the last N log entries"),
147
148
  timestamps: bool = typer.Option(False, "--timestamps", help="Prefix each line with its timestamp"),
148
149
  show_function_id: bool = typer.Option(False, "--show-function-id", help="Prefix each line with its Function ID"),
149
150
  show_function_call_id: bool = typer.Option(
@@ -236,12 +237,14 @@ def logs(
236
237
  source=source_fd,
237
238
  function_id=function_id or "",
238
239
  function_call_id=function_call_id or "",
240
+ task_id=container_id or "",
239
241
  search_text=search or "",
240
242
  )
241
243
 
242
244
  if follow:
243
245
  stream_app_logs(
244
246
  app_id,
247
+ task_id=container_id or "",
245
248
  show_timestamps=timestamps,
246
249
  follow=True,
247
250
  prefix_fields=prefix_fields,
modal/cli/container.py CHANGED
@@ -1,15 +1,19 @@
1
1
  # Copyright Modal Labs 2022
2
+ from datetime import datetime, timezone
2
3
  from typing import Optional, Union
3
4
 
4
5
  import typer
6
+ from click import UsageError
5
7
  from rich.table import Column
6
8
  from rich.text import Text
7
9
 
10
+ from modal._logs import _FETCH_LIMIT, _MAX_FETCH_RANGE, LogsFilters
8
11
  from modal._object import _get_environment_name
9
12
  from modal._output.pty import get_pty_info
10
13
  from modal._utils.async_utils import synchronizer
11
14
  from modal._utils.time_utils import timestamp_to_localized_str
12
- from modal.cli.utils import ENV_OPTION, display_table, is_tty, stream_app_logs
15
+ from modal.cli.app import _DEFAULT_LOGS_TAIL, _SOURCE_OPTIONS, _parse_time_arg
16
+ from modal.cli.utils import ENV_OPTION, display_table, fetch_app_logs, is_tty, stream_app_logs, tail_app_logs
13
17
  from modal.client import _Client
14
18
  from modal.config import config
15
19
  from modal.container_process import _ContainerProcess
@@ -53,13 +57,68 @@ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False, app_id: str
53
57
  display_table(column_names, rows, json=json, title=f"Active Containers in environment: {environment_name}")
54
58
 
55
59
 
56
- @container_cli.command("logs")
57
- def logs(
60
+ @container_cli.command("logs", no_args_is_help=True)
61
+ @synchronizer.create_blocking
62
+ async def logs(
58
63
  container_id: str = typer.Argument(help="Container ID"),
59
64
  follow: bool = typer.Option(False, "-f", "--follow", help="Stream log output until Container stops"),
60
- timestamps: bool = typer.Option(False, "--timestamps", help="Show timestamps for each log line"),
65
+ all_logs: bool = typer.Option(False, "--all", help="Show all logs for the container"),
66
+ since: Optional[str] = typer.Option(
67
+ None,
68
+ "--since",
69
+ help=(
70
+ "Start of time range. Accepts ISO 8601 datetime or relative time, e.g. '1d' (1 day ago), '2h', '30m', etc."
71
+ ),
72
+ ),
73
+ until: Optional[str] = typer.Option(
74
+ None,
75
+ "--until",
76
+ help="End of time range; accepts same argument types as --since",
77
+ ),
78
+ tail: Optional[int] = typer.Option(None, "--tail", "-n", help="Show only the last N log entries"),
79
+ search: Optional[str] = typer.Option(None, "--search", help="Filter by search text"),
80
+ source: Optional[str] = typer.Option(
81
+ None, "--source", "-s", help="Filter by source: 'stdout', 'stderr', or 'system'"
82
+ ),
83
+ timestamps: bool = typer.Option(False, "--timestamps", help="Prefix each line with its timestamp"),
61
84
  ):
62
- """Show logs for a specific container, streaming while active."""
85
+ """Fetch or stream logs for a specific container.
86
+
87
+ By default, this command fetches the last 100 log entries and exits. Use ``-f`` to
88
+ live-stream logs from a running container instead. Fetch and follow are mutually exclusive.
89
+
90
+ **Examples:**
91
+
92
+ Get recent logs for a container:
93
+
94
+ ```
95
+ modal container logs ta-123456
96
+ ```
97
+
98
+ Follow (stream) logs from a running container:
99
+
100
+ ```
101
+ modal container logs ta-123456 -f
102
+ ```
103
+
104
+ Fetch logs from the last 2 hours:
105
+
106
+ ```
107
+ modal container logs ta-123456 --since 2h
108
+ ```
109
+
110
+ Fetch logs in a specific time range:
111
+
112
+ ```
113
+ modal container logs ta-123456 --since 2026-03-01T05:00:00 --until 2026-03-01T08:00:00
114
+ ```
115
+
116
+ Fetch all container logs:
117
+
118
+ ```
119
+ modal container logs ta-123456 --all
120
+ ```
121
+ """
63
122
  task_id, sandbox_id = None, None
64
123
  if container_id.startswith("sb-"):
65
124
  sandbox_id = container_id
@@ -67,7 +126,95 @@ def logs(
67
126
  task_id = container_id
68
127
  else:
69
128
  raise InvalidError(f"Invalid container ID: {container_id}")
70
- stream_app_logs(task_id=task_id, sandbox_id=sandbox_id, show_timestamps=timestamps, follow=follow)
129
+
130
+ if follow and (since or until or tail):
131
+ raise UsageError("--follow cannot be combined with --since, --until, or --tail.")
132
+
133
+ if tail is not None and tail <= 0:
134
+ raise UsageError("--tail value must be positive.")
135
+
136
+ if tail is not None and tail > _FETCH_LIMIT:
137
+ raise UsageError(f"--tail value must not exceed {_FETCH_LIMIT}.")
138
+
139
+ if all_logs and (since or until or tail):
140
+ raise UsageError("--all cannot be combined with --since, --until, or --tail.")
141
+
142
+ if all_logs and follow:
143
+ raise UsageError("--all cannot be combined with --follow.")
144
+
145
+ if source is not None:
146
+ if source not in _SOURCE_OPTIONS:
147
+ raise UsageError(f"Invalid source: '{source}'. Must be 'stdout', 'stderr', or 'system'.")
148
+ source_fd = _SOURCE_OPTIONS[source]
149
+ else:
150
+ source_fd = api_pb2.FILE_DESCRIPTOR_UNSPECIFIED
151
+
152
+ log_filters = LogsFilters(
153
+ source=source_fd,
154
+ task_id=task_id or "",
155
+ sandbox_id=sandbox_id or "",
156
+ search_text=search or "",
157
+ )
158
+
159
+ if follow:
160
+ await stream_app_logs.aio(
161
+ task_id=task_id,
162
+ sandbox_id=sandbox_id,
163
+ show_timestamps=timestamps,
164
+ follow=True,
165
+ filters=log_filters,
166
+ )
167
+ else:
168
+ # Resolve the app_id for the container.
169
+ client = await _Client.from_env()
170
+
171
+ if sandbox_id:
172
+ sb_resp = await client.stub.SandboxGetTaskId(api_pb2.SandboxGetTaskIdRequest(sandbox_id=sandbox_id))
173
+ task_id = sb_resp.task_id
174
+
175
+ task_info_resp = await client.stub.TaskGetInfo(api_pb2.TaskGetInfoRequest(task_id=task_id))
176
+ app_id = task_info_resp.app_id
177
+
178
+ now = datetime.now(timezone.utc)
179
+ if all_logs:
180
+ since_dt = datetime.fromtimestamp(task_info_resp.info.started_at, timezone.utc)
181
+ if task_info_resp.info.finished_at:
182
+ until_dt = datetime.fromtimestamp(task_info_resp.info.finished_at, timezone.utc)
183
+ else:
184
+ until_dt = now
185
+ else:
186
+ since_dt = _parse_time_arg(since, default=now) if since else None
187
+ until_dt = _parse_time_arg(until, default=now) if until else None
188
+
189
+ if since_dt is not None and until_dt is not None and since_dt >= until_dt:
190
+ raise UsageError("--since must be before --until.")
191
+
192
+ if since_dt is not None:
193
+ effective_until = until_dt or now
194
+ if effective_until - since_dt > _MAX_FETCH_RANGE:
195
+ raise UsageError(f"Log fetch time range cannot exceed {_MAX_FETCH_RANGE.days} days.")
196
+
197
+ if all_logs or (since and tail is None):
198
+ # Range mode: --since without --tail fetches everything in the range.
199
+ await fetch_app_logs.aio(
200
+ app_id,
201
+ since_dt,
202
+ until_dt or now,
203
+ show_timestamps=timestamps,
204
+ filters=log_filters,
205
+ )
206
+ else:
207
+ # Tail mode: single fetch with limit.
208
+ # --since is a hard floor, --until shifts the anchor.
209
+ effective_tail = tail if tail is not None else _DEFAULT_LOGS_TAIL
210
+ await tail_app_logs.aio(
211
+ app_id,
212
+ effective_tail,
213
+ show_timestamps=timestamps,
214
+ since=since_dt,
215
+ until=until_dt,
216
+ filters=log_filters,
217
+ )
71
218
 
72
219
 
73
220
  @container_cli.command("exec")
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.3.6.dev12",
38
+ version: str = "1.3.6.dev13",
39
39
  ):
40
40
  """mdmd:hidden
41
41
  The Modal client object is not intended to be instantiated directly by users.
@@ -171,7 +171,7 @@ class Client:
171
171
  server_url: str,
172
172
  client_type: int,
173
173
  credentials: typing.Optional[tuple[str, str]],
174
- version: str = "1.3.6.dev12",
174
+ version: str = "1.3.6.dev13",
175
175
  ):
176
176
  """mdmd:hidden
177
177
  The Modal client object is not intended to be instantiated directly by users.
modal/functions.pyi CHANGED
@@ -408,7 +408,7 @@ class Function(
408
408
 
409
409
  _call_generator: ___call_generator_spec
410
410
 
411
- class __remote_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
411
+ class __remote_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
412
412
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER:
413
413
  """Calls the function remotely, executing it with the given arguments and returning the execution's result."""
414
414
  ...
@@ -417,7 +417,7 @@ class Function(
417
417
  """Calls the function remotely, executing it with the given arguments and returning the execution's result."""
418
418
  ...
419
419
 
420
- remote: __remote_spec[modal._functions.P, modal._functions.ReturnType]
420
+ remote: __remote_spec[modal._functions.ReturnType, modal._functions.P]
421
421
 
422
422
  class __remote_gen_spec(typing_extensions.Protocol):
423
423
  def __call__(self, /, *args, **kwargs) -> typing.Generator[typing.Any, None, None]:
@@ -444,7 +444,7 @@ class Function(
444
444
  """
445
445
  ...
446
446
 
447
- class ___experimental_spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
447
+ class ___experimental_spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
448
448
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
449
449
  """[Experimental] Calls the function with the given arguments, without waiting for the results.
450
450
 
@@ -467,7 +467,7 @@ class Function(
467
467
  """
468
468
  ...
469
469
 
470
- _experimental_spawn: ___experimental_spawn_spec[modal._functions.P, modal._functions.ReturnType]
470
+ _experimental_spawn: ___experimental_spawn_spec[modal._functions.ReturnType, modal._functions.P]
471
471
 
472
472
  class ___spawn_map_inner_spec(typing_extensions.Protocol[P_INNER]):
473
473
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> None: ...
@@ -475,7 +475,7 @@ class Function(
475
475
 
476
476
  _spawn_map_inner: ___spawn_map_inner_spec[modal._functions.P]
477
477
 
478
- class __spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
478
+ class __spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
479
479
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
480
480
  """Calls the function with the given arguments, without waiting for the results.
481
481
 
@@ -496,7 +496,7 @@ class Function(
496
496
  """
497
497
  ...
498
498
 
499
- spawn: __spawn_spec[modal._functions.P, modal._functions.ReturnType]
499
+ spawn: __spawn_spec[modal._functions.ReturnType, modal._functions.P]
500
500
 
501
501
  def get_raw_f(self) -> collections.abc.Callable[..., typing.Any]:
502
502
  """Return the inner Python object wrapped by this Modal Function."""
modal/image.py CHANGED
@@ -40,7 +40,7 @@ from ._utils.docker_utils import (
40
40
  find_dockerignore_file,
41
41
  )
42
42
  from ._utils.function_utils import FunctionInfo
43
- from ._utils.mount_utils import validate_only_modal_volumes
43
+ from ._utils.mount_utils import validate_only_modal_volumes, validate_volumes_by_object_id
44
44
  from .client import _Client
45
45
  from .cloud_bucket_mount import _CloudBucketMount
46
46
  from .config import config, logger, user_config_path
@@ -434,7 +434,7 @@ class _Image(_Object, type_prefix="im"):
434
434
  self._serve_mounts = other._serve_mounts
435
435
  self._deferred_mounts = other._deferred_mounts
436
436
  self._added_python_source_set = other._added_python_source_set
437
- self._is_empty = other._is_empty
437
+ self._is_empty = False
438
438
 
439
439
  def _get_metadata(self) -> Optional[Message]:
440
440
  return self._metadata
@@ -626,6 +626,9 @@ class _Image(_Object, type_prefix="im"):
626
626
  build_function_id = ""
627
627
  _build_function = None
628
628
 
629
+ # Validate that the same volume (by object_id) isn't mounted at multiple paths
630
+ validate_volumes_by_object_id(validated_volumes)
631
+
629
632
  # Relies on dicts being ordered (true as of Python 3.6).
630
633
  volume_mounts = [
631
634
  api_pb2.VolumeMount(
@@ -724,12 +727,6 @@ class _Image(_Object, type_prefix="im"):
724
727
  )
725
728
  return obj
726
729
 
727
- @staticmethod
728
- def _from_scratch() -> "_Image":
729
- image = _Image.from_registry("scratch")
730
- image._is_empty = True
731
- return image
732
-
733
730
  def _copy_mount(self, mount: _Mount, remote_path: Union[str, Path] = ".") -> "_Image":
734
731
  """mdmd:hidden
735
732
  Internal
@@ -2179,6 +2176,36 @@ class _Image(_Object, type_prefix="im"):
2179
2176
  force_build=force_build,
2180
2177
  )
2181
2178
 
2179
+ @staticmethod
2180
+ def from_scratch(force_build: bool = False) -> "_Image":
2181
+ """Create an empty Image, equivalent to `FROM scratch` in Docker.
2182
+
2183
+ The resulting Image has no operating system, shell, or package manager. It is
2184
+ primarily useful as a lightweight filesystem to mount into a Sandbox via
2185
+ `Sandbox.mount_image`.
2186
+
2187
+ Note that since this Image doesn't contain Python or other standard OS utilities,
2188
+ higher-level Image build steps like `pip_install` cannot be chained onto it. It also
2189
+ cannot be used for `modal.Function` execution, which requires a Python interpreter.
2190
+
2191
+ **Example**
2192
+
2193
+ ```python notest
2194
+ image = modal.Image.from_scratch().add_local_file(local_path, "/bin/my_binary", copy=True)
2195
+ ```
2196
+ """
2197
+
2198
+ def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
2199
+ return DockerfileSpec(commands=["FROM scratch"], context_files={})
2200
+
2201
+ image = _Image._from_args(
2202
+ dockerfile_function=build_dockerfile,
2203
+ force_build=force_build,
2204
+ _namespace=api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
2205
+ )
2206
+ image._is_empty = True
2207
+ return image
2208
+
2182
2209
  @staticmethod
2183
2210
  def debian_slim(python_version: Optional[str] = None, force_build: bool = False) -> "_Image":
2184
2211
  """Default image, based on the official `python` Docker images."""
modal/image.pyi CHANGED
@@ -187,8 +187,6 @@ class _Image(modal._object._Object):
187
187
  _namespace: int = 1,
188
188
  _do_assert_no_mount_layers: bool = True,
189
189
  ): ...
190
- @staticmethod
191
- def _from_scratch() -> _Image: ...
192
190
  def _copy_mount(self, mount: modal.mount._Mount, remote_path: typing.Union[str, pathlib.Path] = ".") -> _Image:
193
191
  """mdmd:hidden
194
192
  Internal
@@ -901,6 +899,26 @@ class _Image(modal._object._Object):
901
899
  """
902
900
  ...
903
901
 
902
+ @staticmethod
903
+ def from_scratch(force_build: bool = False) -> _Image:
904
+ """Create an empty Image, equivalent to `FROM scratch` in Docker.
905
+
906
+ The resulting Image has no operating system, shell, or package manager. It is
907
+ primarily useful as a lightweight filesystem to mount into a Sandbox via
908
+ `Sandbox.mount_image`.
909
+
910
+ Note that since this Image doesn't contain Python or other standard OS utilities,
911
+ higher-level Image build steps like `pip_install` cannot be chained onto it. It also
912
+ cannot be used for `modal.Function` execution, which requires a Python interpreter.
913
+
914
+ **Example**
915
+
916
+ ```python notest
917
+ image = modal.Image.from_scratch().add_local_file(local_path, "/bin/my_binary", copy=True)
918
+ ```
919
+ """
920
+ ...
921
+
904
922
  @staticmethod
905
923
  def debian_slim(python_version: typing.Optional[str] = None, force_build: bool = False) -> _Image:
906
924
  """Default image, based on the official `python` Docker images."""
@@ -1109,8 +1127,6 @@ class Image(modal.object.Object):
1109
1127
  _namespace: int = 1,
1110
1128
  _do_assert_no_mount_layers: bool = True,
1111
1129
  ): ...
1112
- @staticmethod
1113
- def _from_scratch() -> Image: ...
1114
1130
  def _copy_mount(self, mount: modal.mount.Mount, remote_path: typing.Union[str, pathlib.Path] = ".") -> Image:
1115
1131
  """mdmd:hidden
1116
1132
  Internal
@@ -1879,6 +1895,26 @@ class Image(modal.object.Object):
1879
1895
  """
1880
1896
  ...
1881
1897
 
1898
+ @staticmethod
1899
+ def from_scratch(force_build: bool = False) -> Image:
1900
+ """Create an empty Image, equivalent to `FROM scratch` in Docker.
1901
+
1902
+ The resulting Image has no operating system, shell, or package manager. It is
1903
+ primarily useful as a lightweight filesystem to mount into a Sandbox via
1904
+ `Sandbox.mount_image`.
1905
+
1906
+ Note that since this Image doesn't contain Python or other standard OS utilities,
1907
+ higher-level Image build steps like `pip_install` cannot be chained onto it. It also
1908
+ cannot be used for `modal.Function` execution, which requires a Python interpreter.
1909
+
1910
+ **Example**
1911
+
1912
+ ```python notest
1913
+ image = modal.Image.from_scratch().add_local_file(local_path, "/bin/my_binary", copy=True)
1914
+ ```
1915
+ """
1916
+ ...
1917
+
1882
1918
  @staticmethod
1883
1919
  def debian_slim(python_version: typing.Optional[str] = None, force_build: bool = False) -> Image:
1884
1920
  """Default image, based on the official `python` Docker images."""
modal/runner.py CHANGED
@@ -214,23 +214,32 @@ async def _stop_and_wait_for_containers(
214
214
  if container.enqueued_at and container.enqueued_at < deployed_at
215
215
  ]
216
216
 
217
+ stopped_ids = set()
218
+
217
219
  async def stop_containers(container_ids: list[str]):
218
220
  sem = asyncio.Semaphore(32)
219
221
 
220
222
  async def stop_one_container(tid: str):
223
+ if tid in stopped_ids:
224
+ return
225
+
221
226
  async with sem:
222
227
  await client.stub.ContainerStop(api_pb2.ContainerStopRequest(task_id=tid))
228
+ stopped_ids.add(tid)
223
229
 
224
- results = await asyncio.gather(*[stop_one_container(tid) for tid in container_ids], return_exceptions=True)
230
+ stop_tasks = [stop_one_container(tid) for tid in container_ids if tid not in stopped_ids]
231
+ if not stop_tasks:
232
+ return
233
+
234
+ results = await asyncio.gather(*stop_tasks, return_exceptions=True)
225
235
  exceptions = [r for r in results if isinstance(r, BaseException)]
226
236
  if exceptions:
227
237
  raise exceptions[0]
228
238
 
229
- async def poll_until_stopped(initial_ids: list[str]):
230
- await stop_containers(initial_ids)
231
- await asyncio.sleep(WAIT_FOR_CONTAINER_STOP_SLEEP_INTERVAL)
232
-
239
+ async def poll_until_stopped():
233
240
  while ids := await get_old_container_ids():
241
+ # Just in case there are new ids from `get_old_container_ids`, we call `stop_containers` again,
242
+ # which will no-op for containers that was already stopped.
234
243
  await stop_containers(ids)
235
244
  await asyncio.sleep(WAIT_FOR_CONTAINER_STOP_SLEEP_INTERVAL)
236
245
 
@@ -239,12 +248,13 @@ async def _stop_and_wait_for_containers(
239
248
  return
240
249
 
241
250
  output = OutputManager.get()
242
- output.print("♻️ Restarting containers...")
251
+ output.print("🧹 Terminating running containers")
252
+ await stop_containers(ids)
243
253
 
244
254
  try:
245
- await asyncio.wait_for(poll_until_stopped(ids), timeout=WAIT_FOR_CONTAINER_STOP_TIMEOUT)
255
+ await asyncio.wait_for(poll_until_stopped(), timeout=WAIT_FOR_CONTAINER_STOP_TIMEOUT)
246
256
  except asyncio.TimeoutError:
247
- raise asyncio.TimeoutError(f"Containers did not restart in under {WAIT_FOR_CONTAINER_STOP_TIMEOUT} seconds.")
257
+ raise asyncio.TimeoutError(f"Containers did not terminate in under {WAIT_FOR_CONTAINER_STOP_TIMEOUT} seconds.")
248
258
 
249
259
 
250
260
  def _validate_deployment_strategy(strategy: str) -> DEPLOYMENT_STRATEGY_TYPE:
@@ -294,7 +304,7 @@ async def _publish_app(
294
304
  )
295
305
  except Exception as exc:
296
306
  warnings.warn(
297
- f"App updated successfully, but containers did not all restart. {exc}",
307
+ f"App updated successfully, but containers did not all terminate. {exc}",
298
308
  UserWarning,
299
309
  )
300
310
 
modal/sandbox.py CHANGED
@@ -30,7 +30,7 @@ from ._resolver import Resolver
30
30
  from ._resources import convert_fn_config_to_resources_config
31
31
  from ._utils.async_utils import TaskContext, synchronize_api
32
32
  from ._utils.deprecation import deprecation_warning
33
- from ._utils.mount_utils import validate_network_file_systems, validate_volumes
33
+ from ._utils.mount_utils import validate_network_file_systems, validate_volumes, validate_volumes_by_object_id
34
34
  from ._utils.name_utils import check_object_name
35
35
  from ._utils.task_command_router_client import TaskCommandRouterClient
36
36
  from .client import _Client
@@ -214,6 +214,9 @@ class _Sandbox(_Object, type_prefix="sb"):
214
214
  async def _load(
215
215
  self: _Sandbox, resolver: Resolver, load_context: LoadContext, _existing_object_id: Optional[str]
216
216
  ):
217
+ # Validate that the same volume (by object_id) isn't mounted at multiple paths
218
+ validate_volumes_by_object_id(validated_volumes)
219
+
217
220
  # Relies on dicts being ordered (true as of Python 3.6).
218
221
  volume_mounts = [
219
222
  api_pb2.VolumeMount(
@@ -739,7 +742,7 @@ class _Sandbox(_Object, type_prefix="sb"):
739
742
  "The `Sandbox._experimental_mount_image()` method is deprecated. Use `Sandbox.mount_image()` instead.",
740
743
  )
741
744
  if image is None:
742
- image = _Image._from_scratch()
745
+ image = _Image.from_scratch()
743
746
  await self.mount_image(path, image)
744
747
 
745
748
  async def snapshot_directory(self, path: Union[PurePosixPath, str]) -> _Image:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.3.6.dev12
3
+ Version: 1.3.6.dev13
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ modal/_billing.py,sha256=ujSuVts2qikBRazQ9oTkUv5oqhBBQ10phW3mzQdyldw,3906
4
4
  modal/_clustered_functions.py,sha256=7amiOxErtlciWeBJBL2KvaxerUomJpTAJ3qElflFHQA,2884
5
5
  modal/_clustered_functions.pyi,sha256=JmYwAGOLEnD5AF-gYF9O5tu-SgGjeoJz-X1j48b1Ijg,1157
6
6
  modal/_container_entrypoint.py,sha256=cD5XFxcqeja2dH1kRDlAWsCNyeYF5aq2CyekZ3CnTH4,20405
7
- modal/_functions.py,sha256=juzmU6p93rMXqdU3TazPgQZhh-jxeBo30E2AQnq4Ezs,92286
7
+ modal/_functions.py,sha256=O0-yQJM3rTBcCIf9OfuufjvF7dTDTOs0_wHH8AbhC5s,92809
8
8
  modal/_grpc_client.py,sha256=G8gWQXChtsuRgyXK9kcPiPNYXOYbl_m9Bz2AVBJb-zs,6924
9
9
  modal/_ipython.py,sha256=Bu9fc3hm3xt2O47qLe_d-Mu2jS81LwMsmXNJ1sawBA0,575
10
10
  modal/_load_context.py,sha256=DvfOWNTevEmHqdlu0-AeO_03JChrmAjoupgppPVW2Pc,4867
@@ -26,7 +26,7 @@ modal/app.pyi,sha256=8lv2eLKP-y3BLT1irRnzdULSD45OgU4_VbCIYEy_Ic0,56387
26
26
  modal/billing.py,sha256=B2zXa7KeMnpqi2AQINycEk-KSXVYaLTTTAQTtuf2Gfs,257
27
27
  modal/call_graph.py,sha256=1g2DGcMIJvRy-xKicuf63IVE98gJSnQsr8R_NVMptNc,2581
28
28
  modal/client.py,sha256=-IxazxLhdkNFAHS2AP2FpyTVpCDVR58RF6ecSVV59r0,14862
29
- modal/client.pyi,sha256=f5Fju-mKirXHYmQrauF9QQhFP7GteEosGUt4Aba7U3A,14932
29
+ modal/client.pyi,sha256=-6kMEK4jyCLrMN-Yvv7YlQk6r7vF1P6XdGA9jykXwlI,14932
30
30
  modal/cloud_bucket_mount.py,sha256=KkaJVy5vpO_u2NtDYtv1JM-M3y4J0UtWBapTDKpQRcA,6034
31
31
  modal/cloud_bucket_mount.pyi,sha256=mGDeEd1XvxGpCE6O5jRBSfIvMXvZ5AtIybea5k-0w9w,7560
32
32
  modal/cls.py,sha256=ozx5ZVGyG6w5SQ1ouj9Y6M3yPtnpL9PfblnmqFzf694,42338
@@ -43,10 +43,10 @@ modal/file_io.py,sha256=AKTaKR4y2JO1vsmJ8tRxMhgJtHmtb-TCe1ZZYDte-w4,22221
43
43
  modal/file_io.pyi,sha256=DPaq0BhySaiCX3g56QHhyIMdU1izbfWsjpSp30JoJUU,15745
44
44
  modal/file_pattern_matcher.py,sha256=A_Kdkej6q7YQyhM_2-BvpFmPqJ0oHb54B6yf9VqvPVE,8116
45
45
  modal/functions.py,sha256=kcNHvqeGBxPI7Cgd57NIBBghkfbeFJzXO44WW0jSmao,325
46
- modal/functions.pyi,sha256=jnC5QZvfs_yPPwXmEoHO16xUXzX448aP6eWawVmeGko,36498
46
+ modal/functions.pyi,sha256=mwwTcswVuCRQeVJhqiBHCbVqKieOKH3z_cIca1NIQBk,36498
47
47
  modal/gpu.py,sha256=JW8C1hIObmXGduXWyJZysvAsLxyBEiaCmtnUAGFqh-8,6852
48
- modal/image.py,sha256=zgCZy0OZz010fRdlzOscD-cTbC-cCTT7mRWaANJRW7Y,109360
49
- modal/image.pyi,sha256=QAY8F2e2kkfQHOQnT2t4B1I-VbzvbNjksJHivx-ZA4k,78536
48
+ modal/image.py,sha256=N72PQHR_kHCw1KzW8TAMdLAluMNn5YMtm97v-RT9fGg,110580
49
+ modal/image.pyi,sha256=q7HEKNQPwfpIbriN_I3NmFqNucvgwMI8BKePL2rmqbk,80040
50
50
  modal/io_streams.py,sha256=JcYbvvjMZFugv53yHc9Id0N4nIvDMwV63ZtnN1bPmZc,30940
51
51
  modal/io_streams.pyi,sha256=aBxCcbHTprL08FLvPlkzu4bypCTT7xUdelNe5kc7OZY,18506
52
52
  modal/mount.py,sha256=Y_Sw7eJYgJc4Qf_AVq0s_Ltg8hJa5M4oaU5M2KUOWw0,34057
@@ -66,10 +66,10 @@ modal/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
66
66
  modal/queue.py,sha256=RxcNKyZZZHa14zUmqfGdBr2Y4dbrzXdkxk_tMF89LaA,27381
67
67
  modal/queue.pyi,sha256=sYwPa0iok8zk2GEN8X_HH6fQt-vfHbT1SDp2n6uR0_Q,40276
68
68
  modal/retries.py,sha256=vPWWKP-FRolJwTBuLPXdWqHV7aJfSQFgEW7vhlBBXRQ,5067
69
- modal/runner.py,sha256=rz3th7ZtN-UlUe7RO38J89gg4XtFlbBrSq4cOGIl1Eg,29594
69
+ modal/runner.py,sha256=vDw9nFvv5sOV9N_f2fTCtT3x1wGGHZecNcY3A-jbhbc,29902
70
70
  modal/runner.pyi,sha256=UgV8jEjoRgnIowZHnOWrbGjZFsLCDm6D-4gpf77H4so,9517
71
71
  modal/running_app.py,sha256=v61mapYNV1-O-Uaho5EfJlryMLvIT9We0amUOSvSGx8,1188
72
- modal/sandbox.py,sha256=lJlx5FHait0BYB2anXc0ciiy78NwAWwKYszQdA6jXhA,67572
72
+ modal/sandbox.py,sha256=jzNmP33tuGHS_GTFu1L7gbkBAy81rzE8Pugt0ZVX020,67755
73
73
  modal/sandbox.pyi,sha256=MLPfBwHxg6EpplMjky44lhNPDb0HDFxBuImJleTJvqU,76610
74
74
  modal/sandbox_fs.py,sha256=mEgfFKGgLFO7Ncvw_iQc54htHZ66E1i56ADNnXj9NHQ,13080
75
75
  modal/sandbox_fs.pyi,sha256=qr3PWlWwHqKRa_fPe9aI2ecj-iNF-NKY52GaL_MXc1U,21245
@@ -120,7 +120,7 @@ modal/_utils/hash_utils.py,sha256=zg3J6OGxTFGSFri1qQ12giDz90lWk8bzaxCTUCRtiX4,30
120
120
  modal/_utils/http_utils.py,sha256=yeTFsXYr0rYMEhB7vBP7audG9Uc7OLhzKBANFDZWVt0,2451
121
121
  modal/_utils/jwt_utils.py,sha256=fxH9plyrbAemTbjSsQtzIdDXE9QXxvMC4DiUZ16G0aA,1360
122
122
  modal/_utils/logger.py,sha256=NgbMKFT9chYYt_TU01DdIior5ByYr2gZtrWIk1SFRLc,1782
123
- modal/_utils/mount_utils.py,sha256=3v6wbSvEjOhIlnLGbyXKOzdoXaG-FwNAZ_Rr0io_v2o,4113
123
+ modal/_utils/mount_utils.py,sha256=YlWhO_aaQjl_yDK-Fo0dzbGQav5-tHL1H5FDEIT9bxo,5082
124
124
  modal/_utils/name_utils.py,sha256=CIQL0Y4z47tq0rfNL2Kdo5dxsGrxoN2hR9Ko0HapeE4,2517
125
125
  modal/_utils/package_utils.py,sha256=jYE7WFVzYWIfO0ZJhno_BYHb6Skp48uupOiw1a3yWzw,2056
126
126
  modal/_utils/pattern_utils.py,sha256=ZUffaECfe2iYBhH6cvCB-0-UWhmEBTZEl_TwG_So3ag,6714
@@ -145,12 +145,12 @@ modal/builder/base-images.json,sha256=9zgY2-a2U_iEBBe60VNLQvErJQosRhQuXXRqcG6mHG
145
145
  modal/cli/__init__.py,sha256=6FRleWQxBDT19y7OayO4lBOzuL6Bs9r0rLINYYYbHwQ,769
146
146
  modal/cli/_download.py,sha256=5PQL3tkTJkG-Wb6I0vxfHY4L8kUzd4Fy83jlT6vh9Og,5062
147
147
  modal/cli/_traceback.py,sha256=fkqKcf4a6sBonha32NmBqGu5G_wqUVNBDpCcY94Hro0,6006
148
- modal/cli/app.py,sha256=3EZ4PgevioGq9ZvKLya2tKyCKiYS0Bv0kedrGlVW0-g,14958
148
+ modal/cli/app.py,sha256=D2MK3FHZ9fqoiNWSiYr-TZid-MJQiNIOVnrnr0Kbx7U,15139
149
149
  modal/cli/billing.py,sha256=9Gj1H_Y8ziRAupJj90XhnPOw6nBMQfGU_Ys_ImheLYk,5690
150
150
  modal/cli/changelog.py,sha256=Ilzy1_3xF3y49w-_nwQkEVjoCupwdht7UuBrfV53nT0,5686
151
151
  modal/cli/cluster.py,sha256=FdCe4a0I_o9dT6Vi3r3k-19D64fOGEWFaPYFvBlnTBE,3337
152
152
  modal/cli/config.py,sha256=qPgj9qhQNClttO9DZEBYYxCmkaHv0WCP8O75u5eqZlY,1704
153
- modal/cli/container.py,sha256=dMRPltGRGaikdMPJZCO4qxlX26_62h2d-sglPe63R5I,4442
153
+ modal/cli/container.py,sha256=bGwULuB-pE3oh5HzgQosjUtEfC2OH9ELR9NAuU2AgIk,9577
154
154
  modal/cli/dashboard.py,sha256=MJdbZq1duj97Hu3p9NNLrPogC1Wm2wL3dmCjBUgrMtU,862
155
155
  modal/cli/dict.py,sha256=uRny4LtN7ej7ONir1cpSLGAC-gvZrIGO8bftbCeK0VE,4743
156
156
  modal/cli/entry_point.py,sha256=cHAYqz4E9iXahyHvt29BzcmoYkpyGU3u3MbDVifniwk,6249
@@ -174,7 +174,7 @@ modal/experimental/__init__.py,sha256=4UyAPlExXYM0tJH7s6WSDt2dIfSVXvzARkRLsLMyuq
174
174
  modal/experimental/flash.py,sha256=ATGhVO8AleJ-TOe2PGolI5T7zfCEtj0kl_ivMPp7Rqg,33362
175
175
  modal/experimental/flash.pyi,sha256=R4h2nQl7-oiwqq_twCp0YqID_LnPjPGqxywXvxbiLIk,17011
176
176
  modal/experimental/ipython.py,sha256=TrCfmol9LGsRZMeDoeMPx3Hv3BFqQhYnmD_iH0pqdhk,2904
177
- modal-1.3.6.dev12.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
177
+ modal-1.3.6.dev13.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
178
178
  modal_docs/__init__.py,sha256=svYKtV8HDwDCN86zbdWqyq5T8sMdGDj0PVlzc2tIxDM,28
179
179
  modal_docs/gen_cli_docs.py,sha256=5sVABw4uGUqPXjcWT0Nqjkpe5KLs89C3tFnla58-D0M,3756
180
180
  modal_docs/gen_cli_docs_main.py,sha256=UPGr7O1DYTIRLwfINe2rbdh89J3cPVTU1OWL-SjxnOQ,198
@@ -196,10 +196,10 @@ modal_proto/task_command_router_pb2.py,sha256=uSSR8Y9Yp07cIL60ds7__wUr5rj1uL2CQY
196
196
  modal_proto/task_command_router_pb2.pyi,sha256=utNwipYgMZUEUuWBE7sJ5cNjRctozHKPKDRpLy6cPLM,25667
197
197
  modal_proto/task_command_router_pb2_grpc.py,sha256=YoinPeLc-uwFShig8gE0MluOi6n-qqJZQ3YvnKwKUz8,23194
198
198
  modal_proto/task_command_router_pb2_grpc.pyi,sha256=VUSE5l1O6ATPT8Wbj4M3faMiIy5vFxSoS5ZlKfTEpHc,7449
199
- modal_version/__init__.py,sha256=NYnGiCe6TFhoL1ngWeaC0j7hNmNeFlPTTmxAfDtwsXQ,121
199
+ modal_version/__init__.py,sha256=qjwicXs0jR9gtQzauLjnL8jebul6ZJWtyzVAiFwIZmw,121
200
200
  modal_version/__main__.py,sha256=nLUbubxKx7GXNzJIkTB7M7_u8080LyLM0IS0rzFkC_c,119
201
- modal-1.3.6.dev12.dist-info/METADATA,sha256=1uCjdmSM9R-eB6PM3luI4zkGKpphklhATvXPDVbk_zw,2365
202
- modal-1.3.6.dev12.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
203
- modal-1.3.6.dev12.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
204
- modal-1.3.6.dev12.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
205
- modal-1.3.6.dev12.dist-info/RECORD,,
201
+ modal-1.3.6.dev13.dist-info/METADATA,sha256=VWl0_UTcy4bxRBAmHeszIJ30t-UEhxhGoN4ewdJrKxo,2365
202
+ modal-1.3.6.dev13.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
203
+ modal-1.3.6.dev13.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
204
+ modal-1.3.6.dev13.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
205
+ modal-1.3.6.dev13.dist-info/RECORD,,
modal_version/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # Copyright Modal Labs 2026
2
2
  """Supplies the current version of the modal client library."""
3
3
 
4
- __version__ = "1.3.6.dev12"
4
+ __version__ = "1.3.6.dev13"