modal 0.73.2__py3-none-any.whl → 0.73.3__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.
modal/_functions.py ADDED
@@ -0,0 +1,1602 @@
1
+ # Copyright Modal Labs 2023
2
+ import dataclasses
3
+ import inspect
4
+ import textwrap
5
+ import time
6
+ import typing
7
+ import warnings
8
+ from collections.abc import AsyncGenerator, Collection, Sequence, Sized
9
+ from dataclasses import dataclass
10
+ from pathlib import PurePosixPath
11
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Union
12
+
13
+ import typing_extensions
14
+ from google.protobuf.message import Message
15
+ from grpclib import GRPCError, Status
16
+ from synchronicity.combined_types import MethodWithAio
17
+ from synchronicity.exceptions import UserCodeException
18
+
19
+ from modal_proto import api_pb2
20
+ from modal_proto.modal_api_grpc import ModalClientModal
21
+
22
+ from ._location import parse_cloud_provider
23
+ from ._object import _get_environment_name, _Object, live_method, live_method_gen
24
+ from ._pty import get_pty_info
25
+ from ._resolver import Resolver
26
+ from ._resources import convert_fn_config_to_resources_config
27
+ from ._runtime.execution_context import current_input_id, is_local
28
+ from ._serialization import serialize, serialize_proto_params
29
+ from ._traceback import print_server_warnings
30
+ from ._utils.async_utils import (
31
+ TaskContext,
32
+ aclosing,
33
+ async_merge,
34
+ callable_to_agen,
35
+ synchronizer,
36
+ warn_if_generator_is_not_consumed,
37
+ )
38
+ from ._utils.deprecation import deprecation_warning, renamed_parameter
39
+ from ._utils.function_utils import (
40
+ ATTEMPT_TIMEOUT_GRACE_PERIOD,
41
+ OUTPUTS_TIMEOUT,
42
+ FunctionCreationStatus,
43
+ FunctionInfo,
44
+ IncludeSourceMode,
45
+ _create_input,
46
+ _process_result,
47
+ _stream_function_call_data,
48
+ get_function_type,
49
+ get_include_source_mode,
50
+ is_async,
51
+ )
52
+ from ._utils.grpc_utils import retry_transient_errors
53
+ from ._utils.mount_utils import validate_network_file_systems, validate_volumes
54
+ from .call_graph import InputInfo, _reconstruct_call_graph
55
+ from .client import _Client
56
+ from .cloud_bucket_mount import _CloudBucketMount, cloud_bucket_mounts_to_proto
57
+ from .config import config
58
+ from .exception import (
59
+ ExecutionError,
60
+ FunctionTimeoutError,
61
+ InternalFailure,
62
+ InvalidError,
63
+ NotFoundError,
64
+ OutputExpiredError,
65
+ )
66
+ from .gpu import GPU_T, parse_gpu_config
67
+ from .image import _Image
68
+ from .mount import _get_client_mount, _Mount, get_auto_mounts
69
+ from .network_file_system import _NetworkFileSystem, network_file_system_mount_protos
70
+ from .output import _get_output_manager
71
+ from .parallel_map import (
72
+ _for_each_async,
73
+ _for_each_sync,
74
+ _map_async,
75
+ _map_invocation,
76
+ _map_sync,
77
+ _starmap_async,
78
+ _starmap_sync,
79
+ _SynchronizedQueue,
80
+ )
81
+ from .proxy import _Proxy
82
+ from .retries import Retries, RetryManager
83
+ from .schedule import Schedule
84
+ from .scheduler_placement import SchedulerPlacement
85
+ from .secret import _Secret
86
+ from .volume import _Volume
87
+
88
+ if TYPE_CHECKING:
89
+ import modal.app
90
+ import modal.cls
91
+ import modal.partial_function
92
+
93
+
94
+ @dataclasses.dataclass
95
+ class _RetryContext:
96
+ function_call_invocation_type: "api_pb2.FunctionCallInvocationType.ValueType"
97
+ retry_policy: api_pb2.FunctionRetryPolicy
98
+ function_call_jwt: str
99
+ input_jwt: str
100
+ input_id: str
101
+ item: api_pb2.FunctionPutInputsItem
102
+
103
+
104
+ class _Invocation:
105
+ """Internal client representation of a single-input call to a Modal Function or Generator"""
106
+
107
+ stub: ModalClientModal
108
+
109
+ def __init__(
110
+ self,
111
+ stub: ModalClientModal,
112
+ function_call_id: str,
113
+ client: _Client,
114
+ retry_context: Optional[_RetryContext] = None,
115
+ ):
116
+ self.stub = stub
117
+ self.client = client # Used by the deserializer.
118
+ self.function_call_id = function_call_id # TODO: remove and use only input_id
119
+ self._retry_context = retry_context
120
+
121
+ @staticmethod
122
+ async def create(
123
+ function: "_Function",
124
+ args,
125
+ kwargs,
126
+ *,
127
+ client: _Client,
128
+ function_call_invocation_type: "api_pb2.FunctionCallInvocationType.ValueType",
129
+ ) -> "_Invocation":
130
+ assert client.stub
131
+ function_id = function.object_id
132
+ item = await _create_input(args, kwargs, client, method_name=function._use_method_name)
133
+
134
+ request = api_pb2.FunctionMapRequest(
135
+ function_id=function_id,
136
+ parent_input_id=current_input_id() or "",
137
+ function_call_type=api_pb2.FUNCTION_CALL_TYPE_UNARY,
138
+ pipelined_inputs=[item],
139
+ function_call_invocation_type=function_call_invocation_type,
140
+ )
141
+ response = await retry_transient_errors(client.stub.FunctionMap, request)
142
+ function_call_id = response.function_call_id
143
+
144
+ if response.pipelined_inputs:
145
+ assert len(response.pipelined_inputs) == 1
146
+ input = response.pipelined_inputs[0]
147
+ retry_context = _RetryContext(
148
+ function_call_invocation_type=function_call_invocation_type,
149
+ retry_policy=response.retry_policy,
150
+ function_call_jwt=response.function_call_jwt,
151
+ input_jwt=input.input_jwt,
152
+ input_id=input.input_id,
153
+ item=item,
154
+ )
155
+ return _Invocation(client.stub, function_call_id, client, retry_context)
156
+
157
+ request_put = api_pb2.FunctionPutInputsRequest(
158
+ function_id=function_id, inputs=[item], function_call_id=function_call_id
159
+ )
160
+ inputs_response: api_pb2.FunctionPutInputsResponse = await retry_transient_errors(
161
+ client.stub.FunctionPutInputs,
162
+ request_put,
163
+ )
164
+ processed_inputs = inputs_response.inputs
165
+ if not processed_inputs:
166
+ raise Exception("Could not create function call - the input queue seems to be full")
167
+ input = inputs_response.inputs[0]
168
+ retry_context = _RetryContext(
169
+ function_call_invocation_type=function_call_invocation_type,
170
+ retry_policy=response.retry_policy,
171
+ function_call_jwt=response.function_call_jwt,
172
+ input_jwt=input.input_jwt,
173
+ input_id=input.input_id,
174
+ item=item,
175
+ )
176
+ return _Invocation(client.stub, function_call_id, client, retry_context)
177
+
178
+ async def pop_function_call_outputs(
179
+ self, timeout: Optional[float], clear_on_success: bool, input_jwts: Optional[list[str]] = None
180
+ ) -> api_pb2.FunctionGetOutputsResponse:
181
+ t0 = time.time()
182
+ if timeout is None:
183
+ backend_timeout = OUTPUTS_TIMEOUT
184
+ else:
185
+ backend_timeout = min(OUTPUTS_TIMEOUT, timeout) # refresh backend call every 55s
186
+
187
+ while True:
188
+ # always execute at least one poll for results, regardless if timeout is 0
189
+ request = api_pb2.FunctionGetOutputsRequest(
190
+ function_call_id=self.function_call_id,
191
+ timeout=backend_timeout,
192
+ last_entry_id="0-0",
193
+ clear_on_success=clear_on_success,
194
+ requested_at=time.time(),
195
+ input_jwts=input_jwts,
196
+ )
197
+ response: api_pb2.FunctionGetOutputsResponse = await retry_transient_errors(
198
+ self.stub.FunctionGetOutputs,
199
+ request,
200
+ attempt_timeout=backend_timeout + ATTEMPT_TIMEOUT_GRACE_PERIOD,
201
+ )
202
+
203
+ if len(response.outputs) > 0:
204
+ return response
205
+
206
+ if timeout is not None:
207
+ # update timeout in retry loop
208
+ backend_timeout = min(OUTPUTS_TIMEOUT, t0 + timeout - time.time())
209
+ if backend_timeout < 0:
210
+ # return the last response to check for state of num_unfinished_inputs
211
+ return response
212
+
213
+ async def _retry_input(self) -> None:
214
+ ctx = self._retry_context
215
+ if not ctx:
216
+ raise ValueError("Cannot retry input when _retry_context is empty.")
217
+
218
+ item = api_pb2.FunctionRetryInputsItem(input_jwt=ctx.input_jwt, input=ctx.item.input)
219
+ request = api_pb2.FunctionRetryInputsRequest(function_call_jwt=ctx.function_call_jwt, inputs=[item])
220
+ await retry_transient_errors(
221
+ self.client.stub.FunctionRetryInputs,
222
+ request,
223
+ )
224
+
225
+ async def _get_single_output(self, expected_jwt: Optional[str] = None) -> Any:
226
+ # waits indefinitely for a single result for the function, and clear the outputs buffer after
227
+ item: api_pb2.FunctionGetOutputsItem = (
228
+ await self.pop_function_call_outputs(
229
+ timeout=None,
230
+ clear_on_success=True,
231
+ input_jwts=[expected_jwt] if expected_jwt else None,
232
+ )
233
+ ).outputs[0]
234
+ return await _process_result(item.result, item.data_format, self.stub, self.client)
235
+
236
+ async def run_function(self) -> Any:
237
+ # Use retry logic only if retry policy is specified and
238
+ ctx = self._retry_context
239
+ if (
240
+ not ctx
241
+ or not ctx.retry_policy
242
+ or ctx.retry_policy.retries == 0
243
+ or ctx.function_call_invocation_type != api_pb2.FUNCTION_CALL_INVOCATION_TYPE_SYNC
244
+ ):
245
+ return await self._get_single_output()
246
+
247
+ # User errors including timeouts are managed by the user specified retry policy.
248
+ user_retry_manager = RetryManager(ctx.retry_policy)
249
+
250
+ while True:
251
+ try:
252
+ return await self._get_single_output(ctx.input_jwt)
253
+ except (UserCodeException, FunctionTimeoutError) as exc:
254
+ await user_retry_manager.raise_or_sleep(exc)
255
+ except InternalFailure:
256
+ # For system failures on the server, we retry immediately.
257
+ pass
258
+ await self._retry_input()
259
+
260
+ async def poll_function(self, timeout: Optional[float] = None):
261
+ """Waits up to timeout for a result from a function.
262
+
263
+ If timeout is `None`, waits indefinitely. This function is not
264
+ cancellation-safe.
265
+ """
266
+ response: api_pb2.FunctionGetOutputsResponse = await self.pop_function_call_outputs(
267
+ timeout=timeout, clear_on_success=False
268
+ )
269
+ if len(response.outputs) == 0 and response.num_unfinished_inputs == 0:
270
+ # if no unfinished inputs and no outputs, then function expired
271
+ raise OutputExpiredError()
272
+ elif len(response.outputs) == 0:
273
+ raise TimeoutError()
274
+
275
+ return await _process_result(
276
+ response.outputs[0].result, response.outputs[0].data_format, self.stub, self.client
277
+ )
278
+
279
+ async def run_generator(self):
280
+ items_received = 0
281
+ items_total: Union[int, None] = None # populated when self.run_function() completes
282
+ async with aclosing(
283
+ async_merge(
284
+ _stream_function_call_data(self.client, self.function_call_id, variant="data_out"),
285
+ callable_to_agen(self.run_function),
286
+ )
287
+ ) as streamer:
288
+ async for item in streamer:
289
+ if isinstance(item, api_pb2.GeneratorDone):
290
+ items_total = item.items_total
291
+ else:
292
+ yield item
293
+ items_received += 1
294
+ # The comparison avoids infinite loops if a non-deterministic generator is retried
295
+ # and produces less data in the second run than what was already sent.
296
+ if items_total is not None and items_received >= items_total:
297
+ break
298
+
299
+
300
+ # Wrapper type for api_pb2.FunctionStats
301
+ @dataclass(frozen=True)
302
+ class FunctionStats:
303
+ """Simple data structure storing stats for a running function."""
304
+
305
+ backlog: int
306
+ num_total_runners: int
307
+
308
+ def __getattr__(self, name):
309
+ if name == "num_active_runners":
310
+ msg = (
311
+ "'FunctionStats.num_active_runners' is deprecated."
312
+ " It currently always has a value of 0,"
313
+ " but it will be removed in a future release."
314
+ )
315
+ deprecation_warning((2024, 6, 14), msg)
316
+ return 0
317
+ raise AttributeError(f"'FunctionStats' object has no attribute '{name}'")
318
+
319
+
320
+ def _parse_retries(
321
+ retries: Optional[Union[int, Retries]],
322
+ source: str = "",
323
+ ) -> Optional[api_pb2.FunctionRetryPolicy]:
324
+ if isinstance(retries, int):
325
+ return Retries(
326
+ max_retries=retries,
327
+ initial_delay=1.0,
328
+ backoff_coefficient=1.0,
329
+ )._to_proto()
330
+ elif isinstance(retries, Retries):
331
+ return retries._to_proto()
332
+ elif retries is None:
333
+ return None
334
+ else:
335
+ extra = f" on {source}" if source else ""
336
+ msg = f"Retries parameter must be an integer or instance of modal.Retries. Found: {type(retries)}{extra}."
337
+ raise InvalidError(msg)
338
+
339
+
340
+ @dataclass
341
+ class _FunctionSpec:
342
+ """
343
+ Stores information about a Function specification.
344
+ This is used for `modal shell` to support running shells with
345
+ the same configuration as a user-defined Function.
346
+ """
347
+
348
+ image: Optional[_Image]
349
+ mounts: Sequence[_Mount]
350
+ secrets: Sequence[_Secret]
351
+ network_file_systems: dict[Union[str, PurePosixPath], _NetworkFileSystem]
352
+ volumes: dict[Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]]
353
+ gpus: Union[GPU_T, list[GPU_T]] # TODO(irfansharif): Somehow assert that it's the first kind, in sandboxes
354
+ cloud: Optional[str]
355
+ cpu: Optional[Union[float, tuple[float, float]]]
356
+ memory: Optional[Union[int, tuple[int, int]]]
357
+ ephemeral_disk: Optional[int]
358
+ scheduler_placement: Optional[SchedulerPlacement]
359
+ proxy: Optional[_Proxy]
360
+
361
+
362
+ P = typing_extensions.ParamSpec("P")
363
+ ReturnType = typing.TypeVar("ReturnType", covariant=True)
364
+ OriginalReturnType = typing.TypeVar(
365
+ "OriginalReturnType", covariant=True
366
+ ) # differs from return type if ReturnType is coroutine
367
+
368
+
369
+ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type_prefix="fu"):
370
+ """Functions are the basic units of serverless execution on Modal.
371
+
372
+ Generally, you will not construct a `Function` directly. Instead, use the
373
+ `App.function()` decorator to register your Python functions with your App.
374
+ """
375
+
376
+ # TODO: more type annotations
377
+ _info: Optional[FunctionInfo]
378
+ _serve_mounts: frozenset[_Mount] # set at load time, only by loader
379
+ _app: Optional["modal.app._App"] = None
380
+ _obj: Optional["modal.cls._Obj"] = None # only set for InstanceServiceFunctions and bound instance methods
381
+
382
+ _webhook_config: Optional[api_pb2.WebhookConfig] = None # this is set in definition scope, only locally
383
+ _web_url: Optional[str] # this is set on hydration
384
+
385
+ _function_name: Optional[str]
386
+ _is_method: bool
387
+ _spec: Optional[_FunctionSpec] = None
388
+ _tag: str
389
+ _raw_f: Optional[Callable[..., Any]] # this is set to None for a "class service [function]"
390
+ _build_args: dict
391
+
392
+ _is_generator: Optional[bool] = None
393
+ _cluster_size: Optional[int] = None
394
+
395
+ # when this is the method of a class/object function, invocation of this function
396
+ # should supply the method name in the FunctionInput:
397
+ _use_method_name: str = ""
398
+
399
+ _class_parameter_info: Optional["api_pb2.ClassParameterInfo"] = None
400
+ _method_handle_metadata: Optional[dict[str, "api_pb2.FunctionHandleMetadata"]] = None
401
+
402
+ def _bind_method(
403
+ self,
404
+ user_cls,
405
+ method_name: str,
406
+ partial_function: "modal.partial_function._PartialFunction",
407
+ ):
408
+ """mdmd:hidden
409
+
410
+ Creates a _Function that is bound to a specific class method name. This _Function is not uniquely tied
411
+ to any backend function -- its object_id is the function ID of the class service function.
412
+
413
+ """
414
+ class_service_function = self
415
+ assert class_service_function._info # has to be a local function to be able to "bind" it
416
+ assert not class_service_function._is_method # should not be used on an already bound method placeholder
417
+ assert not class_service_function._obj # should only be used on base function / class service function
418
+ full_name = f"{user_cls.__name__}.{method_name}"
419
+
420
+ rep = f"Method({full_name})"
421
+ fun = _Object.__new__(_Function)
422
+ fun._init(rep)
423
+ fun._tag = full_name
424
+ fun._raw_f = partial_function.raw_f
425
+ fun._info = FunctionInfo(
426
+ partial_function.raw_f, user_cls=user_cls, serialized=class_service_function.info.is_serialized()
427
+ ) # needed for .local()
428
+ fun._use_method_name = method_name
429
+ fun._app = class_service_function._app
430
+ fun._is_generator = partial_function.is_generator
431
+ fun._cluster_size = partial_function.cluster_size
432
+ fun._spec = class_service_function._spec
433
+ fun._is_method = True
434
+ return fun
435
+
436
+ @staticmethod
437
+ def from_args(
438
+ info: FunctionInfo,
439
+ app,
440
+ image: _Image,
441
+ secrets: Sequence[_Secret] = (),
442
+ schedule: Optional[Schedule] = None,
443
+ is_generator: bool = False,
444
+ gpu: Union[GPU_T, list[GPU_T]] = None,
445
+ # TODO: maybe break this out into a separate decorator for notebooks.
446
+ mounts: Collection[_Mount] = (),
447
+ network_file_systems: dict[Union[str, PurePosixPath], _NetworkFileSystem] = {},
448
+ allow_cross_region_volumes: bool = False,
449
+ volumes: dict[Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]] = {},
450
+ webhook_config: Optional[api_pb2.WebhookConfig] = None,
451
+ memory: Optional[Union[int, tuple[int, int]]] = None,
452
+ proxy: Optional[_Proxy] = None,
453
+ retries: Optional[Union[int, Retries]] = None,
454
+ timeout: Optional[int] = None,
455
+ concurrency_limit: Optional[int] = None,
456
+ allow_concurrent_inputs: Optional[int] = None,
457
+ batch_max_size: Optional[int] = None,
458
+ batch_wait_ms: Optional[int] = None,
459
+ container_idle_timeout: Optional[int] = None,
460
+ cpu: Optional[Union[float, tuple[float, float]]] = None,
461
+ keep_warm: Optional[int] = None, # keep_warm=True is equivalent to keep_warm=1
462
+ cloud: Optional[str] = None,
463
+ scheduler_placement: Optional[SchedulerPlacement] = None,
464
+ is_builder_function: bool = False,
465
+ is_auto_snapshot: bool = False,
466
+ enable_memory_snapshot: bool = False,
467
+ block_network: bool = False,
468
+ i6pn_enabled: bool = False,
469
+ cluster_size: Optional[int] = None, # Experimental: Clustered functions
470
+ max_inputs: Optional[int] = None,
471
+ ephemeral_disk: Optional[int] = None,
472
+ # current default: first-party, future default: main-package
473
+ include_source: Optional[bool] = None,
474
+ _experimental_buffer_containers: Optional[int] = None,
475
+ _experimental_proxy_ip: Optional[str] = None,
476
+ _experimental_custom_scaling_factor: Optional[float] = None,
477
+ ) -> "_Function":
478
+ """mdmd:hidden"""
479
+ # Needed to avoid circular imports
480
+ from .partial_function import _find_partial_methods_for_user_cls, _PartialFunctionFlags
481
+
482
+ tag = info.get_tag()
483
+
484
+ if info.raw_f:
485
+ raw_f = info.raw_f
486
+ assert callable(raw_f)
487
+ if schedule is not None and not info.is_nullary():
488
+ raise InvalidError(
489
+ f"Function {raw_f} has a schedule, so it needs to support being called with no arguments"
490
+ )
491
+ else:
492
+ # must be a "class service function"
493
+ assert info.user_cls
494
+ assert not webhook_config
495
+ assert not schedule
496
+
497
+ explicit_mounts = mounts
498
+
499
+ if is_local():
500
+ include_source_mode = get_include_source_mode(include_source)
501
+ if include_source_mode != IncludeSourceMode.INCLUDE_NOTHING:
502
+ entrypoint_mounts = info.get_entrypoint_mount()
503
+ else:
504
+ entrypoint_mounts = []
505
+
506
+ all_mounts = [
507
+ _get_client_mount(),
508
+ *explicit_mounts,
509
+ *entrypoint_mounts,
510
+ ]
511
+
512
+ if include_source_mode is IncludeSourceMode.INCLUDE_FIRST_PARTY:
513
+ # TODO(elias): if using INCLUDE_FIRST_PARTY *and* mounts are added that haven't already been
514
+ # added to the image via add_local_python_source
515
+ all_mounts += get_auto_mounts()
516
+ else:
517
+ # skip any mount introspection/logic inside containers, since the function
518
+ # should already be hydrated
519
+ # TODO: maybe the entire from_args loader should be exited early if not local?
520
+ # since it will be hydrated
521
+ all_mounts = []
522
+
523
+ retry_policy = _parse_retries(
524
+ retries, f"Function '{info.get_tag()}'" if info.raw_f else f"Class '{info.get_tag()}'"
525
+ )
526
+
527
+ if webhook_config is not None and retry_policy is not None:
528
+ raise InvalidError(
529
+ "Web endpoints do not support retries.",
530
+ )
531
+
532
+ if is_generator and retry_policy is not None:
533
+ deprecation_warning(
534
+ (2024, 6, 25),
535
+ "Retries for generator functions are deprecated and will soon be removed.",
536
+ )
537
+
538
+ if proxy:
539
+ # HACK: remove this once we stop using ssh tunnels for this.
540
+ if image:
541
+ # TODO(elias): this will cause an error if users use prior `.add_local_*` commands without copy=True
542
+ image = image.apt_install("autossh")
543
+
544
+ function_spec = _FunctionSpec(
545
+ mounts=all_mounts,
546
+ secrets=secrets,
547
+ gpus=gpu,
548
+ network_file_systems=network_file_systems,
549
+ volumes=volumes,
550
+ image=image,
551
+ cloud=cloud,
552
+ cpu=cpu,
553
+ memory=memory,
554
+ ephemeral_disk=ephemeral_disk,
555
+ scheduler_placement=scheduler_placement,
556
+ proxy=proxy,
557
+ )
558
+
559
+ if info.user_cls and not is_auto_snapshot:
560
+ build_functions = _find_partial_methods_for_user_cls(info.user_cls, _PartialFunctionFlags.BUILD).items()
561
+ for k, pf in build_functions:
562
+ build_function = pf.raw_f
563
+ snapshot_info = FunctionInfo(build_function, user_cls=info.user_cls)
564
+ snapshot_function = _Function.from_args(
565
+ snapshot_info,
566
+ app=None,
567
+ image=image,
568
+ secrets=secrets,
569
+ gpu=gpu,
570
+ mounts=mounts,
571
+ network_file_systems=network_file_systems,
572
+ volumes=volumes,
573
+ memory=memory,
574
+ timeout=pf.build_timeout,
575
+ cpu=cpu,
576
+ ephemeral_disk=ephemeral_disk,
577
+ is_builder_function=True,
578
+ is_auto_snapshot=True,
579
+ scheduler_placement=scheduler_placement,
580
+ )
581
+ image = _Image._from_args(
582
+ base_images={"base": image},
583
+ build_function=snapshot_function,
584
+ force_build=image.force_build or pf.force_build,
585
+ )
586
+
587
+ if keep_warm is not None and not isinstance(keep_warm, int):
588
+ raise TypeError(f"`keep_warm` must be an int or bool, not {type(keep_warm).__name__}")
589
+
590
+ if (keep_warm is not None) and (concurrency_limit is not None) and concurrency_limit < keep_warm:
591
+ raise InvalidError(
592
+ f"Function `{info.function_name}` has `{concurrency_limit=}`, "
593
+ f"strictly less than its `{keep_warm=}` parameter."
594
+ )
595
+
596
+ if _experimental_custom_scaling_factor is not None and (
597
+ _experimental_custom_scaling_factor < 0 or _experimental_custom_scaling_factor > 1
598
+ ):
599
+ raise InvalidError("`_experimental_custom_scaling_factor` must be between 0.0 and 1.0 inclusive.")
600
+
601
+ if not cloud and not is_builder_function:
602
+ cloud = config.get("default_cloud")
603
+ if cloud:
604
+ cloud_provider = parse_cloud_provider(cloud)
605
+ else:
606
+ cloud_provider = None
607
+
608
+ if is_generator and webhook_config:
609
+ if webhook_config.type == api_pb2.WEBHOOK_TYPE_FUNCTION:
610
+ raise InvalidError(
611
+ """Webhooks cannot be generators. If you want a streaming response, see https://modal.com/docs/guide/streaming-endpoints
612
+ """
613
+ )
614
+ else:
615
+ raise InvalidError("Webhooks cannot be generators")
616
+
617
+ if info.raw_f and batch_max_size:
618
+ func_name = info.raw_f.__name__
619
+ if is_generator:
620
+ raise InvalidError(f"Modal batched function {func_name} cannot return generators")
621
+ for arg in inspect.signature(info.raw_f).parameters.values():
622
+ if arg.default is not inspect.Parameter.empty:
623
+ raise InvalidError(f"Modal batched function {func_name} does not accept default arguments.")
624
+
625
+ if container_idle_timeout is not None and container_idle_timeout <= 0:
626
+ raise InvalidError("`container_idle_timeout` must be > 0")
627
+
628
+ if max_inputs is not None:
629
+ if not isinstance(max_inputs, int):
630
+ raise InvalidError(f"`max_inputs` must be an int, not {type(max_inputs).__name__}")
631
+ if max_inputs <= 0:
632
+ raise InvalidError("`max_inputs` must be positive")
633
+ if max_inputs > 1:
634
+ raise InvalidError("Only `max_inputs=1` is currently supported")
635
+
636
+ # Validate volumes
637
+ validated_volumes = validate_volumes(volumes)
638
+ cloud_bucket_mounts = [(k, v) for k, v in validated_volumes if isinstance(v, _CloudBucketMount)]
639
+ validated_volumes_no_cloud_buckets = [(k, v) for k, v in validated_volumes if isinstance(v, _Volume)]
640
+
641
+ # Validate NFS
642
+ validated_network_file_systems = validate_network_file_systems(network_file_systems)
643
+
644
+ # Validate image
645
+ if image is not None and not isinstance(image, _Image):
646
+ raise InvalidError(f"Expected modal.Image object. Got {type(image)}.")
647
+
648
+ method_definitions: Optional[dict[str, api_pb2.MethodDefinition]] = None
649
+
650
+ if info.user_cls:
651
+ method_definitions = {}
652
+ partial_functions = _find_partial_methods_for_user_cls(info.user_cls, _PartialFunctionFlags.FUNCTION)
653
+ for method_name, partial_function in partial_functions.items():
654
+ function_type = get_function_type(partial_function.is_generator)
655
+ function_name = f"{info.user_cls.__name__}.{method_name}"
656
+ method_definition = api_pb2.MethodDefinition(
657
+ webhook_config=partial_function.webhook_config,
658
+ function_type=function_type,
659
+ function_name=function_name,
660
+ )
661
+ method_definitions[method_name] = method_definition
662
+
663
+ function_type = get_function_type(is_generator)
664
+
665
+ def _deps(only_explicit_mounts=False) -> list[_Object]:
666
+ deps: list[_Object] = list(secrets)
667
+ if only_explicit_mounts:
668
+ # TODO: this is a bit hacky, but all_mounts may differ in the container vs locally
669
+ # We don't want the function dependencies to change, so we have this way to force it to
670
+ # only include its declared dependencies.
671
+ # Only objects that need interaction within a user's container actually need to be
672
+ # included when only_explicit_mounts=True, so omitting auto mounts here
673
+ # wouldn't be a problem as long as Mounts are "passive" and only loaded by the
674
+ # worker runtime
675
+ deps += list(explicit_mounts)
676
+ else:
677
+ deps += list(all_mounts)
678
+ if proxy:
679
+ deps.append(proxy)
680
+ if image:
681
+ deps.append(image)
682
+ for _, nfs in validated_network_file_systems:
683
+ deps.append(nfs)
684
+ for _, vol in validated_volumes_no_cloud_buckets:
685
+ deps.append(vol)
686
+ for _, cloud_bucket_mount in cloud_bucket_mounts:
687
+ if cloud_bucket_mount.secret:
688
+ deps.append(cloud_bucket_mount.secret)
689
+
690
+ return deps
691
+
692
+ async def _preload(self: _Function, resolver: Resolver, existing_object_id: Optional[str]):
693
+ assert resolver.client and resolver.client.stub
694
+
695
+ assert resolver.app_id
696
+ req = api_pb2.FunctionPrecreateRequest(
697
+ app_id=resolver.app_id,
698
+ function_name=info.function_name,
699
+ function_type=function_type,
700
+ existing_function_id=existing_object_id or "",
701
+ )
702
+ if method_definitions:
703
+ for method_name, method_definition in method_definitions.items():
704
+ req.method_definitions[method_name].CopyFrom(method_definition)
705
+ elif webhook_config:
706
+ req.webhook_config.CopyFrom(webhook_config)
707
+ response = await retry_transient_errors(resolver.client.stub.FunctionPrecreate, req)
708
+ self._hydrate(response.function_id, resolver.client, response.handle_metadata)
709
+
710
+ async def _load(self: _Function, resolver: Resolver, existing_object_id: Optional[str]):
711
+ assert resolver.client and resolver.client.stub
712
+ with FunctionCreationStatus(resolver, tag) as function_creation_status:
713
+ timeout_secs = timeout
714
+
715
+ if app and app.is_interactive and not is_builder_function:
716
+ pty_info = get_pty_info(shell=False)
717
+ else:
718
+ pty_info = None
719
+
720
+ if info.is_serialized():
721
+ # Use cloudpickle. Used when working w/ Jupyter notebooks.
722
+ # serialize at _load time, not function decoration time
723
+ # otherwise we can't capture a surrounding class for lifetime methods etc.
724
+ function_serialized = info.serialized_function()
725
+ class_serialized = serialize(info.user_cls) if info.user_cls is not None else None
726
+ # Ensure that large data in global variables does not blow up the gRPC payload,
727
+ # which has maximum size 100 MiB. We set the limit lower for performance reasons.
728
+ if len(function_serialized) > 16 << 20: # 16 MiB
729
+ raise InvalidError(
730
+ f"Function {info.raw_f} has size {len(function_serialized)} bytes when packaged. "
731
+ "This is larger than the maximum limit of 16 MiB. "
732
+ "Try reducing the size of the closure by using parameters or mounts, "
733
+ "not large global variables."
734
+ )
735
+ elif len(function_serialized) > 256 << 10: # 256 KiB
736
+ warnings.warn(
737
+ f"Function {info.raw_f} has size {len(function_serialized)} bytes when packaged. "
738
+ "This is larger than the recommended limit of 256 KiB. "
739
+ "Try reducing the size of the closure by using parameters or mounts, "
740
+ "not large global variables."
741
+ )
742
+ else:
743
+ function_serialized = None
744
+ class_serialized = None
745
+
746
+ app_name = ""
747
+ if app and app.name:
748
+ app_name = app.name
749
+
750
+ # Relies on dicts being ordered (true as of Python 3.6).
751
+ volume_mounts = [
752
+ api_pb2.VolumeMount(
753
+ mount_path=path,
754
+ volume_id=volume.object_id,
755
+ allow_background_commits=True,
756
+ )
757
+ for path, volume in validated_volumes_no_cloud_buckets
758
+ ]
759
+ loaded_mount_ids = {m.object_id for m in all_mounts} | {m.object_id for m in image._mount_layers}
760
+
761
+ # Get object dependencies
762
+ object_dependencies = []
763
+ for dep in _deps(only_explicit_mounts=True):
764
+ if not dep.object_id:
765
+ raise Exception(f"Dependency {dep} isn't hydrated")
766
+ object_dependencies.append(api_pb2.ObjectDependency(object_id=dep.object_id))
767
+
768
+ function_data: Optional[api_pb2.FunctionData] = None
769
+ function_definition: Optional[api_pb2.Function] = None
770
+
771
+ # Create function remotely
772
+ function_definition = api_pb2.Function(
773
+ module_name=info.module_name or "",
774
+ function_name=info.function_name,
775
+ mount_ids=loaded_mount_ids,
776
+ secret_ids=[secret.object_id for secret in secrets],
777
+ image_id=(image.object_id if image else ""),
778
+ definition_type=info.get_definition_type(),
779
+ function_serialized=function_serialized or b"",
780
+ class_serialized=class_serialized or b"",
781
+ function_type=function_type,
782
+ webhook_config=webhook_config,
783
+ method_definitions=method_definitions,
784
+ method_definitions_set=True,
785
+ shared_volume_mounts=network_file_system_mount_protos(
786
+ validated_network_file_systems, allow_cross_region_volumes
787
+ ),
788
+ volume_mounts=volume_mounts,
789
+ proxy_id=(proxy.object_id if proxy else None),
790
+ retry_policy=retry_policy,
791
+ timeout_secs=timeout_secs or 0,
792
+ task_idle_timeout_secs=container_idle_timeout or 0,
793
+ concurrency_limit=concurrency_limit or 0,
794
+ pty_info=pty_info,
795
+ cloud_provider=cloud_provider, # Deprecated at some point
796
+ cloud_provider_str=cloud.upper() if cloud else "", # Supersedes cloud_provider
797
+ warm_pool_size=keep_warm or 0,
798
+ runtime=config.get("function_runtime"),
799
+ runtime_debug=config.get("function_runtime_debug"),
800
+ runtime_perf_record=config.get("runtime_perf_record"),
801
+ app_name=app_name,
802
+ is_builder_function=is_builder_function,
803
+ target_concurrent_inputs=allow_concurrent_inputs or 0,
804
+ batch_max_size=batch_max_size or 0,
805
+ batch_linger_ms=batch_wait_ms or 0,
806
+ worker_id=config.get("worker_id"),
807
+ is_auto_snapshot=is_auto_snapshot,
808
+ is_method=bool(info.user_cls) and not info.is_service_class(),
809
+ checkpointing_enabled=enable_memory_snapshot,
810
+ object_dependencies=object_dependencies,
811
+ block_network=block_network,
812
+ max_inputs=max_inputs or 0,
813
+ cloud_bucket_mounts=cloud_bucket_mounts_to_proto(cloud_bucket_mounts),
814
+ scheduler_placement=scheduler_placement.proto if scheduler_placement else None,
815
+ is_class=info.is_service_class(),
816
+ class_parameter_info=info.class_parameter_info(),
817
+ i6pn_enabled=i6pn_enabled,
818
+ schedule=schedule.proto_message if schedule is not None else None,
819
+ snapshot_debug=config.get("snapshot_debug"),
820
+ _experimental_group_size=cluster_size or 0, # Experimental: Clustered functions
821
+ _experimental_concurrent_cancellations=True,
822
+ _experimental_buffer_containers=_experimental_buffer_containers or 0,
823
+ _experimental_proxy_ip=_experimental_proxy_ip,
824
+ _experimental_custom_scaling=_experimental_custom_scaling_factor is not None,
825
+ )
826
+
827
+ if isinstance(gpu, list):
828
+ function_data = api_pb2.FunctionData(
829
+ module_name=function_definition.module_name,
830
+ function_name=function_definition.function_name,
831
+ function_type=function_definition.function_type,
832
+ warm_pool_size=function_definition.warm_pool_size,
833
+ concurrency_limit=function_definition.concurrency_limit,
834
+ task_idle_timeout_secs=function_definition.task_idle_timeout_secs,
835
+ worker_id=function_definition.worker_id,
836
+ timeout_secs=function_definition.timeout_secs,
837
+ web_url=function_definition.web_url,
838
+ web_url_info=function_definition.web_url_info,
839
+ webhook_config=function_definition.webhook_config,
840
+ custom_domain_info=function_definition.custom_domain_info,
841
+ schedule=schedule.proto_message if schedule is not None else None,
842
+ is_class=function_definition.is_class,
843
+ class_parameter_info=function_definition.class_parameter_info,
844
+ is_method=function_definition.is_method,
845
+ use_function_id=function_definition.use_function_id,
846
+ use_method_name=function_definition.use_method_name,
847
+ method_definitions=function_definition.method_definitions,
848
+ method_definitions_set=function_definition.method_definitions_set,
849
+ _experimental_group_size=function_definition._experimental_group_size,
850
+ _experimental_buffer_containers=function_definition._experimental_buffer_containers,
851
+ _experimental_custom_scaling=function_definition._experimental_custom_scaling,
852
+ _experimental_proxy_ip=function_definition._experimental_proxy_ip,
853
+ snapshot_debug=function_definition.snapshot_debug,
854
+ runtime_perf_record=function_definition.runtime_perf_record,
855
+ )
856
+
857
+ ranked_functions = []
858
+ for rank, _gpu in enumerate(gpu):
859
+ function_definition_copy = api_pb2.Function()
860
+ function_definition_copy.CopyFrom(function_definition)
861
+
862
+ function_definition_copy.resources.CopyFrom(
863
+ convert_fn_config_to_resources_config(
864
+ cpu=cpu, memory=memory, gpu=_gpu, ephemeral_disk=ephemeral_disk
865
+ ),
866
+ )
867
+ ranked_function = api_pb2.FunctionData.RankedFunction(
868
+ rank=rank,
869
+ function=function_definition_copy,
870
+ )
871
+ ranked_functions.append(ranked_function)
872
+ function_data.ranked_functions.extend(ranked_functions)
873
+ function_definition = None # function_definition is not used in this case
874
+ else:
875
+ # TODO(irfansharif): Assert on this specific type once we get rid of python 3.9.
876
+ # assert isinstance(gpu, GPU_T) # includes the case where gpu==None case
877
+ function_definition.resources.CopyFrom(
878
+ convert_fn_config_to_resources_config(
879
+ cpu=cpu, memory=memory, gpu=gpu, ephemeral_disk=ephemeral_disk
880
+ ),
881
+ )
882
+
883
+ assert resolver.app_id
884
+ assert (function_definition is None) != (function_data is None) # xor
885
+ request = api_pb2.FunctionCreateRequest(
886
+ app_id=resolver.app_id,
887
+ function=function_definition,
888
+ function_data=function_data,
889
+ existing_function_id=existing_object_id or "",
890
+ defer_updates=True,
891
+ )
892
+ try:
893
+ response: api_pb2.FunctionCreateResponse = await retry_transient_errors(
894
+ resolver.client.stub.FunctionCreate, request
895
+ )
896
+ except GRPCError as exc:
897
+ if exc.status == Status.INVALID_ARGUMENT:
898
+ raise InvalidError(exc.message)
899
+ if exc.status == Status.FAILED_PRECONDITION:
900
+ raise InvalidError(exc.message)
901
+ if exc.message and "Received :status = '413'" in exc.message:
902
+ raise InvalidError(f"Function {info.function_name} is too large to deploy.")
903
+ raise
904
+ function_creation_status.set_response(response)
905
+ serve_mounts = {m for m in all_mounts if m.is_local()} # needed for modal.serve file watching
906
+ serve_mounts |= image._serve_mounts
907
+ obj._serve_mounts = frozenset(serve_mounts)
908
+ self._hydrate(response.function_id, resolver.client, response.handle_metadata)
909
+
910
+ rep = f"Function({tag})"
911
+ obj = _Function._from_loader(_load, rep, preload=_preload, deps=_deps)
912
+
913
+ obj._raw_f = info.raw_f
914
+ obj._info = info
915
+ obj._tag = tag
916
+ obj._app = app # needed for CLI right now
917
+ obj._obj = None
918
+ obj._is_generator = is_generator
919
+ obj._cluster_size = cluster_size
920
+ obj._is_method = False
921
+ obj._spec = function_spec # needed for modal shell
922
+ obj._webhook_config = webhook_config # only set locally
923
+
924
+ # Used to check whether we should rebuild a modal.Image which uses `run_function`.
925
+ gpus: list[GPU_T] = gpu if isinstance(gpu, list) else [gpu]
926
+ obj._build_args = dict( # See get_build_def
927
+ secrets=repr(secrets),
928
+ gpu_config=repr([parse_gpu_config(_gpu) for _gpu in gpus]),
929
+ mounts=repr(mounts),
930
+ network_file_systems=repr(network_file_systems),
931
+ )
932
+ # these key are excluded if empty to avoid rebuilds on client upgrade
933
+ if volumes:
934
+ obj._build_args["volumes"] = repr(volumes)
935
+ if cloud or scheduler_placement:
936
+ obj._build_args["cloud"] = repr(cloud)
937
+ obj._build_args["scheduler_placement"] = repr(scheduler_placement)
938
+
939
+ return obj
940
+
941
+ def _bind_parameters(
942
+ self,
943
+ obj: "modal.cls._Obj",
944
+ options: Optional[api_pb2.FunctionOptions],
945
+ args: Sized,
946
+ kwargs: dict[str, Any],
947
+ ) -> "_Function":
948
+ """mdmd:hidden
949
+
950
+ Binds a class-function to a specific instance of (init params, options) or a new workspace
951
+ """
952
+
953
+ # In some cases, reuse the base function, i.e. not create new clones of each method or the "service function"
954
+ can_use_parent = len(args) + len(kwargs) == 0 and options is None
955
+ parent = self
956
+
957
+ async def _load(param_bound_func: _Function, resolver: Resolver, existing_object_id: Optional[str]):
958
+ try:
959
+ identity = f"{parent.info.function_name} class service function"
960
+ except Exception:
961
+ # Can't always look up the function name that way, so fall back to generic message
962
+ identity = "class service function for a parametrized class"
963
+ if not parent.is_hydrated:
964
+ if parent.app._running_app is None:
965
+ reason = ", because the App it is defined on is not running"
966
+ else:
967
+ reason = ""
968
+ raise ExecutionError(
969
+ f"The {identity} has not been hydrated with the metadata it needs to run on Modal{reason}."
970
+ )
971
+
972
+ assert parent._client and parent._client.stub
973
+
974
+ if can_use_parent:
975
+ # We can end up here if parent wasn't hydrated when class was instantiated, but has been since.
976
+ param_bound_func._hydrate_from_other(parent)
977
+ return
978
+
979
+ if (
980
+ parent._class_parameter_info
981
+ and parent._class_parameter_info.format == api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PROTO
982
+ ):
983
+ if args:
984
+ # TODO(elias) - We could potentially support positional args as well, if we want to?
985
+ raise InvalidError(
986
+ "Can't use positional arguments with modal.parameter-based synthetic constructors.\n"
987
+ "Use (<parameter_name>=value) keyword arguments when constructing classes instead."
988
+ )
989
+ serialized_params = serialize_proto_params(kwargs, parent._class_parameter_info.schema)
990
+ else:
991
+ serialized_params = serialize((args, kwargs))
992
+ environment_name = _get_environment_name(None, resolver)
993
+ assert parent is not None and parent.is_hydrated
994
+ req = api_pb2.FunctionBindParamsRequest(
995
+ function_id=parent.object_id,
996
+ serialized_params=serialized_params,
997
+ function_options=options,
998
+ environment_name=environment_name
999
+ or "", # TODO: investigate shouldn't environment name always be specified here?
1000
+ )
1001
+
1002
+ response = await retry_transient_errors(parent._client.stub.FunctionBindParams, req)
1003
+ param_bound_func._hydrate(response.bound_function_id, parent._client, response.handle_metadata)
1004
+
1005
+ fun: _Function = _Function._from_loader(_load, "Function(parametrized)", hydrate_lazily=True)
1006
+
1007
+ if can_use_parent and parent.is_hydrated:
1008
+ # skip the resolver altogether:
1009
+ fun._hydrate_from_other(parent)
1010
+
1011
+ fun._info = self._info
1012
+ fun._obj = obj
1013
+ return fun
1014
+
1015
+ @live_method
1016
+ async def keep_warm(self, warm_pool_size: int) -> None:
1017
+ """Set the warm pool size for the function.
1018
+
1019
+ Please exercise care when using this advanced feature!
1020
+ Setting and forgetting a warm pool on functions can lead to increased costs.
1021
+
1022
+ ```python notest
1023
+ # Usage on a regular function.
1024
+ f = modal.Function.from_name("my-app", "function")
1025
+ f.keep_warm(2)
1026
+
1027
+ # Usage on a parametrized function.
1028
+ Model = modal.Cls.from_name("my-app", "Model")
1029
+ Model("fine-tuned-model").keep_warm(2)
1030
+ ```
1031
+ """
1032
+ if self._is_method:
1033
+ raise InvalidError(
1034
+ textwrap.dedent(
1035
+ """
1036
+ The `.keep_warm()` method can not be used on Modal class *methods* deployed using Modal >v0.63.
1037
+
1038
+ Call `.keep_warm()` on the class *instance* instead.
1039
+ """
1040
+ )
1041
+ )
1042
+ request = api_pb2.FunctionUpdateSchedulingParamsRequest(
1043
+ function_id=self.object_id, warm_pool_size_override=warm_pool_size
1044
+ )
1045
+ await retry_transient_errors(self.client.stub.FunctionUpdateSchedulingParams, request)
1046
+
1047
+ @classmethod
1048
+ @renamed_parameter((2024, 12, 18), "tag", "name")
1049
+ def from_name(
1050
+ cls: type["_Function"],
1051
+ app_name: str,
1052
+ name: str,
1053
+ namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
1054
+ environment_name: Optional[str] = None,
1055
+ ) -> "_Function":
1056
+ """Reference a Function from a deployed App by its name.
1057
+
1058
+ In contast to `modal.Function.lookup`, this is a lazy method
1059
+ that defers hydrating the local object with metadata from
1060
+ Modal servers until the first time it is actually used.
1061
+
1062
+ ```python
1063
+ f = modal.Function.from_name("other-app", "function")
1064
+ ```
1065
+ """
1066
+
1067
+ async def _load_remote(self: _Function, resolver: Resolver, existing_object_id: Optional[str]):
1068
+ assert resolver.client and resolver.client.stub
1069
+ request = api_pb2.FunctionGetRequest(
1070
+ app_name=app_name,
1071
+ object_tag=name,
1072
+ namespace=namespace,
1073
+ environment_name=_get_environment_name(environment_name, resolver) or "",
1074
+ )
1075
+ try:
1076
+ response = await retry_transient_errors(resolver.client.stub.FunctionGet, request)
1077
+ except GRPCError as exc:
1078
+ if exc.status == Status.NOT_FOUND:
1079
+ raise NotFoundError(exc.message)
1080
+ else:
1081
+ raise
1082
+
1083
+ print_server_warnings(response.server_warnings)
1084
+
1085
+ self._hydrate(response.function_id, resolver.client, response.handle_metadata)
1086
+
1087
+ rep = f"Ref({app_name})"
1088
+ return cls._from_loader(_load_remote, rep, is_another_app=True, hydrate_lazily=True)
1089
+
1090
+ @staticmethod
1091
+ @renamed_parameter((2024, 12, 18), "tag", "name")
1092
+ async def lookup(
1093
+ app_name: str,
1094
+ name: str,
1095
+ namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
1096
+ client: Optional[_Client] = None,
1097
+ environment_name: Optional[str] = None,
1098
+ ) -> "_Function":
1099
+ """Lookup a Function from a deployed App by its name.
1100
+
1101
+ DEPRECATED: This method is deprecated in favor of `modal.Function.from_name`.
1102
+
1103
+ In contrast to `modal.Function.from_name`, this is an eager method
1104
+ that will hydrate the local object with metadata from Modal servers.
1105
+
1106
+ ```python notest
1107
+ f = modal.Function.lookup("other-app", "function")
1108
+ ```
1109
+ """
1110
+ deprecation_warning(
1111
+ (2025, 1, 27),
1112
+ "`modal.Function.lookup` is deprecated and will be removed in a future release."
1113
+ " It can be replaced with `modal.Function.from_name`."
1114
+ "\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
1115
+ )
1116
+ obj = _Function.from_name(app_name, name, namespace=namespace, environment_name=environment_name)
1117
+ if client is None:
1118
+ client = await _Client.from_env()
1119
+ resolver = Resolver(client=client)
1120
+ await resolver.load(obj)
1121
+ return obj
1122
+
1123
+ @property
1124
+ def tag(self) -> str:
1125
+ """mdmd:hidden"""
1126
+ assert self._tag
1127
+ return self._tag
1128
+
1129
+ @property
1130
+ def app(self) -> "modal.app._App":
1131
+ """mdmd:hidden"""
1132
+ if self._app is None:
1133
+ raise ExecutionError("The app has not been assigned on the function at this point")
1134
+
1135
+ return self._app
1136
+
1137
+ @property
1138
+ def stub(self) -> "modal.app._App":
1139
+ """mdmd:hidden"""
1140
+ # Deprecated soon, only for backwards compatibility
1141
+ return self.app
1142
+
1143
+ @property
1144
+ def info(self) -> FunctionInfo:
1145
+ """mdmd:hidden"""
1146
+ assert self._info
1147
+ return self._info
1148
+
1149
+ @property
1150
+ def spec(self) -> _FunctionSpec:
1151
+ """mdmd:hidden"""
1152
+ assert self._spec
1153
+ return self._spec
1154
+
1155
+ def _is_web_endpoint(self) -> bool:
1156
+ # only defined in definition scope/locally, and not for class methods at the moment
1157
+ return bool(self._webhook_config and self._webhook_config.type != api_pb2.WEBHOOK_TYPE_UNSPECIFIED)
1158
+
1159
+ def get_build_def(self) -> str:
1160
+ """mdmd:hidden"""
1161
+ # Plaintext source and arg definition for the function, so it's part of the image
1162
+ # hash. We can't use the cloudpickle hash because it's not very stable.
1163
+ assert hasattr(self, "_raw_f") and hasattr(self, "_build_args") and self._raw_f is not None
1164
+ return f"{inspect.getsource(self._raw_f)}\n{repr(self._build_args)}"
1165
+
1166
+ # Live handle methods
1167
+
1168
+ def _initialize_from_empty(self):
1169
+ # Overridden concrete implementation of base class method
1170
+ self._progress = None
1171
+ self._is_generator = None
1172
+ self._cluster_size = None
1173
+ self._web_url = None
1174
+ self._function_name = None
1175
+ self._info = None
1176
+ self._serve_mounts = frozenset()
1177
+
1178
+ def _hydrate_metadata(self, metadata: Optional[Message]):
1179
+ # Overridden concrete implementation of base class method
1180
+ assert metadata and isinstance(metadata, api_pb2.FunctionHandleMetadata)
1181
+ self._is_generator = metadata.function_type == api_pb2.Function.FUNCTION_TYPE_GENERATOR
1182
+ self._web_url = metadata.web_url
1183
+ self._function_name = metadata.function_name
1184
+ self._is_method = metadata.is_method
1185
+ self._use_method_name = metadata.use_method_name
1186
+ self._class_parameter_info = metadata.class_parameter_info
1187
+ self._method_handle_metadata = dict(metadata.method_handle_metadata)
1188
+ self._definition_id = metadata.definition_id
1189
+
1190
+ def _get_metadata(self):
1191
+ # Overridden concrete implementation of base class method
1192
+ assert self._function_name, f"Function name must be set before metadata can be retrieved for {self}"
1193
+ return api_pb2.FunctionHandleMetadata(
1194
+ function_name=self._function_name,
1195
+ function_type=get_function_type(self._is_generator),
1196
+ web_url=self._web_url or "",
1197
+ use_method_name=self._use_method_name,
1198
+ is_method=self._is_method,
1199
+ class_parameter_info=self._class_parameter_info,
1200
+ definition_id=self._definition_id,
1201
+ method_handle_metadata=self._method_handle_metadata,
1202
+ )
1203
+
1204
+ def _check_no_web_url(self, fn_name: str):
1205
+ if self._web_url:
1206
+ raise InvalidError(
1207
+ f"A webhook function cannot be invoked for remote execution with `.{fn_name}`. "
1208
+ f"Invoke this function via its web url '{self._web_url}' "
1209
+ + f"or call it locally: {self._function_name}.local()"
1210
+ )
1211
+
1212
+ # TODO (live_method on properties is not great, since it could be blocking the event loop from async contexts)
1213
+ @property
1214
+ @live_method
1215
+ async def web_url(self) -> str:
1216
+ """URL of a Function running as a web endpoint."""
1217
+ if not self._web_url:
1218
+ raise ValueError(
1219
+ f"No web_url can be found for function {self._function_name}. web_url "
1220
+ "can only be referenced from a running app context"
1221
+ )
1222
+ return self._web_url
1223
+
1224
+ @property
1225
+ async def is_generator(self) -> bool:
1226
+ """mdmd:hidden"""
1227
+ # hacky: kind of like @live_method, but not hydrating if we have the value already from local source
1228
+ # TODO(michael) use a common / lightweight method for handling unhydrated metadata properties
1229
+ if self._is_generator is not None:
1230
+ # this is set if the function or class is local
1231
+ return self._is_generator
1232
+
1233
+ # not set - this is a from_name lookup - hydrate
1234
+ await self.hydrate()
1235
+ assert self._is_generator is not None # should be set now
1236
+ return self._is_generator
1237
+
1238
+ @property
1239
+ def cluster_size(self) -> int:
1240
+ """mdmd:hidden"""
1241
+ return self._cluster_size or 1
1242
+
1243
+ @live_method_gen
1244
+ async def _map(
1245
+ self, input_queue: _SynchronizedQueue, order_outputs: bool, return_exceptions: bool
1246
+ ) -> AsyncGenerator[Any, None]:
1247
+ """mdmd:hidden
1248
+
1249
+ Synchronicity-wrapped map implementation. To be safe against invocations of user code in
1250
+ the synchronicity thread it doesn't accept an [async]iterator, and instead takes a
1251
+ _SynchronizedQueue instance that is fed by higher level functions like .map()
1252
+
1253
+ _SynchronizedQueue is used instead of asyncio.Queue so that the main thread can put
1254
+ items in the queue safely.
1255
+ """
1256
+ self._check_no_web_url("map")
1257
+ if self._is_generator:
1258
+ raise InvalidError("A generator function cannot be called with `.map(...)`.")
1259
+
1260
+ assert self._function_name
1261
+ if output_mgr := _get_output_manager():
1262
+ count_update_callback = output_mgr.function_progress_callback(self._function_name, total=None)
1263
+ else:
1264
+ count_update_callback = None
1265
+
1266
+ async with aclosing(
1267
+ _map_invocation(
1268
+ self,
1269
+ input_queue,
1270
+ self.client,
1271
+ order_outputs,
1272
+ return_exceptions,
1273
+ count_update_callback,
1274
+ )
1275
+ ) as stream:
1276
+ async for item in stream:
1277
+ yield item
1278
+
1279
+ async def _call_function(self, args, kwargs) -> ReturnType:
1280
+ if config.get("client_retries"):
1281
+ function_call_invocation_type = api_pb2.FUNCTION_CALL_INVOCATION_TYPE_SYNC
1282
+ else:
1283
+ function_call_invocation_type = api_pb2.FUNCTION_CALL_INVOCATION_TYPE_SYNC_LEGACY
1284
+ invocation = await _Invocation.create(
1285
+ self,
1286
+ args,
1287
+ kwargs,
1288
+ client=self.client,
1289
+ function_call_invocation_type=function_call_invocation_type,
1290
+ )
1291
+
1292
+ return await invocation.run_function()
1293
+
1294
+ async def _call_function_nowait(
1295
+ self, args, kwargs, function_call_invocation_type: "api_pb2.FunctionCallInvocationType.ValueType"
1296
+ ) -> _Invocation:
1297
+ return await _Invocation.create(
1298
+ self, args, kwargs, client=self.client, function_call_invocation_type=function_call_invocation_type
1299
+ )
1300
+
1301
+ @warn_if_generator_is_not_consumed()
1302
+ @live_method_gen
1303
+ @synchronizer.no_input_translation
1304
+ async def _call_generator(self, args, kwargs):
1305
+ invocation = await _Invocation.create(
1306
+ self,
1307
+ args,
1308
+ kwargs,
1309
+ client=self.client,
1310
+ function_call_invocation_type=api_pb2.FUNCTION_CALL_INVOCATION_TYPE_SYNC_LEGACY,
1311
+ )
1312
+ async for res in invocation.run_generator():
1313
+ yield res
1314
+
1315
+ @synchronizer.no_io_translation
1316
+ async def _call_generator_nowait(self, args, kwargs):
1317
+ deprecation_warning(
1318
+ (2024, 12, 11),
1319
+ "Calling spawn on a generator function is deprecated and will soon raise an exception.",
1320
+ )
1321
+ return await _Invocation.create(
1322
+ self,
1323
+ args,
1324
+ kwargs,
1325
+ client=self.client,
1326
+ function_call_invocation_type=api_pb2.FUNCTION_CALL_INVOCATION_TYPE_ASYNC_LEGACY,
1327
+ )
1328
+
1329
+ @synchronizer.no_io_translation
1330
+ @live_method
1331
+ async def remote(self, *args: P.args, **kwargs: P.kwargs) -> ReturnType:
1332
+ """
1333
+ Calls the function remotely, executing it with the given arguments and returning the execution's result.
1334
+ """
1335
+ # TODO: Generics/TypeVars
1336
+ self._check_no_web_url("remote")
1337
+ if self._is_generator:
1338
+ raise InvalidError(
1339
+ "A generator function cannot be called with `.remote(...)`. Use `.remote_gen(...)` instead."
1340
+ )
1341
+
1342
+ return await self._call_function(args, kwargs)
1343
+
1344
+ @synchronizer.no_io_translation
1345
+ @live_method_gen
1346
+ async def remote_gen(self, *args, **kwargs) -> AsyncGenerator[Any, None]:
1347
+ """
1348
+ Calls the generator remotely, executing it with the given arguments and returning the execution's result.
1349
+ """
1350
+ # TODO: Generics/TypeVars
1351
+ self._check_no_web_url("remote_gen")
1352
+
1353
+ if not self._is_generator:
1354
+ raise InvalidError(
1355
+ "A non-generator function cannot be called with `.remote_gen(...)`. Use `.remote(...)` instead."
1356
+ )
1357
+ async for item in self._call_generator(args, kwargs):
1358
+ yield item
1359
+
1360
+ def _is_local(self):
1361
+ return self._info is not None
1362
+
1363
+ def _get_info(self) -> FunctionInfo:
1364
+ if not self._info:
1365
+ raise ExecutionError("Can't get info for a function that isn't locally defined")
1366
+ return self._info
1367
+
1368
+ def _get_obj(self) -> Optional["modal.cls._Obj"]:
1369
+ if not self._is_method:
1370
+ return None
1371
+ elif not self._obj:
1372
+ raise ExecutionError("Method has no local object")
1373
+ else:
1374
+ return self._obj
1375
+
1376
+ @synchronizer.nowrap
1377
+ def local(self, *args: P.args, **kwargs: P.kwargs) -> OriginalReturnType:
1378
+ """
1379
+ Calls the function locally, executing it with the given arguments and returning the execution's result.
1380
+
1381
+ The function will execute in the same environment as the caller, just like calling the underlying function
1382
+ directly in Python. In particular, only secrets available in the caller environment will be available
1383
+ through environment variables.
1384
+ """
1385
+ # TODO(erikbern): it would be nice to remove the nowrap thing, but right now that would cause
1386
+ # "user code" to run on the synchronicity thread, which seems bad
1387
+ if not self._is_local():
1388
+ msg = (
1389
+ "The definition for this function is missing here so it is not possible to invoke it locally. "
1390
+ "If this function was retrieved via `Function.lookup` you need to use `.remote()`."
1391
+ )
1392
+ raise ExecutionError(msg)
1393
+
1394
+ info = self._get_info()
1395
+ if not info.raw_f:
1396
+ # Here if calling .local on a service function itself which should never happen
1397
+ # TODO: check if we end up here in a container for a serialized function?
1398
+ raise ExecutionError("Can't call .local on service function")
1399
+
1400
+ if is_local() and self.spec.volumes or self.spec.network_file_systems:
1401
+ warnings.warn(
1402
+ f"The {info.function_name} function is executing locally "
1403
+ + "and will not have access to the mounted Volume or NetworkFileSystem data"
1404
+ )
1405
+
1406
+ obj: Optional["modal.cls._Obj"] = self._get_obj()
1407
+
1408
+ if not obj:
1409
+ fun = info.raw_f
1410
+ return fun(*args, **kwargs)
1411
+ else:
1412
+ # This is a method on a class, so bind the self to the function
1413
+ user_cls_instance = obj._cached_user_cls_instance()
1414
+ fun = info.raw_f.__get__(user_cls_instance)
1415
+
1416
+ # TODO: replace implicit local enter/exit with a context manager
1417
+ if is_async(info.raw_f):
1418
+ # We want to run __aenter__ and fun in the same coroutine
1419
+ async def coro():
1420
+ await obj._aenter()
1421
+ return await fun(*args, **kwargs)
1422
+
1423
+ return coro() # type: ignore
1424
+ else:
1425
+ obj._enter()
1426
+ return fun(*args, **kwargs)
1427
+
1428
+ @synchronizer.no_input_translation
1429
+ @live_method
1430
+ async def _experimental_spawn(self, *args: P.args, **kwargs: P.kwargs) -> "_FunctionCall[ReturnType]":
1431
+ """[Experimental] Calls the function with the given arguments, without waiting for the results.
1432
+
1433
+ This experimental version of the spawn method allows up to 1 million inputs to be spawned.
1434
+
1435
+ Returns a `modal.functions.FunctionCall` object, that can later be polled or
1436
+ waited for using `.get(timeout=...)`.
1437
+ Conceptually similar to `multiprocessing.pool.apply_async`, or a Future/Promise in other contexts.
1438
+ """
1439
+ self._check_no_web_url("_experimental_spawn")
1440
+ if self._is_generator:
1441
+ invocation = await self._call_generator_nowait(args, kwargs)
1442
+ else:
1443
+ invocation = await self._call_function_nowait(
1444
+ args, kwargs, function_call_invocation_type=api_pb2.FUNCTION_CALL_INVOCATION_TYPE_ASYNC
1445
+ )
1446
+
1447
+ fc: _FunctionCall[ReturnType] = _FunctionCall._new_hydrated(
1448
+ invocation.function_call_id, invocation.client, None
1449
+ )
1450
+ fc._is_generator = self._is_generator if self._is_generator else False
1451
+ return fc
1452
+
1453
+ @synchronizer.no_input_translation
1454
+ @live_method
1455
+ async def spawn(self, *args: P.args, **kwargs: P.kwargs) -> "_FunctionCall[ReturnType]":
1456
+ """Calls the function with the given arguments, without waiting for the results.
1457
+
1458
+ Returns a `modal.functions.FunctionCall` object, that can later be polled or
1459
+ waited for using `.get(timeout=...)`.
1460
+ Conceptually similar to `multiprocessing.pool.apply_async`, or a Future/Promise in other contexts.
1461
+ """
1462
+ self._check_no_web_url("spawn")
1463
+ if self._is_generator:
1464
+ invocation = await self._call_generator_nowait(args, kwargs)
1465
+ else:
1466
+ invocation = await self._call_function_nowait(
1467
+ args, kwargs, api_pb2.FUNCTION_CALL_INVOCATION_TYPE_ASYNC_LEGACY
1468
+ )
1469
+
1470
+ fc: _FunctionCall[ReturnType] = _FunctionCall._new_hydrated(
1471
+ invocation.function_call_id, invocation.client, None
1472
+ )
1473
+ fc._is_generator = self._is_generator if self._is_generator else False
1474
+ return fc
1475
+
1476
+ def get_raw_f(self) -> Callable[..., Any]:
1477
+ """Return the inner Python object wrapped by this Modal Function."""
1478
+ assert self._raw_f is not None
1479
+ return self._raw_f
1480
+
1481
+ @live_method
1482
+ async def get_current_stats(self) -> FunctionStats:
1483
+ """Return a `FunctionStats` object describing the current function's queue and runner counts."""
1484
+ resp = await retry_transient_errors(
1485
+ self.client.stub.FunctionGetCurrentStats,
1486
+ api_pb2.FunctionGetCurrentStatsRequest(function_id=self.object_id),
1487
+ total_timeout=10.0,
1488
+ )
1489
+ return FunctionStats(backlog=resp.backlog, num_total_runners=resp.num_total_tasks)
1490
+
1491
+ # A bit hacky - but the map-style functions need to not be synchronicity-wrapped
1492
+ # in order to not execute their input iterators on the synchronicity event loop.
1493
+ # We still need to wrap them using MethodWithAio to maintain a synchronicity-like
1494
+ # api with `.aio` and get working type-stubs and reference docs generation:
1495
+ map = MethodWithAio(_map_sync, _map_async, synchronizer)
1496
+ starmap = MethodWithAio(_starmap_sync, _starmap_async, synchronizer)
1497
+ for_each = MethodWithAio(_for_each_sync, _for_each_async, synchronizer)
1498
+
1499
+
1500
+ class _FunctionCall(typing.Generic[ReturnType], _Object, type_prefix="fc"):
1501
+ """A reference to an executed function call.
1502
+
1503
+ Constructed using `.spawn(...)` on a Modal function with the same
1504
+ arguments that a function normally takes. Acts as a reference to
1505
+ an ongoing function call that can be passed around and used to
1506
+ poll or fetch function results at some later time.
1507
+
1508
+ Conceptually similar to a Future/Promise/AsyncResult in other contexts and languages.
1509
+ """
1510
+
1511
+ _is_generator: bool = False
1512
+
1513
+ def _invocation(self):
1514
+ return _Invocation(self.client.stub, self.object_id, self.client)
1515
+
1516
+ async def get(self, timeout: Optional[float] = None) -> ReturnType:
1517
+ """Get the result of the function call.
1518
+
1519
+ This function waits indefinitely by default. It takes an optional
1520
+ `timeout` argument that specifies the maximum number of seconds to wait,
1521
+ which can be set to `0` to poll for an output immediately.
1522
+
1523
+ The returned coroutine is not cancellation-safe.
1524
+ """
1525
+
1526
+ if self._is_generator:
1527
+ raise Exception("Cannot get the result of a generator function call. Use `get_gen` instead.")
1528
+
1529
+ return await self._invocation().poll_function(timeout=timeout)
1530
+
1531
+ async def get_gen(self) -> AsyncGenerator[Any, None]:
1532
+ """
1533
+ Calls the generator remotely, executing it with the given arguments and returning the execution's result.
1534
+ """
1535
+ if not self._is_generator:
1536
+ raise Exception("Cannot iterate over a non-generator function call. Use `get` instead.")
1537
+
1538
+ async for res in self._invocation().run_generator():
1539
+ yield res
1540
+
1541
+ async def get_call_graph(self) -> list[InputInfo]:
1542
+ """Returns a structure representing the call graph from a given root
1543
+ call ID, along with the status of execution for each node.
1544
+
1545
+ See [`modal.call_graph`](/docs/reference/modal.call_graph) reference page
1546
+ for documentation on the structure of the returned `InputInfo` items.
1547
+ """
1548
+ assert self._client and self._client.stub
1549
+ request = api_pb2.FunctionGetCallGraphRequest(function_call_id=self.object_id)
1550
+ response = await retry_transient_errors(self._client.stub.FunctionGetCallGraph, request)
1551
+ return _reconstruct_call_graph(response)
1552
+
1553
+ async def cancel(
1554
+ self,
1555
+ terminate_containers: bool = False, # if true, containers running the inputs are forcibly terminated
1556
+ ):
1557
+ """Cancels the function call, which will stop its execution and mark its inputs as
1558
+ [`TERMINATED`](/docs/reference/modal.call_graph#modalcall_graphinputstatus).
1559
+
1560
+ If `terminate_containers=True` - the containers running the cancelled inputs are all terminated
1561
+ causing any non-cancelled inputs on those containers to be rescheduled in new containers.
1562
+ """
1563
+ request = api_pb2.FunctionCallCancelRequest(
1564
+ function_call_id=self.object_id, terminate_containers=terminate_containers
1565
+ )
1566
+ assert self._client and self._client.stub
1567
+ await retry_transient_errors(self._client.stub.FunctionCallCancel, request)
1568
+
1569
+ @staticmethod
1570
+ async def from_id(
1571
+ function_call_id: str, client: Optional[_Client] = None, is_generator: bool = False
1572
+ ) -> "_FunctionCall[Any]":
1573
+ if client is None:
1574
+ client = await _Client.from_env()
1575
+
1576
+ fc: _FunctionCall[Any] = _FunctionCall._new_hydrated(function_call_id, client, None)
1577
+ fc._is_generator = is_generator
1578
+ return fc
1579
+
1580
+
1581
+ async def _gather(*function_calls: _FunctionCall[ReturnType]) -> typing.Sequence[ReturnType]:
1582
+ """Wait until all Modal function calls have results before returning
1583
+
1584
+ Accepts a variable number of FunctionCall objects as returned by `Function.spawn()`.
1585
+
1586
+ Returns a list of results from each function call, or raises an exception
1587
+ of the first failing function call.
1588
+
1589
+ E.g.
1590
+
1591
+ ```python notest
1592
+ function_call_1 = slow_func_1.spawn()
1593
+ function_call_2 = slow_func_2.spawn()
1594
+
1595
+ result_1, result_2 = gather(function_call_1, function_call_2)
1596
+ ```
1597
+ """
1598
+ try:
1599
+ return await TaskContext.gather(*[fc.get() for fc in function_calls])
1600
+ except Exception as exc:
1601
+ # TODO: kill all running function calls
1602
+ raise exc