modal 1.1.5.dev66__py3-none-any.whl → 1.3.1.dev8__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 (143) hide show
  1. modal/__init__.py +4 -4
  2. modal/__main__.py +4 -29
  3. modal/_billing.py +84 -0
  4. modal/_clustered_functions.py +1 -3
  5. modal/_container_entrypoint.py +33 -208
  6. modal/_functions.py +171 -138
  7. modal/_grpc_client.py +191 -0
  8. modal/_ipython.py +16 -6
  9. modal/_load_context.py +106 -0
  10. modal/_object.py +72 -21
  11. modal/_output.py +12 -14
  12. modal/_partial_function.py +31 -4
  13. modal/_resolver.py +44 -57
  14. modal/_runtime/container_io_manager.py +30 -28
  15. modal/_runtime/container_io_manager.pyi +42 -44
  16. modal/_runtime/gpu_memory_snapshot.py +9 -7
  17. modal/_runtime/user_code_event_loop.py +80 -0
  18. modal/_runtime/user_code_imports.py +236 -10
  19. modal/_serialization.py +2 -1
  20. modal/_traceback.py +4 -13
  21. modal/_tunnel.py +16 -11
  22. modal/_tunnel.pyi +25 -3
  23. modal/_utils/async_utils.py +337 -10
  24. modal/_utils/auth_token_manager.py +1 -4
  25. modal/_utils/blob_utils.py +29 -22
  26. modal/_utils/function_utils.py +20 -21
  27. modal/_utils/grpc_testing.py +6 -3
  28. modal/_utils/grpc_utils.py +223 -64
  29. modal/_utils/mount_utils.py +26 -1
  30. modal/_utils/name_utils.py +2 -3
  31. modal/_utils/package_utils.py +0 -1
  32. modal/_utils/rand_pb_testing.py +8 -1
  33. modal/_utils/task_command_router_client.py +524 -0
  34. modal/_vendor/cloudpickle.py +144 -48
  35. modal/app.py +285 -105
  36. modal/app.pyi +216 -53
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +6 -3
  39. modal/builder/PREVIEW.txt +2 -1
  40. modal/builder/base-images.json +4 -2
  41. modal/cli/_download.py +19 -3
  42. modal/cli/cluster.py +4 -2
  43. modal/cli/config.py +3 -1
  44. modal/cli/container.py +5 -4
  45. modal/cli/dict.py +5 -2
  46. modal/cli/entry_point.py +26 -2
  47. modal/cli/environment.py +2 -16
  48. modal/cli/launch.py +1 -76
  49. modal/cli/network_file_system.py +5 -20
  50. modal/cli/programs/run_jupyter.py +1 -1
  51. modal/cli/programs/vscode.py +1 -1
  52. modal/cli/queues.py +5 -4
  53. modal/cli/run.py +24 -204
  54. modal/cli/secret.py +1 -2
  55. modal/cli/shell.py +375 -0
  56. modal/cli/utils.py +1 -13
  57. modal/cli/volume.py +11 -17
  58. modal/client.py +16 -125
  59. modal/client.pyi +94 -144
  60. modal/cloud_bucket_mount.py +3 -1
  61. modal/cloud_bucket_mount.pyi +4 -0
  62. modal/cls.py +101 -64
  63. modal/cls.pyi +9 -8
  64. modal/config.py +21 -1
  65. modal/container_process.py +288 -12
  66. modal/container_process.pyi +99 -38
  67. modal/dict.py +72 -33
  68. modal/dict.pyi +88 -57
  69. modal/environments.py +16 -8
  70. modal/environments.pyi +6 -2
  71. modal/exception.py +154 -16
  72. modal/experimental/__init__.py +24 -53
  73. modal/experimental/flash.py +161 -74
  74. modal/experimental/flash.pyi +97 -49
  75. modal/file_io.py +50 -92
  76. modal/file_io.pyi +117 -89
  77. modal/functions.pyi +70 -87
  78. modal/image.py +82 -47
  79. modal/image.pyi +51 -30
  80. modal/io_streams.py +500 -149
  81. modal/io_streams.pyi +279 -189
  82. modal/mount.py +60 -46
  83. modal/mount.pyi +41 -17
  84. modal/network_file_system.py +19 -11
  85. modal/network_file_system.pyi +72 -39
  86. modal/object.pyi +114 -22
  87. modal/parallel_map.py +42 -44
  88. modal/parallel_map.pyi +9 -17
  89. modal/partial_function.pyi +4 -2
  90. modal/proxy.py +14 -6
  91. modal/proxy.pyi +10 -2
  92. modal/queue.py +45 -38
  93. modal/queue.pyi +88 -52
  94. modal/runner.py +96 -96
  95. modal/runner.pyi +44 -27
  96. modal/sandbox.py +225 -107
  97. modal/sandbox.pyi +226 -60
  98. modal/secret.py +58 -56
  99. modal/secret.pyi +28 -13
  100. modal/serving.py +7 -11
  101. modal/serving.pyi +7 -8
  102. modal/snapshot.py +29 -15
  103. modal/snapshot.pyi +18 -10
  104. modal/token_flow.py +1 -1
  105. modal/token_flow.pyi +4 -6
  106. modal/volume.py +102 -55
  107. modal/volume.pyi +125 -66
  108. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/METADATA +10 -9
  109. modal-1.3.1.dev8.dist-info/RECORD +189 -0
  110. modal_proto/api.proto +141 -70
  111. modal_proto/api_grpc.py +42 -26
  112. modal_proto/api_pb2.py +1123 -1103
  113. modal_proto/api_pb2.pyi +331 -83
  114. modal_proto/api_pb2_grpc.py +80 -48
  115. modal_proto/api_pb2_grpc.pyi +26 -18
  116. modal_proto/modal_api_grpc.py +175 -174
  117. modal_proto/task_command_router.proto +164 -0
  118. modal_proto/task_command_router_grpc.py +138 -0
  119. modal_proto/task_command_router_pb2.py +180 -0
  120. modal_proto/{sandbox_router_pb2.pyi → task_command_router_pb2.pyi} +148 -57
  121. modal_proto/task_command_router_pb2_grpc.py +272 -0
  122. modal_proto/task_command_router_pb2_grpc.pyi +100 -0
  123. modal_version/__init__.py +1 -1
  124. modal_version/__main__.py +1 -1
  125. modal/cli/programs/launch_instance_ssh.py +0 -94
  126. modal/cli/programs/run_marimo.py +0 -95
  127. modal-1.1.5.dev66.dist-info/RECORD +0 -191
  128. modal_proto/modal_options_grpc.py +0 -3
  129. modal_proto/options.proto +0 -19
  130. modal_proto/options_grpc.py +0 -3
  131. modal_proto/options_pb2.py +0 -35
  132. modal_proto/options_pb2.pyi +0 -20
  133. modal_proto/options_pb2_grpc.py +0 -4
  134. modal_proto/options_pb2_grpc.pyi +0 -7
  135. modal_proto/sandbox_router.proto +0 -125
  136. modal_proto/sandbox_router_grpc.py +0 -89
  137. modal_proto/sandbox_router_pb2.py +0 -128
  138. modal_proto/sandbox_router_pb2_grpc.py +0 -169
  139. modal_proto/sandbox_router_pb2_grpc.pyi +0 -63
  140. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
  141. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
  142. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
  143. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/top_level.txt +0 -0
modal/cli/shell.py ADDED
@@ -0,0 +1,375 @@
1
+ # Copyright Modal Labs 2022
2
+ import inspect
3
+ import platform
4
+ import shlex
5
+ from pathlib import Path, PurePosixPath
6
+ from typing import Any, Callable, Optional
7
+
8
+ import typer
9
+ from click import ClickException
10
+
11
+ from .._functions import _FunctionSpec
12
+ from ..app import App
13
+ from ..environments import ensure_env
14
+ from ..exception import InvalidError, NotFoundError
15
+ from ..functions import Function
16
+ from ..image import Image
17
+ from ..mount import _Mount
18
+ from ..runner import interactive_shell
19
+ from ..sandbox import Sandbox
20
+ from ..secret import Secret
21
+ from ..volume import Volume
22
+ from .container import exec
23
+ from .import_refs import (
24
+ MethodReference,
25
+ import_and_filter,
26
+ parse_import_ref,
27
+ )
28
+ from .run import _get_runnable_list
29
+ from .utils import ENV_OPTION, is_tty
30
+
31
+
32
+ def _params_from_signature(
33
+ func: Callable[..., Any],
34
+ ) -> dict[str, typer.models.ParameterInfo]:
35
+ sig = inspect.signature(func)
36
+ params = {param_name: param.default for param_name, param in sig.parameters.items()}
37
+ assert all(isinstance(param, typer.models.ParameterInfo) for param in params.values()), (
38
+ f"All params to {func.__name__} must be of type typer.models.ParameterInfo."
39
+ )
40
+ return params
41
+
42
+
43
+ def _passed_forbidden_args(
44
+ param_objs: dict[str, typer.models.ParameterInfo],
45
+ passed_args: dict[str, Any],
46
+ allowed: Callable[[str], bool],
47
+ ) -> list[str]:
48
+ """Check which forbidden arguments were passed with non-default values."""
49
+ passed_forbidden: list[str] = []
50
+ for param_name, param_obj in param_objs.items():
51
+ if allowed(param_name):
52
+ continue
53
+
54
+ assert param_obj.param_decls is not None, "All params must be typer.models.ParameterInfo, and have param_decls."
55
+
56
+ if passed_args.get(param_name) != param_obj.default:
57
+ passed_forbidden.append("/".join(param_obj.param_decls))
58
+
59
+ return passed_forbidden
60
+
61
+
62
+ def _is_valid_modal_id(ref: str, prefix: str) -> bool:
63
+ assert prefix.endswith("-")
64
+ return ref.startswith(prefix) and len(ref[len(prefix) :]) > 0 and ref[len(prefix) :].isalnum()
65
+
66
+
67
+ def _is_running_container_ref(ref: Optional[str]) -> bool:
68
+ if ref is None:
69
+ return False
70
+ return _is_valid_modal_id(ref, "sb-") or _is_valid_modal_id(ref, "ta-")
71
+
72
+
73
+ def _start_shell_in_running_container(ref: str, cmd: str, pty: bool) -> None:
74
+ if _is_valid_modal_id(ref, "sb-"):
75
+ try:
76
+ sandbox = Sandbox.from_id(ref)
77
+ ref = sandbox._get_task_id()
78
+ except NotFoundError as e:
79
+ raise ClickException(f"Sandbox '{ref}' not found (is it still running?)")
80
+ except Exception as e:
81
+ raise ClickException(f"Error connecting to Sandbox '{ref}': {str(e)}")
82
+
83
+ assert _is_valid_modal_id(ref, "ta-")
84
+ try:
85
+ exec(container_id=ref, command=shlex.split(cmd), pty=pty)
86
+ except NotFoundError as e:
87
+ raise ClickException(f"Container '{ref}' not found (is it still running?)")
88
+ except Exception as e:
89
+ raise ClickException(f"Error connecting to container '{ref}': {str(e)}")
90
+
91
+
92
+ def _function_spec_from_ref(ref: str, use_module_mode: bool) -> _FunctionSpec:
93
+ import_ref = parse_import_ref(ref, use_module_mode=use_module_mode)
94
+ runnable, all_usable_commands = import_and_filter(
95
+ import_ref, base_cmd="modal shell", accept_local_entrypoint=False, accept_webhook=True
96
+ )
97
+ if not runnable:
98
+ help_header = (
99
+ "Specify a Modal function to start a shell session for. E.g.\n"
100
+ f"> modal shell {import_ref.file_or_module}::my_function"
101
+ )
102
+
103
+ if all_usable_commands:
104
+ help_footer = f"The selected module '{import_ref.file_or_module}' has the following choices:\n\n"
105
+ help_footer += _get_runnable_list(all_usable_commands)
106
+ else:
107
+ help_footer = f"The selected module '{import_ref.file_or_module}' has no Modal functions or classes."
108
+
109
+ raise ClickException(f"{help_header}\n\n{help_footer}")
110
+
111
+ if isinstance(runnable, MethodReference):
112
+ # TODO: let users specify a class instead of a method, since they use the same environment
113
+ class_service_function = runnable.cls._get_class_service_function()
114
+ return class_service_function.spec
115
+ elif isinstance(runnable, Function):
116
+ return runnable.spec
117
+
118
+ raise ValueError("Referenced entity is not a Modal Function or Cls")
119
+
120
+
121
+ def _start_shell_from_function_spec(
122
+ app: App,
123
+ cmds: list[str],
124
+ env: str,
125
+ timeout: int,
126
+ function_spec: _FunctionSpec,
127
+ pty: bool,
128
+ ) -> None:
129
+ interactive_shell(
130
+ app,
131
+ cmds=cmds,
132
+ environment_name=env,
133
+ timeout=timeout,
134
+ image=function_spec.image,
135
+ mounts=function_spec.mounts,
136
+ secrets=function_spec.secrets,
137
+ network_file_systems=function_spec.network_file_systems,
138
+ gpu=function_spec.gpus,
139
+ cloud=function_spec.cloud,
140
+ cpu=function_spec.cpu,
141
+ memory=function_spec.memory,
142
+ volumes=function_spec.volumes,
143
+ region=function_spec.scheduler_placement.regions if function_spec.scheduler_placement else None,
144
+ pty=pty,
145
+ proxy=function_spec.proxy,
146
+ )
147
+
148
+
149
+ def _start_shell_from_image(
150
+ app: App,
151
+ cmds: list[str],
152
+ env: str,
153
+ timeout: int,
154
+ modal_image: Optional[Image],
155
+ volume: list[str],
156
+ secret: list[str],
157
+ add_local: list[str],
158
+ cpu: Optional[int],
159
+ memory: Optional[int],
160
+ gpu: Optional[str],
161
+ cloud: Optional[str],
162
+ region: Optional[str],
163
+ pty: bool,
164
+ ) -> None:
165
+ volumes = {f"/mnt/{vol}": Volume.from_name(vol) for vol in volume}
166
+ secrets = [Secret.from_name(s) for s in secret]
167
+
168
+ mounts = []
169
+ for local_path_str in add_local:
170
+ local_path = Path(local_path_str).expanduser().resolve()
171
+ remote_path = PurePosixPath(f"/mnt/{local_path.name}")
172
+
173
+ if local_path.is_dir():
174
+ m = _Mount._from_local_dir(local_path, remote_path=remote_path)
175
+ else:
176
+ m = _Mount._from_local_file(local_path, remote_path=remote_path)
177
+ mounts.append(m)
178
+
179
+ interactive_shell(
180
+ app,
181
+ cmds=cmds,
182
+ environment_name=env,
183
+ timeout=timeout,
184
+ image=modal_image,
185
+ mounts=mounts,
186
+ cpu=cpu,
187
+ memory=memory,
188
+ gpu=gpu,
189
+ cloud=cloud,
190
+ volumes=volumes,
191
+ secrets=secrets,
192
+ region=region.split(",") if region else [],
193
+ pty=pty,
194
+ )
195
+
196
+
197
+ def shell(
198
+ ref: Optional[str] = typer.Argument(
199
+ default=None,
200
+ help=(
201
+ "ID of running container or Sandbox, or path to a Python file containing an App."
202
+ " Can also include a Function specifier, like `module.py::func`, if the file defines multiple Functions."
203
+ ),
204
+ ),
205
+ cmd: str = typer.Option("/bin/bash", "-c", "--cmd", help="Command to run inside the Modal image."),
206
+ env: Optional[str] = ENV_OPTION,
207
+ image: Optional[str] = typer.Option(
208
+ None, "--image", help="Container image tag for inside the shell (if not using REF)."
209
+ ),
210
+ add_python: Optional[str] = typer.Option(None, "--add-python", help="Add Python to the image (if not using REF)."),
211
+ volume: Optional[list[str]] = typer.Option(
212
+ None,
213
+ "--volume",
214
+ help=(
215
+ "Name of a `modal.Volume` to mount inside the shell at `/mnt/{name}` (if not using REF)."
216
+ " Can be used multiple times."
217
+ ),
218
+ ),
219
+ add_local: Optional[list[str]] = typer.Option(
220
+ None,
221
+ "--add-local",
222
+ help=(
223
+ "Local file or directory to mount inside the shell at `/mnt/{basename}` (if not using REF)."
224
+ " Can be used multiple times."
225
+ ),
226
+ ),
227
+ secret: Optional[list[str]] = typer.Option(
228
+ None,
229
+ "--secret",
230
+ help=("Name of a `modal.Secret` to mount inside the shell (if not using REF). Can be used multiple times."),
231
+ ),
232
+ cpu: Optional[int] = typer.Option(
233
+ None, "--cpu", help="Number of CPUs to allocate to the shell (if not using REF)."
234
+ ),
235
+ memory: Optional[int] = typer.Option(
236
+ None, "--memory", help="Memory to allocate for the shell, in MiB (if not using REF)."
237
+ ),
238
+ gpu: Optional[str] = typer.Option(
239
+ None,
240
+ "--gpu",
241
+ help="GPUs to request for the shell, if any. Examples are `any`, `a10g`, `a100:4` (if not using REF).",
242
+ ),
243
+ cloud: Optional[str] = typer.Option(
244
+ None,
245
+ "--cloud",
246
+ help=(
247
+ "Cloud provider to run the shell on. Possible values are `aws`, `gcp`, `oci`, `auto` (if not using REF)."
248
+ ),
249
+ ),
250
+ region: Optional[str] = typer.Option(
251
+ None,
252
+ "--region",
253
+ help=(
254
+ "Region(s) to run the container on. "
255
+ "Can be a single region or a comma-separated list to choose from (if not using REF)."
256
+ ),
257
+ ),
258
+ pty: Optional[bool] = typer.Option(None, "--pty", help="Run the command using a PTY."),
259
+ use_module_mode: bool = typer.Option(
260
+ False, "-m", help="Interpret argument as a Python module path instead of a file/script path"
261
+ ),
262
+ ):
263
+ """Run a command or interactive shell inside a Modal container.
264
+
265
+ **Examples:**
266
+
267
+ Start an interactive shell inside the default Debian-based image:
268
+
269
+ ```
270
+ modal shell
271
+ ```
272
+
273
+ Start an interactive shell with the spec for `my_function` in your App
274
+ (uses the same image, volumes, mounts, etc.):
275
+
276
+ ```
277
+ modal shell hello_world.py::my_function
278
+ ```
279
+
280
+ Or, if you're using a [modal.Cls](https://modal.com/docs/reference/modal.Cls)
281
+ you can refer to a `@modal.method` directly:
282
+
283
+ ```
284
+ modal shell hello_world.py::MyClass.my_method
285
+ ```
286
+
287
+ Start a `python` shell:
288
+
289
+ ```
290
+ modal shell hello_world.py --cmd=python
291
+ ```
292
+
293
+ Run a command with your function's spec and pipe the output to a file:
294
+
295
+ ```
296
+ modal shell hello_world.py -c 'uv pip list' > env.txt
297
+ ```
298
+
299
+ Connect to a running Sandbox by ID:
300
+
301
+ ```
302
+ modal shell sb-abc123xyz
303
+ ```
304
+ """
305
+ if pty is None:
306
+ pty = is_tty()
307
+
308
+ if platform.system() == "Windows":
309
+ raise InvalidError("`modal shell` is currently not supported on Windows")
310
+
311
+ param_objs = _params_from_signature(shell)
312
+
313
+ if ref is not None and _is_running_container_ref(ref):
314
+ # We're attaching to an already running container or Sandbox.
315
+ if passed_forbidden := _passed_forbidden_args(
316
+ param_objs, locals(), allowed=lambda p: p in {"cmd", "pty", "ref"}
317
+ ):
318
+ raise ClickException(
319
+ f"Cannot specify container configuration arguments ({', '.join(passed_forbidden)}) "
320
+ f"when attaching to an already running container or Sandbox ('{ref}')."
321
+ )
322
+
323
+ _start_shell_in_running_container(ref, cmd, pty)
324
+ return
325
+
326
+ # We're not attaching to an existing container, so we need to create a new one.
327
+ env = ensure_env(env)
328
+ app = App("modal shell")
329
+
330
+ # NB: invoking under bash makes --cmd a lot more flexible.
331
+ cmds = shlex.split(f'/bin/bash -c "{cmd}"')
332
+ timeout = 3600
333
+
334
+ if ref is not None and not _is_valid_modal_id(ref, "im-"):
335
+ # If ref it not a Modal Image ID, then it's a function reference, and we'll start a new container from its spec.
336
+ if passed_forbidden := _passed_forbidden_args(
337
+ param_objs, locals(), allowed=lambda p: p in {"cmd", "env", "pty", "ref", "use_module_mode"}
338
+ ):
339
+ raise ClickException(
340
+ f"Cannot specify container configuration arguments ({', '.join(passed_forbidden)}) "
341
+ f"when starting a new container from a function reference ('{ref}')."
342
+ )
343
+
344
+ function_spec = _function_spec_from_ref(ref, use_module_mode)
345
+ _start_shell_from_function_spec(app, cmds, env, timeout, function_spec, pty)
346
+ return
347
+
348
+ if ref is not None and _is_valid_modal_id(ref, "im-"):
349
+ if passed_forbidden := _passed_forbidden_args(
350
+ param_objs, locals(), allowed=lambda p: p not in {"add_python", "image"}
351
+ ):
352
+ raise ClickException(
353
+ f"Cannot specify {', '.join(passed_forbidden)} argument(s) "
354
+ f"when starting a new container from a Modal Image ID ('{ref}')."
355
+ )
356
+ modal_image = Image.from_id(ref)
357
+ else:
358
+ modal_image = Image.from_registry(image, add_python=add_python) if image else None
359
+
360
+ _start_shell_from_image(
361
+ app,
362
+ cmds,
363
+ env,
364
+ timeout,
365
+ modal_image,
366
+ volume or [],
367
+ secret or [],
368
+ add_local or [],
369
+ cpu,
370
+ memory,
371
+ gpu,
372
+ cloud,
373
+ region,
374
+ pty,
375
+ )
modal/cli/utils.py CHANGED
@@ -5,8 +5,6 @@ from json import dumps
5
5
  from typing import Optional, Union
6
6
 
7
7
  import typer
8
- from click import UsageError
9
- from grpclib import GRPCError, Status
10
8
  from rich.table import Column, Table
11
9
  from rich.text import Text
12
10
 
@@ -33,11 +31,6 @@ async def stream_app_logs(
33
31
  await get_app_logs_loop(client, output_mgr, app_id=app_id, task_id=task_id, app_logs_url=app_logs_url)
34
32
  except asyncio.CancelledError:
35
33
  pass
36
- except GRPCError as exc:
37
- if exc.status in (Status.INVALID_ARGUMENT, Status.NOT_FOUND):
38
- raise UsageError(exc.message)
39
- else:
40
- raise
41
34
  except KeyboardInterrupt:
42
35
  pass
43
36
 
@@ -48,12 +41,7 @@ async def get_app_id_from_name(name: str, env: Optional[str], client: Optional[_
48
41
  client = await _Client.from_env()
49
42
  env_name = ensure_env(env)
50
43
  request = api_pb2.AppGetByDeploymentNameRequest(name=name, environment_name=env_name)
51
- try:
52
- resp = await client.stub.AppGetByDeploymentName(request)
53
- except GRPCError as exc:
54
- if exc.status in (Status.INVALID_ARGUMENT, Status.NOT_FOUND):
55
- raise UsageError(exc.message or "")
56
- raise
44
+ resp = await client.stub.AppGetByDeploymentName(request)
57
45
  if not resp.app_id:
58
46
  env_comment = f" in the '{env_name}' environment" if env_name else ""
59
47
  raise NotFoundError(f"Could not find a deployed app named '{name}'{env_comment}.")
modal/cli/volume.py CHANGED
@@ -6,7 +6,6 @@ from typing import Optional
6
6
 
7
7
  import typer
8
8
  from click import UsageError
9
- from grpclib import GRPCError, Status
10
9
  from rich.syntax import Syntax
11
10
  from typer import Argument, Option, Typer
12
11
 
@@ -96,7 +95,13 @@ async def get(
96
95
  console = make_console()
97
96
  progress_handler = ProgressHandler(type="download", console=console)
98
97
  with progress_handler.live:
99
- await _volume_download(volume, remote_path, destination, force, progress_cb=progress_handler.progress)
98
+ await _volume_download(
99
+ volume=volume,
100
+ remote_path=remote_path,
101
+ local_destination=destination,
102
+ overwrite=force,
103
+ progress_cb=progress_handler.progress,
104
+ )
100
105
  console.print(OutputManager.step_completed("Finished downloading files to local!"))
101
106
 
102
107
 
@@ -131,18 +136,12 @@ async def ls(
131
136
  ):
132
137
  ensure_env(env)
133
138
  vol = _Volume.from_name(volume_name, environment_name=env)
134
-
135
- try:
136
- entries = await vol.listdir(path)
137
- except GRPCError as exc:
138
- if exc.status in (Status.INVALID_ARGUMENT, Status.NOT_FOUND):
139
- raise UsageError(exc.message)
140
- raise
139
+ entries = await vol.listdir(path)
141
140
 
142
141
  if not json and not sys.stdout.isatty():
143
142
  # Legacy behavior -- I am not sure why exactly we did this originally but I don't want to break it
144
143
  for entry in entries:
145
- print(entry.path)
144
+ print(entry.path) # noqa: T201
146
145
  else:
147
146
  rows = []
148
147
  for entry in entries:
@@ -241,14 +240,9 @@ async def rm(
241
240
  ):
242
241
  ensure_env(env)
243
242
  volume = _Volume.from_name(volume_name, environment_name=env)
243
+ await volume.remove_file(remote_path, recursive=recursive)
244
244
  console = make_console()
245
- try:
246
- await volume.remove_file(remote_path, recursive=recursive)
247
- console.print(OutputManager.step_completed(f"{remote_path} was deleted successfully!"))
248
- except GRPCError as exc:
249
- if exc.status in (Status.NOT_FOUND, Status.INVALID_ARGUMENT):
250
- raise UsageError(exc.message)
251
- raise
245
+ console.print(OutputManager.step_completed(f"{remote_path} was deleted successfully!"))
252
246
 
253
247
 
254
248
  @volume_cli.command(
modal/client.py CHANGED
@@ -5,33 +5,25 @@ import platform
5
5
  import sys
6
6
  import urllib.parse
7
7
  import warnings
8
- from collections.abc import AsyncGenerator, AsyncIterator, Collection, Mapping
9
- from typing import (
10
- Any,
11
- ClassVar,
12
- Generic,
13
- Optional,
14
- TypeVar,
15
- Union,
16
- )
8
+ from collections.abc import AsyncGenerator, Collection, Mapping
9
+ from typing import Any, ClassVar, Optional, TypeVar, Union
17
10
 
18
11
  import grpclib.client
19
12
  from google.protobuf import empty_pb2
20
13
  from google.protobuf.message import Message
21
- from grpclib import GRPCError, Status
22
14
  from synchronicity.async_wrap import asynccontextmanager
23
15
 
24
16
  from modal._utils.async_utils import synchronizer
25
- from modal_proto import api_grpc, api_pb2, modal_api_grpc
17
+ from modal_proto import api_pb2, modal_api_grpc
26
18
  from modal_version import __version__
27
19
 
28
- from ._traceback import print_server_warnings, suppress_tb_frames
20
+ from ._traceback import print_server_warnings
29
21
  from ._utils import async_utils
30
22
  from ._utils.async_utils import TaskContext, synchronize_api
31
23
  from ._utils.auth_token_manager import _AuthTokenManager
32
- from ._utils.grpc_utils import ConnectionManager, retry_transient_errors
24
+ from ._utils.grpc_utils import ConnectionManager
33
25
  from .config import _check_config, _is_remote, config, logger
34
- from .exception import AuthError, ClientClosed, NotFoundError
26
+ from .exception import AuthError, ClientClosed
35
27
 
36
28
  HEARTBEAT_INTERVAL: float = config.get("heartbeat_interval")
37
29
  HEARTBEAT_TIMEOUT: float = HEARTBEAT_INTERVAL + 0.1
@@ -78,15 +70,16 @@ class _Client:
78
70
  _client_from_env: ClassVar[Optional["_Client"]] = None
79
71
  _client_from_env_lock: ClassVar[Optional[asyncio.Lock]] = None
80
72
  _cancellation_context: TaskContext
81
- _cancellation_context_event_loop: asyncio.AbstractEventLoop = None
82
- _stub: Optional[api_grpc.ModalClientStub]
83
- _auth_token_manager: _AuthTokenManager = None
84
- _snapshotted: bool
73
+ _cancellation_context_event_loop: Optional[asyncio.AbstractEventLoop] = None
74
+ _stub: Optional[modal_api_grpc.ModalClientModal] = None
75
+ _auth_token_manager: Optional[_AuthTokenManager] = None
76
+ _snapshotted: bool = False
77
+ client_type: "api_pb2.ClientType.ValueType"
85
78
 
86
79
  def __init__(
87
80
  self,
88
81
  server_url: str,
89
- client_type: int,
82
+ client_type: "api_pb2.ClientType.ValueType",
90
83
  credentials: Optional[tuple[str, str]],
91
84
  version: str = __version__,
92
85
  ):
@@ -98,8 +91,8 @@ class _Client:
98
91
  self._credentials = credentials
99
92
  self.version = version
100
93
  self._closed = False
101
- self._stub: Optional[modal_api_grpc.ModalClientModal] = None
102
- self._auth_token_manager: Optional[_AuthTokenManager] = None
94
+ self._stub = None
95
+ self._auth_token_manager = None
103
96
  self._snapshotted = False
104
97
  self._owner_pid = None
105
98
 
@@ -159,7 +152,7 @@ class _Client:
159
152
  async def hello(self):
160
153
  """Connect to server and retrieve version information; raise appropriate error for various failures."""
161
154
  logger.debug(f"Client ({id(self)}): Starting")
162
- resp = await retry_transient_errors(self.stub.ClientHello, empty_pb2.Empty())
155
+ resp = await self.stub.ClientHello(empty_pb2.Empty())
163
156
  print_server_warnings(resp.server_warnings)
164
157
 
165
158
  async def __aenter__(self):
@@ -171,7 +164,7 @@ class _Client:
171
164
 
172
165
  @classmethod
173
166
  @asynccontextmanager
174
- async def anonymous(cls, server_url: str) -> AsyncIterator["_Client"]:
167
+ async def anonymous(cls, server_url: str) -> AsyncGenerator["_Client", None]:
175
168
  """mdmd:hidden
176
169
  Create a connection with no credentials; to be used for token creation.
177
170
  """
@@ -362,105 +355,3 @@ class _Client:
362
355
 
363
356
 
364
357
  Client = synchronize_api(_Client)
365
-
366
-
367
- class grpc_error_converter:
368
- def __enter__(self):
369
- pass
370
-
371
- def __exit__(self, exc_type, exc, traceback) -> bool:
372
- # skip all internal frames from grpclib
373
- use_full_traceback = config.get("traceback")
374
- with suppress_tb_frames(1):
375
- if isinstance(exc, GRPCError):
376
- if exc.status == Status.NOT_FOUND:
377
- if use_full_traceback:
378
- raise NotFoundError(exc.message)
379
- else:
380
- raise NotFoundError(exc.message) from None # from None to skip the grpc-internal cause
381
-
382
- if not use_full_traceback:
383
- # just include the frame in grpclib that actually raises the GRPCError
384
- tb = exc.__traceback__
385
- while tb.tb_next:
386
- tb = tb.tb_next
387
- exc.with_traceback(tb)
388
- raise exc from None # from None to skip the grpc-internal cause
389
- raise exc
390
-
391
- return False
392
-
393
-
394
- class UnaryUnaryWrapper(Generic[RequestType, ResponseType]):
395
- # Calls a grpclib.UnaryUnaryMethod using a specific Client instance, respecting
396
- # if that client is closed etc. and possibly introducing Modal-specific retry logic
397
- wrapped_method: grpclib.client.UnaryUnaryMethod[RequestType, ResponseType]
398
- client: _Client
399
-
400
- def __init__(
401
- self,
402
- wrapped_method: grpclib.client.UnaryUnaryMethod[RequestType, ResponseType],
403
- client: _Client,
404
- server_url: str,
405
- ):
406
- self.wrapped_method = wrapped_method
407
- self.client = client
408
- self.server_url = server_url
409
-
410
- @property
411
- def name(self) -> str:
412
- return self.wrapped_method.name
413
-
414
- async def __call__(
415
- self,
416
- req: RequestType,
417
- *,
418
- timeout: Optional[float] = None,
419
- metadata: Optional[_MetadataLike] = None,
420
- ) -> ResponseType:
421
- if self.client._snapshotted:
422
- logger.debug(f"refreshing client after snapshot for {self.name.rsplit('/', 1)[1]}")
423
- self.client = await _Client.from_env()
424
-
425
- # Note: We override the grpclib method's channel (see grpclib's code [1]). I think this is fine
426
- # since grpclib's code doesn't seem to change very much, but we could also recreate the
427
- # grpclib stub if we aren't comfortable with this. The downside is then we need to cache
428
- # the grpclib stub so the rest of our code becomes a bit more complicated.
429
- #
430
- # We need to override the channel because after the process is forked or the client is
431
- # snapshotted, the existing channel may be stale / unusable.
432
- #
433
- # [1]: https://github.com/vmagamedov/grpclib/blob/62f968a4c84e3f64e6966097574ff0a59969ea9b/grpclib/client.py#L844
434
- self.wrapped_method.channel = await self.client._get_channel(self.server_url)
435
- with suppress_tb_frames(1), grpc_error_converter():
436
- return await self.client._call_unary(self.wrapped_method, req, timeout=timeout, metadata=metadata)
437
-
438
-
439
- class UnaryStreamWrapper(Generic[RequestType, ResponseType]):
440
- wrapped_method: grpclib.client.UnaryStreamMethod[RequestType, ResponseType]
441
-
442
- def __init__(
443
- self,
444
- wrapped_method: grpclib.client.UnaryStreamMethod[RequestType, ResponseType],
445
- client: _Client,
446
- server_url: str,
447
- ):
448
- self.wrapped_method = wrapped_method
449
- self.client = client
450
- self.server_url = server_url
451
-
452
- @property
453
- def name(self) -> str:
454
- return self.wrapped_method.name
455
-
456
- async def unary_stream(
457
- self,
458
- request,
459
- metadata: Optional[Any] = None,
460
- ):
461
- if self.client._snapshotted:
462
- logger.debug(f"refreshing client after snapshot for {self.name.rsplit('/', 1)[1]}")
463
- self.client = await _Client.from_env()
464
- self.wrapped_method.channel = await self.client._get_channel(self.server_url)
465
- async for response in self.client._call_stream(self.wrapped_method, request, metadata=metadata):
466
- yield response