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.
- modal/__init__.py +4 -4
- modal/__main__.py +4 -29
- modal/_billing.py +84 -0
- modal/_clustered_functions.py +1 -3
- modal/_container_entrypoint.py +33 -208
- modal/_functions.py +171 -138
- modal/_grpc_client.py +191 -0
- modal/_ipython.py +16 -6
- modal/_load_context.py +106 -0
- modal/_object.py +72 -21
- modal/_output.py +12 -14
- modal/_partial_function.py +31 -4
- modal/_resolver.py +44 -57
- modal/_runtime/container_io_manager.py +30 -28
- modal/_runtime/container_io_manager.pyi +42 -44
- modal/_runtime/gpu_memory_snapshot.py +9 -7
- modal/_runtime/user_code_event_loop.py +80 -0
- modal/_runtime/user_code_imports.py +236 -10
- modal/_serialization.py +2 -1
- modal/_traceback.py +4 -13
- modal/_tunnel.py +16 -11
- modal/_tunnel.pyi +25 -3
- modal/_utils/async_utils.py +337 -10
- modal/_utils/auth_token_manager.py +1 -4
- modal/_utils/blob_utils.py +29 -22
- modal/_utils/function_utils.py +20 -21
- modal/_utils/grpc_testing.py +6 -3
- modal/_utils/grpc_utils.py +223 -64
- modal/_utils/mount_utils.py +26 -1
- modal/_utils/name_utils.py +2 -3
- modal/_utils/package_utils.py +0 -1
- modal/_utils/rand_pb_testing.py +8 -1
- modal/_utils/task_command_router_client.py +524 -0
- modal/_vendor/cloudpickle.py +144 -48
- modal/app.py +285 -105
- modal/app.pyi +216 -53
- modal/billing.py +5 -0
- modal/builder/2025.06.txt +6 -3
- modal/builder/PREVIEW.txt +2 -1
- modal/builder/base-images.json +4 -2
- modal/cli/_download.py +19 -3
- modal/cli/cluster.py +4 -2
- modal/cli/config.py +3 -1
- modal/cli/container.py +5 -4
- modal/cli/dict.py +5 -2
- modal/cli/entry_point.py +26 -2
- modal/cli/environment.py +2 -16
- modal/cli/launch.py +1 -76
- modal/cli/network_file_system.py +5 -20
- modal/cli/programs/run_jupyter.py +1 -1
- modal/cli/programs/vscode.py +1 -1
- modal/cli/queues.py +5 -4
- modal/cli/run.py +24 -204
- modal/cli/secret.py +1 -2
- modal/cli/shell.py +375 -0
- modal/cli/utils.py +1 -13
- modal/cli/volume.py +11 -17
- modal/client.py +16 -125
- modal/client.pyi +94 -144
- modal/cloud_bucket_mount.py +3 -1
- modal/cloud_bucket_mount.pyi +4 -0
- modal/cls.py +101 -64
- modal/cls.pyi +9 -8
- modal/config.py +21 -1
- modal/container_process.py +288 -12
- modal/container_process.pyi +99 -38
- modal/dict.py +72 -33
- modal/dict.pyi +88 -57
- modal/environments.py +16 -8
- modal/environments.pyi +6 -2
- modal/exception.py +154 -16
- modal/experimental/__init__.py +24 -53
- modal/experimental/flash.py +161 -74
- modal/experimental/flash.pyi +97 -49
- modal/file_io.py +50 -92
- modal/file_io.pyi +117 -89
- modal/functions.pyi +70 -87
- modal/image.py +82 -47
- modal/image.pyi +51 -30
- modal/io_streams.py +500 -149
- modal/io_streams.pyi +279 -189
- modal/mount.py +60 -46
- modal/mount.pyi +41 -17
- modal/network_file_system.py +19 -11
- modal/network_file_system.pyi +72 -39
- modal/object.pyi +114 -22
- modal/parallel_map.py +42 -44
- modal/parallel_map.pyi +9 -17
- modal/partial_function.pyi +4 -2
- modal/proxy.py +14 -6
- modal/proxy.pyi +10 -2
- modal/queue.py +45 -38
- modal/queue.pyi +88 -52
- modal/runner.py +96 -96
- modal/runner.pyi +44 -27
- modal/sandbox.py +225 -107
- modal/sandbox.pyi +226 -60
- modal/secret.py +58 -56
- modal/secret.pyi +28 -13
- modal/serving.py +7 -11
- modal/serving.pyi +7 -8
- modal/snapshot.py +29 -15
- modal/snapshot.pyi +18 -10
- modal/token_flow.py +1 -1
- modal/token_flow.pyi +4 -6
- modal/volume.py +102 -55
- modal/volume.pyi +125 -66
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/METADATA +10 -9
- modal-1.3.1.dev8.dist-info/RECORD +189 -0
- modal_proto/api.proto +141 -70
- modal_proto/api_grpc.py +42 -26
- modal_proto/api_pb2.py +1123 -1103
- modal_proto/api_pb2.pyi +331 -83
- modal_proto/api_pb2_grpc.py +80 -48
- modal_proto/api_pb2_grpc.pyi +26 -18
- modal_proto/modal_api_grpc.py +175 -174
- modal_proto/task_command_router.proto +164 -0
- modal_proto/task_command_router_grpc.py +138 -0
- modal_proto/task_command_router_pb2.py +180 -0
- modal_proto/{sandbox_router_pb2.pyi → task_command_router_pb2.pyi} +148 -57
- modal_proto/task_command_router_pb2_grpc.py +272 -0
- modal_proto/task_command_router_pb2_grpc.pyi +100 -0
- modal_version/__init__.py +1 -1
- modal_version/__main__.py +1 -1
- modal/cli/programs/launch_instance_ssh.py +0 -94
- modal/cli/programs/run_marimo.py +0 -95
- modal-1.1.5.dev66.dist-info/RECORD +0 -191
- modal_proto/modal_options_grpc.py +0 -3
- modal_proto/options.proto +0 -19
- modal_proto/options_grpc.py +0 -3
- modal_proto/options_pb2.py +0 -35
- modal_proto/options_pb2.pyi +0 -20
- modal_proto/options_pb2_grpc.py +0 -4
- modal_proto/options_pb2_grpc.pyi +0 -7
- modal_proto/sandbox_router.proto +0 -125
- modal_proto/sandbox_router_grpc.py +0 -89
- modal_proto/sandbox_router_pb2.py +0 -128
- modal_proto/sandbox_router_pb2_grpc.py +0 -169
- modal_proto/sandbox_router_pb2_grpc.pyi +0 -63
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
- {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
24
|
+
from ._utils.grpc_utils import ConnectionManager
|
|
33
25
|
from .config import _check_config, _is_remote, config, logger
|
|
34
|
-
from .exception import AuthError, ClientClosed
|
|
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[
|
|
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:
|
|
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
|
|
102
|
-
self._auth_token_manager
|
|
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
|
|
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) ->
|
|
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
|