modal 1.1.1.dev41__py3-none-any.whl → 1.1.2__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.
Potentially problematic release.
This version of modal might be problematic. Click here for more details.
- modal/__main__.py +1 -2
- modal/_container_entrypoint.py +18 -7
- modal/_functions.py +135 -13
- modal/_object.py +13 -2
- modal/_partial_function.py +8 -8
- modal/_runtime/asgi.py +3 -2
- modal/_runtime/container_io_manager.py +20 -14
- modal/_runtime/container_io_manager.pyi +38 -13
- modal/_runtime/execution_context.py +18 -2
- modal/_runtime/execution_context.pyi +4 -1
- modal/_runtime/gpu_memory_snapshot.py +158 -54
- modal/_utils/blob_utils.py +83 -24
- modal/_utils/function_utils.py +4 -3
- modal/_utils/time_utils.py +28 -4
- modal/app.py +8 -4
- modal/app.pyi +8 -8
- modal/cli/dict.py +14 -11
- modal/cli/entry_point.py +9 -3
- modal/cli/launch.py +102 -4
- modal/cli/profile.py +1 -0
- modal/cli/programs/launch_instance_ssh.py +94 -0
- modal/cli/programs/run_marimo.py +95 -0
- modal/cli/queues.py +49 -19
- modal/cli/secret.py +45 -18
- modal/cli/volume.py +14 -16
- modal/client.pyi +2 -10
- modal/cls.py +12 -2
- modal/cls.pyi +9 -1
- modal/config.py +7 -7
- modal/dict.py +206 -12
- modal/dict.pyi +358 -4
- modal/experimental/__init__.py +130 -0
- modal/file_io.py +1 -1
- modal/file_io.pyi +2 -2
- modal/file_pattern_matcher.py +25 -16
- modal/functions.pyi +111 -11
- modal/image.py +9 -3
- modal/image.pyi +7 -7
- modal/mount.py +20 -13
- modal/mount.pyi +16 -3
- modal/network_file_system.py +8 -2
- modal/object.pyi +3 -0
- modal/parallel_map.py +346 -101
- modal/parallel_map.pyi +108 -0
- modal/proxy.py +2 -1
- modal/queue.py +199 -9
- modal/queue.pyi +357 -3
- modal/sandbox.py +6 -5
- modal/sandbox.pyi +17 -14
- modal/secret.py +196 -3
- modal/secret.pyi +372 -0
- modal/volume.py +239 -23
- modal/volume.pyi +405 -10
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/METADATA +2 -2
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/RECORD +68 -66
- modal_docs/mdmd/mdmd.py +11 -1
- modal_proto/api.proto +37 -10
- modal_proto/api_grpc.py +32 -0
- modal_proto/api_pb2.py +627 -597
- modal_proto/api_pb2.pyi +107 -19
- modal_proto/api_pb2_grpc.py +67 -2
- modal_proto/api_pb2_grpc.pyi +24 -8
- modal_proto/modal_api_grpc.py +2 -0
- modal_version/__init__.py +1 -1
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/WHEEL +0 -0
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/entry_points.txt +0 -0
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/licenses/LICENSE +0 -0
- {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/top_level.txt +0 -0
modal/_utils/blob_utils.py
CHANGED
|
@@ -444,14 +444,24 @@ def get_file_upload_spec_from_fileobj(fp: BinaryIO, mount_filename: PurePosixPat
|
|
|
444
444
|
_FileUploadSource2 = Callable[[], ContextManager[BinaryIO]]
|
|
445
445
|
|
|
446
446
|
|
|
447
|
+
@dataclasses.dataclass
|
|
448
|
+
class FileUploadBlock:
|
|
449
|
+
# The start (byte offset, inclusive) of the block within the file
|
|
450
|
+
start: int
|
|
451
|
+
# The end (byte offset, exclusive) of the block, after having removed any trailing zeroes
|
|
452
|
+
end: int
|
|
453
|
+
# Raw (unencoded 32 byte) SHA256 sum of the block, not including trailing zeroes
|
|
454
|
+
contents_sha256: bytes
|
|
455
|
+
|
|
456
|
+
|
|
447
457
|
@dataclasses.dataclass
|
|
448
458
|
class FileUploadSpec2:
|
|
449
459
|
source: _FileUploadSource2
|
|
450
460
|
source_description: Union[str, Path]
|
|
451
461
|
|
|
452
462
|
path: str
|
|
453
|
-
#
|
|
454
|
-
|
|
463
|
+
# 8MiB file blocks
|
|
464
|
+
blocks: list[FileUploadBlock]
|
|
455
465
|
mode: int # file permission bits (last 12 bits of st_mode)
|
|
456
466
|
size: int
|
|
457
467
|
|
|
@@ -522,53 +532,102 @@ class FileUploadSpec2:
|
|
|
522
532
|
source_fp.seek(0, os.SEEK_END)
|
|
523
533
|
size = source_fp.tell()
|
|
524
534
|
|
|
525
|
-
|
|
535
|
+
blocks = await _gather_blocks(source, size, hash_semaphore)
|
|
526
536
|
|
|
527
537
|
return FileUploadSpec2(
|
|
528
538
|
source=source,
|
|
529
539
|
source_description=source_description,
|
|
530
540
|
path=mount_filename.as_posix(),
|
|
531
|
-
|
|
541
|
+
blocks=blocks,
|
|
532
542
|
mode=mode & 0o7777,
|
|
533
543
|
size=size,
|
|
534
544
|
)
|
|
535
545
|
|
|
536
546
|
|
|
537
|
-
async def
|
|
547
|
+
async def _gather_blocks(
|
|
538
548
|
source: _FileUploadSource2,
|
|
539
549
|
size: int,
|
|
540
550
|
hash_semaphore: asyncio.Semaphore,
|
|
541
|
-
) -> list[
|
|
551
|
+
) -> list[FileUploadBlock]:
|
|
542
552
|
def ceildiv(a: int, b: int) -> int:
|
|
543
553
|
return -(a // -b)
|
|
544
554
|
|
|
545
555
|
num_blocks = ceildiv(size, BLOCK_SIZE)
|
|
546
556
|
|
|
547
|
-
def
|
|
548
|
-
|
|
549
|
-
|
|
557
|
+
async def gather_block(block_idx: int) -> FileUploadBlock:
|
|
558
|
+
async with hash_semaphore:
|
|
559
|
+
return await asyncio.to_thread(_gather_block, source, block_idx)
|
|
550
560
|
|
|
551
|
-
|
|
552
|
-
|
|
561
|
+
tasks = (gather_block(idx) for idx in range(num_blocks))
|
|
562
|
+
return await asyncio.gather(*tasks)
|
|
553
563
|
|
|
554
|
-
num_bytes_read = 0
|
|
555
|
-
while num_bytes_read < BLOCK_SIZE:
|
|
556
|
-
chunk = block_fp.read(BLOCK_SIZE - num_bytes_read)
|
|
557
564
|
|
|
558
|
-
|
|
559
|
-
|
|
565
|
+
def _gather_block(source: _FileUploadSource2, block_idx: int) -> FileUploadBlock:
|
|
566
|
+
start = block_idx * BLOCK_SIZE
|
|
567
|
+
end = _find_end_of_block(source, start, start + BLOCK_SIZE)
|
|
568
|
+
contents_sha256 = _hash_range_sha256(source, start, end)
|
|
569
|
+
return FileUploadBlock(start=start, end=end, contents_sha256=contents_sha256)
|
|
560
570
|
|
|
561
|
-
num_bytes_read += len(chunk)
|
|
562
|
-
sha256_hash.update(chunk)
|
|
563
571
|
|
|
564
|
-
|
|
572
|
+
def _hash_range_sha256(source: _FileUploadSource2, start, end):
|
|
573
|
+
sha256_hash = hashlib.sha256()
|
|
574
|
+
range_size = end - start
|
|
565
575
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
576
|
+
with source() as fp:
|
|
577
|
+
fp.seek(start)
|
|
578
|
+
|
|
579
|
+
num_bytes_read = 0
|
|
580
|
+
while num_bytes_read < range_size:
|
|
581
|
+
chunk = fp.read(range_size - num_bytes_read)
|
|
582
|
+
|
|
583
|
+
if not chunk:
|
|
584
|
+
break
|
|
585
|
+
|
|
586
|
+
num_bytes_read += len(chunk)
|
|
587
|
+
sha256_hash.update(chunk)
|
|
588
|
+
|
|
589
|
+
return sha256_hash.digest()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _find_end_of_block(source: _FileUploadSource2, start: int, end: int) -> Optional[int]:
|
|
593
|
+
"""Finds the appropriate end of a block, which is the index of the byte just past the last non-zero byte in the
|
|
594
|
+
block.
|
|
595
|
+
|
|
596
|
+
>>> _find_end_of_block(lambda: BytesIO(b"abc123\0\0\0"), 0, 1024)
|
|
597
|
+
6
|
|
598
|
+
>>> _find_end_of_block(lambda: BytesIO(b"abc123\0\0\0"), 3, 1024)
|
|
599
|
+
6
|
|
600
|
+
>>> _find_end_of_block(lambda: BytesIO(b"abc123\0\0\0"), 0, 3)
|
|
601
|
+
4
|
|
602
|
+
>>> _find_end_of_block(lambda: BytesIO(b"abc123\0\0\0a"), 0, 9)
|
|
603
|
+
6
|
|
604
|
+
>>> _find_end_of_block(lambda: BytesIO(b"\0\0\0"), 0, 3)
|
|
605
|
+
0
|
|
606
|
+
>>> _find_end_of_block(lambda: BytesIO(b"\0\0\0\0\0\0"), 3, 6)
|
|
607
|
+
3
|
|
608
|
+
>>> _find_end_of_block(lambda: BytesIO(b""), 0, 1024)
|
|
609
|
+
0
|
|
610
|
+
"""
|
|
611
|
+
size = end - start
|
|
612
|
+
new_end = start
|
|
569
613
|
|
|
570
|
-
|
|
571
|
-
|
|
614
|
+
with source() as block_fp:
|
|
615
|
+
block_fp.seek(start)
|
|
616
|
+
|
|
617
|
+
num_bytes_read = 0
|
|
618
|
+
while num_bytes_read < size:
|
|
619
|
+
chunk = block_fp.read(size - num_bytes_read)
|
|
620
|
+
|
|
621
|
+
if not chunk:
|
|
622
|
+
break
|
|
623
|
+
|
|
624
|
+
stripped_chunk = chunk.rstrip(b"\0")
|
|
625
|
+
if stripped_chunk:
|
|
626
|
+
new_end = start + num_bytes_read + len(stripped_chunk)
|
|
627
|
+
|
|
628
|
+
num_bytes_read += len(chunk)
|
|
629
|
+
|
|
630
|
+
return new_end
|
|
572
631
|
|
|
573
632
|
|
|
574
633
|
def use_md5(url: str) -> bool:
|
modal/_utils/function_utils.py
CHANGED
|
@@ -392,8 +392,8 @@ async def _stream_function_call_data(
|
|
|
392
392
|
attempt_token: Optional[str] = None,
|
|
393
393
|
) -> AsyncGenerator[Any, None]:
|
|
394
394
|
"""Read from the `data_in` or `data_out` stream of a function call."""
|
|
395
|
-
if function_call_id
|
|
396
|
-
raise ValueError("function_call_id or attempt_token is required
|
|
395
|
+
if not function_call_id and not attempt_token:
|
|
396
|
+
raise ValueError("function_call_id or attempt_token is required to read from a data stream")
|
|
397
397
|
|
|
398
398
|
if stub is None:
|
|
399
399
|
stub = client.stub
|
|
@@ -415,8 +415,9 @@ async def _stream_function_call_data(
|
|
|
415
415
|
req = api_pb2.FunctionCallGetDataRequest(
|
|
416
416
|
function_call_id=function_call_id,
|
|
417
417
|
last_index=last_index,
|
|
418
|
-
attempt_token=attempt_token,
|
|
419
418
|
)
|
|
419
|
+
if attempt_token:
|
|
420
|
+
req.attempt_token = attempt_token # oneof clears function_call_id.
|
|
420
421
|
try:
|
|
421
422
|
async for chunk in stub_fn.unary_stream(req):
|
|
422
423
|
if chunk.index <= last_index:
|
modal/_utils/time_utils.py
CHANGED
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
# Copyright Modal Labs 2025
|
|
2
|
-
from datetime import datetime
|
|
3
|
-
from typing import Optional
|
|
2
|
+
from datetime import datetime, tzinfo
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def locale_tz() -> tzinfo:
|
|
7
|
+
return datetime.now().astimezone().tzinfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def as_timestamp(arg: Optional[Union[datetime, str]]) -> float:
|
|
11
|
+
"""Coerce a user-provided argument to a timestamp.
|
|
12
|
+
|
|
13
|
+
An argument provided without timezone information will be treated as local time.
|
|
14
|
+
|
|
15
|
+
When the argument is null, returns the current time.
|
|
16
|
+
"""
|
|
17
|
+
if arg is None:
|
|
18
|
+
dt = datetime.now().astimezone()
|
|
19
|
+
elif isinstance(arg, str):
|
|
20
|
+
dt = datetime.fromisoformat(arg)
|
|
21
|
+
elif isinstance(arg, datetime):
|
|
22
|
+
dt = arg
|
|
23
|
+
else:
|
|
24
|
+
raise TypeError(f"Invalid argument: {arg}")
|
|
25
|
+
|
|
26
|
+
if dt.tzinfo is None:
|
|
27
|
+
dt = dt.replace(tzinfo=locale_tz())
|
|
28
|
+
return dt.timestamp()
|
|
4
29
|
|
|
5
30
|
|
|
6
31
|
def timestamp_to_localized_dt(ts: float) -> datetime:
|
|
7
|
-
|
|
8
|
-
return datetime.fromtimestamp(ts, tz=locale_tz)
|
|
32
|
+
return datetime.fromtimestamp(ts, tz=locale_tz())
|
|
9
33
|
|
|
10
34
|
|
|
11
35
|
def timestamp_to_localized_str(ts: float, isotz: bool = True) -> Optional[str]:
|
modal/app.py
CHANGED
|
@@ -612,7 +612,7 @@ class _App:
|
|
|
612
612
|
@warn_on_renamed_autoscaler_settings
|
|
613
613
|
def function(
|
|
614
614
|
self,
|
|
615
|
-
_warn_parentheses_missing
|
|
615
|
+
_warn_parentheses_missing=None, # mdmd:line-hidden
|
|
616
616
|
*,
|
|
617
617
|
image: Optional[_Image] = None, # The image to run as the container for the function
|
|
618
618
|
schedule: Optional[Schedule] = None, # An optional Modal Schedule for the function
|
|
@@ -641,7 +641,7 @@ class _App:
|
|
|
641
641
|
scaledown_window: Optional[int] = None, # Max time (in seconds) a container can remain idle while scaling down.
|
|
642
642
|
proxy: Optional[_Proxy] = None, # Reference to a Modal Proxy to use in front of this function.
|
|
643
643
|
retries: Optional[Union[int, Retries]] = None, # Number of times to retry each input in case of failure.
|
|
644
|
-
timeout:
|
|
644
|
+
timeout: int = 300, # Maximum execution time in seconds.
|
|
645
645
|
name: Optional[str] = None, # Sets the Modal name of the function within the app
|
|
646
646
|
is_generator: Optional[
|
|
647
647
|
bool
|
|
@@ -841,7 +841,7 @@ class _App:
|
|
|
841
841
|
@warn_on_renamed_autoscaler_settings
|
|
842
842
|
def cls(
|
|
843
843
|
self,
|
|
844
|
-
_warn_parentheses_missing
|
|
844
|
+
_warn_parentheses_missing=None, # mdmd:line-hidden
|
|
845
845
|
*,
|
|
846
846
|
image: Optional[_Image] = None, # The image to run as the container for the function
|
|
847
847
|
secrets: Sequence[_Secret] = (), # Optional Modal Secret objects with environment variables for the container
|
|
@@ -869,7 +869,7 @@ class _App:
|
|
|
869
869
|
scaledown_window: Optional[int] = None, # Max time (in seconds) a container can remain idle while scaling down.
|
|
870
870
|
proxy: Optional[_Proxy] = None, # Reference to a Modal Proxy to use in front of this function.
|
|
871
871
|
retries: Optional[Union[int, Retries]] = None, # Number of times to retry each input in case of failure.
|
|
872
|
-
timeout:
|
|
872
|
+
timeout: int = 300, # Maximum execution time in seconds; applies independently to startup and each input.
|
|
873
873
|
cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
|
|
874
874
|
region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
|
|
875
875
|
enable_memory_snapshot: bool = False, # Enable memory checkpointing for faster cold starts.
|
|
@@ -931,13 +931,16 @@ class _App:
|
|
|
931
931
|
|
|
932
932
|
if wrapped_cls.flags & _PartialFunctionFlags.CLUSTERED:
|
|
933
933
|
cluster_size = wrapped_cls.params.cluster_size
|
|
934
|
+
rdma = wrapped_cls.params.rdma
|
|
934
935
|
else:
|
|
935
936
|
cluster_size = None
|
|
937
|
+
rdma = None
|
|
936
938
|
else:
|
|
937
939
|
user_cls = wrapped_cls
|
|
938
940
|
max_concurrent_inputs = allow_concurrent_inputs
|
|
939
941
|
target_concurrent_inputs = None
|
|
940
942
|
cluster_size = None
|
|
943
|
+
rdma = None
|
|
941
944
|
if not inspect.isclass(user_cls):
|
|
942
945
|
raise TypeError("The @app.cls decorator must be used on a class.")
|
|
943
946
|
|
|
@@ -1007,6 +1010,7 @@ class _App:
|
|
|
1007
1010
|
scheduler_placement=scheduler_placement,
|
|
1008
1011
|
i6pn_enabled=i6pn_enabled,
|
|
1009
1012
|
cluster_size=cluster_size,
|
|
1013
|
+
rdma=rdma,
|
|
1010
1014
|
include_source=include_source if include_source is not None else self._include_source_default,
|
|
1011
1015
|
experimental_options={k: str(v) for k, v in (experimental_options or {}).items()},
|
|
1012
1016
|
_experimental_proxy_ip=_experimental_proxy_ip,
|
modal/app.pyi
CHANGED
|
@@ -387,7 +387,7 @@ class _App:
|
|
|
387
387
|
|
|
388
388
|
def function(
|
|
389
389
|
self,
|
|
390
|
-
_warn_parentheses_missing
|
|
390
|
+
_warn_parentheses_missing=None,
|
|
391
391
|
*,
|
|
392
392
|
image: typing.Optional[modal.image._Image] = None,
|
|
393
393
|
schedule: typing.Optional[modal.schedule.Schedule] = None,
|
|
@@ -410,7 +410,7 @@ class _App:
|
|
|
410
410
|
scaledown_window: typing.Optional[int] = None,
|
|
411
411
|
proxy: typing.Optional[modal.proxy._Proxy] = None,
|
|
412
412
|
retries: typing.Union[int, modal.retries.Retries, None] = None,
|
|
413
|
-
timeout:
|
|
413
|
+
timeout: int = 300,
|
|
414
414
|
name: typing.Optional[str] = None,
|
|
415
415
|
is_generator: typing.Optional[bool] = None,
|
|
416
416
|
cloud: typing.Optional[str] = None,
|
|
@@ -441,7 +441,7 @@ class _App:
|
|
|
441
441
|
)
|
|
442
442
|
def cls(
|
|
443
443
|
self,
|
|
444
|
-
_warn_parentheses_missing
|
|
444
|
+
_warn_parentheses_missing=None,
|
|
445
445
|
*,
|
|
446
446
|
image: typing.Optional[modal.image._Image] = None,
|
|
447
447
|
secrets: collections.abc.Sequence[modal.secret._Secret] = (),
|
|
@@ -463,7 +463,7 @@ class _App:
|
|
|
463
463
|
scaledown_window: typing.Optional[int] = None,
|
|
464
464
|
proxy: typing.Optional[modal.proxy._Proxy] = None,
|
|
465
465
|
retries: typing.Union[int, modal.retries.Retries, None] = None,
|
|
466
|
-
timeout:
|
|
466
|
+
timeout: int = 300,
|
|
467
467
|
cloud: typing.Optional[str] = None,
|
|
468
468
|
region: typing.Union[str, collections.abc.Sequence[str], None] = None,
|
|
469
469
|
enable_memory_snapshot: bool = False,
|
|
@@ -990,7 +990,7 @@ class App:
|
|
|
990
990
|
|
|
991
991
|
def function(
|
|
992
992
|
self,
|
|
993
|
-
_warn_parentheses_missing
|
|
993
|
+
_warn_parentheses_missing=None,
|
|
994
994
|
*,
|
|
995
995
|
image: typing.Optional[modal.image.Image] = None,
|
|
996
996
|
schedule: typing.Optional[modal.schedule.Schedule] = None,
|
|
@@ -1013,7 +1013,7 @@ class App:
|
|
|
1013
1013
|
scaledown_window: typing.Optional[int] = None,
|
|
1014
1014
|
proxy: typing.Optional[modal.proxy.Proxy] = None,
|
|
1015
1015
|
retries: typing.Union[int, modal.retries.Retries, None] = None,
|
|
1016
|
-
timeout:
|
|
1016
|
+
timeout: int = 300,
|
|
1017
1017
|
name: typing.Optional[str] = None,
|
|
1018
1018
|
is_generator: typing.Optional[bool] = None,
|
|
1019
1019
|
cloud: typing.Optional[str] = None,
|
|
@@ -1044,7 +1044,7 @@ class App:
|
|
|
1044
1044
|
)
|
|
1045
1045
|
def cls(
|
|
1046
1046
|
self,
|
|
1047
|
-
_warn_parentheses_missing
|
|
1047
|
+
_warn_parentheses_missing=None,
|
|
1048
1048
|
*,
|
|
1049
1049
|
image: typing.Optional[modal.image.Image] = None,
|
|
1050
1050
|
secrets: collections.abc.Sequence[modal.secret.Secret] = (),
|
|
@@ -1066,7 +1066,7 @@ class App:
|
|
|
1066
1066
|
scaledown_window: typing.Optional[int] = None,
|
|
1067
1067
|
proxy: typing.Optional[modal.proxy.Proxy] = None,
|
|
1068
1068
|
retries: typing.Union[int, modal.retries.Retries, None] = None,
|
|
1069
|
-
timeout:
|
|
1069
|
+
timeout: int = 300,
|
|
1070
1070
|
cloud: typing.Optional[str] = None,
|
|
1071
1071
|
region: typing.Union[str, collections.abc.Sequence[str], None] = None,
|
|
1072
1072
|
enable_memory_snapshot: bool = False,
|
modal/cli/dict.py
CHANGED
|
@@ -7,13 +7,11 @@ from typer import Argument, Option, Typer
|
|
|
7
7
|
from modal._output import make_console
|
|
8
8
|
from modal._resolver import Resolver
|
|
9
9
|
from modal._utils.async_utils import synchronizer
|
|
10
|
-
from modal._utils.grpc_utils import retry_transient_errors
|
|
11
10
|
from modal._utils.time_utils import timestamp_to_localized_str
|
|
12
11
|
from modal.cli.utils import ENV_OPTION, YES_OPTION, display_table
|
|
13
12
|
from modal.client import _Client
|
|
14
13
|
from modal.dict import _Dict
|
|
15
14
|
from modal.environments import ensure_env
|
|
16
|
-
from modal_proto import api_pb2
|
|
17
15
|
|
|
18
16
|
dict_cli = Typer(
|
|
19
17
|
name="dict",
|
|
@@ -40,12 +38,13 @@ async def create(name: str, *, env: Optional[str] = ENV_OPTION):
|
|
|
40
38
|
async def list_(*, json: bool = False, env: Optional[str] = ENV_OPTION):
|
|
41
39
|
"""List all named Dicts."""
|
|
42
40
|
env = ensure_env(env)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
dicts = await _Dict.objects.list(environment_name=env)
|
|
42
|
+
rows = []
|
|
43
|
+
for obj in dicts:
|
|
44
|
+
info = await obj.info()
|
|
45
|
+
rows.append((info.name, timestamp_to_localized_str(info.created_at.timestamp(), json), info.created_by))
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
display_table(["Name", "Created at"], rows, json)
|
|
47
|
+
display_table(["Name", "Created at", "Created by"], rows, json)
|
|
49
48
|
|
|
50
49
|
|
|
51
50
|
@dict_cli.command("clear", rich_help_panel="Management")
|
|
@@ -64,17 +63,21 @@ async def clear(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_O
|
|
|
64
63
|
|
|
65
64
|
@dict_cli.command(name="delete", rich_help_panel="Management")
|
|
66
65
|
@synchronizer.create_blocking
|
|
67
|
-
async def delete(
|
|
66
|
+
async def delete(
|
|
67
|
+
name: str,
|
|
68
|
+
*,
|
|
69
|
+
allow_missing: bool = Option(False, "--allow-missing", help="Don't error if the Dict doesn't exist."),
|
|
70
|
+
yes: bool = YES_OPTION,
|
|
71
|
+
env: Optional[str] = ENV_OPTION,
|
|
72
|
+
):
|
|
68
73
|
"""Delete a named Dict and all of its data."""
|
|
69
|
-
# Lookup first to validate the name, even though delete is a staticmethod
|
|
70
|
-
await _Dict.from_name(name, environment_name=env).hydrate()
|
|
71
74
|
if not yes:
|
|
72
75
|
typer.confirm(
|
|
73
76
|
f"Are you sure you want to irrevocably delete the modal.Dict '{name}'?",
|
|
74
77
|
default=False,
|
|
75
78
|
abort=True,
|
|
76
79
|
)
|
|
77
|
-
await _Dict.delete(name, environment_name=env)
|
|
80
|
+
await _Dict.objects.delete(name, environment_name=env, allow_missing=allow_missing)
|
|
78
81
|
|
|
79
82
|
|
|
80
83
|
@dict_cli.command(name="get", rich_help_panel="Inspection")
|
modal/cli/entry_point.py
CHANGED
|
@@ -33,7 +33,7 @@ def version_callback(value: bool):
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
entrypoint_cli_typer = typer.Typer(
|
|
36
|
-
no_args_is_help=
|
|
36
|
+
no_args_is_help=False,
|
|
37
37
|
add_completion=False,
|
|
38
38
|
rich_markup_mode="markdown",
|
|
39
39
|
help="""
|
|
@@ -45,12 +45,18 @@ entrypoint_cli_typer = typer.Typer(
|
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
@entrypoint_cli_typer.callback()
|
|
48
|
+
@entrypoint_cli_typer.callback(invoke_without_command=True)
|
|
49
49
|
def modal(
|
|
50
50
|
ctx: typer.Context,
|
|
51
51
|
version: bool = typer.Option(None, "--version", callback=version_callback),
|
|
52
52
|
):
|
|
53
|
-
|
|
53
|
+
# TODO: When https://github.com/fastapi/typer/pull/1240 gets shipped, then
|
|
54
|
+
# - set invoke_without_command=False in the callback decorator
|
|
55
|
+
# - set no_args_is_help=True in entrypoint_cli_typer
|
|
56
|
+
if ctx.invoked_subcommand is None:
|
|
57
|
+
console = make_console()
|
|
58
|
+
console.print(ctx.get_help())
|
|
59
|
+
raise typer.Exit()
|
|
54
60
|
|
|
55
61
|
|
|
56
62
|
def check_path():
|
modal/cli/launch.py
CHANGED
|
@@ -3,11 +3,16 @@ import asyncio
|
|
|
3
3
|
import inspect
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
from typing import Any, Optional
|
|
8
10
|
|
|
11
|
+
import rich.panel
|
|
12
|
+
from rich.markdown import Markdown
|
|
9
13
|
from typer import Typer
|
|
10
14
|
|
|
15
|
+
from .._output import make_console
|
|
11
16
|
from ..exception import _CliUserExecutionError
|
|
12
17
|
from ..output import enable_output
|
|
13
18
|
from ..runner import run_app
|
|
@@ -16,15 +21,25 @@ from .import_refs import ImportRef, _get_runnable_app, import_file_or_module
|
|
|
16
21
|
launch_cli = Typer(
|
|
17
22
|
name="launch",
|
|
18
23
|
no_args_is_help=True,
|
|
24
|
+
rich_markup_mode="markdown",
|
|
19
25
|
help="""
|
|
20
26
|
Open a serverless app instance on Modal.
|
|
21
|
-
|
|
22
|
-
This command is in preview and may change in the future.
|
|
27
|
+
>⚠️ `modal launch` is **experimental** and may change in the future.
|
|
23
28
|
""",
|
|
24
29
|
)
|
|
25
30
|
|
|
26
31
|
|
|
27
|
-
def _launch_program(
|
|
32
|
+
def _launch_program(
|
|
33
|
+
name: str, filename: str, detach: bool, args: dict[str, Any], *, description: Optional[str] = None
|
|
34
|
+
) -> None:
|
|
35
|
+
console = make_console()
|
|
36
|
+
console.print(
|
|
37
|
+
rich.panel.Panel(
|
|
38
|
+
Markdown(f"⚠️ `modal launch {name}` is **experimental** and may change in the future."),
|
|
39
|
+
border_style="yellow",
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
28
43
|
os.environ["MODAL_LAUNCH_ARGS"] = json.dumps(args)
|
|
29
44
|
|
|
30
45
|
program_path = str(Path(__file__).parent / "programs" / filename)
|
|
@@ -33,7 +48,7 @@ def _launch_program(name: str, filename: str, detach: bool, args: dict[str, Any]
|
|
|
33
48
|
entrypoint = module.main
|
|
34
49
|
|
|
35
50
|
app = _get_runnable_app(entrypoint)
|
|
36
|
-
app.set_description(base_cmd)
|
|
51
|
+
app.set_description(description if description else base_cmd)
|
|
37
52
|
|
|
38
53
|
# `launch/` scripts must have a `local_entrypoint()` with no args, for simplicity here.
|
|
39
54
|
func = entrypoint.info.raw_f
|
|
@@ -61,6 +76,17 @@ def jupyter(
|
|
|
61
76
|
volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
|
|
62
77
|
detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects
|
|
63
78
|
):
|
|
79
|
+
console = make_console()
|
|
80
|
+
console.print(
|
|
81
|
+
rich.panel.Panel(
|
|
82
|
+
(
|
|
83
|
+
"[link=https://modal.com/notebooks]Try Modal Notebooks! "
|
|
84
|
+
"modal.com/notebooks[/link]\n"
|
|
85
|
+
"Notebooks have a new UI, saved content, real-time collaboration and more."
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
style="bold cyan",
|
|
89
|
+
)
|
|
64
90
|
args = {
|
|
65
91
|
"cpu": cpu,
|
|
66
92
|
"memory": memory,
|
|
@@ -95,3 +121,75 @@ def vscode(
|
|
|
95
121
|
"volume": volume,
|
|
96
122
|
}
|
|
97
123
|
_launch_program("vscode", "vscode.py", detach, args)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@launch_cli.command(name="machine", help="Start an instance on Modal, with direct SSH access.", hidden=True)
|
|
127
|
+
def machine(
|
|
128
|
+
name: str, # Name of the machine App.
|
|
129
|
+
cpu: int = 8, # Reservation of CPU cores (can burst above this value).
|
|
130
|
+
memory: int = 32768, # Reservation of memory in MiB (can burst above this value).
|
|
131
|
+
gpu: Optional[str] = None, # GPU type and count, e.g. "t4" or "h100:2".
|
|
132
|
+
image: Optional[str] = None, # Image tag to use from registry. Defaults to the notebook base image.
|
|
133
|
+
timeout: int = 3600 * 24, # Timeout in seconds for the instance.
|
|
134
|
+
volume: str = "machine-vol", # Attach a persisted `modal.Volume` at /workspace (created if missing).
|
|
135
|
+
):
|
|
136
|
+
tempdir = Path(tempfile.gettempdir())
|
|
137
|
+
key_path = tempdir / "modal-machine-keyfile.pem"
|
|
138
|
+
# Generate a new SSH key pair for this machine instance.
|
|
139
|
+
if not key_path.exists():
|
|
140
|
+
subprocess.run(
|
|
141
|
+
["ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", ""],
|
|
142
|
+
check=True,
|
|
143
|
+
stdout=subprocess.DEVNULL,
|
|
144
|
+
)
|
|
145
|
+
# Add the key with expiry 1d to ssh agent.
|
|
146
|
+
subprocess.run(
|
|
147
|
+
["ssh-add", "-t", "1d", str(key_path)],
|
|
148
|
+
check=True,
|
|
149
|
+
stdout=subprocess.DEVNULL,
|
|
150
|
+
stderr=subprocess.DEVNULL,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
os.environ["SSH_PUBLIC_KEY"] = Path(str(key_path) + ".pub").read_text()
|
|
154
|
+
os.environ["MODAL_LOGS_TIMEOUT"] = "0" # hack to work with --detach
|
|
155
|
+
|
|
156
|
+
args = {
|
|
157
|
+
"cpu": cpu,
|
|
158
|
+
"memory": memory,
|
|
159
|
+
"gpu": gpu,
|
|
160
|
+
"image": image,
|
|
161
|
+
"timeout": timeout,
|
|
162
|
+
"volume": volume,
|
|
163
|
+
}
|
|
164
|
+
_launch_program(
|
|
165
|
+
"machine",
|
|
166
|
+
"launch_instance_ssh.py",
|
|
167
|
+
True,
|
|
168
|
+
args,
|
|
169
|
+
description=name,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@launch_cli.command(name="marimo", help="Start a remote Marimo notebook on Modal.", hidden=True)
|
|
174
|
+
def marimo(
|
|
175
|
+
cpu: int = 8,
|
|
176
|
+
memory: int = 32768,
|
|
177
|
+
gpu: Optional[str] = None,
|
|
178
|
+
image: str = "debian:12",
|
|
179
|
+
timeout: int = 3600,
|
|
180
|
+
add_python: Optional[str] = "3.12",
|
|
181
|
+
mount: Optional[str] = None, # Create a `modal.Mount` from a local directory.
|
|
182
|
+
volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
|
|
183
|
+
detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects
|
|
184
|
+
):
|
|
185
|
+
args = {
|
|
186
|
+
"cpu": cpu,
|
|
187
|
+
"memory": memory,
|
|
188
|
+
"gpu": gpu,
|
|
189
|
+
"timeout": timeout,
|
|
190
|
+
"image": image,
|
|
191
|
+
"add_python": add_python,
|
|
192
|
+
"mount": mount,
|
|
193
|
+
"volume": volume,
|
|
194
|
+
}
|
|
195
|
+
_launch_program("marimo", "run_marimo.py", detach, args)
|
modal/cli/profile.py
CHANGED
|
@@ -19,6 +19,7 @@ profile_cli = typer.Typer(name="profile", help="Switch between Modal profiles.",
|
|
|
19
19
|
@profile_cli.command(help="Change the active Modal profile.")
|
|
20
20
|
def activate(profile: str = typer.Argument(..., help="Modal profile to activate.")):
|
|
21
21
|
config_set_active_profile(profile)
|
|
22
|
+
typer.echo(f"Active profile: {profile}")
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
@profile_cli.command(help="Print the currently active Modal profile.")
|