modal 0.62.115__py3-none-any.whl → 0.72.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. modal/__init__.py +13 -9
  2. modal/__main__.py +41 -3
  3. modal/_clustered_functions.py +80 -0
  4. modal/_clustered_functions.pyi +22 -0
  5. modal/_container_entrypoint.py +402 -398
  6. modal/_ipython.py +3 -13
  7. modal/_location.py +17 -10
  8. modal/_output.py +243 -99
  9. modal/_pty.py +2 -2
  10. modal/_resolver.py +55 -60
  11. modal/_resources.py +26 -7
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1025 -0
  15. modal/{execution_context.py → _runtime/execution_context.py} +11 -2
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +123 -6
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +50 -14
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +386 -104
  24. modal/_utils/blob_utils.py +157 -186
  25. modal/_utils/bytes_io_segment_payload.py +97 -0
  26. modal/_utils/deprecation.py +89 -0
  27. modal/_utils/docker_utils.py +98 -0
  28. modal/_utils/function_utils.py +299 -98
  29. modal/_utils/grpc_testing.py +47 -34
  30. modal/_utils/grpc_utils.py +54 -21
  31. modal/_utils/hash_utils.py +51 -10
  32. modal/_utils/http_utils.py +39 -9
  33. modal/_utils/logger.py +2 -1
  34. modal/_utils/mount_utils.py +34 -16
  35. modal/_utils/name_utils.py +58 -0
  36. modal/_utils/package_utils.py +14 -1
  37. modal/_utils/pattern_utils.py +205 -0
  38. modal/_utils/rand_pb_testing.py +3 -3
  39. modal/_utils/shell_utils.py +15 -49
  40. modal/_vendor/a2wsgi_wsgi.py +62 -72
  41. modal/_vendor/cloudpickle.py +1 -1
  42. modal/_watcher.py +12 -10
  43. modal/app.py +561 -323
  44. modal/app.pyi +474 -262
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +22 -6
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +203 -42
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +61 -13
  51. modal/cli/dict.py +128 -0
  52. modal/cli/entry_point.py +26 -13
  53. modal/cli/environment.py +40 -9
  54. modal/cli/import_refs.py +21 -48
  55. modal/cli/launch.py +28 -14
  56. modal/cli/network_file_system.py +57 -21
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +34 -9
  59. modal/cli/programs/vscode.py +58 -8
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +199 -96
  62. modal/cli/secret.py +5 -4
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +74 -8
  65. modal/cli/volume.py +97 -56
  66. modal/client.py +248 -144
  67. modal/client.pyi +156 -124
  68. modal/cloud_bucket_mount.py +43 -30
  69. modal/cloud_bucket_mount.pyi +32 -25
  70. modal/cls.py +528 -141
  71. modal/cls.pyi +189 -145
  72. modal/config.py +32 -15
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +50 -54
  76. modal/dict.pyi +120 -164
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +30 -43
  80. modal/experimental.py +62 -2
  81. modal/file_io.py +537 -0
  82. modal/file_io.pyi +235 -0
  83. modal/file_pattern_matcher.py +196 -0
  84. modal/functions.py +846 -428
  85. modal/functions.pyi +446 -387
  86. modal/gpu.py +57 -44
  87. modal/image.py +943 -417
  88. modal/image.pyi +584 -245
  89. modal/io_streams.py +434 -0
  90. modal/io_streams.pyi +122 -0
  91. modal/mount.py +223 -90
  92. modal/mount.pyi +241 -243
  93. modal/network_file_system.py +85 -86
  94. modal/network_file_system.pyi +151 -110
  95. modal/object.py +66 -36
  96. modal/object.pyi +166 -143
  97. modal/output.py +63 -0
  98. modal/parallel_map.py +73 -47
  99. modal/parallel_map.pyi +51 -63
  100. modal/partial_function.py +272 -107
  101. modal/partial_function.pyi +219 -120
  102. modal/proxy.py +15 -12
  103. modal/proxy.pyi +3 -8
  104. modal/queue.py +96 -72
  105. modal/queue.pyi +210 -135
  106. modal/requirements/2024.04.txt +2 -1
  107. modal/requirements/2024.10.txt +16 -0
  108. modal/requirements/README.md +21 -0
  109. modal/requirements/base-images.json +22 -0
  110. modal/retries.py +45 -4
  111. modal/runner.py +325 -203
  112. modal/runner.pyi +124 -110
  113. modal/running_app.py +27 -4
  114. modal/sandbox.py +509 -231
  115. modal/sandbox.pyi +396 -169
  116. modal/schedule.py +2 -2
  117. modal/scheduler_placement.py +20 -3
  118. modal/secret.py +41 -25
  119. modal/secret.pyi +62 -42
  120. modal/serving.py +39 -49
  121. modal/serving.pyi +37 -43
  122. modal/stream_type.py +15 -0
  123. modal/token_flow.py +5 -3
  124. modal/token_flow.pyi +37 -32
  125. modal/volume.py +123 -137
  126. modal/volume.pyi +228 -221
  127. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/METADATA +5 -5
  128. modal-0.72.13.dist-info/RECORD +174 -0
  129. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/top_level.txt +0 -1
  130. modal_docs/gen_reference_docs.py +3 -1
  131. modal_docs/mdmd/mdmd.py +0 -1
  132. modal_docs/mdmd/signatures.py +1 -2
  133. modal_global_objects/images/base_images.py +28 -0
  134. modal_global_objects/mounts/python_standalone.py +2 -2
  135. modal_proto/__init__.py +1 -1
  136. modal_proto/api.proto +1231 -531
  137. modal_proto/api_grpc.py +750 -430
  138. modal_proto/api_pb2.py +2102 -1176
  139. modal_proto/api_pb2.pyi +8859 -0
  140. modal_proto/api_pb2_grpc.py +1329 -675
  141. modal_proto/api_pb2_grpc.pyi +1416 -0
  142. modal_proto/modal_api_grpc.py +149 -0
  143. modal_proto/modal_options_grpc.py +3 -0
  144. modal_proto/options_pb2.pyi +20 -0
  145. modal_proto/options_pb2_grpc.pyi +7 -0
  146. modal_proto/py.typed +0 -0
  147. modal_version/__init__.py +1 -1
  148. modal_version/_version_generated.py +2 -2
  149. modal/_asgi.py +0 -370
  150. modal/_container_exec.py +0 -128
  151. modal/_container_io_manager.py +0 -646
  152. modal/_container_io_manager.pyi +0 -412
  153. modal/_sandbox_shell.py +0 -49
  154. modal/app_utils.py +0 -20
  155. modal/app_utils.pyi +0 -17
  156. modal/execution_context.pyi +0 -37
  157. modal/shared_volume.py +0 -23
  158. modal/shared_volume.pyi +0 -24
  159. modal-0.62.115.dist-info/RECORD +0 -207
  160. modal_global_objects/images/conda.py +0 -15
  161. modal_global_objects/images/debian_slim.py +0 -15
  162. modal_global_objects/images/micromamba.py +0 -15
  163. test/__init__.py +0 -1
  164. test/aio_test.py +0 -12
  165. test/async_utils_test.py +0 -279
  166. test/blob_test.py +0 -67
  167. test/cli_imports_test.py +0 -149
  168. test/cli_test.py +0 -674
  169. test/client_test.py +0 -203
  170. test/cloud_bucket_mount_test.py +0 -22
  171. test/cls_test.py +0 -636
  172. test/config_test.py +0 -149
  173. test/conftest.py +0 -1485
  174. test/container_app_test.py +0 -50
  175. test/container_test.py +0 -1405
  176. test/cpu_test.py +0 -23
  177. test/decorator_test.py +0 -85
  178. test/deprecation_test.py +0 -34
  179. test/dict_test.py +0 -51
  180. test/e2e_test.py +0 -68
  181. test/error_test.py +0 -7
  182. test/function_serialization_test.py +0 -32
  183. test/function_test.py +0 -791
  184. test/function_utils_test.py +0 -101
  185. test/gpu_test.py +0 -159
  186. test/grpc_utils_test.py +0 -82
  187. test/helpers.py +0 -47
  188. test/image_test.py +0 -814
  189. test/live_reload_test.py +0 -80
  190. test/lookup_test.py +0 -70
  191. test/mdmd_test.py +0 -329
  192. test/mount_test.py +0 -162
  193. test/mounted_files_test.py +0 -327
  194. test/network_file_system_test.py +0 -188
  195. test/notebook_test.py +0 -66
  196. test/object_test.py +0 -41
  197. test/package_utils_test.py +0 -25
  198. test/queue_test.py +0 -115
  199. test/resolver_test.py +0 -59
  200. test/retries_test.py +0 -67
  201. test/runner_test.py +0 -85
  202. test/sandbox_test.py +0 -191
  203. test/schedule_test.py +0 -15
  204. test/scheduler_placement_test.py +0 -57
  205. test/secret_test.py +0 -89
  206. test/serialization_test.py +0 -50
  207. test/stub_composition_test.py +0 -10
  208. test/stub_test.py +0 -361
  209. test/test_asgi_wrapper.py +0 -234
  210. test/token_flow_test.py +0 -18
  211. test/traceback_test.py +0 -135
  212. test/tunnel_test.py +0 -29
  213. test/utils_test.py +0 -88
  214. test/version_test.py +0 -14
  215. test/volume_test.py +0 -397
  216. test/watcher_test.py +0 -58
  217. test/webhook_test.py +0 -145
  218. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/LICENSE +0 -0
  219. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/WHEEL +0 -0
  220. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/entry_points.txt +0 -0
modal/_serialization.py CHANGED
@@ -1,13 +1,16 @@
1
1
  # Copyright Modal Labs 2022
2
2
  import io
3
3
  import pickle
4
+ import typing
5
+ from dataclasses import dataclass
4
6
  from typing import Any
5
7
 
8
+ from modal._utils.async_utils import synchronizer
6
9
  from modal_proto import api_pb2
7
10
 
8
11
  from ._vendor import cloudpickle
9
12
  from .config import logger
10
- from .exception import DeserializationError, InvalidError
13
+ from .exception import DeserializationError, ExecutionError, InvalidError
11
14
  from .object import Object, _Object
12
15
 
13
16
  PICKLE_PROTOCOL = 4 # Support older Python versions.
@@ -18,10 +21,31 @@ class Pickler(cloudpickle.Pickler):
18
21
  super().__init__(buf, protocol=PICKLE_PROTOCOL)
19
22
 
20
23
  def persistent_id(self, obj):
24
+ from modal.partial_function import PartialFunction
25
+
21
26
  if isinstance(obj, _Object):
22
27
  flag = "_o"
23
28
  elif isinstance(obj, Object):
24
29
  flag = "o"
30
+ elif isinstance(obj, PartialFunction):
31
+ # Special case for PartialObject since it's a synchronicity wrapped object
32
+ # that's set on serialized classes.
33
+ # The resulting pickled instance can't be deserialized without this in a
34
+ # new process, since the original referenced synchronizer will have different
35
+ # values for `._original_attr` etc.
36
+
37
+ impl_object = synchronizer._translate_in(obj)
38
+ attributes = impl_object.__dict__.copy()
39
+ # ugly - we remove the `._wrapped_attr` attribute from the implementation instance
40
+ # to avoid referencing and therefore pickling the wrapped instance despite having
41
+ # translated it to the implementation type
42
+
43
+ # it would be nice if we could avoid this by not having the wrapped instances
44
+ # be directly linked from objects and instead having a lookup table in the Synchronizer:
45
+ if synchronizer._wrapped_attr and synchronizer._wrapped_attr in attributes:
46
+ attributes.pop(synchronizer._wrapped_attr)
47
+
48
+ return ("sync", (impl_object.__class__, attributes))
25
49
  else:
26
50
  return
27
51
  if not obj.object_id:
@@ -35,8 +59,20 @@ class Unpickler(pickle.Unpickler):
35
59
  super().__init__(buf)
36
60
 
37
61
  def persistent_load(self, pid):
62
+ if len(pid) == 2:
63
+ # more general protocol
64
+ obj_type, obj_data = pid
65
+ if obj_type == "sync": # synchronicity wrapped object
66
+ # not actually a proto object in this case but the underlying object of a synchronicity object
67
+ impl_class, attributes = obj_data
68
+ impl_instance = impl_class.__new__(impl_class)
69
+ impl_instance.__dict__.update(attributes)
70
+ return synchronizer._translate_out(impl_instance)
71
+ else:
72
+ raise ExecutionError("Unknown serialization format")
73
+
74
+ # old protocol, always a 3-tuple
38
75
  (object_id, flag, handle_proto) = pid
39
-
40
76
  if flag in ("o", "p", "h"):
41
77
  return Object._new_hydrated(object_id, self.client, handle_proto)
42
78
  elif flag in ("_o", "_p", "_h"):
@@ -54,6 +90,9 @@ def serialize(obj: Any) -> bytes:
54
90
 
55
91
  def deserialize(s: bytes, client) -> Any:
56
92
  """Deserializes object and replaces all client placeholders by self."""
93
+ from ._runtime.execution_context import is_local # Avoid circular import
94
+
95
+ env = "local" if is_local() else "remote"
57
96
  try:
58
97
  return Unpickler(client, io.BytesIO(s)).load()
59
98
  except AttributeError as exc:
@@ -72,11 +111,19 @@ def deserialize(s: bytes, client) -> Any:
72
111
  " you have different versions of a library in your local and remote environments."
73
112
  ) from exc
74
113
  except ModuleNotFoundError as exc:
75
- from .execution_context import is_local # Avoid circular import
76
-
77
- dest = "local" if is_local() else "remote"
78
114
  raise DeserializationError(
79
- f"Deserialization failed because the '{exc.name}' module is not available in the {dest} environment."
115
+ f"Deserialization failed because the '{exc.name}' module is not available in the {env} environment."
116
+ ) from exc
117
+ except Exception as exc:
118
+ if env == "remote":
119
+ # We currently don't always package the full traceback from errors in the remote entrypoint logic.
120
+ # So try to include as much information as we can in the main error message.
121
+ more = f": {type(exc)}({str(exc)})"
122
+ else:
123
+ # When running locally, we can just rely on standard exception chaining.
124
+ more = " (see above for details)"
125
+ raise DeserializationError(
126
+ f"Encountered an error when deserializing an object in the {env} environment{more}."
80
127
  ) from exc
81
128
 
82
129
 
@@ -336,3 +383,73 @@ def check_valid_cls_constructor_arg(key, obj):
336
383
  raise ValueError(
337
384
  f"Only pickle-able types are allowed in remote class constructors: argument {key} of type {type(obj)}."
338
385
  )
386
+
387
+
388
+ @dataclass
389
+ class ParamTypeInfo:
390
+ default_field: str
391
+ proto_field: str
392
+ converter: typing.Callable[[str], typing.Any]
393
+
394
+
395
+ PARAM_TYPE_MAPPING = {
396
+ api_pb2.PARAM_TYPE_STRING: ParamTypeInfo(default_field="string_default", proto_field="string_value", converter=str),
397
+ api_pb2.PARAM_TYPE_INT: ParamTypeInfo(default_field="int_default", proto_field="int_value", converter=int),
398
+ }
399
+
400
+
401
+ def serialize_proto_params(python_params: dict[str, Any], schema: typing.Sequence[api_pb2.ClassParameterSpec]) -> bytes:
402
+ proto_params: list[api_pb2.ClassParameterValue] = []
403
+ for schema_param in schema:
404
+ type_info = PARAM_TYPE_MAPPING.get(schema_param.type)
405
+ if not type_info:
406
+ raise ValueError(f"Unsupported parameter type: {schema_param.type}")
407
+ proto_param = api_pb2.ClassParameterValue(
408
+ name=schema_param.name,
409
+ type=schema_param.type,
410
+ )
411
+ python_value = python_params.get(schema_param.name)
412
+ if python_value is None:
413
+ if schema_param.has_default:
414
+ python_value = getattr(schema_param, type_info.default_field)
415
+ else:
416
+ raise ValueError(f"Missing required parameter: {schema_param.name}")
417
+ try:
418
+ converted_value = type_info.converter(python_value)
419
+ except ValueError as exc:
420
+ raise ValueError(f"Invalid type for parameter {schema_param.name}: {exc}")
421
+ setattr(proto_param, type_info.proto_field, converted_value)
422
+ proto_params.append(proto_param)
423
+ proto_bytes = api_pb2.ClassParameterSet(parameters=proto_params).SerializeToString(deterministic=True)
424
+ return proto_bytes
425
+
426
+
427
+ def deserialize_proto_params(serialized_params: bytes, schema: list[api_pb2.ClassParameterSpec]) -> dict[str, Any]:
428
+ proto_struct = api_pb2.ClassParameterSet()
429
+ proto_struct.ParseFromString(serialized_params)
430
+ value_by_name = {p.name: p for p in proto_struct.parameters}
431
+ python_params = {}
432
+ for schema_param in schema:
433
+ if schema_param.name not in value_by_name:
434
+ # TODO: handle default values? Could just be a flag on the FunctionParameter schema spec,
435
+ # allowing it to not be supplied in the FunctionParameterSet?
436
+ raise AttributeError(f"Constructor arguments don't match declared parameters (missing {schema_param.name})")
437
+ param_value = value_by_name[schema_param.name]
438
+ if schema_param.type != param_value.type:
439
+ raise ValueError(
440
+ "Constructor arguments types don't match declared parameters "
441
+ f"({schema_param.name}: type {schema_param.type} != type {param_value.type})"
442
+ )
443
+ python_value: Any
444
+ if schema_param.type == api_pb2.PARAM_TYPE_STRING:
445
+ python_value = param_value.string_value
446
+ elif schema_param.type == api_pb2.PARAM_TYPE_INT:
447
+ python_value = param_value.int_value
448
+ else:
449
+ # TODO(elias): based on `parameters` declared types, we could add support for
450
+ # custom non proto types encoded as bytes in the proto, e.g. PARAM_TYPE_PYTHON_PICKLE
451
+ raise NotImplementedError("Only strings and ints are supported parameter value types at the moment")
452
+
453
+ python_params[schema_param.name] = python_value
454
+
455
+ return python_params
modal/_traceback.py CHANGED
@@ -1,24 +1,27 @@
1
1
  # Copyright Modal Labs 2022
2
- import functools
2
+ """Helper functions related to operating on exceptions, warnings, and traceback objects.
3
+
4
+ Functions related to *displaying* tracebacks should go in `modal/cli/_traceback.py`
5
+ so that Rich is not a dependency of the container Client.
6
+ """
7
+
8
+ import re
9
+ import sys
3
10
  import traceback
4
11
  import warnings
5
12
  from types import TracebackType
6
- from typing import Any, Dict, Optional, Tuple
13
+ from typing import Any, Iterable, Optional
7
14
 
8
- from rich.console import Console, RenderResult, group
9
- from rich.panel import Panel
10
- from rich.syntax import Syntax
11
- from rich.text import Text
12
- from rich.traceback import PathHighlighter, Stack, Traceback, install
15
+ from modal_proto import api_pb2
13
16
 
14
17
  from ._vendor.tblib import Traceback as TBLibTraceback
15
- from .exception import DeprecationError
18
+ from .exception import ServerWarning
16
19
 
17
- TBDictType = Dict[str, Any]
18
- LineCacheType = Dict[Tuple[str, str], str]
20
+ TBDictType = dict[str, Any]
21
+ LineCacheType = dict[tuple[str, str], str]
19
22
 
20
23
 
21
- def extract_traceback(exc: BaseException, task_id: str) -> Tuple[TBDictType, LineCacheType]:
24
+ def extract_traceback(exc: BaseException, task_id: str) -> tuple[TBDictType, LineCacheType]:
22
25
  """Given an exception, extract a serializable traceback (with task ID markers included),
23
26
  and a line cache that maps (filename, lineno) to line contents. The latter is used to show
24
27
  a helpful traceback to the user, even if they don't have packages installed locally that
@@ -37,6 +40,8 @@ def extract_traceback(exc: BaseException, task_id: str) -> Tuple[TBDictType, Lin
37
40
  # container. This means we've reached the end of the local traceback.
38
41
  if file.startswith("<"):
39
42
  break
43
+ # We rely on this specific filename format when inferring where the exception was raised
44
+ # in various other exception-related code
40
45
  cur.tb_frame.f_code.co_filename = f"<{task_id}>:{file}"
41
46
  cur = cur.tb_next
42
47
 
@@ -67,13 +72,17 @@ def append_modal_tb(exc: BaseException, tb_dict: TBDictType, line_cache: LineCac
67
72
  setattr(exc, "__line_cache__", line_cache)
68
73
 
69
74
 
70
- def reduce_traceback_to_user_code(tb: TracebackType, user_source: str) -> TracebackType:
75
+ def reduce_traceback_to_user_code(tb: Optional[TracebackType], user_source: str) -> TracebackType:
71
76
  """Return a traceback that does not contain modal entrypoint or synchronicity frames."""
72
- # Step forward all the way through the traceback and drop any synchronicity frames
77
+
78
+ # Step forward all the way through the traceback and drop any "Modal support" frames
79
+ def skip_frame(filename: str) -> bool:
80
+ return "/site-packages/synchronicity/" in filename or "modal/_utils/deprecation" in filename
81
+
73
82
  tb_root = tb
74
83
  while tb is not None:
75
84
  while tb.tb_next is not None:
76
- if "/site-packages/synchronicity/" in tb.tb_next.tb_frame.f_code.co_filename:
85
+ if skip_frame(tb.tb_next.tb_frame.f_code.co_filename):
77
86
  tb.tb_next = tb.tb_next.tb_next
78
87
  else:
79
88
  break
@@ -94,176 +103,27 @@ def reduce_traceback_to_user_code(tb: TracebackType, user_source: str) -> Traceb
94
103
  return tb
95
104
 
96
105
 
97
- @group()
98
- def _render_stack(self, stack: Stack) -> RenderResult:
99
- """Patched variant of rich.Traceback._render_stack that uses the line from the modal StackSummary,
100
- when the file isn't available to be read locally."""
101
-
102
- path_highlighter = PathHighlighter()
103
- theme = self.theme
104
- code_cache: Dict[str, str] = {}
105
- line_cache = getattr(stack, "line_cache", {})
106
- task_id = None
107
-
108
- def read_code(filename: str) -> str:
109
- code = code_cache.get(filename)
110
- if code is None:
111
- with open(filename, "rt", encoding="utf-8", errors="replace") as code_file:
112
- code = code_file.read()
113
- code_cache[filename] = code
114
- return code
115
-
116
- exclude_frames: Optional[range] = None
117
- if self.max_frames != 0:
118
- exclude_frames = range(
119
- self.max_frames // 2,
120
- len(stack.frames) - self.max_frames // 2,
121
- )
122
-
123
- excluded = False
124
- for frame_index, frame in enumerate(stack.frames):
125
- if exclude_frames and frame_index in exclude_frames:
126
- excluded = True
127
- continue
128
-
129
- if excluded:
130
- assert exclude_frames is not None
131
- yield Text(
132
- f"\n... {len(exclude_frames)} frames hidden ...",
133
- justify="center",
134
- style="traceback.error",
135
- )
136
- excluded = False
137
-
138
- first = frame_index == 0
139
- # Patched Modal-specific code.
140
- if frame.filename.startswith("<") and ":" in frame.filename:
141
- next_task_id, frame_filename = frame.filename.split(":", 1)
142
- next_task_id = next_task_id.strip("<>")
143
- else:
144
- frame_filename = frame.filename
145
- next_task_id = None
146
- suppressed = any(frame_filename.startswith(path) for path in self.suppress)
147
-
148
- if next_task_id != task_id:
149
- task_id = next_task_id
150
- yield ""
151
- yield Text(
152
- f"...Remote call to Modal Function ({task_id})...",
153
- justify="center",
154
- style="green",
155
- )
156
-
157
- text = Text.assemble(
158
- path_highlighter(Text(frame_filename, style="pygments.string")),
159
- (":", "pygments.text"),
160
- (str(frame.lineno), "pygments.number"),
161
- " in ",
162
- (frame.name, "pygments.function"),
163
- style="pygments.text",
164
- )
165
- if not frame_filename.startswith("<") and not first:
166
- yield ""
167
-
168
- yield text
169
- if not suppressed:
170
- try:
171
- code = read_code(frame_filename)
172
- lexer_name = self._guess_lexer(frame_filename, code)
173
- syntax = Syntax(
174
- code,
175
- lexer_name,
176
- theme=theme,
177
- line_numbers=True,
178
- line_range=(
179
- frame.lineno - self.extra_lines,
180
- frame.lineno + self.extra_lines,
181
- ),
182
- highlight_lines={frame.lineno},
183
- word_wrap=self.word_wrap,
184
- code_width=88,
185
- indent_guides=self.indent_guides,
186
- dedent=False,
187
- )
188
- yield ""
189
- except Exception as error:
190
- # Patched Modal-specific code.
191
- line = line_cache.get((frame_filename, frame.lineno))
192
- if line:
193
- try:
194
- lexer_name = self._guess_lexer(frame_filename, line)
195
- yield ""
196
- yield Syntax(
197
- line,
198
- lexer_name,
199
- theme=theme,
200
- line_numbers=True,
201
- line_range=(0, 1),
202
- highlight_lines={frame.lineno},
203
- word_wrap=self.word_wrap,
204
- code_width=88,
205
- indent_guides=self.indent_guides,
206
- dedent=False,
207
- start_line=frame.lineno,
208
- )
209
- except Exception:
210
- yield Text.assemble(
211
- (f"\n{error}", "traceback.error"),
212
- )
213
- yield ""
214
- else:
215
- yield syntax
216
-
217
-
218
- def setup_rich_traceback() -> None:
219
- from_exception = Traceback.from_exception
220
-
221
- @functools.wraps(Traceback.from_exception)
222
- def _from_exception(exc_type, exc_value, *args, **kwargs):
223
- """Patch from_exception to grab the Modal line_cache and store it with the
224
- Stack object, so it's available to render_stack at display time."""
225
-
226
- line_cache = getattr(exc_value, "__line_cache__", {})
227
- tb = from_exception(exc_type, exc_value, *args, **kwargs)
228
- for stack in tb.trace.stacks:
229
- stack.line_cache = line_cache # type: ignore
230
- return tb
231
-
232
- Traceback._render_stack = _render_stack # type: ignore
233
- Traceback.from_exception = _from_exception # type: ignore
234
-
235
- import click
236
- import grpclib
237
- import synchronicity
238
- import typer
239
-
240
- install(suppress=[synchronicity, grpclib, click, typer], extra_lines=1)
241
-
242
-
243
- def highlight_modal_deprecation_warnings() -> None:
244
- """Patch the warnings module to make client deprecation warnings more salient in the CLI."""
245
- base_showwarning = warnings.showwarning
246
-
247
- def showwarning(warning, category, filename, lineno, file=None, line=None):
248
- if issubclass(category, DeprecationError):
249
- content = str(warning)
250
- date = content[:10]
251
- message = content[11:].strip()
252
- try:
253
- with open(filename, "rt", encoding="utf-8", errors="replace") as code_file:
254
- source = code_file.readlines()[lineno - 1].strip()
255
- message = f"{message}\n\nSource: {filename}:{lineno}\n {source}"
256
- except OSError:
257
- # e.g., when filename is "<unknown>"; raises FileNotFoundError on posix but OSError on windows
258
- pass
259
- panel = Panel(
260
- message,
261
- style="yellow",
262
- title=f"Modal Deprecation Warning ({date})",
263
- title_align="left",
264
- )
265
- Console().print(panel)
266
- else:
267
- base_showwarning(warning, category, filename, lineno, file=None, line=None)
268
-
269
- warnings.showwarning = showwarning
106
+ def traceback_contains_remote_call(tb: Optional[TracebackType]) -> bool:
107
+ """Inspect the traceback stack to determine whether an error was raised locally or remotely."""
108
+ while tb is not None:
109
+ if re.match(r"^<ta-[0-9A-Z]{26}>:", tb.tb_frame.f_code.co_filename):
110
+ return True
111
+ tb = tb.tb_next
112
+ return False
113
+
114
+
115
+ def print_exception(exc: Optional[type[BaseException]], value: Optional[BaseException], tb: Optional[TracebackType]):
116
+ """Add backwards compatibility for printing exceptions with "notes" for Python<3.11."""
117
+ traceback.print_exception(exc, value, tb)
118
+ if sys.version_info < (3, 11) and value is not None:
119
+ notes = getattr(value, "__notes__", [])
120
+ print(*notes, sep="\n", file=sys.stderr)
121
+
122
+
123
+ def print_server_warnings(server_warnings: Iterable[api_pb2.Warning]):
124
+ """Issue a warning originating from the server with empty metadata about local origin.
125
+
126
+ When using the Modal CLI, these warnings should get caught and coerced into Rich panels.
127
+ """
128
+ for warning in server_warnings:
129
+ warnings.warn_explicit(warning.message, ServerWarning, "<modal-server>", 0)
modal/_tunnel.py CHANGED
@@ -1,8 +1,9 @@
1
1
  # Copyright Modal Labs 2023
2
2
  """Client for Modal relay servers, allowing users to expose TLS."""
3
3
 
4
+ from collections.abc import AsyncIterator
4
5
  from dataclasses import dataclass
5
- from typing import AsyncIterator, Optional, Tuple
6
+ from typing import Optional
6
7
 
7
8
  from grpclib import GRPCError, Status
8
9
  from synchronicity.async_wrap import asynccontextmanager
@@ -35,12 +36,12 @@ class Tunnel:
35
36
  return value
36
37
 
37
38
  @property
38
- def tls_socket(self) -> Tuple[str, int]:
39
+ def tls_socket(self) -> tuple[str, int]:
39
40
  """Get the public TLS socket as a (host, port) tuple."""
40
41
  return (self.host, self.port)
41
42
 
42
43
  @property
43
- def tcp_socket(self) -> Tuple[str, int]:
44
+ def tcp_socket(self) -> tuple[str, int]:
44
45
  """Get the public TCP socket as a (host, port) tuple."""
45
46
  if not self.unencrypted_host:
46
47
  raise InvalidError(
@@ -54,22 +55,22 @@ async def _forward(port: int, *, unencrypted: bool = False, client: Optional[_Cl
54
55
  """Expose a port publicly from inside a running Modal container, with TLS.
55
56
 
56
57
  If `unencrypted` is set, this also exposes the TCP socket without encryption on a random port
57
- number. This can be used to SSH into a container. Note that it is on the public Internet, so
58
+ number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
58
59
  make sure you are using a secure protocol over TCP.
59
60
 
60
61
  **Important:** This is an experimental API which may change in the future.
61
62
 
62
63
  **Usage:**
63
64
 
64
- ```python
65
+ ```python notest
66
+ import modal
65
67
  from flask import Flask
66
- from modal import Image, App, forward
67
68
 
68
- app = App(image=Image.debian_slim().pip_install("Flask")) # Note: "app" was called "stub" up until April 2024
69
- app = Flask(__name__)
69
+ app = modal.App(image=modal.Image.debian_slim().pip_install("Flask"))
70
+ flask_app = Flask(__name__)
70
71
 
71
72
 
72
- @app.route("/")
73
+ @flask_app.route("/")
73
74
  def hello_world():
74
75
  return "Hello, World!"
75
76
 
@@ -78,9 +79,9 @@ async def _forward(port: int, *, unencrypted: bool = False, client: Optional[_Cl
78
79
  def run_app():
79
80
  # Start a web server inside the container at port 8000. `modal.forward(8000)` lets us
80
81
  # expose that port to the world at a random HTTPS URL.
81
- with forward(8000) as tunnel:
82
+ with modal.forward(8000) as tunnel:
82
83
  print("Server listening at", tunnel.url)
83
- app.run("0.0.0.0", 8000)
84
+ flask_app.run("0.0.0.0", 8000)
84
85
 
85
86
  # When the context manager exits, the port is no longer exposed.
86
87
  ```
@@ -90,7 +91,8 @@ async def _forward(port: int, *, unencrypted: bool = False, client: Optional[_Cl
90
91
  ```python
91
92
  import socket
92
93
  import threading
93
- from modal import App, forward
94
+
95
+ import modal
94
96
 
95
97
 
96
98
  def run_echo_server(port: int):
@@ -115,17 +117,51 @@ async def _forward(port: int, *, unencrypted: bool = False, client: Optional[_Cl
115
117
  threading.Thread(target=handle, args=(conn,)).start()
116
118
 
117
119
 
118
- app = App() # Note: "app" was called "stub" up until April 2024
120
+ app = modal.App()
119
121
 
120
122
 
121
123
  @app.function()
122
124
  def tcp_tunnel():
123
125
  # This exposes port 8000 to public Internet traffic over TCP.
124
- with forward(8000, unencrypted=True) as tunnel:
126
+ with modal.forward(8000, unencrypted=True) as tunnel:
125
127
  # You can connect to this TCP socket from outside the container, for example, using `nc`:
126
128
  # nc <HOST> <PORT>
127
129
  print("TCP tunnel listening at:", tunnel.tcp_socket)
128
130
  run_echo_server(8000)
131
+ ```
132
+
133
+ **SSH example:**
134
+ This assumes you have a rsa keypair in `~/.ssh/id_rsa{.pub}`, this is a bare-bones example
135
+ letting you SSH into a Modal container.
136
+
137
+ ```python
138
+ import subprocess
139
+ import time
140
+
141
+ import modal
142
+
143
+ app = modal.App()
144
+ image = (
145
+ modal.Image.debian_slim()
146
+ .apt_install("openssh-server")
147
+ .run_commands("mkdir /run/sshd")
148
+ .copy_local_file("~/.ssh/id_rsa.pub", "/root/.ssh/authorized_keys")
149
+ )
150
+
151
+
152
+ @app.function(image=image, timeout=3600)
153
+ def some_function():
154
+ subprocess.Popen(["/usr/sbin/sshd", "-D", "-e"])
155
+ with modal.forward(port=22, unencrypted=True) as tunnel:
156
+ hostname, port = tunnel.tcp_socket
157
+ connection_cmd = f'ssh -p {port} root@{hostname}'
158
+ print(f"ssh into container using: {connection_cmd}")
159
+ time.sleep(3600) # keep alive for 1 hour or until killed
160
+ ```
161
+
162
+ If you intend to use this more generally, a suggestion is to put the subprocess and port
163
+ forwarding code in an `@enter` lifecycle method of an @app.cls, to only make a single
164
+ ssh server and port for each container (and not one for each input to the function).
129
165
  """
130
166
 
131
167
  if not isinstance(port, int):
modal/_tunnel.pyi CHANGED
@@ -10,45 +10,28 @@ class Tunnel:
10
10
  unencrypted_port: int
11
11
 
12
12
  @property
13
- def url(self) -> str:
14
- ...
15
-
13
+ def url(self) -> str: ...
16
14
  @property
17
- def tls_socket(self) -> typing.Tuple[str, int]:
18
- ...
19
-
15
+ def tls_socket(self) -> tuple[str, int]: ...
20
16
  @property
21
- def tcp_socket(self) -> typing.Tuple[str, int]:
22
- ...
23
-
24
- def __init__(self, host: str, port: int, unencrypted_host: str, unencrypted_port: int) -> None:
25
- ...
26
-
27
- def __repr__(self):
28
- ...
29
-
30
- def __eq__(self, other):
31
- ...
32
-
33
- def __setattr__(self, name, value):
34
- ...
35
-
36
- def __delattr__(self, name):
37
- ...
38
-
39
- def __hash__(self):
40
- ...
41
-
42
-
43
- def _forward(port: int, *, unencrypted: bool = False, client: typing.Union[modal.client._Client, None] = None) -> typing.AsyncContextManager[Tunnel]:
44
- ...
45
-
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): ...
24
+
25
+ def _forward(
26
+ port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client._Client] = None
27
+ ) -> typing.AsyncContextManager[Tunnel]: ...
46
28
 
47
29
  class __forward_spec(typing_extensions.Protocol):
48
- def __call__(self, port: int, *, unencrypted: bool = False, client: typing.Union[modal.client.Client, None] = None) -> synchronicity.combined_types.AsyncAndBlockingContextManager[Tunnel]:
49
- ...
50
-
51
- def aio(self, port: int, *, unencrypted: bool = False, client: typing.Union[modal.client.Client, None] = None) -> typing.AsyncContextManager[Tunnel]:
52
- ...
30
+ def __call__(
31
+ self, port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client.Client] = None
32
+ ) -> synchronicity.combined_types.AsyncAndBlockingContextManager[Tunnel]: ...
33
+ def aio(
34
+ self, port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client.Client] = None
35
+ ) -> typing.AsyncContextManager[Tunnel]: ...
53
36
 
54
37
  forward: __forward_spec
modal/_utils/app_utils.py CHANGED
@@ -1,17 +1,3 @@
1
- # Copyright Modal Labs 2022
2
- import re
3
-
4
- # https://www.rfc-editor.org/rfc/rfc1035
5
- subdomain_regex = re.compile("^(?![0-9]+$)(?!-)[a-z0-9-]{,63}(?<!-)$")
6
-
7
-
8
- def is_valid_subdomain_label(label: str):
9
- return subdomain_regex.match(label) is not None
10
-
11
-
12
- def replace_invalid_subdomain_chars(label: str):
13
- return re.sub("[^a-z0-9-]", "-", label.lower())
14
-
15
-
16
- def is_valid_app_name(name: str):
17
- return len(name) <= 64 and re.match("^[a-zA-Z0-9-_.]+$", name) is not None
1
+ # Copyright Modal Labs 2024
2
+ # Temporary shim as we use this in the server
3
+ from .name_utils import * # noqa