modal 0.68.42__py3-none-any.whl → 0.70.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.
@@ -464,7 +464,7 @@ def main(container_args: api_pb2.ContainerArguments, client: Client):
464
464
  batch_wait_ms = function_def.batch_linger_ms or 0
465
465
 
466
466
  # Get ids and metadata for objects (primarily functions and classes) on the app
467
- container_app: RunningApp = container_io_manager.get_app_objects()
467
+ container_app: RunningApp = container_io_manager.get_app_objects(container_args.app_layout)
468
468
 
469
469
  # Initialize objects on the app.
470
470
  # This is basically only functions and classes - anything else is deprecated and will be unsupported soon
modal/_runtime/asgi.py CHANGED
@@ -26,6 +26,7 @@ class LifespanManager:
26
26
  shutdown: asyncio.Future
27
27
  queue: asyncio.Queue
28
28
  has_run_init: bool = False
29
+ lifespan_supported: bool = False
29
30
 
30
31
  def __init__(self, asgi_app, state):
31
32
  self.asgi_app = asgi_app
@@ -46,6 +47,7 @@ class LifespanManager:
46
47
  await self.ensure_init()
47
48
 
48
49
  async def receive():
50
+ self.lifespan_supported = True
49
51
  return await self.queue.get()
50
52
 
51
53
  async def send(message):
@@ -63,16 +65,21 @@ class LifespanManager:
63
65
  try:
64
66
  await self.asgi_app({"type": "lifespan", "state": self.state}, receive, send)
65
67
  except Exception as e:
68
+ if not self.lifespan_supported:
69
+ logger.info(f"ASGI lifespan task exited before receiving any messages with exception:\n{e}")
70
+ self.startup.set_result(None)
71
+ self.shutdown.set_result(None)
72
+ return
73
+
66
74
  logger.error(f"Error in ASGI lifespan task: {e}")
67
75
  if not self.startup.done():
68
76
  self.startup.set_exception(ExecutionError("ASGI lifespan task exited startup"))
69
77
  if not self.shutdown.done():
70
78
  self.shutdown.set_exception(ExecutionError("ASGI lifespan task exited shutdown"))
71
79
  else:
72
- if not self.startup.done():
73
- self.startup.set_result("ASGI Lifespan protocol is probably not supported by this library")
74
- if not self.shutdown.done():
75
- self.shutdown.set_result("ASGI Lifespan protocol is probably not supported by this library")
80
+ logger.info("ASGI Lifespan protocol is probably not supported by this library")
81
+ self.startup.set_result(None)
82
+ self.shutdown.set_result(None)
76
83
 
77
84
  async def lifespan_startup(self):
78
85
  await self.ensure_init()
@@ -21,7 +21,6 @@ from typing import (
21
21
  )
22
22
 
23
23
  from google.protobuf.empty_pb2 import Empty
24
- from google.protobuf.message import Message
25
24
  from grpclib import Status
26
25
  from synchronicity.async_wrap import asynccontextmanager
27
26
 
@@ -31,12 +30,12 @@ from modal._traceback import extract_traceback, print_exception
31
30
  from modal._utils.async_utils import TaskContext, asyncify, synchronize_api, synchronizer
32
31
  from modal._utils.blob_utils import MAX_OBJECT_SIZE_BYTES, blob_download, blob_upload
33
32
  from modal._utils.function_utils import _stream_function_call_data
34
- from modal._utils.grpc_utils import get_proto_oneof, retry_transient_errors
33
+ from modal._utils.grpc_utils import retry_transient_errors
35
34
  from modal._utils.package_utils import parse_major_minor_version
36
35
  from modal.client import HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, _Client
37
36
  from modal.config import config, logger
38
37
  from modal.exception import ClientClosed, InputCancellation, InvalidError, SerializationError
39
- from modal.running_app import RunningApp
38
+ from modal.running_app import RunningApp, running_app_from_layout
40
39
  from modal_proto import api_pb2
41
40
 
42
41
  if TYPE_CHECKING:
@@ -450,25 +449,19 @@ class _ContainerIOManager:
450
449
 
451
450
  await asyncio.sleep(DYNAMIC_CONCURRENCY_INTERVAL_SECS)
452
451
 
453
- async def get_app_objects(self) -> RunningApp:
454
- req = api_pb2.AppGetObjectsRequest(app_id=self.app_id, include_unindexed=True, only_class_function=True)
455
- resp = await retry_transient_errors(self._client.stub.AppGetObjects, req)
456
- logger.debug(f"AppGetObjects received {len(resp.items)} objects for app {self.app_id}")
452
+ async def get_app_objects(self, app_layout: api_pb2.AppLayout) -> RunningApp:
453
+ if len(app_layout.objects) == 0:
454
+ # TODO(erikbern): this should never happen! let's keep it in here for a short second
455
+ # until we've sanity checked that this is, in fact, dead code.
456
+ req = api_pb2.AppGetLayoutRequest(app_id=self.app_id)
457
+ resp = await retry_transient_errors(self._client.stub.AppGetLayout, req)
458
+ app_layout = resp.app_layout
457
459
 
458
- tag_to_object_id = {}
459
- object_handle_metadata = {}
460
- for item in resp.items:
461
- handle_metadata: Optional[Message] = get_proto_oneof(item.object, "handle_metadata_oneof")
462
- object_handle_metadata[item.object.object_id] = handle_metadata
463
- if item.tag:
464
- tag_to_object_id[item.tag] = item.object.object_id
465
-
466
- return RunningApp(
460
+ return running_app_from_layout(
467
461
  self.app_id,
462
+ app_layout,
463
+ self._client,
468
464
  environment_name=self._environment_name,
469
- tag_to_object_id=tag_to_object_id,
470
- object_handle_metadata=object_handle_metadata,
471
- client=self._client,
472
465
  )
473
466
 
474
467
  async def get_serialized_function(self) -> tuple[Optional[Any], Optional[Callable[..., Any]]]:
@@ -0,0 +1,64 @@
1
+ # Copyright Modal Labs 2024
2
+ import re
3
+ import shlex
4
+ from typing import Sequence
5
+
6
+ from ..exception import InvalidError
7
+
8
+
9
+ def extract_copy_command_patterns(dockerfile_lines: Sequence[str]) -> list[str]:
10
+ """
11
+ Extract all COPY command sources from a Dockerfile.
12
+ Combines multiline COPY commands into a single line.
13
+ """
14
+ copy_source_patterns: set[str] = set()
15
+ current_command = ""
16
+ copy_pattern = re.compile(r"^\s*COPY\s+(.+)$", re.IGNORECASE)
17
+
18
+ # First pass: handle line continuations and collect full commands
19
+ for line in dockerfile_lines:
20
+ line = line.strip()
21
+ if not line or line.startswith("#"):
22
+ # ignore comments and empty lines
23
+ continue
24
+
25
+ if current_command:
26
+ # Continue previous line
27
+ current_command += " " + line.rstrip("\\").strip()
28
+ else:
29
+ # Start new command
30
+ current_command = line.rstrip("\\").strip()
31
+
32
+ if not line.endswith("\\"):
33
+ # Command is complete
34
+
35
+ match = copy_pattern.match(current_command)
36
+ if match:
37
+ args = match.group(1)
38
+ parts = shlex.split(args)
39
+
40
+ # COPY --from=... commands reference external sources and do not need a context mount.
41
+ # https://docs.docker.com/reference/dockerfile/#copy---from
42
+ if parts[0].startswith("--from="):
43
+ current_command = ""
44
+ continue
45
+
46
+ if len(parts) >= 2:
47
+ # Last part is destination, everything else is a mount source
48
+ sources = parts[:-1]
49
+
50
+ for source in sources:
51
+ special_pattern = re.compile(r"^\s*--|\$\s*")
52
+ if special_pattern.match(source):
53
+ raise InvalidError(
54
+ f"COPY command: {source} using special flags/arguments/variables are not supported"
55
+ )
56
+
57
+ if source == ".":
58
+ copy_source_patterns.add("./**")
59
+ else:
60
+ copy_source_patterns.add(source)
61
+
62
+ current_command = ""
63
+
64
+ return list(copy_source_patterns)
@@ -99,16 +99,18 @@ def get_function_type(is_generator: Optional[bool]) -> "api_pb2.Function.Functio
99
99
 
100
100
 
101
101
  class FunctionInfo:
102
- """Class that helps us extract a bunch of information about a locally defined function.
102
+ """Utility that determines serialization/deserialization mechanisms for functions
103
103
 
104
- Used for populating the definition of a remote function, and for making .local() calls
105
- on a host with the local definition available.
104
+ * Stored as file vs serialized
105
+ * If serialized: how to serialize the function
106
+ * If file: which module/function name should be used to retrieve
107
+
108
+ Used for populating the definition of a remote function
106
109
  """
107
110
 
108
111
  raw_f: Optional[Callable[..., Any]] # if None - this is a "class service function"
109
112
  function_name: str
110
113
  user_cls: Optional[type[Any]]
111
- definition_type: "modal_proto.api_pb2.Function.DefinitionType.ValueType"
112
114
  module_name: Optional[str]
113
115
 
114
116
  _type: FunctionInfoType
@@ -116,6 +118,12 @@ class FunctionInfo:
116
118
  _base_dir: str
117
119
  _remote_dir: Optional[PurePosixPath] = None
118
120
 
121
+ def get_definition_type(self) -> "modal_proto.api_pb2.Function.DefinitionType.ValueType":
122
+ if self.is_serialized():
123
+ return modal_proto.api_pb2.Function.DEFINITION_TYPE_SERIALIZED
124
+ else:
125
+ return modal_proto.api_pb2.Function.DEFINITION_TYPE_FILE
126
+
119
127
  def is_service_class(self):
120
128
  if self.raw_f is None:
121
129
  assert self.user_cls
@@ -172,7 +180,7 @@ class FunctionInfo:
172
180
  self._base_dir = base_dirs[0]
173
181
  self.module_name = module.__spec__.name
174
182
  self._remote_dir = ROOT_DIR / PurePosixPath(module.__package__.split(".")[0])
175
- self.definition_type = api_pb2.Function.DEFINITION_TYPE_FILE
183
+ self._is_serialized = False
176
184
  self._type = FunctionInfoType.PACKAGE
177
185
  elif hasattr(module, "__file__") and not serialized:
178
186
  # This generally covers the case where it's invoked with
@@ -182,18 +190,18 @@ class FunctionInfo:
182
190
  self._file = os.path.abspath(inspect.getfile(module))
183
191
  self.module_name = inspect.getmodulename(self._file)
184
192
  self._base_dir = os.path.dirname(self._file)
185
- self.definition_type = api_pb2.Function.DEFINITION_TYPE_FILE
193
+ self._is_serialized = False
186
194
  self._type = FunctionInfoType.FILE
187
195
  else:
188
196
  self.module_name = None
189
197
  self._base_dir = os.path.abspath("") # get current dir
190
- self.definition_type = api_pb2.Function.DEFINITION_TYPE_SERIALIZED
191
- if serialized:
198
+ self._is_serialized = True # either explicitly, or by being in a notebook
199
+ if serialized: # if explicit
192
200
  self._type = FunctionInfoType.SERIALIZED
193
201
  else:
194
202
  self._type = FunctionInfoType.NOTEBOOK
195
203
 
196
- if self.definition_type == api_pb2.Function.DEFINITION_TYPE_FILE:
204
+ if not self.is_serialized():
197
205
  # Sanity check that this function is defined in global scope
198
206
  # Unfortunately, there's no "clean" way to do this in Python
199
207
  qualname = f.__qualname__ if f else user_cls.__qualname__
@@ -203,7 +211,7 @@ class FunctionInfo:
203
211
  )
204
212
 
205
213
  def is_serialized(self) -> bool:
206
- return self.definition_type == api_pb2.Function.DEFINITION_TYPE_SERIALIZED
214
+ return self._is_serialized
207
215
 
208
216
  def serialized_function(self) -> bytes:
209
217
  # Note: this should only be called from .load() and not at function decoration time
@@ -312,7 +320,7 @@ class FunctionInfo:
312
320
  if self._type == FunctionInfoType.PACKAGE:
313
321
  if config.get("automount"):
314
322
  return [_Mount.from_local_python_packages(self.module_name)]
315
- elif self.definition_type == api_pb2.Function.DEFINITION_TYPE_FILE:
323
+ elif not self.is_serialized():
316
324
  # mount only relevant file and __init__.py:s
317
325
  return [
318
326
  _Mount.from_local_dir(
@@ -322,7 +330,7 @@ class FunctionInfo:
322
330
  condition=entrypoint_only_package_mount_condition(self._file),
323
331
  )
324
332
  ]
325
- elif self.definition_type == api_pb2.Function.DEFINITION_TYPE_FILE:
333
+ elif not self.is_serialized():
326
334
  remote_path = ROOT_DIR / Path(self._file).name
327
335
  if not _is_modal_path(remote_path):
328
336
  return [
@@ -570,12 +578,15 @@ class FunctionCreationStatus:
570
578
 
571
579
  elif self.response.function.web_url:
572
580
  url_info = self.response.function.web_url_info
581
+ requires_proxy_auth = self.response.function.webhook_config.requires_proxy_auth
582
+ proxy_auth_suffix = " 🔑" if requires_proxy_auth else ""
573
583
  # Ensure terms used here match terms used in modal.com/docs/guide/webhook-urls doc.
574
584
  suffix = _get_suffix_from_web_url_info(url_info)
575
585
  # TODO: this is only printed when we're showing progress. Maybe move this somewhere else.
576
586
  web_url = self.response.handle_metadata.web_url
577
587
  self.status_row.finish(
578
- f"Created web function {self.tag} => [magenta underline]{web_url}[/magenta underline]{suffix}"
588
+ f"Created web function {self.tag} => [magenta underline]{web_url}[/magenta underline]"
589
+ f"{proxy_auth_suffix}{suffix}"
579
590
  )
580
591
 
581
592
  # Print custom domain in terminal
modal/cli/launch.py CHANGED
@@ -77,6 +77,7 @@ def vscode(
77
77
  cpu: int = 8,
78
78
  memory: int = 32768,
79
79
  gpu: Optional[str] = None,
80
+ image: str = "debian:12",
80
81
  timeout: int = 3600,
81
82
  mount: Optional[str] = None, # Create a `modal.Mount` from a local directory.
82
83
  volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
@@ -86,6 +87,7 @@ def vscode(
86
87
  "cpu": cpu,
87
88
  "memory": memory,
88
89
  "gpu": gpu,
90
+ "image": image,
89
91
  "timeout": timeout,
90
92
  "mount": mount,
91
93
  "volume": volume,
@@ -15,9 +15,34 @@ from modal import App, Image, Mount, Queue, Secret, Volume, forward
15
15
  # Passed by `modal launch` locally via CLI, plumbed to remote runner through secrets.
16
16
  args: dict[str, Any] = json.loads(os.environ.get("MODAL_LAUNCH_ARGS", "{}"))
17
17
 
18
+ CODE_SERVER_INSTALLER = "https://code-server.dev/install.sh"
19
+ CODE_SERVER_ENTRYPOINT = (
20
+ "https://raw.githubusercontent.com/coder/code-server/refs/tags/v4.96.1/ci/release-image/entrypoint.sh"
21
+ )
22
+ FIXUD_INSTALLER = "https://github.com/boxboat/fixuid/releases/download/v0.6.0/fixuid-0.6.0-linux-$ARCH.tar.gz"
23
+
18
24
 
19
25
  app = App()
20
- app.image = Image.from_registry("codercom/code-server", add_python="3.11").dockerfile_commands("ENTRYPOINT []")
26
+ app.image = (
27
+ Image.from_registry(args.get("image"), add_python="3.11")
28
+ .apt_install("curl", "dumb-init", "git", "git-lfs")
29
+ .run_commands(
30
+ f"curl -fsSL {CODE_SERVER_INSTALLER} | sh",
31
+ f"curl -fsSL {CODE_SERVER_ENTRYPOINT} > /code-server.sh",
32
+ "chmod u+x /code-server.sh",
33
+ )
34
+ .run_commands(
35
+ 'ARCH="$(dpkg --print-architecture)"'
36
+ f' && curl -fsSL "{FIXUD_INSTALLER}" | tar -C /usr/local/bin -xzf - '
37
+ " && chown root:root /usr/local/bin/fixuid"
38
+ " && chmod 4755 /usr/local/bin/fixuid"
39
+ " && mkdir -p /etc/fixuid"
40
+ ' && echo "user: root" >> /etc/fixuid/config.yml'
41
+ ' && echo "group: root" >> /etc/fixuid/config.yml'
42
+ )
43
+ .run_commands("mkdir /home/coder")
44
+ .env({"ENTRYPOINTD": ""})
45
+ )
21
46
 
22
47
 
23
48
  mount = (
@@ -71,7 +96,7 @@ def run_vscode(q: Queue):
71
96
  url = tunnel.url
72
97
  threading.Thread(target=wait_for_port, args=((url, token), q)).start()
73
98
  subprocess.run(
74
- ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "."],
99
+ ["/code-server.sh", "--bind-addr", "0.0.0.0:8080", "."],
75
100
  env={**os.environ, "SHELL": "/bin/bash", "PASSWORD": token},
76
101
  )
77
102
  q.put("done")
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.68.42"
29
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.70.2"
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.68.42"
84
+ self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.70.2"
85
85
  ): ...
86
86
  def is_closed(self) -> bool: ...
87
87
  @property
modal/file_io.py CHANGED
@@ -18,7 +18,8 @@ from ._utils.grpc_utils import RETRYABLE_GRPC_STATUS_CODES
18
18
  from .client import _Client
19
19
  from .exception import FilesystemExecutionError, InvalidError
20
20
 
21
- LARGE_FILE_SIZE_LIMIT = 16 * 1024 * 1024 # 16 MiB
21
+ WRITE_CHUNK_SIZE = 16 * 1024 * 1024 # 16 MiB
22
+ WRITE_FILE_SIZE_LIMIT = 1024 * 1024 * 1024 # 1 GiB
22
23
  READ_FILE_SIZE_LIMIT = 100 * 1024 * 1024 # 100 MiB
23
24
 
24
25
  ERROR_MAPPING = {
@@ -77,7 +78,7 @@ async def _replace_bytes(file: "_FileIO", data: bytes, start: Optional[int] = No
77
78
  if start is not None and end is not None:
78
79
  if start >= end:
79
80
  raise InvalidError("start must be less than end")
80
- if len(data) > LARGE_FILE_SIZE_LIMIT:
81
+ if len(data) > WRITE_CHUNK_SIZE:
81
82
  raise InvalidError("Write request payload exceeds 16 MiB limit")
82
83
  resp = await file._make_request(
83
84
  api_pb2.ContainerFilesystemExecRequest(
@@ -288,15 +289,20 @@ class _FileIO(Generic[T]):
288
289
  self._validate_type(data)
289
290
  if isinstance(data, str):
290
291
  data = data.encode("utf-8")
291
- if len(data) > LARGE_FILE_SIZE_LIMIT:
292
- raise ValueError("Write request payload exceeds 16 MiB limit")
293
- resp = await self._make_request(
294
- api_pb2.ContainerFilesystemExecRequest(
295
- file_write_request=api_pb2.ContainerFileWriteRequest(file_descriptor=self._file_descriptor, data=data),
296
- task_id=self._task_id,
292
+ if len(data) > WRITE_FILE_SIZE_LIMIT:
293
+ raise ValueError("Write request payload exceeds 1 GiB limit")
294
+ for i in range(0, len(data), WRITE_CHUNK_SIZE):
295
+ chunk = data[i : i + WRITE_CHUNK_SIZE]
296
+ resp = await self._make_request(
297
+ api_pb2.ContainerFilesystemExecRequest(
298
+ file_write_request=api_pb2.ContainerFileWriteRequest(
299
+ file_descriptor=self._file_descriptor,
300
+ data=chunk,
301
+ ),
302
+ task_id=self._task_id,
303
+ )
297
304
  )
298
- )
299
- await self._wait(resp.exec_id)
305
+ await self._wait(resp.exec_id)
300
306
 
301
307
  async def flush(self) -> None:
302
308
  """Flush the buffer to disk."""
@@ -12,7 +12,7 @@ then asking it whether file paths match any of its patterns.
12
12
  import os
13
13
  from abc import abstractmethod
14
14
  from pathlib import Path
15
- from typing import Callable, Optional
15
+ from typing import Callable, Optional, Sequence, Union
16
16
 
17
17
  from ._utils.pattern_utils import Pattern
18
18
 
@@ -152,3 +152,13 @@ class FilePatternMatcher(_AbstractPatternMatcher):
152
152
  # with_repr allows us to use this matcher as a default value in a function signature
153
153
  # and get a nice repr in the docs and auto-generated type stubs:
154
154
  NON_PYTHON_FILES = (~FilePatternMatcher("**/*.py")).with_repr(f"{__name__}.NON_PYTHON_FILES")
155
+ _NOTHING = (~FilePatternMatcher()).with_repr(f"{__name__}._NOTHING") # match everything = ignore nothing
156
+
157
+
158
+ def _ignore_fn(ignore: Union[Sequence[str], Callable[[Path], bool]]) -> Callable[[Path], bool]:
159
+ # if a callable is passed, return it
160
+ # otherwise, treat input as a sequence of patterns and return a callable pattern matcher for those
161
+ if callable(ignore):
162
+ return ignore
163
+
164
+ return FilePatternMatcher(*ignore)
modal/functions.py CHANGED
@@ -753,7 +753,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
753
753
  mount_ids=loaded_mount_ids,
754
754
  secret_ids=[secret.object_id for secret in secrets],
755
755
  image_id=(image.object_id if image else ""),
756
- definition_type=info.definition_type,
756
+ definition_type=info.get_definition_type(),
757
757
  function_serialized=function_serialized or b"",
758
758
  class_serialized=class_serialized or b"",
759
759
  function_type=function_type,
modal/functions.pyi CHANGED
@@ -462,11 +462,11 @@ class Function(typing.Generic[P, ReturnType, OriginalReturnType], modal.object.O
462
462
 
463
463
  _call_generator_nowait: ___call_generator_nowait_spec
464
464
 
465
- class __remote_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
465
+ class __remote_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
466
466
  def __call__(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER: ...
467
467
  async def aio(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER: ...
468
468
 
469
- remote: __remote_spec[P, ReturnType]
469
+ remote: __remote_spec[ReturnType, P]
470
470
 
471
471
  class __remote_gen_spec(typing_extensions.Protocol):
472
472
  def __call__(self, *args, **kwargs) -> typing.Generator[typing.Any, None, None]: ...
@@ -479,17 +479,17 @@ class Function(typing.Generic[P, ReturnType, OriginalReturnType], modal.object.O
479
479
  def _get_obj(self) -> typing.Optional[modal.cls.Obj]: ...
480
480
  def local(self, *args: P.args, **kwargs: P.kwargs) -> OriginalReturnType: ...
481
481
 
482
- class ___experimental_spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
482
+ class ___experimental_spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
483
483
  def __call__(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
484
484
  async def aio(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
485
485
 
486
- _experimental_spawn: ___experimental_spawn_spec[P, ReturnType]
486
+ _experimental_spawn: ___experimental_spawn_spec[ReturnType, P]
487
487
 
488
- class __spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
488
+ class __spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
489
489
  def __call__(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
490
490
  async def aio(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
491
491
 
492
- spawn: __spawn_spec[P, ReturnType]
492
+ spawn: __spawn_spec[ReturnType, P]
493
493
 
494
494
  def get_raw_f(self) -> typing.Callable[..., typing.Any]: ...
495
495
 
modal/gpu.py CHANGED
@@ -137,6 +137,27 @@ class H100(_GPUConfig):
137
137
  return f"GPU(H100, count={self.count})"
138
138
 
139
139
 
140
+ class L40S(_GPUConfig):
141
+ """
142
+ [NVIDIA L40S](https://www.nvidia.com/en-us/data-center/l40s/) GPU class.
143
+
144
+ The L40S is a data center GPU for the Ada Lovelace architecture. It has 48 GB of on-chip
145
+ GDDR6 RAM and enhanced support for FP8 precision.
146
+ """
147
+
148
+ def __init__(
149
+ self,
150
+ *,
151
+ # Number of GPUs per container. Defaults to 1.
152
+ # Useful if you have very large models that don't fit on a single GPU.
153
+ count: int = 1,
154
+ ):
155
+ super().__init__(api_pb2.GPU_TYPE_L40S, count)
156
+
157
+ def __repr__(self):
158
+ return f"GPU(L40S, count={self.count})"
159
+
160
+
140
161
  class Any(_GPUConfig):
141
162
  """Selects any one of the GPU classes available within Modal, according to availability."""
142
163
 
@@ -154,6 +175,7 @@ STRING_TO_GPU_CONFIG: dict[str, Callable] = {
154
175
  "a100-80gb": lambda: A100(size="80GB"),
155
176
  "h100": H100,
156
177
  "a10g": A10G,
178
+ "l40s": L40S,
157
179
  "any": Any,
158
180
  }
159
181
  display_string_to_config = "\n".join(f'- "{key}" → `{c()}`' for key, c in STRING_TO_GPU_CONFIG.items() if key != "inf2")