modal 1.0.3.dev10__py3-none-any.whl → 1.2.3.dev7__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 (160) hide show
  1. modal/__init__.py +0 -2
  2. modal/__main__.py +3 -4
  3. modal/_billing.py +80 -0
  4. modal/_clustered_functions.py +7 -3
  5. modal/_clustered_functions.pyi +15 -3
  6. modal/_container_entrypoint.py +51 -69
  7. modal/_functions.py +508 -240
  8. modal/_grpc_client.py +171 -0
  9. modal/_load_context.py +105 -0
  10. modal/_object.py +81 -21
  11. modal/_output.py +58 -45
  12. modal/_partial_function.py +48 -73
  13. modal/_pty.py +7 -3
  14. modal/_resolver.py +26 -46
  15. modal/_runtime/asgi.py +4 -3
  16. modal/_runtime/container_io_manager.py +358 -220
  17. modal/_runtime/container_io_manager.pyi +296 -101
  18. modal/_runtime/execution_context.py +18 -2
  19. modal/_runtime/execution_context.pyi +64 -7
  20. modal/_runtime/gpu_memory_snapshot.py +262 -57
  21. modal/_runtime/user_code_imports.py +28 -58
  22. modal/_serialization.py +90 -6
  23. modal/_traceback.py +42 -1
  24. modal/_tunnel.pyi +380 -12
  25. modal/_utils/async_utils.py +84 -29
  26. modal/_utils/auth_token_manager.py +111 -0
  27. modal/_utils/blob_utils.py +181 -58
  28. modal/_utils/deprecation.py +19 -0
  29. modal/_utils/function_utils.py +91 -47
  30. modal/_utils/grpc_utils.py +89 -66
  31. modal/_utils/mount_utils.py +26 -1
  32. modal/_utils/name_utils.py +17 -3
  33. modal/_utils/task_command_router_client.py +536 -0
  34. modal/_utils/time_utils.py +34 -6
  35. modal/app.py +256 -88
  36. modal/app.pyi +909 -92
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +18 -0
  39. modal/builder/PREVIEW.txt +18 -0
  40. modal/builder/base-images.json +58 -0
  41. modal/cli/_download.py +19 -3
  42. modal/cli/_traceback.py +3 -2
  43. modal/cli/app.py +4 -4
  44. modal/cli/cluster.py +15 -7
  45. modal/cli/config.py +5 -3
  46. modal/cli/container.py +7 -6
  47. modal/cli/dict.py +22 -16
  48. modal/cli/entry_point.py +12 -5
  49. modal/cli/environment.py +5 -4
  50. modal/cli/import_refs.py +3 -3
  51. modal/cli/launch.py +102 -5
  52. modal/cli/network_file_system.py +11 -12
  53. modal/cli/profile.py +3 -2
  54. modal/cli/programs/launch_instance_ssh.py +94 -0
  55. modal/cli/programs/run_jupyter.py +1 -1
  56. modal/cli/programs/run_marimo.py +95 -0
  57. modal/cli/programs/vscode.py +1 -1
  58. modal/cli/queues.py +57 -26
  59. modal/cli/run.py +91 -23
  60. modal/cli/secret.py +48 -22
  61. modal/cli/token.py +7 -8
  62. modal/cli/utils.py +4 -7
  63. modal/cli/volume.py +31 -25
  64. modal/client.py +15 -85
  65. modal/client.pyi +183 -62
  66. modal/cloud_bucket_mount.py +5 -3
  67. modal/cloud_bucket_mount.pyi +197 -5
  68. modal/cls.py +200 -126
  69. modal/cls.pyi +446 -68
  70. modal/config.py +29 -11
  71. modal/container_process.py +319 -19
  72. modal/container_process.pyi +190 -20
  73. modal/dict.py +290 -71
  74. modal/dict.pyi +835 -83
  75. modal/environments.py +15 -27
  76. modal/environments.pyi +46 -24
  77. modal/exception.py +14 -2
  78. modal/experimental/__init__.py +194 -40
  79. modal/experimental/flash.py +618 -0
  80. modal/experimental/flash.pyi +380 -0
  81. modal/experimental/ipython.py +11 -7
  82. modal/file_io.py +29 -36
  83. modal/file_io.pyi +251 -53
  84. modal/file_pattern_matcher.py +56 -16
  85. modal/functions.pyi +673 -92
  86. modal/gpu.py +1 -1
  87. modal/image.py +528 -176
  88. modal/image.pyi +1572 -145
  89. modal/io_streams.py +458 -128
  90. modal/io_streams.pyi +433 -52
  91. modal/mount.py +216 -151
  92. modal/mount.pyi +225 -78
  93. modal/network_file_system.py +45 -62
  94. modal/network_file_system.pyi +277 -56
  95. modal/object.pyi +93 -17
  96. modal/parallel_map.py +942 -129
  97. modal/parallel_map.pyi +294 -15
  98. modal/partial_function.py +0 -2
  99. modal/partial_function.pyi +234 -19
  100. modal/proxy.py +17 -8
  101. modal/proxy.pyi +36 -3
  102. modal/queue.py +270 -65
  103. modal/queue.pyi +817 -57
  104. modal/runner.py +115 -101
  105. modal/runner.pyi +205 -49
  106. modal/sandbox.py +512 -136
  107. modal/sandbox.pyi +845 -111
  108. modal/schedule.py +1 -1
  109. modal/secret.py +300 -70
  110. modal/secret.pyi +589 -34
  111. modal/serving.py +7 -11
  112. modal/serving.pyi +7 -8
  113. modal/snapshot.py +11 -8
  114. modal/snapshot.pyi +25 -4
  115. modal/token_flow.py +4 -4
  116. modal/token_flow.pyi +28 -8
  117. modal/volume.py +416 -158
  118. modal/volume.pyi +1117 -121
  119. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +10 -9
  120. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  121. modal_docs/mdmd/mdmd.py +17 -4
  122. modal_proto/api.proto +534 -79
  123. modal_proto/api_grpc.py +337 -1
  124. modal_proto/api_pb2.py +1522 -968
  125. modal_proto/api_pb2.pyi +1619 -134
  126. modal_proto/api_pb2_grpc.py +699 -4
  127. modal_proto/api_pb2_grpc.pyi +226 -14
  128. modal_proto/modal_api_grpc.py +175 -154
  129. modal_proto/sandbox_router.proto +145 -0
  130. modal_proto/sandbox_router_grpc.py +105 -0
  131. modal_proto/sandbox_router_pb2.py +149 -0
  132. modal_proto/sandbox_router_pb2.pyi +333 -0
  133. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  134. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  135. modal_proto/task_command_router.proto +144 -0
  136. modal_proto/task_command_router_grpc.py +105 -0
  137. modal_proto/task_command_router_pb2.py +149 -0
  138. modal_proto/task_command_router_pb2.pyi +333 -0
  139. modal_proto/task_command_router_pb2_grpc.py +203 -0
  140. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  141. modal_version/__init__.py +1 -1
  142. modal/requirements/PREVIEW.txt +0 -16
  143. modal/requirements/base-images.json +0 -26
  144. modal-1.0.3.dev10.dist-info/RECORD +0 -179
  145. modal_proto/modal_options_grpc.py +0 -3
  146. modal_proto/options.proto +0 -19
  147. modal_proto/options_grpc.py +0 -3
  148. modal_proto/options_pb2.py +0 -35
  149. modal_proto/options_pb2.pyi +0 -20
  150. modal_proto/options_pb2_grpc.py +0 -4
  151. modal_proto/options_pb2_grpc.pyi +0 -7
  152. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  153. /modal/{requirements → builder}/2023.12.txt +0 -0
  154. /modal/{requirements → builder}/2024.04.txt +0 -0
  155. /modal/{requirements → builder}/2024.10.txt +0 -0
  156. /modal/{requirements → builder}/README.md +0 -0
  157. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  158. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  159. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  160. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
modal/_serialization.py CHANGED
@@ -6,14 +6,24 @@ import typing
6
6
  from inspect import Parameter
7
7
  from typing import Any
8
8
 
9
+ from modal._traceback import extract_traceback
10
+ from modal.config import config
11
+
12
+ try:
13
+ import cbor2 # type: ignore
14
+ except ImportError: # pragma: no cover - optional dependency
15
+ cbor2 = None
16
+
17
+ import google.protobuf.message
18
+
9
19
  from modal._utils.async_utils import synchronizer
10
20
  from modal_proto import api_pb2
11
21
 
12
22
  from ._object import _Object
13
23
  from ._type_manager import parameter_serde_registry, schema_registry
14
24
  from ._vendor import cloudpickle
15
- from .config import config, logger
16
- from .exception import DeserializationError, ExecutionError, InvalidError
25
+ from .config import logger
26
+ from .exception import DeserializationError, ExecutionError, InvalidError, SerializationError
17
27
  from .object import Object
18
28
 
19
29
  if typing.TYPE_CHECKING:
@@ -344,6 +354,12 @@ def _deserialize_asgi(asgi: api_pb2.Asgi) -> Any:
344
354
  return None
345
355
 
346
356
 
357
+ def get_preferred_payload_format() -> "api_pb2.DataFormat.ValueType":
358
+ payload_format = (config.get("payload_format") or "pickle").lower()
359
+ data_format = api_pb2.DATA_FORMAT_CBOR if payload_format == "cbor" else api_pb2.DATA_FORMAT_PICKLE
360
+ return data_format
361
+
362
+
347
363
  def serialize_data_format(obj: Any, data_format: int) -> bytes:
348
364
  """Similar to serialize(), but supports other data formats."""
349
365
  if data_format == api_pb2.DATA_FORMAT_PICKLE:
@@ -353,6 +369,21 @@ def serialize_data_format(obj: Any, data_format: int) -> bytes:
353
369
  elif data_format == api_pb2.DATA_FORMAT_GENERATOR_DONE:
354
370
  assert isinstance(obj, api_pb2.GeneratorDone)
355
371
  return obj.SerializeToString(deterministic=True)
372
+ elif data_format == api_pb2.DATA_FORMAT_CBOR:
373
+ if cbor2 is None:
374
+ raise InvalidError("CBOR support requires the 'cbor2' package to be installed.")
375
+ try:
376
+ return cbor2.dumps(obj)
377
+ except cbor2.CBOREncodeTypeError:
378
+ try:
379
+ typename = f"{type(obj).__module__}.{type(obj).__name__}"
380
+ except Exception:
381
+ typename = str(type(obj))
382
+ raise SerializationError(
383
+ # TODO (elias): add documentation link for more information on this
384
+ f"Can not serialize type {typename} as cbor. If you need to use a custom data type, "
385
+ "try to serialize it yourself e.g. by using pickle.dumps(my_data)"
386
+ )
356
387
  else:
357
388
  raise InvalidError(f"Unknown data format {data_format!r}")
358
389
 
@@ -364,6 +395,10 @@ def deserialize_data_format(s: bytes, data_format: int, client) -> Any:
364
395
  return _deserialize_asgi(api_pb2.Asgi.FromString(s))
365
396
  elif data_format == api_pb2.DATA_FORMAT_GENERATOR_DONE:
366
397
  return api_pb2.GeneratorDone.FromString(s)
398
+ elif data_format == api_pb2.DATA_FORMAT_CBOR:
399
+ if cbor2 is None:
400
+ raise InvalidError("CBOR support requires the 'cbor2' package to be installed.")
401
+ return cbor2.loads(s)
367
402
  else:
368
403
  raise InvalidError(f"Unknown data format {data_format!r}")
369
404
 
@@ -470,10 +505,31 @@ def deserialize_params(serialized_params: bytes, function_def: api_pb2.Function,
470
505
  api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PICKLE,
471
506
  ):
472
507
  # legacy serialization format - pickle of `(args, kwargs)` w/ support for modal object arguments
473
- param_args, param_kwargs = deserialize(serialized_params, _client)
508
+ try:
509
+ param_args, param_kwargs = deserialize(serialized_params, _client)
510
+ except DeserializationError as original_exc:
511
+ # Fallback in case of proto -> pickle downgrades of a parameter serialization format
512
+ # I.e. FunctionBindParams binding proto serialized params to a function defintion
513
+ # that now assumes pickled data according to class_parameter_info
514
+ param_args = ()
515
+ try:
516
+ param_kwargs = deserialize_proto_params(serialized_params)
517
+ except Exception:
518
+ raise original_exc
519
+
474
520
  elif function_def.class_parameter_info.format == api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PROTO:
475
521
  param_args = () # we use kwargs only for our implicit constructors
476
- param_kwargs = deserialize_proto_params(serialized_params)
522
+ try:
523
+ param_kwargs = deserialize_proto_params(serialized_params)
524
+ except google.protobuf.message.DecodeError as original_exc:
525
+ # Fallback in case of pickle -> proto upgrades of a parameter serialization format
526
+ # I.e. FunctionBindParams binding pickle serialized params to a function defintion
527
+ # that now assumes proto data according to class_parameter_info
528
+ try:
529
+ param_args, param_kwargs = deserialize(serialized_params, _client)
530
+ except Exception:
531
+ raise original_exc
532
+
477
533
  else:
478
534
  raise ExecutionError(
479
535
  f"Unknown class parameter serialization format: {function_def.class_parameter_info.format}"
@@ -532,11 +588,16 @@ def get_callable_schema(
532
588
  callable: typing.Callable, *, is_web_endpoint: bool, ignore_first_argument: bool = False
533
589
  ) -> typing.Optional[api_pb2.FunctionSchema]:
534
590
  # ignore_first_argument can be used in case of unbound methods where we want to ignore the first (self) argument
535
- if is_web_endpoint or not config.get("function_schemas"):
591
+ if is_web_endpoint:
536
592
  # we don't support schemas on web endpoints for now
537
593
  return None
538
594
 
539
- sig = inspect.signature(callable)
595
+ try:
596
+ sig = inspect.signature(callable)
597
+ except Exception as e:
598
+ logger.debug(f"Error getting signature for function {callable}", exc_info=e)
599
+ return None
600
+
540
601
  # TODO: treat no return value annotation as None return?
541
602
  return_type_proto = schema_registry.get_proto_generic_type(sig.return_annotation)
542
603
  arguments = []
@@ -551,3 +612,26 @@ def get_callable_schema(
551
612
  arguments=arguments,
552
613
  return_type=return_type_proto,
553
614
  )
615
+
616
+
617
+ def pickle_exception(exc: BaseException) -> bytes:
618
+ try:
619
+ return serialize(exc)
620
+ except Exception as serialization_exc:
621
+ # We can't always serialize exceptions.
622
+ err = f"Failed to serialize exception {exc} of type {type(exc)}: {serialization_exc}"
623
+ logger.info(err)
624
+ return serialize(SerializationError(err))
625
+
626
+
627
+ def pickle_traceback(exc: BaseException, task_id: str) -> tuple[bytes, bytes]:
628
+ serialized_tb, tb_line_cache = b"", b""
629
+
630
+ try:
631
+ tb_dict, line_cache = extract_traceback(exc, task_id)
632
+ serialized_tb = serialize(tb_dict)
633
+ tb_line_cache = serialize(line_cache)
634
+ except Exception:
635
+ logger.info("Failed to serialize exception traceback.")
636
+
637
+ return serialized_tb, tb_line_cache
modal/_traceback.py CHANGED
@@ -8,10 +8,12 @@ so that Rich is not a dependency of the container Client.
8
8
  import re
9
9
  import sys
10
10
  import traceback
11
+ import typing
11
12
  import warnings
12
13
  from types import TracebackType
13
14
  from typing import Any, Iterable, Optional
14
15
 
16
+ from modal.config import config, logger
15
17
  from modal_proto import api_pb2
16
18
 
17
19
  from ._vendor.tblib import Traceback as TBLibTraceback
@@ -115,7 +117,7 @@ def traceback_contains_remote_call(tb: Optional[TracebackType]) -> bool:
115
117
  def print_exception(exc: Optional[type[BaseException]], value: Optional[BaseException], tb: Optional[TracebackType]):
116
118
  """Add backwards compatibility for printing exceptions with "notes" for Python<3.11."""
117
119
  traceback.print_exception(exc, value, tb)
118
- if sys.version_info < (3, 11) and value is not None:
120
+ if sys.version_info < (3, 11) and value is not None: # type: ignore
119
121
  notes = getattr(value, "__notes__", [])
120
122
  print(*notes, sep="\n", file=sys.stderr)
121
123
 
@@ -127,3 +129,42 @@ def print_server_warnings(server_warnings: Iterable[api_pb2.Warning]):
127
129
  """
128
130
  for warning in server_warnings:
129
131
  warnings.warn_explicit(warning.message, ServerWarning, "<modal-server>", 0)
132
+
133
+
134
+ # for some reason, the traceback cleanup here can't be moved into a context manager :(
135
+ traceback_suppression_note = (
136
+ "Internal Modal traceback frames are suppressed for readability. Use MODAL_TRACEBACK=1 to show a full traceback."
137
+ )
138
+
139
+
140
+ class suppress_tb_frames:
141
+ def __init__(self, n: int):
142
+ self.n = n
143
+
144
+ def __enter__(self):
145
+ pass
146
+
147
+ def __exit__(
148
+ self, exc_type: Optional[type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType]
149
+ ) -> typing.Literal[False]:
150
+ # *base* exceptions like CancelledError, SystemExit etc. can come from random places,
151
+ # so we don't suppress tracebacks for those
152
+ is_base_exception = not isinstance(exc, Exception)
153
+ if config.get("traceback") or exc_type is None or is_base_exception:
154
+ return False
155
+
156
+ # modify traceback on exception object
157
+ try:
158
+ final_tb = tb
159
+ for _ in range(self.n):
160
+ final_tb = final_tb.tb_next
161
+ except AttributeError:
162
+ logger.debug(f"Failed to suppress {self.n} traceback frames from {str(exc_type)} {str(exc)}")
163
+ raise
164
+
165
+ exc.with_traceback(final_tb)
166
+ notes = getattr(exc, "__notes__", [])
167
+ if traceback_suppression_note not in notes:
168
+ # .add_note was added in Python 3.11
169
+ notes.append(traceback_suppression_note)
170
+ return False
modal/_tunnel.pyi CHANGED
@@ -4,34 +4,402 @@ import typing
4
4
  import typing_extensions
5
5
 
6
6
  class Tunnel:
7
+ """A port forwarded from within a running Modal container. Created by `modal.forward()`.
8
+
9
+ **Important:** This is an experimental API which may change in the future.
10
+ """
11
+
7
12
  host: str
8
13
  port: int
9
14
  unencrypted_host: str
10
15
  unencrypted_port: int
11
16
 
12
17
  @property
13
- def url(self) -> str: ...
18
+ def url(self) -> str:
19
+ """Get the public HTTPS URL of the forwarded port."""
20
+ ...
21
+
14
22
  @property
15
- def tls_socket(self) -> tuple[str, int]: ...
23
+ def tls_socket(self) -> tuple[str, int]:
24
+ """Get the public TLS socket as a (host, port) tuple."""
25
+ ...
26
+
16
27
  @property
17
- def tcp_socket(self) -> tuple[str, int]: ...
18
- def __init__(self, host: str, port: int, unencrypted_host: str, unencrypted_port: int) -> None: ...
19
- def __repr__(self): ...
20
- def __eq__(self, other): ...
21
- def __setattr__(self, name, value): ...
22
- def __delattr__(self, name): ...
23
- def __hash__(self): ...
28
+ def tcp_socket(self) -> tuple[str, int]:
29
+ """Get the public TCP socket as a (host, port) tuple."""
30
+ ...
31
+
32
+ def __init__(self, host: str, port: int, unencrypted_host: str, unencrypted_port: int) -> None:
33
+ """Initialize self. See help(type(self)) for accurate signature."""
34
+ ...
35
+
36
+ def __repr__(self):
37
+ """Return repr(self)."""
38
+ ...
39
+
40
+ def __eq__(self, other):
41
+ """Return self==value."""
42
+ ...
43
+
44
+ def __setattr__(self, name, value):
45
+ """Implement setattr(self, name, value)."""
46
+ ...
47
+
48
+ def __delattr__(self, name):
49
+ """Implement delattr(self, name)."""
50
+ ...
51
+
52
+ def __hash__(self):
53
+ """Return hash(self)."""
54
+ ...
24
55
 
25
56
  def _forward(
26
57
  port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client._Client] = None
27
- ) -> typing.AsyncContextManager[Tunnel]: ...
58
+ ) -> typing.AsyncContextManager[Tunnel]:
59
+ '''Expose a port publicly from inside a running Modal container, with TLS.
60
+
61
+ If `unencrypted` is set, this also exposes the TCP socket without encryption on a random port
62
+ number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
63
+ make sure you are using a secure protocol over TCP.
64
+
65
+ **Important:** This is an experimental API which may change in the future.
66
+
67
+ **Usage:**
68
+
69
+ ```python notest
70
+ import modal
71
+ from flask import Flask
72
+
73
+ app = modal.App(image=modal.Image.debian_slim().pip_install("Flask"))
74
+ flask_app = Flask(__name__)
75
+
76
+
77
+ @flask_app.route("/")
78
+ def hello_world():
79
+ return "Hello, World!"
80
+
81
+
82
+ @app.function()
83
+ def run_app():
84
+ # Start a web server inside the container at port 8000. `modal.forward(8000)` lets us
85
+ # expose that port to the world at a random HTTPS URL.
86
+ with modal.forward(8000) as tunnel:
87
+ print("Server listening at", tunnel.url)
88
+ flask_app.run("0.0.0.0", 8000)
89
+
90
+ # When the context manager exits, the port is no longer exposed.
91
+ ```
92
+
93
+ **Raw TCP usage:**
94
+
95
+ ```python
96
+ import socket
97
+ import threading
98
+
99
+ import modal
100
+
101
+
102
+ def run_echo_server(port: int):
103
+ """Run a TCP echo server listening on the given port."""
104
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
105
+ sock.bind(("0.0.0.0", port))
106
+ sock.listen(1)
107
+
108
+ while True:
109
+ conn, addr = sock.accept()
110
+ print("Connection from:", addr)
111
+
112
+ # Start a new thread to handle the connection
113
+ def handle(conn):
114
+ with conn:
115
+ while True:
116
+ data = conn.recv(1024)
117
+ if not data:
118
+ break
119
+ conn.sendall(data)
120
+
121
+ threading.Thread(target=handle, args=(conn,)).start()
122
+
123
+
124
+ app = modal.App()
125
+
126
+
127
+ @app.function()
128
+ def tcp_tunnel():
129
+ # This exposes port 8000 to public Internet traffic over TCP.
130
+ with modal.forward(8000, unencrypted=True) as tunnel:
131
+ # You can connect to this TCP socket from outside the container, for example, using `nc`:
132
+ # nc <HOST> <PORT>
133
+ print("TCP tunnel listening at:", tunnel.tcp_socket)
134
+ run_echo_server(8000)
135
+ ```
136
+
137
+ **SSH example:**
138
+ This assumes you have a rsa keypair in `~/.ssh/id_rsa{.pub}`, this is a bare-bones example
139
+ letting you SSH into a Modal container.
140
+
141
+ ```python
142
+ import subprocess
143
+ import time
144
+
145
+ import modal
146
+
147
+ app = modal.App()
148
+ image = (
149
+ modal.Image.debian_slim()
150
+ .apt_install("openssh-server")
151
+ .run_commands("mkdir /run/sshd")
152
+ .add_local_file("~/.ssh/id_rsa.pub", "/root/.ssh/authorized_keys", copy=True)
153
+ )
154
+
155
+
156
+ @app.function(image=image, timeout=3600)
157
+ def some_function():
158
+ subprocess.Popen(["/usr/sbin/sshd", "-D", "-e"])
159
+ with modal.forward(port=22, unencrypted=True) as tunnel:
160
+ hostname, port = tunnel.tcp_socket
161
+ connection_cmd = f'ssh -p {port} root@{hostname}'
162
+ print(f"ssh into container using: {connection_cmd}")
163
+ time.sleep(3600) # keep alive for 1 hour or until killed
164
+ ```
165
+
166
+ If you intend to use this more generally, a suggestion is to put the subprocess and port
167
+ forwarding code in an `@enter` lifecycle method of an @app.cls, to only make a single
168
+ ssh server and port for each container (and not one for each input to the function).
169
+ '''
170
+ ...
28
171
 
29
172
  class __forward_spec(typing_extensions.Protocol):
30
173
  def __call__(
31
174
  self, /, port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client.Client] = None
32
- ) -> synchronicity.combined_types.AsyncAndBlockingContextManager[Tunnel]: ...
175
+ ) -> synchronicity.combined_types.AsyncAndBlockingContextManager[Tunnel]:
176
+ '''Expose a port publicly from inside a running Modal container, with TLS.
177
+
178
+ If `unencrypted` is set, this also exposes the TCP socket without encryption on a random port
179
+ number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
180
+ make sure you are using a secure protocol over TCP.
181
+
182
+ **Important:** This is an experimental API which may change in the future.
183
+
184
+ **Usage:**
185
+
186
+ ```python notest
187
+ import modal
188
+ from flask import Flask
189
+
190
+ app = modal.App(image=modal.Image.debian_slim().pip_install("Flask"))
191
+ flask_app = Flask(__name__)
192
+
193
+
194
+ @flask_app.route("/")
195
+ def hello_world():
196
+ return "Hello, World!"
197
+
198
+
199
+ @app.function()
200
+ def run_app():
201
+ # Start a web server inside the container at port 8000. `modal.forward(8000)` lets us
202
+ # expose that port to the world at a random HTTPS URL.
203
+ with modal.forward(8000) as tunnel:
204
+ print("Server listening at", tunnel.url)
205
+ flask_app.run("0.0.0.0", 8000)
206
+
207
+ # When the context manager exits, the port is no longer exposed.
208
+ ```
209
+
210
+ **Raw TCP usage:**
211
+
212
+ ```python
213
+ import socket
214
+ import threading
215
+
216
+ import modal
217
+
218
+
219
+ def run_echo_server(port: int):
220
+ """Run a TCP echo server listening on the given port."""
221
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
222
+ sock.bind(("0.0.0.0", port))
223
+ sock.listen(1)
224
+
225
+ while True:
226
+ conn, addr = sock.accept()
227
+ print("Connection from:", addr)
228
+
229
+ # Start a new thread to handle the connection
230
+ def handle(conn):
231
+ with conn:
232
+ while True:
233
+ data = conn.recv(1024)
234
+ if not data:
235
+ break
236
+ conn.sendall(data)
237
+
238
+ threading.Thread(target=handle, args=(conn,)).start()
239
+
240
+
241
+ app = modal.App()
242
+
243
+
244
+ @app.function()
245
+ def tcp_tunnel():
246
+ # This exposes port 8000 to public Internet traffic over TCP.
247
+ with modal.forward(8000, unencrypted=True) as tunnel:
248
+ # You can connect to this TCP socket from outside the container, for example, using `nc`:
249
+ # nc <HOST> <PORT>
250
+ print("TCP tunnel listening at:", tunnel.tcp_socket)
251
+ run_echo_server(8000)
252
+ ```
253
+
254
+ **SSH example:**
255
+ This assumes you have a rsa keypair in `~/.ssh/id_rsa{.pub}`, this is a bare-bones example
256
+ letting you SSH into a Modal container.
257
+
258
+ ```python
259
+ import subprocess
260
+ import time
261
+
262
+ import modal
263
+
264
+ app = modal.App()
265
+ image = (
266
+ modal.Image.debian_slim()
267
+ .apt_install("openssh-server")
268
+ .run_commands("mkdir /run/sshd")
269
+ .add_local_file("~/.ssh/id_rsa.pub", "/root/.ssh/authorized_keys", copy=True)
270
+ )
271
+
272
+
273
+ @app.function(image=image, timeout=3600)
274
+ def some_function():
275
+ subprocess.Popen(["/usr/sbin/sshd", "-D", "-e"])
276
+ with modal.forward(port=22, unencrypted=True) as tunnel:
277
+ hostname, port = tunnel.tcp_socket
278
+ connection_cmd = f'ssh -p {port} root@{hostname}'
279
+ print(f"ssh into container using: {connection_cmd}")
280
+ time.sleep(3600) # keep alive for 1 hour or until killed
281
+ ```
282
+
283
+ If you intend to use this more generally, a suggestion is to put the subprocess and port
284
+ forwarding code in an `@enter` lifecycle method of an @app.cls, to only make a single
285
+ ssh server and port for each container (and not one for each input to the function).
286
+ '''
287
+ ...
288
+
33
289
  def aio(
34
290
  self, /, port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client.Client] = None
35
- ) -> typing.AsyncContextManager[Tunnel]: ...
291
+ ) -> typing.AsyncContextManager[Tunnel]:
292
+ '''Expose a port publicly from inside a running Modal container, with TLS.
293
+
294
+ If `unencrypted` is set, this also exposes the TCP socket without encryption on a random port
295
+ number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
296
+ make sure you are using a secure protocol over TCP.
297
+
298
+ **Important:** This is an experimental API which may change in the future.
299
+
300
+ **Usage:**
301
+
302
+ ```python notest
303
+ import modal
304
+ from flask import Flask
305
+
306
+ app = modal.App(image=modal.Image.debian_slim().pip_install("Flask"))
307
+ flask_app = Flask(__name__)
308
+
309
+
310
+ @flask_app.route("/")
311
+ def hello_world():
312
+ return "Hello, World!"
313
+
314
+
315
+ @app.function()
316
+ def run_app():
317
+ # Start a web server inside the container at port 8000. `modal.forward(8000)` lets us
318
+ # expose that port to the world at a random HTTPS URL.
319
+ with modal.forward(8000) as tunnel:
320
+ print("Server listening at", tunnel.url)
321
+ flask_app.run("0.0.0.0", 8000)
322
+
323
+ # When the context manager exits, the port is no longer exposed.
324
+ ```
325
+
326
+ **Raw TCP usage:**
327
+
328
+ ```python
329
+ import socket
330
+ import threading
331
+
332
+ import modal
333
+
334
+
335
+ def run_echo_server(port: int):
336
+ """Run a TCP echo server listening on the given port."""
337
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
338
+ sock.bind(("0.0.0.0", port))
339
+ sock.listen(1)
340
+
341
+ while True:
342
+ conn, addr = sock.accept()
343
+ print("Connection from:", addr)
344
+
345
+ # Start a new thread to handle the connection
346
+ def handle(conn):
347
+ with conn:
348
+ while True:
349
+ data = conn.recv(1024)
350
+ if not data:
351
+ break
352
+ conn.sendall(data)
353
+
354
+ threading.Thread(target=handle, args=(conn,)).start()
355
+
356
+
357
+ app = modal.App()
358
+
359
+
360
+ @app.function()
361
+ def tcp_tunnel():
362
+ # This exposes port 8000 to public Internet traffic over TCP.
363
+ with modal.forward(8000, unencrypted=True) as tunnel:
364
+ # You can connect to this TCP socket from outside the container, for example, using `nc`:
365
+ # nc <HOST> <PORT>
366
+ print("TCP tunnel listening at:", tunnel.tcp_socket)
367
+ run_echo_server(8000)
368
+ ```
369
+
370
+ **SSH example:**
371
+ This assumes you have a rsa keypair in `~/.ssh/id_rsa{.pub}`, this is a bare-bones example
372
+ letting you SSH into a Modal container.
373
+
374
+ ```python
375
+ import subprocess
376
+ import time
377
+
378
+ import modal
379
+
380
+ app = modal.App()
381
+ image = (
382
+ modal.Image.debian_slim()
383
+ .apt_install("openssh-server")
384
+ .run_commands("mkdir /run/sshd")
385
+ .add_local_file("~/.ssh/id_rsa.pub", "/root/.ssh/authorized_keys", copy=True)
386
+ )
387
+
388
+
389
+ @app.function(image=image, timeout=3600)
390
+ def some_function():
391
+ subprocess.Popen(["/usr/sbin/sshd", "-D", "-e"])
392
+ with modal.forward(port=22, unencrypted=True) as tunnel:
393
+ hostname, port = tunnel.tcp_socket
394
+ connection_cmd = f'ssh -p {port} root@{hostname}'
395
+ print(f"ssh into container using: {connection_cmd}")
396
+ time.sleep(3600) # keep alive for 1 hour or until killed
397
+ ```
398
+
399
+ If you intend to use this more generally, a suggestion is to put the subprocess and port
400
+ forwarding code in an `@enter` lifecycle method of an @app.cls, to only make a single
401
+ ssh server and port for each container (and not one for each input to the function).
402
+ '''
403
+ ...
36
404
 
37
405
  forward: __forward_spec