modal 0.67.43__py3-none-any.whl → 0.68.24__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 (52) hide show
  1. modal/__init__.py +2 -0
  2. modal/_container_entrypoint.py +4 -1
  3. modal/_ipython.py +3 -13
  4. modal/_runtime/asgi.py +4 -0
  5. modal/_runtime/container_io_manager.py +3 -0
  6. modal/_runtime/user_code_imports.py +17 -20
  7. modal/_traceback.py +16 -2
  8. modal/_utils/blob_utils.py +27 -92
  9. modal/_utils/bytes_io_segment_payload.py +97 -0
  10. modal/_utils/function_utils.py +5 -1
  11. modal/_utils/grpc_testing.py +6 -2
  12. modal/_utils/hash_utils.py +51 -10
  13. modal/_utils/http_utils.py +19 -10
  14. modal/_utils/{pattern_matcher.py → pattern_utils.py} +1 -70
  15. modal/_utils/shell_utils.py +11 -5
  16. modal/cli/_traceback.py +11 -4
  17. modal/cli/run.py +25 -12
  18. modal/client.py +6 -37
  19. modal/client.pyi +2 -6
  20. modal/cls.py +132 -62
  21. modal/cls.pyi +13 -7
  22. modal/exception.py +20 -0
  23. modal/file_io.py +380 -0
  24. modal/file_io.pyi +185 -0
  25. modal/file_pattern_matcher.py +121 -0
  26. modal/functions.py +33 -11
  27. modal/functions.pyi +11 -9
  28. modal/image.py +88 -8
  29. modal/image.pyi +20 -4
  30. modal/mount.py +49 -9
  31. modal/mount.pyi +19 -4
  32. modal/network_file_system.py +4 -1
  33. modal/object.py +4 -2
  34. modal/partial_function.py +22 -10
  35. modal/partial_function.pyi +10 -2
  36. modal/runner.py +5 -4
  37. modal/runner.pyi +2 -1
  38. modal/sandbox.py +40 -0
  39. modal/sandbox.pyi +18 -0
  40. modal/volume.py +5 -1
  41. {modal-0.67.43.dist-info → modal-0.68.24.dist-info}/METADATA +2 -2
  42. {modal-0.67.43.dist-info → modal-0.68.24.dist-info}/RECORD +52 -48
  43. modal_docs/gen_reference_docs.py +1 -0
  44. modal_proto/api.proto +33 -1
  45. modal_proto/api_pb2.py +813 -737
  46. modal_proto/api_pb2.pyi +160 -13
  47. modal_version/__init__.py +1 -1
  48. modal_version/_version_generated.py +1 -1
  49. {modal-0.67.43.dist-info → modal-0.68.24.dist-info}/LICENSE +0 -0
  50. {modal-0.67.43.dist-info → modal-0.68.24.dist-info}/WHEEL +0 -0
  51. {modal-0.67.43.dist-info → modal-0.68.24.dist-info}/entry_points.txt +0 -0
  52. {modal-0.67.43.dist-info → modal-0.68.24.dist-info}/top_level.txt +0 -0
@@ -1,18 +1,18 @@
1
1
  # Copyright Modal Labs 2022
2
2
  import contextlib
3
- import socket
4
- import ssl
5
- from typing import Optional
3
+ from typing import TYPE_CHECKING, Optional
6
4
 
7
- import certifi
8
- from aiohttp import ClientSession, ClientTimeout, TCPConnector
9
- from aiohttp.web import Application
10
- from aiohttp.web_runner import AppRunner, SockSite
5
+ # Note: importing aiohttp seems to take about 100ms, and it's not really necessarily,
6
+ # unless we need to work with blobs. So that's why we import it lazily instead.
7
+
8
+ if TYPE_CHECKING:
9
+ from aiohttp import ClientSession
10
+ from aiohttp.web import Application
11
11
 
12
12
  from .async_utils import on_shutdown
13
13
 
14
14
 
15
- def _http_client_with_tls(timeout: Optional[float]) -> ClientSession:
15
+ def _http_client_with_tls(timeout: Optional[float]) -> "ClientSession":
16
16
  """Create a new HTTP client session with standard, bundled TLS certificates.
17
17
 
18
18
  This is necessary to prevent client issues on some system where Python does
@@ -22,13 +22,18 @@ def _http_client_with_tls(timeout: Optional[float]) -> ClientSession:
22
22
  Specifically: the error "unable to get local issuer certificate" when making
23
23
  an aiohttp request.
24
24
  """
25
+ import ssl
26
+
27
+ import certifi
28
+ from aiohttp import ClientSession, ClientTimeout, TCPConnector
29
+
25
30
  ssl_context = ssl.create_default_context(cafile=certifi.where())
26
31
  connector = TCPConnector(ssl=ssl_context)
27
32
  return ClientSession(connector=connector, timeout=ClientTimeout(total=timeout))
28
33
 
29
34
 
30
35
  class ClientSessionRegistry:
31
- _client_session: ClientSession
36
+ _client_session: "ClientSession"
32
37
  _client_session_active: bool = False
33
38
 
34
39
  @staticmethod
@@ -47,9 +52,13 @@ class ClientSessionRegistry:
47
52
 
48
53
 
49
54
  @contextlib.asynccontextmanager
50
- async def run_temporary_http_server(app: Application):
55
+ async def run_temporary_http_server(app: "Application"):
51
56
  # Allocates a random port, runs a server in a context manager
52
57
  # This is used in various tests
58
+ import socket
59
+
60
+ from aiohttp.web_runner import AppRunner, SockSite
61
+
53
62
  sock = socket.socket()
54
63
  sock.bind(("", 0))
55
64
  port = sock.getsockname()[1]
@@ -5,7 +5,7 @@ This is the same pattern-matching logic used by Docker, except it is written in
5
5
  Python rather than Go. Also, the original Go library has a couple deprecated
6
6
  functions that we don't implement in this port.
7
7
 
8
- The main way to use this library is by constructing a `PatternMatcher` object,
8
+ The main way to use this library is by constructing a `FilePatternMatcher` object,
9
9
  then asking it whether file paths match any of its patterns.
10
10
  """
11
11
 
@@ -148,75 +148,6 @@ class Pattern:
148
148
  return False
149
149
 
150
150
 
151
- class PatternMatcher:
152
- """Allows checking paths against a list of patterns."""
153
-
154
- def __init__(self, patterns: list[str]) -> None:
155
- """Initialize a new PatternMatcher instance.
156
-
157
- Args:
158
- patterns (list): A list of pattern strings.
159
-
160
- Raises:
161
- ValueError: If an illegal exclusion pattern is provided.
162
- """
163
- self.patterns: list[Pattern] = []
164
- self.exclusions = False
165
- for pattern in patterns:
166
- pattern = pattern.strip()
167
- if not pattern:
168
- continue
169
- pattern = os.path.normpath(pattern)
170
- new_pattern = Pattern()
171
- if pattern[0] == "!":
172
- if len(pattern) == 1:
173
- raise ValueError('Illegal exclusion pattern: "!"')
174
- new_pattern.exclusion = True
175
- pattern = pattern[1:]
176
- self.exclusions = True
177
- # In Python, we can proceed without explicit syntax checking
178
- new_pattern.cleaned_pattern = pattern
179
- new_pattern.dirs = pattern.split(os.path.sep)
180
- self.patterns.append(new_pattern)
181
-
182
- def matches(self, file_path: str) -> bool:
183
- """Check if the file path or any of its parent directories match the patterns.
184
-
185
- This is equivalent to `MatchesOrParentMatches()` in the original Go
186
- library. The reason is that `Matches()` in the original library is
187
- deprecated due to buggy behavior.
188
- """
189
- matched = False
190
- file_path = os.path.normpath(file_path)
191
- if file_path == ".":
192
- # Don't let them exclude everything; kind of silly.
193
- return False
194
- parent_path = os.path.dirname(file_path)
195
- if parent_path == "":
196
- parent_path = "."
197
- parent_path_dirs = parent_path.split(os.path.sep)
198
-
199
- for pattern in self.patterns:
200
- # Skip evaluation based on current match status and pattern exclusion
201
- if pattern.exclusion != matched:
202
- continue
203
-
204
- match = pattern.match(file_path)
205
-
206
- if not match and parent_path != ".":
207
- # Check if the pattern matches any of the parent directories
208
- for i in range(len(parent_path_dirs)):
209
- dir_path = os.path.sep.join(parent_path_dirs[: i + 1])
210
- if pattern.match(dir_path):
211
- match = True
212
- break
213
-
214
- if match:
215
- matched = not pattern.exclusion
216
-
217
- return matched
218
-
219
-
220
151
  def read_ignorefile(reader: TextIO) -> list[str]:
221
152
  """Read an ignore file from a reader and return the list of file patterns to
222
153
  ignore, applying the following rules:
@@ -19,14 +19,20 @@ def write_to_fd(fd: int, data: bytes):
19
19
  future = loop.create_future()
20
20
 
21
21
  def try_write():
22
+ nonlocal data
22
23
  try:
23
24
  nbytes = os.write(fd, data)
24
- loop.remove_writer(fd)
25
- future.set_result(nbytes)
25
+ data = data[nbytes:]
26
+ if not data:
27
+ loop.remove_writer(fd)
28
+ future.set_result(None)
26
29
  except OSError as e:
27
- if e.errno != errno.EAGAIN:
28
- future.set_exception(e)
29
- raise
30
+ if e.errno == errno.EAGAIN:
31
+ # Wait for the next write notification
32
+ return
33
+ # Fail if it's not EAGAIN
34
+ loop.remove_writer(fd)
35
+ future.set_exception(e)
30
36
 
31
37
  loop.add_writer(fd, try_write)
32
38
  return future
modal/cli/_traceback.py CHANGED
@@ -1,5 +1,6 @@
1
1
  # Copyright Modal Labs 2024
2
2
  """Helper functions related to displaying tracebacks in the CLI."""
3
+
3
4
  import functools
4
5
  import re
5
6
  import warnings
@@ -11,7 +12,7 @@ from rich.syntax import Syntax
11
12
  from rich.text import Text
12
13
  from rich.traceback import PathHighlighter, Stack, Traceback, install
13
14
 
14
- from ..exception import DeprecationError, PendingDeprecationError
15
+ from ..exception import DeprecationError, PendingDeprecationError, ServerWarning
15
16
 
16
17
 
17
18
  @group()
@@ -165,7 +166,7 @@ def highlight_modal_deprecation_warnings() -> None:
165
166
  base_showwarning = warnings.showwarning
166
167
 
167
168
  def showwarning(warning, category, filename, lineno, file=None, line=None):
168
- if issubclass(category, (DeprecationError, PendingDeprecationError)):
169
+ if issubclass(category, (DeprecationError, PendingDeprecationError, ServerWarning)):
169
170
  content = str(warning)
170
171
  if re.match(r"^\d{4}-\d{2}-\d{2}", content):
171
172
  date = content[:10]
@@ -180,10 +181,16 @@ def highlight_modal_deprecation_warnings() -> None:
180
181
  except OSError:
181
182
  # e.g., when filename is "<unknown>"; raises FileNotFoundError on posix but OSError on windows
182
183
  pass
184
+ if issubclass(category, ServerWarning):
185
+ title = "Modal Warning"
186
+ else:
187
+ title = "Modal Deprecation Warning"
188
+ if date:
189
+ title += f" ({date})"
183
190
  panel = Panel(
184
191
  message,
185
- style="yellow",
186
- title=f"Modal Deprecation Warning ({date})" if date else "Modal Deprecation Warning",
192
+ border_style="yellow",
193
+ title=title,
187
194
  title_align="left",
188
195
  )
189
196
  Console().print(panel)
modal/cli/run.py CHANGED
@@ -13,8 +13,6 @@ from typing import Any, Callable, Optional, get_type_hints
13
13
 
14
14
  import click
15
15
  import typer
16
- from rich.console import Console
17
- from rich.panel import Panel
18
16
  from typing_extensions import TypedDict
19
17
 
20
18
  from .. import Cls
@@ -135,6 +133,18 @@ def _get_clean_app_description(func_ref: str) -> str:
135
133
  return " ".join(sys.argv)
136
134
 
137
135
 
136
+ def _write_local_result(result_path: str, res: Any):
137
+ if isinstance(res, str):
138
+ mode = "wt"
139
+ elif isinstance(res, bytes):
140
+ mode = "wb"
141
+ else:
142
+ res_type = type(res).__name__
143
+ raise InvalidError(f"Function must return str or bytes when using `--write-result`; got {res_type}.")
144
+ with open(result_path, mode) as fid:
145
+ fid.write(res)
146
+
147
+
138
148
  def _get_click_command_for_function(app: App, function_tag):
139
149
  function = app.registered_functions.get(function_tag)
140
150
  if not function or (isinstance(function, Function) and function.info.user_cls is not None):
@@ -179,7 +189,7 @@ def _get_click_command_for_function(app: App, function_tag):
179
189
  interactive=ctx.obj["interactive"],
180
190
  ):
181
191
  if cls is None:
182
- function.remote(**kwargs)
192
+ res = function.remote(**kwargs)
183
193
  else:
184
194
  # unpool class and method arguments
185
195
  # TODO(erikbern): this code is a bit hacky
@@ -188,7 +198,10 @@ def _get_click_command_for_function(app: App, function_tag):
188
198
 
189
199
  instance = cls(**cls_kwargs)
190
200
  method: Function = getattr(instance, method_name)
191
- method.remote(**fun_kwargs)
201
+ res = method.remote(**fun_kwargs)
202
+
203
+ if result_path := ctx.obj["result_path"]:
204
+ _write_local_result(result_path, res)
192
205
 
193
206
  with_click_options = _add_click_options(f, signature)
194
207
  return click.command(with_click_options)
@@ -216,12 +229,15 @@ def _get_click_command_for_local_entrypoint(app: App, entrypoint: LocalEntrypoin
216
229
  ):
217
230
  try:
218
231
  if isasync:
219
- asyncio.run(func(*args, **kwargs))
232
+ res = asyncio.run(func(*args, **kwargs))
220
233
  else:
221
- func(*args, **kwargs)
234
+ res = func(*args, **kwargs)
222
235
  except Exception as exc:
223
236
  raise _CliUserExecutionError(inspect.getsourcefile(func)) from exc
224
237
 
238
+ if result_path := ctx.obj["result_path"]:
239
+ _write_local_result(result_path, res)
240
+
225
241
  with_click_options = _add_click_options(f, _get_signature(func))
226
242
  return click.command(with_click_options)
227
243
 
@@ -250,12 +266,13 @@ class RunGroup(click.Group):
250
266
  cls=RunGroup,
251
267
  subcommand_metavar="FUNC_REF",
252
268
  )
269
+ @click.option("-w", "--write-result", help="Write return value (which must be str or bytes) to this local path.")
253
270
  @click.option("-q", "--quiet", is_flag=True, help="Don't show Modal progress indicators.")
254
271
  @click.option("-d", "--detach", is_flag=True, help="Don't stop the app if the local process dies or disconnects.")
255
272
  @click.option("-i", "--interactive", is_flag=True, help="Run the app in interactive mode.")
256
273
  @click.option("-e", "--env", help=ENV_OPTION_HELP, default=None)
257
274
  @click.pass_context
258
- def run(ctx, detach, quiet, interactive, env):
275
+ def run(ctx, write_result, detach, quiet, interactive, env):
259
276
  """Run a Modal function or local entrypoint.
260
277
 
261
278
  `FUNC_REF` should be of the format `{file or module}::{function name}`.
@@ -286,6 +303,7 @@ def run(ctx, detach, quiet, interactive, env):
286
303
  ```
287
304
  """
288
305
  ctx.ensure_object(dict)
306
+ ctx.obj["result_path"] = write_result
289
307
  ctx.obj["detach"] = detach # if subcommand would be a click command...
290
308
  ctx.obj["show_progress"] = False if quiet else True
291
309
  ctx.obj["interactive"] = interactive
@@ -308,11 +326,6 @@ def deploy(
308
326
 
309
327
  with enable_output():
310
328
  res = deploy_app(app, name=name, environment_name=env or "", tag=tag)
311
- if res.warnings:
312
- console = Console()
313
- for warning in res.warnings:
314
- panel = Panel(warning, title="Warning", title_align="left", border_style="yellow")
315
- console.print(panel, highlight=False)
316
329
 
317
330
  if stream_logs:
318
331
  stream_app_logs(app_id=res.app_id, app_logs_url=res.app_logs_url)
modal/client.py CHANGED
@@ -16,23 +16,21 @@ from typing import (
16
16
  import grpclib.client
17
17
  from google.protobuf import empty_pb2
18
18
  from google.protobuf.message import Message
19
- from grpclib import GRPCError, Status
20
19
  from synchronicity.async_wrap import asynccontextmanager
21
20
 
22
21
  from modal._utils.async_utils import synchronizer
23
22
  from modal_proto import api_grpc, api_pb2, modal_api_grpc
24
23
  from modal_version import __version__
25
24
 
25
+ from ._traceback import print_server_warnings
26
26
  from ._utils import async_utils
27
27
  from ._utils.async_utils import TaskContext, synchronize_api
28
28
  from ._utils.grpc_utils import connect_channel, create_channel, retry_transient_errors
29
29
  from .config import _check_config, _is_remote, config, logger
30
- from .exception import AuthError, ClientClosed, ConnectionError, DeprecationError, VersionError
30
+ from .exception import AuthError, ClientClosed, ConnectionError
31
31
 
32
32
  HEARTBEAT_INTERVAL: float = config.get("heartbeat_interval")
33
33
  HEARTBEAT_TIMEOUT: float = HEARTBEAT_INTERVAL + 0.1
34
- CLIENT_CREATE_ATTEMPT_TIMEOUT: float = 4.0
35
- CLIENT_CREATE_TOTAL_TIMEOUT: float = 15.0
36
34
 
37
35
 
38
36
  def _get_metadata(client_type: int, credentials: Optional[tuple[str, str]], version: str) -> dict[str, str]:
@@ -137,32 +135,11 @@ class _Client:
137
135
  async def hello(self):
138
136
  """Connect to server and retrieve version information; raise appropriate error for various failures."""
139
137
  logger.debug(f"Client ({id(self)}): Starting")
140
- try:
141
- req = empty_pb2.Empty()
142
- resp = await retry_transient_errors(
143
- self.stub.ClientHello,
144
- req,
145
- attempt_timeout=CLIENT_CREATE_ATTEMPT_TIMEOUT,
146
- total_timeout=CLIENT_CREATE_TOTAL_TIMEOUT,
147
- )
148
- if resp.warning:
149
- ALARM_EMOJI = chr(0x1F6A8)
150
- warnings.warn_explicit(f"{ALARM_EMOJI} {resp.warning} {ALARM_EMOJI}", DeprecationError, "<unknown>", 0)
151
- except GRPCError as exc:
152
- if exc.status == Status.FAILED_PRECONDITION:
153
- raise VersionError(
154
- f"The client version ({self.version}) is too old. Please update (pip install --upgrade modal)."
155
- )
156
- else:
157
- raise exc
138
+ resp = await retry_transient_errors(self.stub.ClientHello, empty_pb2.Empty())
139
+ print_server_warnings(resp.server_warnings)
158
140
 
159
141
  async def __aenter__(self):
160
142
  await self._open()
161
- try:
162
- await self.hello()
163
- except BaseException:
164
- await self._close()
165
- raise
166
143
  return self
167
144
 
168
145
  async def __aexit__(self, exc_type, exc, tb):
@@ -178,7 +155,6 @@ class _Client:
178
155
  client = cls(server_url, api_pb2.CLIENT_TYPE_CLIENT, credentials=None)
179
156
  try:
180
157
  await client._open()
181
- # Skip client.hello
182
158
  yield client
183
159
  finally:
184
160
  await client._close()
@@ -229,7 +205,6 @@ class _Client:
229
205
  client = _Client(server_url, client_type, credentials)
230
206
  await client._open()
231
207
  async_utils.on_shutdown(client._close())
232
- await client.hello()
233
208
  cls._client_from_env = client
234
209
  return client
235
210
 
@@ -252,11 +227,6 @@ class _Client:
252
227
  credentials = (token_id, token_secret)
253
228
  client = _Client(server_url, client_type, credentials)
254
229
  await client._open()
255
- try:
256
- await client.hello()
257
- except BaseException:
258
- await client._close()
259
- raise
260
230
  async_utils.on_shutdown(client._close())
261
231
  return client
262
232
 
@@ -265,8 +235,8 @@ class _Client:
265
235
  """mdmd:hidden
266
236
  Check whether can the client can connect to this server with these credentials; raise if not.
267
237
  """
268
- async with cls(server_url, api_pb2.CLIENT_TYPE_CLIENT, credentials):
269
- pass # Will call ClientHello RPC and possibly raise AuthError or ConnectionError
238
+ async with cls(server_url, api_pb2.CLIENT_TYPE_CLIENT, credentials) as client:
239
+ await client.hello() # Will call ClientHello RPC and possibly raise AuthError or ConnectionError
270
240
 
271
241
  @classmethod
272
242
  def set_env_client(cls, client: Optional["_Client"]):
@@ -316,7 +286,6 @@ class _Client:
316
286
  self.set_env_client(None)
317
287
  # TODO(elias): reset _cancellation_context in case ?
318
288
  await self._open()
319
- # intentionally not doing self.hello since we should already be authenticated etc.
320
289
 
321
290
  async def _get_grpclib_method(self, method_name: str) -> Any:
322
291
  # safely get grcplib method that is bound to a valid channel
modal/client.pyi CHANGED
@@ -26,7 +26,7 @@ class _Client:
26
26
  _stub: typing.Optional[modal_proto.api_grpc.ModalClientStub]
27
27
 
28
28
  def __init__(
29
- self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.67.43"
29
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.68.24"
30
30
  ): ...
31
31
  def is_closed(self) -> bool: ...
32
32
  @property
@@ -81,7 +81,7 @@ class Client:
81
81
  _stub: typing.Optional[modal_proto.api_grpc.ModalClientStub]
82
82
 
83
83
  def __init__(
84
- self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.67.43"
84
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.68.24"
85
85
  ): ...
86
86
  def is_closed(self) -> bool: ...
87
87
  @property
@@ -194,7 +194,3 @@ class UnaryStreamWrapper(typing.Generic[RequestType, ResponseType]):
194
194
  HEARTBEAT_INTERVAL: float
195
195
 
196
196
  HEARTBEAT_TIMEOUT: float
197
-
198
- CLIENT_CREATE_ATTEMPT_TIMEOUT: float
199
-
200
- CLIENT_CREATE_TOTAL_TIMEOUT: float