modal 0.68.50__py3-none-any.whl → 0.71.5__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.
Files changed (44) hide show
  1. modal/_container_entrypoint.py +24 -16
  2. modal/_runtime/container_io_manager.py +11 -23
  3. modal/_utils/docker_utils.py +64 -0
  4. modal/_utils/function_utils.py +10 -1
  5. modal/app.py +25 -23
  6. modal/app.pyi +6 -2
  7. modal/cli/launch.py +2 -0
  8. modal/cli/programs/vscode.py +27 -2
  9. modal/cli/run.py +1 -1
  10. modal/client.pyi +2 -2
  11. modal/exception.py +6 -0
  12. modal/experimental.py +3 -0
  13. modal/file_io.py +102 -10
  14. modal/file_io.pyi +59 -0
  15. modal/file_pattern_matcher.py +11 -1
  16. modal/functions.py +20 -5
  17. modal/functions.pyi +2 -2
  18. modal/image.py +95 -39
  19. modal/image.pyi +11 -2
  20. modal/io_streams.py +15 -27
  21. modal/io_streams_helper.py +53 -0
  22. modal/mount.py +3 -5
  23. modal/mount.pyi +4 -4
  24. modal/partial_function.py +4 -4
  25. modal/runner.py +34 -37
  26. modal/runner.pyi +6 -3
  27. modal/running_app.py +23 -4
  28. modal/sandbox.py +19 -6
  29. modal/sandbox.pyi +25 -0
  30. {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/METADATA +1 -1
  31. {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/RECORD +44 -42
  32. modal_proto/api.proto +13 -0
  33. modal_proto/api_grpc.py +16 -0
  34. modal_proto/api_pb2.py +456 -436
  35. modal_proto/api_pb2.pyi +41 -1
  36. modal_proto/api_pb2_grpc.py +34 -1
  37. modal_proto/api_pb2_grpc.pyi +13 -3
  38. modal_proto/modal_api_grpc.py +1 -0
  39. modal_version/__init__.py +1 -1
  40. modal_version/_version_generated.py +2 -2
  41. {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/LICENSE +0 -0
  42. {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/WHEEL +0 -0
  43. {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/entry_points.txt +0 -0
  44. {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/top_level.txt +0 -0
@@ -11,7 +11,6 @@ if telemetry_socket:
11
11
  instrument_imports(telemetry_socket)
12
12
 
13
13
  import asyncio
14
- import base64
15
14
  import concurrent.futures
16
15
  import inspect
17
16
  import queue
@@ -337,14 +336,17 @@ def call_function(
337
336
  signal.signal(signal.SIGUSR1, usr1_handler) # reset signal handler
338
337
 
339
338
 
340
- def get_active_app_fallback(function_def: api_pb2.Function) -> Optional[_App]:
339
+ def get_active_app_fallback(function_def: api_pb2.Function) -> _App:
341
340
  # This branch is reached in the special case that the imported function/class is:
342
341
  # 1) not serialized, and
343
342
  # 2) isn't a FunctionHandle - i.e, not decorated at definition time
344
343
  # Look at all instantiated apps - if there is only one with the indicated name, use that one
345
344
  app_name: Optional[str] = function_def.app_name or None # coalesce protobuf field to None
346
345
  matching_apps = _App._all_apps.get(app_name, [])
347
- active_app = None
346
+ if len(matching_apps) == 1:
347
+ active_app: _App = matching_apps[0]
348
+ return active_app
349
+
348
350
  if len(matching_apps) > 1:
349
351
  if app_name is not None:
350
352
  warning_sub_message = f"app with the same name ('{app_name}')"
@@ -354,12 +356,10 @@ def get_active_app_fallback(function_def: api_pb2.Function) -> Optional[_App]:
354
356
  f"You have more than one {warning_sub_message}. "
355
357
  "It's recommended to name all your Apps uniquely when using multiple apps"
356
358
  )
357
- elif len(matching_apps) == 1:
358
- (active_app,) = matching_apps
359
- # there could also technically be zero found apps, but that should probably never be an
360
- # issue since that would mean user won't use is_inside or other function handles anyway
361
359
 
362
- return active_app
360
+ # If we don't have an active app, create one on the fly
361
+ # The app object is used to carry the app layout etc
362
+ return _App()
363
363
 
364
364
 
365
365
  def call_lifecycle_functions(
@@ -403,7 +403,7 @@ def main(container_args: api_pb2.ContainerArguments, client: Client):
403
403
  # This is a bit weird but we need both the blocking and async versions of ContainerIOManager.
404
404
  # At some point, we should fix that by having built-in support for running "user code"
405
405
  container_io_manager = ContainerIOManager(container_args, client)
406
- active_app: Optional[_App] = None
406
+ active_app: _App
407
407
  service: Service
408
408
  function_def = container_args.function_def
409
409
  is_auto_snapshot: bool = function_def.is_auto_snapshot
@@ -450,8 +450,9 @@ def main(container_args: api_pb2.ContainerArguments, client: Client):
450
450
  )
451
451
 
452
452
  # If the cls/function decorator was applied in local scope, but the app is global, we can look it up
453
- active_app = service.app
454
- if active_app is None:
453
+ if service.app is not None:
454
+ active_app = service.app
455
+ else:
455
456
  # if the app can't be inferred by the imported function, use name-based fallback
456
457
  active_app = get_active_app_fallback(function_def)
457
458
 
@@ -464,13 +465,12 @@ def main(container_args: api_pb2.ContainerArguments, client: Client):
464
465
  batch_wait_ms = function_def.batch_linger_ms or 0
465
466
 
466
467
  # Get ids and metadata for objects (primarily functions and classes) on the app
467
- container_app: RunningApp = container_io_manager.get_app_objects()
468
+ container_app: RunningApp = container_io_manager.get_app_objects(container_args.app_layout)
468
469
 
469
470
  # Initialize objects on the app.
470
471
  # This is basically only functions and classes - anything else is deprecated and will be unsupported soon
471
- if active_app is not None:
472
- app: App = synchronizer._translate_out(active_app)
473
- app._init_container(client, container_app)
472
+ app: App = synchronizer._translate_out(active_app)
473
+ app._init_container(client, container_app)
474
474
 
475
475
  # Hydrate all function dependencies.
476
476
  # TODO(erikbern): we an remove this once we
@@ -581,7 +581,15 @@ if __name__ == "__main__":
581
581
  logger.debug("Container: starting")
582
582
 
583
583
  container_args = api_pb2.ContainerArguments()
584
- container_args.ParseFromString(base64.b64decode(sys.argv[1]))
584
+
585
+ container_arguments_path: Optional[str] = os.environ.get("MODAL_CONTAINER_ARGUMENTS_PATH")
586
+ if container_arguments_path is None:
587
+ # TODO(erikbern): this fallback is for old workers and we can remove it very soon (days)
588
+ import base64
589
+
590
+ container_args.ParseFromString(base64.b64decode(sys.argv[1]))
591
+ else:
592
+ container_args.ParseFromString(open(container_arguments_path, "rb").read())
585
593
 
586
594
  # Note that we're creating the client in a synchronous context, but it will be running in a separate thread.
587
595
  # This is good because if the function is long running then we the client can still send heartbeats
@@ -21,7 +21,6 @@ from typing import (
21
21
  )
22
22
 
23
23
  from google.protobuf.empty_pb2 import Empty
24
- from google.protobuf.message import Message
25
24
  from grpclib import Status
26
25
  from synchronicity.async_wrap import asynccontextmanager
27
26
 
@@ -31,12 +30,12 @@ from modal._traceback import extract_traceback, print_exception
31
30
  from modal._utils.async_utils import TaskContext, asyncify, synchronize_api, synchronizer
32
31
  from modal._utils.blob_utils import MAX_OBJECT_SIZE_BYTES, blob_download, blob_upload
33
32
  from modal._utils.function_utils import _stream_function_call_data
34
- from modal._utils.grpc_utils import get_proto_oneof, retry_transient_errors
33
+ from modal._utils.grpc_utils import retry_transient_errors
35
34
  from modal._utils.package_utils import parse_major_minor_version
36
35
  from modal.client import HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, _Client
37
36
  from modal.config import config, logger
38
37
  from modal.exception import ClientClosed, InputCancellation, InvalidError, SerializationError
39
- from modal.running_app import RunningApp
38
+ from modal.running_app import RunningApp, running_app_from_layout
40
39
  from modal_proto import api_pb2
41
40
 
42
41
  if TYPE_CHECKING:
@@ -450,26 +449,15 @@ class _ContainerIOManager:
450
449
 
451
450
  await asyncio.sleep(DYNAMIC_CONCURRENCY_INTERVAL_SECS)
452
451
 
453
- async def get_app_objects(self) -> RunningApp:
454
- req = api_pb2.AppGetObjectsRequest(app_id=self.app_id, include_unindexed=True, only_class_function=True)
455
- resp = await retry_transient_errors(self._client.stub.AppGetObjects, req)
456
- logger.debug(f"AppGetObjects received {len(resp.items)} objects for app {self.app_id}")
457
-
458
- tag_to_object_id = {}
459
- object_handle_metadata = {}
460
- for item in resp.items:
461
- handle_metadata: Optional[Message] = get_proto_oneof(item.object, "handle_metadata_oneof")
462
- object_handle_metadata[item.object.object_id] = handle_metadata
463
- if item.tag:
464
- tag_to_object_id[item.tag] = item.object.object_id
465
-
466
- return RunningApp(
467
- self.app_id,
468
- environment_name=self._environment_name,
469
- tag_to_object_id=tag_to_object_id,
470
- object_handle_metadata=object_handle_metadata,
471
- client=self._client,
472
- )
452
+ async def get_app_objects(self, app_layout: api_pb2.AppLayout) -> RunningApp:
453
+ if len(app_layout.objects) == 0:
454
+ # TODO(erikbern): this should never happen! let's keep it in here for a short second
455
+ # until we've sanity checked that this is, in fact, dead code.
456
+ req = api_pb2.AppGetLayoutRequest(app_id=self.app_id)
457
+ resp = await retry_transient_errors(self._client.stub.AppGetLayout, req)
458
+ app_layout = resp.app_layout
459
+
460
+ return running_app_from_layout(self.app_id, app_layout)
473
461
 
474
462
  async def get_serialized_function(self) -> tuple[Optional[Any], Optional[Callable[..., Any]]]:
475
463
  # Fetch the serialized function definition
@@ -0,0 +1,64 @@
1
+ # Copyright Modal Labs 2024
2
+ import re
3
+ import shlex
4
+ from typing import Sequence
5
+
6
+ from ..exception import InvalidError
7
+
8
+
9
+ def extract_copy_command_patterns(dockerfile_lines: Sequence[str]) -> list[str]:
10
+ """
11
+ Extract all COPY command sources from a Dockerfile.
12
+ Combines multiline COPY commands into a single line.
13
+ """
14
+ copy_source_patterns: set[str] = set()
15
+ current_command = ""
16
+ copy_pattern = re.compile(r"^\s*COPY\s+(.+)$", re.IGNORECASE)
17
+
18
+ # First pass: handle line continuations and collect full commands
19
+ for line in dockerfile_lines:
20
+ line = line.strip()
21
+ if not line or line.startswith("#"):
22
+ # ignore comments and empty lines
23
+ continue
24
+
25
+ if current_command:
26
+ # Continue previous line
27
+ current_command += " " + line.rstrip("\\").strip()
28
+ else:
29
+ # Start new command
30
+ current_command = line.rstrip("\\").strip()
31
+
32
+ if not line.endswith("\\"):
33
+ # Command is complete
34
+
35
+ match = copy_pattern.match(current_command)
36
+ if match:
37
+ args = match.group(1)
38
+ parts = shlex.split(args)
39
+
40
+ # COPY --from=... commands reference external sources and do not need a context mount.
41
+ # https://docs.docker.com/reference/dockerfile/#copy---from
42
+ if parts[0].startswith("--from="):
43
+ current_command = ""
44
+ continue
45
+
46
+ if len(parts) >= 2:
47
+ # Last part is destination, everything else is a mount source
48
+ sources = parts[:-1]
49
+
50
+ for source in sources:
51
+ special_pattern = re.compile(r"^\s*--|\$\s*")
52
+ if special_pattern.match(source):
53
+ raise InvalidError(
54
+ f"COPY command: {source} using special flags/arguments/variables are not supported"
55
+ )
56
+
57
+ if source == ".":
58
+ copy_source_patterns.add("./**")
59
+ else:
60
+ copy_source_patterns.add(source)
61
+
62
+ current_command = ""
63
+
64
+ return list(copy_source_patterns)
@@ -17,7 +17,14 @@ from modal_proto import api_pb2
17
17
  from .._serialization import deserialize, deserialize_data_format, serialize
18
18
  from .._traceback import append_modal_tb
19
19
  from ..config import config, logger
20
- from ..exception import DeserializationError, ExecutionError, FunctionTimeoutError, InvalidError, RemoteError
20
+ from ..exception import (
21
+ DeserializationError,
22
+ ExecutionError,
23
+ FunctionTimeoutError,
24
+ InternalFailure,
25
+ InvalidError,
26
+ RemoteError,
27
+ )
21
28
  from ..mount import ROOT_DIR, _is_modal_path, _Mount
22
29
  from .blob_utils import MAX_OBJECT_SIZE_BYTES, blob_download, blob_upload
23
30
  from .grpc_utils import RETRYABLE_GRPC_STATUS_CODES
@@ -463,6 +470,8 @@ async def _process_result(result: api_pb2.GenericResult, data_format: int, stub,
463
470
 
464
471
  if result.status == api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT:
465
472
  raise FunctionTimeoutError(result.exception)
473
+ elif result.status == api_pb2.GenericResult.GENERIC_STATUS_INTERNAL_FAILURE:
474
+ raise InternalFailure(result.exception)
466
475
  elif result.status != api_pb2.GenericResult.GENERIC_STATUS_SUCCESS:
467
476
  if data:
468
477
  try:
modal/app.py CHANGED
@@ -168,7 +168,7 @@ class _App:
168
168
  """
169
169
 
170
170
  _all_apps: ClassVar[dict[Optional[str], list["_App"]]] = {}
171
- _container_app: ClassVar[Optional[RunningApp]] = None
171
+ _container_app: ClassVar[Optional["_App"]] = None
172
172
 
173
173
  _name: Optional[str]
174
174
  _description: Optional[str]
@@ -294,12 +294,7 @@ class _App:
294
294
  app = _App(name)
295
295
  app._app_id = response.app_id
296
296
  app._client = client
297
- app._running_app = RunningApp(
298
- response.app_id,
299
- client=client,
300
- environment_name=environment_name,
301
- interactive=False,
302
- )
297
+ app._running_app = RunningApp(response.app_id, interactive=False)
303
298
  return app
304
299
 
305
300
  def set_description(self, description: str):
@@ -463,8 +458,8 @@ class _App:
463
458
  if self._running_app:
464
459
  # If this is inside a container, then objects can be defined after app initialization.
465
460
  # So we may have to initialize objects once they get bound to the app.
466
- if function.tag in self._running_app.tag_to_object_id:
467
- object_id: str = self._running_app.tag_to_object_id[function.tag]
461
+ if function.tag in self._running_app.function_ids:
462
+ object_id: str = self._running_app.function_ids[function.tag]
468
463
  metadata: Message = self._running_app.object_handle_metadata[object_id]
469
464
  function._hydrate(object_id, self._client, metadata)
470
465
 
@@ -476,8 +471,8 @@ class _App:
476
471
  if self._running_app:
477
472
  # If this is inside a container, then objects can be defined after app initialization.
478
473
  # So we may have to initialize objects once they get bound to the app.
479
- if tag in self._running_app.tag_to_object_id:
480
- object_id: str = self._running_app.tag_to_object_id[tag]
474
+ if tag in self._running_app.class_ids:
475
+ object_id: str = self._running_app.class_ids[tag]
481
476
  metadata: Message = self._running_app.object_handle_metadata[object_id]
482
477
  cls._hydrate(object_id, self._client, metadata)
483
478
 
@@ -488,21 +483,21 @@ class _App:
488
483
  self._running_app = running_app
489
484
  self._client = client
490
485
 
491
- _App._container_app = running_app
492
-
493
- # Hydrate objects on app -- hydrating functions first so that when a class is being hydrated its
494
- # corresponding class service function is already hydrated.
495
- def hydrate_objects(objects_dict):
496
- for tag, object_id in running_app.tag_to_object_id.items():
497
- if tag in objects_dict:
498
- obj = objects_dict[tag]
499
- handle_metadata = running_app.object_handle_metadata[object_id]
500
- obj._hydrate(object_id, client, handle_metadata)
486
+ _App._container_app = self
501
487
 
502
488
  # Hydrate function objects
503
- hydrate_objects(self._functions)
489
+ for tag, object_id in running_app.function_ids.items():
490
+ if tag in self._functions:
491
+ obj = self._functions[tag]
492
+ handle_metadata = running_app.object_handle_metadata[object_id]
493
+ obj._hydrate(object_id, client, handle_metadata)
494
+
504
495
  # Hydrate class objects
505
- hydrate_objects(self._classes)
496
+ for tag, object_id in running_app.class_ids.items():
497
+ if tag in self._classes:
498
+ obj = self._classes[tag]
499
+ handle_metadata = running_app.object_handle_metadata[object_id]
500
+ obj._hydrate(object_id, client, handle_metadata)
506
501
 
507
502
  @property
508
503
  def registered_functions(self) -> dict[str, _Function]:
@@ -1047,6 +1042,13 @@ class _App:
1047
1042
  if log.data:
1048
1043
  yield log.data
1049
1044
 
1045
+ @classmethod
1046
+ def _get_container_app(cls) -> Optional["_App"]:
1047
+ """Returns the `App` running inside a container.
1048
+
1049
+ This will return `None` outside of a Modal container."""
1050
+ return cls._container_app
1051
+
1050
1052
  @classmethod
1051
1053
  def _reset_container_app(cls):
1052
1054
  """Only used for tests."""
modal/app.pyi CHANGED
@@ -73,7 +73,7 @@ class _FunctionDecoratorType:
73
73
 
74
74
  class _App:
75
75
  _all_apps: typing.ClassVar[dict[typing.Optional[str], list[_App]]]
76
- _container_app: typing.ClassVar[typing.Optional[modal.running_app.RunningApp]]
76
+ _container_app: typing.ClassVar[typing.Optional[_App]]
77
77
  _name: typing.Optional[str]
78
78
  _description: typing.Optional[str]
79
79
  _functions: dict[str, modal.functions._Function]
@@ -266,11 +266,13 @@ class _App:
266
266
  self, client: typing.Optional[modal.client._Client] = None
267
267
  ) -> collections.abc.AsyncGenerator[str, None]: ...
268
268
  @classmethod
269
+ def _get_container_app(cls) -> typing.Optional[_App]: ...
270
+ @classmethod
269
271
  def _reset_container_app(cls): ...
270
272
 
271
273
  class App:
272
274
  _all_apps: typing.ClassVar[dict[typing.Optional[str], list[App]]]
273
- _container_app: typing.ClassVar[typing.Optional[modal.running_app.RunningApp]]
275
+ _container_app: typing.ClassVar[typing.Optional[App]]
274
276
  _name: typing.Optional[str]
275
277
  _description: typing.Optional[str]
276
278
  _functions: dict[str, modal.functions.Function]
@@ -530,6 +532,8 @@ class App:
530
532
 
531
533
  _logs: ___logs_spec
532
534
 
535
+ @classmethod
536
+ def _get_container_app(cls) -> typing.Optional[App]: ...
533
537
  @classmethod
534
538
  def _reset_container_app(cls): ...
535
539
 
modal/cli/launch.py CHANGED
@@ -77,6 +77,7 @@ def vscode(
77
77
  cpu: int = 8,
78
78
  memory: int = 32768,
79
79
  gpu: Optional[str] = None,
80
+ image: str = "debian:12",
80
81
  timeout: int = 3600,
81
82
  mount: Optional[str] = None, # Create a `modal.Mount` from a local directory.
82
83
  volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
@@ -86,6 +87,7 @@ def vscode(
86
87
  "cpu": cpu,
87
88
  "memory": memory,
88
89
  "gpu": gpu,
90
+ "image": image,
89
91
  "timeout": timeout,
90
92
  "mount": mount,
91
93
  "volume": volume,
@@ -15,9 +15,34 @@ from modal import App, Image, Mount, Queue, Secret, Volume, forward
15
15
  # Passed by `modal launch` locally via CLI, plumbed to remote runner through secrets.
16
16
  args: dict[str, Any] = json.loads(os.environ.get("MODAL_LAUNCH_ARGS", "{}"))
17
17
 
18
+ CODE_SERVER_INSTALLER = "https://code-server.dev/install.sh"
19
+ CODE_SERVER_ENTRYPOINT = (
20
+ "https://raw.githubusercontent.com/coder/code-server/refs/tags/v4.96.1/ci/release-image/entrypoint.sh"
21
+ )
22
+ FIXUD_INSTALLER = "https://github.com/boxboat/fixuid/releases/download/v0.6.0/fixuid-0.6.0-linux-$ARCH.tar.gz"
23
+
18
24
 
19
25
  app = App()
20
- app.image = Image.from_registry("codercom/code-server", add_python="3.11").dockerfile_commands("ENTRYPOINT []")
26
+ app.image = (
27
+ Image.from_registry(args.get("image"), add_python="3.11")
28
+ .apt_install("curl", "dumb-init", "git", "git-lfs")
29
+ .run_commands(
30
+ f"curl -fsSL {CODE_SERVER_INSTALLER} | sh",
31
+ f"curl -fsSL {CODE_SERVER_ENTRYPOINT} > /code-server.sh",
32
+ "chmod u+x /code-server.sh",
33
+ )
34
+ .run_commands(
35
+ 'ARCH="$(dpkg --print-architecture)"'
36
+ f' && curl -fsSL "{FIXUD_INSTALLER}" | tar -C /usr/local/bin -xzf - '
37
+ " && chown root:root /usr/local/bin/fixuid"
38
+ " && chmod 4755 /usr/local/bin/fixuid"
39
+ " && mkdir -p /etc/fixuid"
40
+ ' && echo "user: root" >> /etc/fixuid/config.yml'
41
+ ' && echo "group: root" >> /etc/fixuid/config.yml'
42
+ )
43
+ .run_commands("mkdir /home/coder")
44
+ .env({"ENTRYPOINTD": ""})
45
+ )
21
46
 
22
47
 
23
48
  mount = (
@@ -71,7 +96,7 @@ def run_vscode(q: Queue):
71
96
  url = tunnel.url
72
97
  threading.Thread(target=wait_for_port, args=((url, token), q)).start()
73
98
  subprocess.run(
74
- ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "."],
99
+ ["/code-server.sh", "--bind-addr", "0.0.0.0:8080", "."],
75
100
  env={**os.environ, "SHELL": "/bin/bash", "PASSWORD": token},
76
101
  )
77
102
  q.put("done")
modal/cli/run.py CHANGED
@@ -461,7 +461,7 @@ def shell(
461
461
  ):
462
462
  from .container import exec
463
463
 
464
- exec(container_id=container_or_function, command=shlex.split(cmd))
464
+ exec(container_id=container_or_function, command=shlex.split(cmd), pty=pty)
465
465
  return
466
466
 
467
467
  function = import_function(
modal/client.pyi CHANGED
@@ -26,7 +26,7 @@ class _Client:
26
26
  _stub: typing.Optional[modal_proto.api_grpc.ModalClientStub]
27
27
 
28
28
  def __init__(
29
- self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.68.50"
29
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.71.5"
30
30
  ): ...
31
31
  def is_closed(self) -> bool: ...
32
32
  @property
@@ -81,7 +81,7 @@ class Client:
81
81
  _stub: typing.Optional[modal_proto.api_grpc.ModalClientStub]
82
82
 
83
83
  def __init__(
84
- self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.68.50"
84
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.71.5"
85
85
  ): ...
86
86
  def is_closed(self) -> bool: ...
87
87
  @property
modal/exception.py CHANGED
@@ -108,6 +108,12 @@ class ServerWarning(UserWarning):
108
108
  """Warning originating from the Modal server and re-issued in client code."""
109
109
 
110
110
 
111
+ class InternalFailure(Error):
112
+ """
113
+ Retriable internal error.
114
+ """
115
+
116
+
111
117
  class _CliUserExecutionError(Exception):
112
118
  """mdmd:hidden
113
119
  Private wrapper for exceptions during when importing or running stubs from the CLI.
modal/experimental.py CHANGED
@@ -48,6 +48,9 @@ def clustered(size: int, broadcast: bool = True):
48
48
 
49
49
  assert broadcast, "broadcast=False has not been implemented yet!"
50
50
 
51
+ if size <= 0:
52
+ raise ValueError("cluster size must be greater than 0")
53
+
51
54
  def wrapper(raw_f: Callable[..., Any]) -> _PartialFunction:
52
55
  if isinstance(raw_f, _Function):
53
56
  raw_f = raw_f.get_raw_f()
modal/file_io.py CHANGED
@@ -1,6 +1,8 @@
1
1
  # Copyright Modal Labs 2024
2
2
  import asyncio
3
+ import enum
3
4
  import io
5
+ from dataclasses import dataclass
4
6
  from typing import TYPE_CHECKING, AsyncIterator, Generic, Optional, Sequence, TypeVar, Union, cast
5
7
 
6
8
  if TYPE_CHECKING:
@@ -11,6 +13,7 @@ import json
11
13
  from grpclib.exceptions import GRPCError, StreamTerminatedError
12
14
 
13
15
  from modal._utils.grpc_utils import retry_transient_errors
16
+ from modal.io_streams_helper import consume_stream_with_retries
14
17
  from modal_proto import api_pb2
15
18
 
16
19
  from ._utils.async_utils import synchronize_api
@@ -18,7 +21,8 @@ from ._utils.grpc_utils import RETRYABLE_GRPC_STATUS_CODES
18
21
  from .client import _Client
19
22
  from .exception import FilesystemExecutionError, InvalidError
20
23
 
21
- LARGE_FILE_SIZE_LIMIT = 16 * 1024 * 1024 # 16 MiB
24
+ WRITE_CHUNK_SIZE = 16 * 1024 * 1024 # 16 MiB
25
+ WRITE_FILE_SIZE_LIMIT = 1024 * 1024 * 1024 # 1 GiB
22
26
  READ_FILE_SIZE_LIMIT = 100 * 1024 * 1024 # 100 MiB
23
27
 
24
28
  ERROR_MAPPING = {
@@ -77,7 +81,7 @@ async def _replace_bytes(file: "_FileIO", data: bytes, start: Optional[int] = No
77
81
  if start is not None and end is not None:
78
82
  if start >= end:
79
83
  raise InvalidError("start must be less than end")
80
- if len(data) > LARGE_FILE_SIZE_LIMIT:
84
+ if len(data) > WRITE_CHUNK_SIZE:
81
85
  raise InvalidError("Write request payload exceeds 16 MiB limit")
82
86
  resp = await file._make_request(
83
87
  api_pb2.ContainerFilesystemExecRequest(
@@ -93,6 +97,20 @@ async def _replace_bytes(file: "_FileIO", data: bytes, start: Optional[int] = No
93
97
  await file._wait(resp.exec_id)
94
98
 
95
99
 
100
+ class FileWatchEventType(enum.Enum):
101
+ Unknown = "Unknown"
102
+ Access = "Access"
103
+ Create = "Create"
104
+ Modify = "Modify"
105
+ Remove = "Remove"
106
+
107
+
108
+ @dataclass
109
+ class FileWatchEvent:
110
+ paths: list[str]
111
+ type: FileWatchEventType
112
+
113
+
96
114
  # The FileIO class is designed to mimic Python's io.FileIO
97
115
  # See https://github.com/python/cpython/blob/main/Lib/_pyio.py#L1459
98
116
  class _FileIO(Generic[T]):
@@ -123,6 +141,7 @@ class _FileIO(Generic[T]):
123
141
  _task_id: str = ""
124
142
  _file_descriptor: str = ""
125
143
  _client: Optional[_Client] = None
144
+ _watch_output_buffer: list[Optional[bytes]] = []
126
145
 
127
146
  def _validate_mode(self, mode: str) -> None:
128
147
  if not any(char in mode for char in "rwax"):
@@ -166,6 +185,44 @@ class _FileIO(Generic[T]):
166
185
  for message in batch.output:
167
186
  yield message
168
187
 
188
+ async def _consume_watch_output(self, exec_id: str) -> None:
189
+ def item_handler(item: Optional[bytes]):
190
+ self._watch_output_buffer.append(item)
191
+
192
+ def completion_check(item: Optional[bytes]):
193
+ return item is None
194
+
195
+ await consume_stream_with_retries(
196
+ self._consume_output(exec_id),
197
+ item_handler,
198
+ completion_check,
199
+ )
200
+
201
+ async def _parse_watch_output(self, event: bytes) -> Optional[FileWatchEvent]:
202
+ try:
203
+ event_json = json.loads(event.decode())
204
+ return FileWatchEvent(type=FileWatchEventType(event_json["event_type"]), paths=event_json["paths"])
205
+ except (json.JSONDecodeError, KeyError, ValueError):
206
+ # skip invalid events
207
+ return None
208
+
209
+ async def _stream_watch_output(self) -> AsyncIterator[FileWatchEvent]:
210
+ buffer = b""
211
+ while True:
212
+ if len(self._watch_output_buffer) > 0:
213
+ item = self._watch_output_buffer.pop(0)
214
+ if item is None:
215
+ break
216
+ buffer += item
217
+ # a single event may be split across multiple messages, the end of an event is marked by two newlines
218
+ if buffer.endswith(b"\n\n"):
219
+ event = await self._parse_watch_output(buffer.strip())
220
+ if event is not None:
221
+ yield event
222
+ buffer = b""
223
+ else:
224
+ await asyncio.sleep(0.1)
225
+
169
226
  async def _wait(self, exec_id: str) -> bytes:
170
227
  # The logic here is similar to how output is read from `exec`
171
228
  output = b""
@@ -288,15 +345,20 @@ class _FileIO(Generic[T]):
288
345
  self._validate_type(data)
289
346
  if isinstance(data, str):
290
347
  data = data.encode("utf-8")
291
- if len(data) > LARGE_FILE_SIZE_LIMIT:
292
- raise ValueError("Write request payload exceeds 16 MiB limit")
293
- resp = await self._make_request(
294
- api_pb2.ContainerFilesystemExecRequest(
295
- file_write_request=api_pb2.ContainerFileWriteRequest(file_descriptor=self._file_descriptor, data=data),
296
- task_id=self._task_id,
348
+ if len(data) > WRITE_FILE_SIZE_LIMIT:
349
+ raise ValueError("Write request payload exceeds 1 GiB limit")
350
+ for i in range(0, len(data), WRITE_CHUNK_SIZE):
351
+ chunk = data[i : i + WRITE_CHUNK_SIZE]
352
+ resp = await self._make_request(
353
+ api_pb2.ContainerFilesystemExecRequest(
354
+ file_write_request=api_pb2.ContainerFileWriteRequest(
355
+ file_descriptor=self._file_descriptor,
356
+ data=chunk,
357
+ ),
358
+ task_id=self._task_id,
359
+ )
297
360
  )
298
- )
299
- await self._wait(resp.exec_id)
361
+ await self._wait(resp.exec_id)
300
362
 
301
363
  async def flush(self) -> None:
302
364
  """Flush the buffer to disk."""
@@ -385,6 +447,36 @@ class _FileIO(Generic[T]):
385
447
  )
386
448
  await self._wait(resp.exec_id)
387
449
 
450
+ @classmethod
451
+ async def watch(
452
+ cls,
453
+ path: str,
454
+ client: _Client,
455
+ task_id: str,
456
+ filter: Optional[list[FileWatchEventType]] = None,
457
+ recursive: bool = False,
458
+ timeout: Optional[int] = None,
459
+ ) -> AsyncIterator[FileWatchEvent]:
460
+ self = cls.__new__(cls)
461
+ self._client = client
462
+ self._task_id = task_id
463
+ resp = await self._make_request(
464
+ api_pb2.ContainerFilesystemExecRequest(
465
+ file_watch_request=api_pb2.ContainerFileWatchRequest(
466
+ path=path,
467
+ recursive=recursive,
468
+ timeout_secs=timeout,
469
+ ),
470
+ task_id=self._task_id,
471
+ )
472
+ )
473
+ task = asyncio.create_task(self._consume_watch_output(resp.exec_id))
474
+ async for event in self._stream_watch_output():
475
+ if filter and event.type not in filter:
476
+ continue
477
+ yield event
478
+ task.cancel()
479
+
388
480
  async def _close(self) -> None:
389
481
  # Buffer is flushed by the runner on close
390
482
  resp = await self._make_request(