modal 1.0.6.dev58__py3-none-any.whl → 1.2.3.dev7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of modal might be problematic. Click here for more details.

Files changed (147) hide show
  1. modal/__main__.py +3 -4
  2. modal/_billing.py +80 -0
  3. modal/_clustered_functions.py +7 -3
  4. modal/_clustered_functions.pyi +4 -2
  5. modal/_container_entrypoint.py +41 -49
  6. modal/_functions.py +424 -195
  7. modal/_grpc_client.py +171 -0
  8. modal/_load_context.py +105 -0
  9. modal/_object.py +68 -20
  10. modal/_output.py +58 -45
  11. modal/_partial_function.py +36 -11
  12. modal/_pty.py +7 -3
  13. modal/_resolver.py +21 -35
  14. modal/_runtime/asgi.py +4 -3
  15. modal/_runtime/container_io_manager.py +301 -186
  16. modal/_runtime/container_io_manager.pyi +70 -61
  17. modal/_runtime/execution_context.py +18 -2
  18. modal/_runtime/execution_context.pyi +4 -1
  19. modal/_runtime/gpu_memory_snapshot.py +170 -63
  20. modal/_runtime/user_code_imports.py +28 -58
  21. modal/_serialization.py +57 -1
  22. modal/_utils/async_utils.py +33 -12
  23. modal/_utils/auth_token_manager.py +2 -5
  24. modal/_utils/blob_utils.py +110 -53
  25. modal/_utils/function_utils.py +49 -42
  26. modal/_utils/grpc_utils.py +80 -50
  27. modal/_utils/mount_utils.py +26 -1
  28. modal/_utils/name_utils.py +17 -3
  29. modal/_utils/task_command_router_client.py +536 -0
  30. modal/_utils/time_utils.py +34 -6
  31. modal/app.py +219 -83
  32. modal/app.pyi +229 -56
  33. modal/billing.py +5 -0
  34. modal/{requirements → builder}/2025.06.txt +1 -0
  35. modal/{requirements → builder}/PREVIEW.txt +1 -0
  36. modal/cli/_download.py +19 -3
  37. modal/cli/_traceback.py +3 -2
  38. modal/cli/app.py +4 -4
  39. modal/cli/cluster.py +15 -7
  40. modal/cli/config.py +5 -3
  41. modal/cli/container.py +7 -6
  42. modal/cli/dict.py +22 -16
  43. modal/cli/entry_point.py +12 -5
  44. modal/cli/environment.py +5 -4
  45. modal/cli/import_refs.py +3 -3
  46. modal/cli/launch.py +102 -5
  47. modal/cli/network_file_system.py +9 -13
  48. modal/cli/profile.py +3 -2
  49. modal/cli/programs/launch_instance_ssh.py +94 -0
  50. modal/cli/programs/run_jupyter.py +1 -1
  51. modal/cli/programs/run_marimo.py +95 -0
  52. modal/cli/programs/vscode.py +1 -1
  53. modal/cli/queues.py +57 -26
  54. modal/cli/run.py +58 -16
  55. modal/cli/secret.py +48 -22
  56. modal/cli/utils.py +3 -4
  57. modal/cli/volume.py +28 -25
  58. modal/client.py +13 -116
  59. modal/client.pyi +9 -91
  60. modal/cloud_bucket_mount.py +5 -3
  61. modal/cloud_bucket_mount.pyi +5 -1
  62. modal/cls.py +130 -102
  63. modal/cls.pyi +45 -85
  64. modal/config.py +29 -10
  65. modal/container_process.py +291 -13
  66. modal/container_process.pyi +95 -32
  67. modal/dict.py +282 -63
  68. modal/dict.pyi +423 -73
  69. modal/environments.py +15 -27
  70. modal/environments.pyi +5 -15
  71. modal/exception.py +8 -0
  72. modal/experimental/__init__.py +143 -38
  73. modal/experimental/flash.py +247 -78
  74. modal/experimental/flash.pyi +137 -9
  75. modal/file_io.py +14 -28
  76. modal/file_io.pyi +2 -2
  77. modal/file_pattern_matcher.py +25 -16
  78. modal/functions.pyi +134 -61
  79. modal/image.py +255 -86
  80. modal/image.pyi +300 -62
  81. modal/io_streams.py +436 -126
  82. modal/io_streams.pyi +236 -171
  83. modal/mount.py +62 -157
  84. modal/mount.pyi +45 -172
  85. modal/network_file_system.py +30 -53
  86. modal/network_file_system.pyi +16 -76
  87. modal/object.pyi +42 -8
  88. modal/parallel_map.py +821 -113
  89. modal/parallel_map.pyi +134 -0
  90. modal/partial_function.pyi +4 -1
  91. modal/proxy.py +16 -7
  92. modal/proxy.pyi +10 -2
  93. modal/queue.py +263 -61
  94. modal/queue.pyi +409 -66
  95. modal/runner.py +112 -92
  96. modal/runner.pyi +45 -27
  97. modal/sandbox.py +451 -124
  98. modal/sandbox.pyi +513 -67
  99. modal/secret.py +291 -67
  100. modal/secret.pyi +425 -19
  101. modal/serving.py +7 -11
  102. modal/serving.pyi +7 -8
  103. modal/snapshot.py +11 -8
  104. modal/token_flow.py +4 -4
  105. modal/volume.py +344 -98
  106. modal/volume.pyi +464 -68
  107. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +9 -8
  108. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  109. modal_docs/mdmd/mdmd.py +11 -1
  110. modal_proto/api.proto +399 -67
  111. modal_proto/api_grpc.py +241 -1
  112. modal_proto/api_pb2.py +1395 -1000
  113. modal_proto/api_pb2.pyi +1239 -79
  114. modal_proto/api_pb2_grpc.py +499 -4
  115. modal_proto/api_pb2_grpc.pyi +162 -14
  116. modal_proto/modal_api_grpc.py +175 -160
  117. modal_proto/sandbox_router.proto +145 -0
  118. modal_proto/sandbox_router_grpc.py +105 -0
  119. modal_proto/sandbox_router_pb2.py +149 -0
  120. modal_proto/sandbox_router_pb2.pyi +333 -0
  121. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  122. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  123. modal_proto/task_command_router.proto +144 -0
  124. modal_proto/task_command_router_grpc.py +105 -0
  125. modal_proto/task_command_router_pb2.py +149 -0
  126. modal_proto/task_command_router_pb2.pyi +333 -0
  127. modal_proto/task_command_router_pb2_grpc.py +203 -0
  128. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  129. modal_version/__init__.py +1 -1
  130. modal-1.0.6.dev58.dist-info/RECORD +0 -183
  131. modal_proto/modal_options_grpc.py +0 -3
  132. modal_proto/options.proto +0 -19
  133. modal_proto/options_grpc.py +0 -3
  134. modal_proto/options_pb2.py +0 -35
  135. modal_proto/options_pb2.pyi +0 -20
  136. modal_proto/options_pb2_grpc.py +0 -4
  137. modal_proto/options_pb2_grpc.pyi +0 -7
  138. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  139. /modal/{requirements → builder}/2023.12.txt +0 -0
  140. /modal/{requirements → builder}/2024.04.txt +0 -0
  141. /modal/{requirements → builder}/2024.10.txt +0 -0
  142. /modal/{requirements → builder}/README.md +0 -0
  143. /modal/{requirements → builder}/base-images.json +0 -0
  144. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  145. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  146. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  147. {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
modal/__main__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  # Copyright Modal Labs 2022
2
2
  import sys
3
3
 
4
+ from ._output import make_console
4
5
  from ._traceback import reduce_traceback_to_user_code
5
6
  from .cli._traceback import highlight_modal_warnings, setup_rich_traceback
6
7
  from .cli.entry_point import entrypoint_cli
@@ -35,9 +36,7 @@ def main():
35
36
  raise
36
37
 
37
38
  from grpclib import GRPCError, Status
38
- from rich.console import Console
39
39
  from rich.panel import Panel
40
- from rich.text import Text
41
40
 
42
41
  if isinstance(exc, GRPCError):
43
42
  status_map = {
@@ -68,8 +67,8 @@ def main():
68
67
  if notes := getattr(exc, "__notes__", []):
69
68
  content = f"{content}\n\nNote: {' '.join(notes)}"
70
69
 
71
- console = Console(stderr=True)
72
- panel = Panel(Text(content), title=title, title_align="left", border_style="red")
70
+ console = make_console(stderr=True)
71
+ panel = Panel(content, title=title, title_align="left", border_style="red")
73
72
  console.print(panel, highlight=False)
74
73
  sys.exit(1)
75
74
 
modal/_billing.py ADDED
@@ -0,0 +1,80 @@
1
+ # Copyright Modal Labs 2025
2
+ from datetime import datetime, timezone
3
+ from decimal import Decimal
4
+ from typing import Any, Optional, TypedDict
5
+
6
+ from modal_proto import api_pb2
7
+
8
+ from .client import _Client
9
+ from .exception import InvalidError
10
+
11
+
12
+ class WorkspaceBillingReportItem(TypedDict):
13
+ object_id: str
14
+ description: str
15
+ environment_name: str
16
+ interval_start: datetime
17
+ cost: Decimal
18
+ tags: dict[str, str]
19
+
20
+
21
+ async def _workspace_billing_report(
22
+ *,
23
+ start: datetime, # Start of the report, inclusive
24
+ end: Optional[datetime] = None, # End of the report, exclusive
25
+ resolution: str = "d", # Resolution, e.g. "d" for daily or "h" for hourly
26
+ tag_names: Optional[list[str]] = None, # Optional additional metadata to include
27
+ client: Optional[_Client] = None,
28
+ ) -> list[dict[str, Any]]:
29
+ """Generate a tabular report of workspace usage by object and time.
30
+
31
+ The result will be a list of dictionaries for each interval (determined by `resolution`)
32
+ between the `start` and `end` limits. The dictionary represents a single Modal object
33
+ that billing can be attributed to (e.g., an App) along with metadata (including user-defined
34
+ tags) for identifying that object.
35
+
36
+ The `start` and `end` parameters are required to either have a UTC timezone or to be
37
+ timezone-naive (which will be interpreted as UTC times). The timestamps in the result will
38
+ be in UTC. Cost will be reported for full intervals, even if the provided `start` or `end`
39
+ parameters are partial: `start` will be rounded to the beginning of its interval, while
40
+ partial `end` intervals will be excluded.
41
+
42
+ Additional user-provided metadata can be included in the report if the objects have tags
43
+ and `tag_names` (i.e., keys) are specified in the request. Note that tags will be attributed
44
+ to the entire interval even if they were added or removed at some point within it.
45
+
46
+ """
47
+ if client is None:
48
+ client = await _Client.from_env()
49
+
50
+ tag_names = tag_names or []
51
+
52
+ if end is None:
53
+ end = datetime.now(timezone.utc)
54
+
55
+ for dt in (start, end):
56
+ if dt.tzinfo is None:
57
+ dt = dt.replace(tzinfo=timezone.utc)
58
+ elif dt.tzinfo != timezone.utc:
59
+ raise InvalidError("Timezone-aware start/end limits must be in UTC.")
60
+
61
+ request = api_pb2.WorkspaceBillingReportRequest(
62
+ resolution=resolution,
63
+ tag_names=tag_names,
64
+ )
65
+ request.start_timestamp.FromDatetime(start)
66
+ request.end_timestamp.FromDatetime(end)
67
+
68
+ rows = []
69
+ async for pb_item in client.stub.WorkspaceBillingReport.unary_stream(request):
70
+ item = {
71
+ "object_id": pb_item.object_id,
72
+ "description": pb_item.description,
73
+ "environment_name": pb_item.environment_name,
74
+ "interval_start": pb_item.interval.ToDatetime().replace(tzinfo=timezone.utc),
75
+ "cost": Decimal(pb_item.cost),
76
+ "tags": dict(pb_item.tags),
77
+ }
78
+ rows.append(item)
79
+
80
+ return rows
@@ -5,7 +5,6 @@ from dataclasses import dataclass
5
5
  from typing import Optional
6
6
 
7
7
  from modal._utils.async_utils import synchronize_api
8
- from modal._utils.grpc_utils import retry_transient_errors
9
8
  from modal.client import _Client
10
9
  from modal.exception import InvalidError
11
10
  from modal_proto import api_pb2
@@ -14,7 +13,9 @@ from modal_proto import api_pb2
14
13
  @dataclass
15
14
  class ClusterInfo:
16
15
  rank: int
16
+ cluster_id: str
17
17
  container_ips: list[str]
18
+ container_ipv4_ips: list[str]
18
19
 
19
20
 
20
21
  cluster_info: Optional[ClusterInfo] = None
@@ -59,8 +60,7 @@ async def _initialize_clustered_function(client: _Client, task_id: str, world_si
59
60
  os.environ["NCCL_NSOCKS_PERTHREAD"] = "1"
60
61
 
61
62
  if world_size > 1:
62
- resp: api_pb2.TaskClusterHelloResponse = await retry_transient_errors(
63
- client.stub.TaskClusterHello,
63
+ resp = await client.stub.TaskClusterHello(
64
64
  api_pb2.TaskClusterHelloRequest(
65
65
  task_id=task_id,
66
66
  container_ip=container_ip,
@@ -68,12 +68,16 @@ async def _initialize_clustered_function(client: _Client, task_id: str, world_si
68
68
  )
69
69
  cluster_info = ClusterInfo(
70
70
  rank=resp.cluster_rank,
71
+ cluster_id=resp.cluster_id,
71
72
  container_ips=resp.container_ips,
73
+ container_ipv4_ips=resp.container_ipv4_ips,
72
74
  )
73
75
  else:
74
76
  cluster_info = ClusterInfo(
75
77
  rank=0,
78
+ cluster_id="", # No cluster ID for single-node # TODO(irfansharif): Is this right?
76
79
  container_ips=[container_ip],
80
+ container_ipv4_ips=[], # No IPv4 IPs for single-node
77
81
  )
78
82
 
79
83
 
@@ -3,12 +3,14 @@ import typing
3
3
  import typing_extensions
4
4
 
5
5
  class ClusterInfo:
6
- """ClusterInfo(rank: int, container_ips: list[str])"""
6
+ """ClusterInfo(rank: int, cluster_id: str, container_ips: list[str], container_ipv4_ips: list[str])"""
7
7
 
8
8
  rank: int
9
+ cluster_id: str
9
10
  container_ips: list[str]
11
+ container_ipv4_ips: list[str]
10
12
 
11
- def __init__(self, rank: int, container_ips: list[str]) -> None:
13
+ def __init__(self, rank: int, cluster_id: str, container_ips: list[str], container_ipv4_ips: list[str]) -> None:
12
14
  """Initialize self. See help(type(self)) for accurate signature."""
13
15
  ...
14
16
 
@@ -32,14 +32,14 @@ from modal._partial_function import (
32
32
  _PartialFunctionFlags,
33
33
  )
34
34
  from modal._serialization import deserialize, deserialize_params
35
- from modal._utils.async_utils import TaskContext, synchronizer
35
+ from modal._utils.async_utils import TaskContext, aclosing, synchronizer
36
36
  from modal._utils.function_utils import (
37
37
  callable_has_non_self_params,
38
38
  )
39
39
  from modal.app import App, _App
40
40
  from modal.client import Client, _Client
41
41
  from modal.config import logger
42
- from modal.exception import ExecutionError, InputCancellation, InvalidError
42
+ from modal.exception import ExecutionError, InputCancellation
43
43
  from modal.running_app import RunningApp, running_app_from_layout
44
44
  from modal_proto import api_pb2
45
45
 
@@ -184,86 +184,75 @@ def call_function(
184
184
  batch_wait_ms: int,
185
185
  ):
186
186
  async def run_input_async(io_context: IOContext) -> None:
187
+ reset_context = execution_context._set_current_context_ids(
188
+ io_context.input_ids, io_context.function_call_ids, io_context.attempt_tokens
189
+ )
187
190
  started_at = time.time()
188
- input_ids, function_call_ids = io_context.input_ids, io_context.function_call_ids
189
- reset_context = execution_context._set_current_context_ids(input_ids, function_call_ids)
190
191
  async with container_io_manager.handle_input_exception.aio(io_context, started_at):
191
- res = io_context.call_finalized_function()
192
192
  # TODO(erikbern): any exception below shouldn't be considered a user exception
193
193
  if io_context.finalized_function.is_generator:
194
- if not inspect.isasyncgen(res):
195
- raise InvalidError(f"Async generator function returned value of type {type(res)}")
196
-
197
194
  # Send up to this many outputs at a time.
195
+ current_function_call_id = execution_context.current_function_call_id()
196
+ assert current_function_call_id is not None # Set above.
197
+ current_attempt_token = execution_context.current_attempt_token()
198
+ assert current_attempt_token is not None # Set above, but can be empty string.
198
199
  generator_queue: asyncio.Queue[Any] = await container_io_manager._queue_create.aio(1024)
199
200
  async with container_io_manager.generator_output_sender(
200
- function_call_ids[0],
201
- io_context.finalized_function.data_format,
201
+ current_function_call_id,
202
+ current_attempt_token,
203
+ io_context._generator_output_format(),
202
204
  generator_queue,
203
205
  ):
204
206
  item_count = 0
205
- async for value in res:
206
- await container_io_manager._queue_put.aio(generator_queue, value)
207
- item_count += 1
207
+ async with aclosing(io_context.call_generator_async()) as gen:
208
+ async for value in gen:
209
+ await container_io_manager._queue_put.aio(generator_queue, value)
210
+ item_count += 1
208
211
 
209
- message = api_pb2.GeneratorDone(items_total=item_count)
210
- await container_io_manager.push_outputs.aio(
211
- io_context,
212
- started_at,
213
- message,
214
- api_pb2.DATA_FORMAT_GENERATOR_DONE,
212
+ await container_io_manager._send_outputs.aio(
213
+ started_at, io_context.output_items_generator_done(started_at, item_count)
215
214
  )
216
215
  else:
217
- if not inspect.iscoroutine(res) or inspect.isgenerator(res) or inspect.isasyncgen(res):
218
- raise InvalidError(
219
- f"Async (non-generator) function returned value of type {type(res)}"
220
- " You might need to use @app.function(..., is_generator=True)."
221
- )
222
- value = await res
216
+ value = await io_context.call_function_async()
223
217
  await container_io_manager.push_outputs.aio(
224
218
  io_context,
225
219
  started_at,
226
220
  value,
227
- io_context.finalized_function.data_format,
228
221
  )
229
222
  reset_context()
230
223
 
231
224
  def run_input_sync(io_context: IOContext) -> None:
232
225
  started_at = time.time()
233
- input_ids, function_call_ids = io_context.input_ids, io_context.function_call_ids
234
- reset_context = execution_context._set_current_context_ids(input_ids, function_call_ids)
226
+ reset_context = execution_context._set_current_context_ids(
227
+ io_context.input_ids, io_context.function_call_ids, io_context.attempt_tokens
228
+ )
235
229
  with container_io_manager.handle_input_exception(io_context, started_at):
236
- res = io_context.call_finalized_function()
237
-
238
230
  # TODO(erikbern): any exception below shouldn't be considered a user exception
239
231
  if io_context.finalized_function.is_generator:
240
- if not inspect.isgenerator(res):
241
- raise InvalidError(f"Generator function returned value of type {type(res)}")
242
-
232
+ gen = io_context.call_generator_sync()
243
233
  # Send up to this many outputs at a time.
234
+ current_function_call_id = execution_context.current_function_call_id()
235
+ assert current_function_call_id is not None # Set above.
236
+ current_attempt_token = execution_context.current_attempt_token()
237
+ assert current_attempt_token is not None # Set above, but can be empty string.
244
238
  generator_queue: asyncio.Queue[Any] = container_io_manager._queue_create(1024)
245
-
246
239
  with container_io_manager.generator_output_sender(
247
- function_call_ids[0],
248
- io_context.finalized_function.data_format,
240
+ current_function_call_id,
241
+ current_attempt_token,
242
+ io_context._generator_output_format(),
249
243
  generator_queue,
250
244
  ):
251
245
  item_count = 0
252
- for value in res:
246
+ for value in gen:
253
247
  container_io_manager._queue_put(generator_queue, value)
254
248
  item_count += 1
255
249
 
256
- message = api_pb2.GeneratorDone(items_total=item_count)
257
- container_io_manager.push_outputs(io_context, started_at, message, api_pb2.DATA_FORMAT_GENERATOR_DONE)
258
- else:
259
- if inspect.iscoroutine(res) or inspect.isgenerator(res) or inspect.isasyncgen(res):
260
- raise InvalidError(
261
- f"Sync (non-generator) function return value of type {type(res)}."
262
- " You might need to use @app.function(..., is_generator=True)."
263
- )
264
- container_io_manager.push_outputs(
265
- io_context, started_at, res, io_context.finalized_function.data_format
250
+ container_io_manager._send_outputs(
251
+ started_at, io_context.output_items_generator_done(started_at, item_count)
266
252
  )
253
+ else:
254
+ values = io_context.call_function_sync()
255
+ container_io_manager.push_outputs(io_context, started_at, values)
267
256
  reset_context()
268
257
 
269
258
  if container_io_manager.input_concurrency_enabled:
@@ -426,9 +415,9 @@ def main(container_args: api_pb2.ContainerArguments, client: Client):
426
415
  param_kwargs,
427
416
  )
428
417
  else:
418
+ assert ser_usr_cls is None
429
419
  service = import_single_function_service(
430
420
  function_def,
431
- ser_usr_cls,
432
421
  ser_fun,
433
422
  )
434
423
 
@@ -461,7 +450,10 @@ def main(container_args: api_pb2.ContainerArguments, client: Client):
461
450
  f"Function has {len(service.service_deps)} dependencies"
462
451
  f" but container got {len(dep_object_ids)} object ids.\n"
463
452
  f"Code deps: {service.service_deps}\n"
464
- f"Object ids: {dep_object_ids}"
453
+ f"Object ids: {dep_object_ids}\n"
454
+ "\n"
455
+ "This can happen if you are defining Modal objects under a conditional statement "
456
+ "that evaluates differently in the local and remote environments."
465
457
  )
466
458
  for object_id, obj in zip(dep_object_ids, service.service_deps):
467
459
  metadata: Message = container_app.object_handle_metadata[object_id]