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.

Files changed (68) hide show
  1. modal/__main__.py +1 -2
  2. modal/_container_entrypoint.py +18 -7
  3. modal/_functions.py +135 -13
  4. modal/_object.py +13 -2
  5. modal/_partial_function.py +8 -8
  6. modal/_runtime/asgi.py +3 -2
  7. modal/_runtime/container_io_manager.py +20 -14
  8. modal/_runtime/container_io_manager.pyi +38 -13
  9. modal/_runtime/execution_context.py +18 -2
  10. modal/_runtime/execution_context.pyi +4 -1
  11. modal/_runtime/gpu_memory_snapshot.py +158 -54
  12. modal/_utils/blob_utils.py +83 -24
  13. modal/_utils/function_utils.py +4 -3
  14. modal/_utils/time_utils.py +28 -4
  15. modal/app.py +8 -4
  16. modal/app.pyi +8 -8
  17. modal/cli/dict.py +14 -11
  18. modal/cli/entry_point.py +9 -3
  19. modal/cli/launch.py +102 -4
  20. modal/cli/profile.py +1 -0
  21. modal/cli/programs/launch_instance_ssh.py +94 -0
  22. modal/cli/programs/run_marimo.py +95 -0
  23. modal/cli/queues.py +49 -19
  24. modal/cli/secret.py +45 -18
  25. modal/cli/volume.py +14 -16
  26. modal/client.pyi +2 -10
  27. modal/cls.py +12 -2
  28. modal/cls.pyi +9 -1
  29. modal/config.py +7 -7
  30. modal/dict.py +206 -12
  31. modal/dict.pyi +358 -4
  32. modal/experimental/__init__.py +130 -0
  33. modal/file_io.py +1 -1
  34. modal/file_io.pyi +2 -2
  35. modal/file_pattern_matcher.py +25 -16
  36. modal/functions.pyi +111 -11
  37. modal/image.py +9 -3
  38. modal/image.pyi +7 -7
  39. modal/mount.py +20 -13
  40. modal/mount.pyi +16 -3
  41. modal/network_file_system.py +8 -2
  42. modal/object.pyi +3 -0
  43. modal/parallel_map.py +346 -101
  44. modal/parallel_map.pyi +108 -0
  45. modal/proxy.py +2 -1
  46. modal/queue.py +199 -9
  47. modal/queue.pyi +357 -3
  48. modal/sandbox.py +6 -5
  49. modal/sandbox.pyi +17 -14
  50. modal/secret.py +196 -3
  51. modal/secret.pyi +372 -0
  52. modal/volume.py +239 -23
  53. modal/volume.pyi +405 -10
  54. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/METADATA +2 -2
  55. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/RECORD +68 -66
  56. modal_docs/mdmd/mdmd.py +11 -1
  57. modal_proto/api.proto +37 -10
  58. modal_proto/api_grpc.py +32 -0
  59. modal_proto/api_pb2.py +627 -597
  60. modal_proto/api_pb2.pyi +107 -19
  61. modal_proto/api_pb2_grpc.py +67 -2
  62. modal_proto/api_pb2_grpc.pyi +24 -8
  63. modal_proto/modal_api_grpc.py +2 -0
  64. modal_version/__init__.py +1 -1
  65. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/WHEEL +0 -0
  66. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/entry_points.txt +0 -0
  67. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/licenses/LICENSE +0 -0
  68. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/top_level.txt +0 -0
@@ -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
- # Raw (unencoded 32 byte) SHA256 sum per 8MiB file block
454
- blocks_sha256: list[bytes]
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
- blocks_sha256 = await hash_blocks_sha256(source, size, hash_semaphore)
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
- blocks_sha256=blocks_sha256,
541
+ blocks=blocks,
532
542
  mode=mode & 0o7777,
533
543
  size=size,
534
544
  )
535
545
 
536
546
 
537
- async def hash_blocks_sha256(
547
+ async def _gather_blocks(
538
548
  source: _FileUploadSource2,
539
549
  size: int,
540
550
  hash_semaphore: asyncio.Semaphore,
541
- ) -> list[bytes]:
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 blocking_hash_block_sha256(block_idx: int) -> bytes:
548
- sha256_hash = hashlib.sha256()
549
- block_start = block_idx * BLOCK_SIZE
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
- with source() as block_fp:
552
- block_fp.seek(block_start)
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
- if not chunk:
559
- break
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
- return sha256_hash.digest()
572
+ def _hash_range_sha256(source: _FileUploadSource2, start, end):
573
+ sha256_hash = hashlib.sha256()
574
+ range_size = end - start
565
575
 
566
- async def hash_block_sha256(block_idx: int) -> bytes:
567
- async with hash_semaphore:
568
- return await asyncio.to_thread(blocking_hash_block_sha256, block_idx)
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
- tasks = (hash_block_sha256(idx) for idx in range(num_blocks))
571
- return await asyncio.gather(*tasks)
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:
@@ -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 is None and attempt_token is None:
396
- raise ValueError("function_call_id or attempt_token is required for data_out stream")
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:
@@ -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
- locale_tz = datetime.now().astimezone().tzinfo
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: Any = None,
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: Optional[int] = None, # Maximum execution time of the function in seconds.
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: Optional[bool] = None,
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: Optional[int] = None, # Maximum execution time of the function in seconds.
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: typing.Any = None,
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: typing.Optional[int] = None,
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: typing.Optional[bool] = None,
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: typing.Optional[int] = None,
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: typing.Any = None,
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: typing.Optional[int] = None,
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: typing.Optional[bool] = None,
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: typing.Optional[int] = None,
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
- client = await _Client.from_env()
44
- request = api_pb2.DictListRequest(environment_name=env)
45
- response = await retry_transient_errors(client.stub.DictList, request)
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
- rows = [(d.name, timestamp_to_localized_str(d.created_at, json)) for d in response.dicts]
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(name: str, *, yes: bool = YES_OPTION, env: Optional[str] = ENV_OPTION):
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=True,
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
- pass
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(name: str, filename: str, detach: bool, args: dict[str, Any]) -> None:
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.")