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.
- modal/_container_entrypoint.py +24 -16
- modal/_runtime/container_io_manager.py +11 -23
- modal/_utils/docker_utils.py +64 -0
- modal/_utils/function_utils.py +10 -1
- modal/app.py +25 -23
- modal/app.pyi +6 -2
- modal/cli/launch.py +2 -0
- modal/cli/programs/vscode.py +27 -2
- modal/cli/run.py +1 -1
- modal/client.pyi +2 -2
- modal/exception.py +6 -0
- modal/experimental.py +3 -0
- modal/file_io.py +102 -10
- modal/file_io.pyi +59 -0
- modal/file_pattern_matcher.py +11 -1
- modal/functions.py +20 -5
- modal/functions.pyi +2 -2
- modal/image.py +95 -39
- modal/image.pyi +11 -2
- modal/io_streams.py +15 -27
- modal/io_streams_helper.py +53 -0
- modal/mount.py +3 -5
- modal/mount.pyi +4 -4
- modal/partial_function.py +4 -4
- modal/runner.py +34 -37
- modal/runner.pyi +6 -3
- modal/running_app.py +23 -4
- modal/sandbox.py +19 -6
- modal/sandbox.pyi +25 -0
- {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/METADATA +1 -1
- {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/RECORD +44 -42
- modal_proto/api.proto +13 -0
- modal_proto/api_grpc.py +16 -0
- modal_proto/api_pb2.py +456 -436
- modal_proto/api_pb2.pyi +41 -1
- modal_proto/api_pb2_grpc.py +34 -1
- modal_proto/api_pb2_grpc.pyi +13 -3
- modal_proto/modal_api_grpc.py +1 -0
- modal_version/__init__.py +1 -1
- modal_version/_version_generated.py +2 -2
- {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/LICENSE +0 -0
- {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/WHEEL +0 -0
- {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/entry_points.txt +0 -0
- {modal-0.68.50.dist-info → modal-0.71.5.dist-info}/top_level.txt +0 -0
modal/_container_entrypoint.py
CHANGED
@@ -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) ->
|
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
|
-
|
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
|
-
|
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:
|
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
|
-
|
454
|
-
|
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
|
-
|
472
|
-
|
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
|
-
|
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
|
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
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
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)
|
modal/_utils/function_utils.py
CHANGED
@@ -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
|
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[
|
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.
|
467
|
-
object_id: str = self._running_app.
|
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.
|
480
|
-
object_id: str = self._running_app.
|
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 =
|
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
|
-
|
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
|
-
|
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[
|
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[
|
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,
|
modal/cli/programs/vscode.py
CHANGED
@@ -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 =
|
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
|
-
["/
|
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
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.
|
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.
|
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
|
-
|
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) >
|
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) >
|
292
|
-
raise ValueError("Write request payload exceeds
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
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(
|