modAL 1.3.5.dev0__py3-none-any.whl → 1.3.6.dev1__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 +4 -1
- modal/_output/pty.py +3 -2
- modal/_output/rich.py +18 -12
- modal/_tunnel.py +9 -0
- modal/_utils/sandbox_fs_utils.py +157 -0
- modal/app.py +15 -2
- modal/app.pyi +3 -0
- modal/cli/app.py +3 -2
- modal/cli/changelog.py +7 -5
- modal/cli/container.py +4 -3
- modal/cli/run.py +9 -3
- modal/cli/utils.py +18 -6
- modal/client.pyi +2 -2
- modal/exception.py +36 -0
- modal/experimental/flash.py +1 -1
- modal/file_io.py +252 -165
- modal/file_io.pyi +39 -33
- modal/runner.py +99 -4
- modal/runner.pyi +14 -0
- modal/sandbox.py +31 -5
- modal/sandbox.pyi +21 -0
- modal/sandbox_fs.py +311 -0
- modal/sandbox_fs.pyi +507 -0
- modal/serving.py +1 -1
- {modal-1.3.5.dev0.dist-info → modal-1.3.6.dev1.dist-info}/METADATA +4 -3
- {modal-1.3.5.dev0.dist-info → modal-1.3.6.dev1.dist-info}/RECORD +42 -36
- modal-1.3.6.dev1.dist-info/licenses/LICENSE +176 -0
- modal_docs/gen_cli_docs.py +0 -1
- modal_docs/gen_cli_docs_main.py +8 -0
- modal_docs/gen_reference_docs.py +0 -1
- modal_docs/gen_reference_docs_main.py +8 -0
- modal_docs/mdmd/mdmd.py +17 -2
- modal_proto/api_grpc.py +48 -0
- modal_proto/api_pb2.py +1128 -1057
- modal_proto/api_pb2.pyi +197 -4
- modal_proto/api_pb2_grpc.py +99 -0
- modal_proto/api_pb2_grpc.pyi +30 -0
- modal_proto/modal_api_grpc.py +3 -0
- modal_version/__init__.py +1 -1
- {modal-1.3.5.dev0.dist-info → modal-1.3.6.dev1.dist-info}/WHEEL +0 -0
- {modal-1.3.5.dev0.dist-info → modal-1.3.6.dev1.dist-info}/entry_points.txt +0 -0
- {modal-1.3.5.dev0.dist-info → modal-1.3.6.dev1.dist-info}/top_level.txt +0 -0
modal/_functions.py
CHANGED
|
@@ -550,6 +550,7 @@ class FunctionStats:
|
|
|
550
550
|
|
|
551
551
|
backlog: int
|
|
552
552
|
num_total_runners: int
|
|
553
|
+
num_running_inputs: int
|
|
553
554
|
|
|
554
555
|
|
|
555
556
|
def _parse_retries(
|
|
@@ -1917,7 +1918,9 @@ Use the `Function.get_web_url()` method instead.
|
|
|
1917
1918
|
api_pb2.FunctionGetCurrentStatsRequest(function_id=self.object_id),
|
|
1918
1919
|
retry=Retry(total_timeout=10.0),
|
|
1919
1920
|
)
|
|
1920
|
-
return FunctionStats(
|
|
1921
|
+
return FunctionStats(
|
|
1922
|
+
backlog=resp.backlog, num_total_runners=resp.num_total_tasks, num_running_inputs=resp.num_running_inputs
|
|
1923
|
+
)
|
|
1921
1924
|
|
|
1922
1925
|
@live_method
|
|
1923
1926
|
async def _get_schema(self) -> api_pb2.FunctionSchema:
|
modal/_output/pty.py
CHANGED
|
@@ -70,6 +70,7 @@ async def get_app_logs_loop(
|
|
|
70
70
|
app_id: Optional[str] = None,
|
|
71
71
|
task_id: Optional[str] = None,
|
|
72
72
|
sandbox_id: Optional[str] = None,
|
|
73
|
+
follow: bool = True,
|
|
73
74
|
):
|
|
74
75
|
last_log_batch_entry_id = ""
|
|
75
76
|
|
|
@@ -124,7 +125,7 @@ async def get_app_logs_loop(
|
|
|
124
125
|
app_id=app_id or "",
|
|
125
126
|
task_id=task_id or "",
|
|
126
127
|
sandbox_id=sandbox_id or "",
|
|
127
|
-
timeout=55,
|
|
128
|
+
timeout=55 if follow else 0,
|
|
128
129
|
last_entry_id=last_log_batch_entry_id,
|
|
129
130
|
)
|
|
130
131
|
log_batch: api_pb2.TaskLogsBatch
|
|
@@ -192,7 +193,7 @@ async def get_app_logs_loop(
|
|
|
192
193
|
continue
|
|
193
194
|
raise
|
|
194
195
|
|
|
195
|
-
if last_log_batch_entry_id is None:
|
|
196
|
+
if last_log_batch_entry_id is None or not follow:
|
|
196
197
|
break
|
|
197
198
|
|
|
198
199
|
await stop_pty_shell()
|
modal/_output/rich.py
CHANGED
|
@@ -14,6 +14,7 @@ import platform
|
|
|
14
14
|
import re
|
|
15
15
|
from collections.abc import Generator
|
|
16
16
|
from datetime import timedelta
|
|
17
|
+
from pathlib import Path
|
|
17
18
|
from typing import Any, Callable
|
|
18
19
|
|
|
19
20
|
from rich.console import Console, Group, RenderableType
|
|
@@ -321,7 +322,11 @@ class RichOutputManager(OutputManager):
|
|
|
321
322
|
default Python warning display.
|
|
322
323
|
"""
|
|
323
324
|
# For non-Modal warnings, fall back to the default display
|
|
324
|
-
|
|
325
|
+
import modal
|
|
326
|
+
|
|
327
|
+
is_modal_warning = issubclass(category, (DeprecationError, PendingDeprecationError, ServerWarning))
|
|
328
|
+
filename_in_modal = modal.__path__ and Path(filename).is_relative_to(modal.__path__[0])
|
|
329
|
+
if not is_modal_warning and not filename_in_modal:
|
|
325
330
|
base_showwarning(warning, category, filename, lineno, file=None, line=None)
|
|
326
331
|
return
|
|
327
332
|
|
|
@@ -334,20 +339,21 @@ class RichOutputManager(OutputManager):
|
|
|
334
339
|
date = ""
|
|
335
340
|
message = content
|
|
336
341
|
|
|
337
|
-
# Try to add source context
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
342
|
+
# Try to add source context for non-Modal file sources
|
|
343
|
+
if not filename_in_modal:
|
|
344
|
+
try:
|
|
345
|
+
with open(filename, encoding="utf-8", errors="replace") as code_file:
|
|
346
|
+
source = code_file.readlines()[lineno - 1].strip()
|
|
347
|
+
message = f"{message}\n\nSource: {filename}:{lineno}\n {source}"
|
|
348
|
+
except OSError:
|
|
349
|
+
# e.g., when filename is "<unknown>"; raises FileNotFoundError on posix but OSError on windows
|
|
350
|
+
pass
|
|
345
351
|
|
|
346
352
|
# Build title
|
|
347
|
-
if issubclass(category,
|
|
348
|
-
title = "Modal Warning"
|
|
349
|
-
else:
|
|
353
|
+
if issubclass(category, (DeprecationError, PendingDeprecationError)):
|
|
350
354
|
title = "Modal Deprecation Warning"
|
|
355
|
+
else:
|
|
356
|
+
title = "Modal Warning"
|
|
351
357
|
if date:
|
|
352
358
|
title += f" ({date})"
|
|
353
359
|
|
modal/_tunnel.py
CHANGED
|
@@ -10,6 +10,7 @@ from synchronicity.async_wrap import asynccontextmanager
|
|
|
10
10
|
from modal_proto import api_pb2
|
|
11
11
|
|
|
12
12
|
from ._utils.async_utils import synchronize_api
|
|
13
|
+
from ._utils.deprecation import deprecation_warning
|
|
13
14
|
from .client import _Client
|
|
14
15
|
from .exception import AlreadyExistsError, InvalidError, RemoteError, ServiceError
|
|
15
16
|
|
|
@@ -29,6 +30,14 @@ class Tunnel:
|
|
|
29
30
|
@property
|
|
30
31
|
def url(self) -> str:
|
|
31
32
|
"""Get the public HTTPS URL of the forwarded port."""
|
|
33
|
+
if self.unencrypted_host:
|
|
34
|
+
deprecation_warning(
|
|
35
|
+
(2026, 3, 5),
|
|
36
|
+
"`.url` on unencrypted tunnels will be deprecated in a future release. "
|
|
37
|
+
"For HTTPS, use an encrypted tunnel instead: with `forward()`, call "
|
|
38
|
+
"`forward(port)` without `unencrypted=True`; with sandboxes, declare the port "
|
|
39
|
+
"in `encrypted_ports` instead of `unencrypted_ports`.",
|
|
40
|
+
)
|
|
32
41
|
value = f"https://{self.host}"
|
|
33
42
|
if self.port != 443:
|
|
34
43
|
value += f":{self.port}"
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Copyright Modal Labs 2026
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
from typing import NoReturn, Optional, Union
|
|
8
|
+
|
|
9
|
+
from ..config import logger
|
|
10
|
+
from ..exception import (
|
|
11
|
+
ConnectionError,
|
|
12
|
+
Error as ModalError,
|
|
13
|
+
InvalidError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
SandboxFilesystemError,
|
|
16
|
+
SandboxFilesystemFileTooLargeError,
|
|
17
|
+
SandboxFilesystemIsADirectoryError,
|
|
18
|
+
SandboxFilesystemNotADirectoryError,
|
|
19
|
+
SandboxFilesystemNotFoundError,
|
|
20
|
+
SandboxFilesystemPermissionError,
|
|
21
|
+
ServiceError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_EXEC_SANDBOX_UNAVAILABLE_ERROR_TYPES = (NotFoundError, ServiceError, ConnectionError)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ErrorPayload:
|
|
29
|
+
error_kind: str
|
|
30
|
+
message: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _stderr_to_text(stderr: Union[str, bytes]) -> str:
|
|
34
|
+
stderr_bytes = stderr if isinstance(stderr, bytes) else stderr.encode("utf-8")
|
|
35
|
+
return stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def try_parse_error_payload(stderr: Union[str, bytes]) -> Optional[ErrorPayload]:
|
|
39
|
+
stderr_text = _stderr_to_text(stderr)
|
|
40
|
+
if not stderr_text:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
payload = json.loads(stderr_text)
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
return None
|
|
46
|
+
if not isinstance(payload, dict):
|
|
47
|
+
return None
|
|
48
|
+
error_kind = payload.get("error_kind")
|
|
49
|
+
message = payload.get("message")
|
|
50
|
+
if not isinstance(error_kind, str):
|
|
51
|
+
return None
|
|
52
|
+
if not isinstance(message, str) or not message.strip():
|
|
53
|
+
return None
|
|
54
|
+
return ErrorPayload(error_kind=error_kind, message=message)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def raise_read_file_error(returncode: int, stderr: Union[str, bytes], remote_path: str) -> NoReturn:
|
|
58
|
+
if payload := try_parse_error_payload(stderr):
|
|
59
|
+
if payload.error_kind == "NotFound":
|
|
60
|
+
raise SandboxFilesystemNotFoundError(f"{payload.message}: {remote_path}")
|
|
61
|
+
if payload.error_kind == "IsDirectory":
|
|
62
|
+
raise SandboxFilesystemIsADirectoryError(f"{payload.message}: {remote_path}")
|
|
63
|
+
if payload.error_kind == "PermissionDenied":
|
|
64
|
+
raise SandboxFilesystemPermissionError(f"{payload.message}: {remote_path}")
|
|
65
|
+
if payload.error_kind == "FileTooLarge":
|
|
66
|
+
raise SandboxFilesystemFileTooLargeError(f"{payload.message}: {remote_path}")
|
|
67
|
+
raise SandboxFilesystemError(payload.message)
|
|
68
|
+
|
|
69
|
+
if stderr_text := _stderr_to_text(stderr):
|
|
70
|
+
logger.debug(f"Unstructured modal-sandbox-fs-tools stderr: {stderr_text}")
|
|
71
|
+
raise SandboxFilesystemError(f"Operation on '{remote_path}' failed with exit code {returncode}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _extract_support_error_code(exc: Exception) -> Optional[str]:
|
|
75
|
+
if match := re.search(r"Error code:\s*([A-Z0-9]{8})", str(exc)):
|
|
76
|
+
return match.group(1)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _translate_exec_sandbox_unavailable_error(operation: str, path: str, exc: Exception) -> Exception:
|
|
81
|
+
logger.debug(
|
|
82
|
+
f"Sandbox filesystem control-plane error for operation={operation}, path={path}: {type(exc).__name__}: {exc}"
|
|
83
|
+
)
|
|
84
|
+
return NotFoundError("The Sandbox is unavailable. This Sandbox may have already shut down.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def translate_exec_unexpected_error(operation: str, path: str, exc: Exception) -> Exception:
|
|
88
|
+
"""Translate an unexpected exec-level error into a generic user-facing error.
|
|
89
|
+
|
|
90
|
+
This is less than ideal — it discards the original exception and returns a
|
|
91
|
+
generic message — but it's necessary to avoid surfacing "call to exec()
|
|
92
|
+
failed"-style messages to users who shouldn't be aware that the filesystem
|
|
93
|
+
API is implemented in terms of Sandbox.exec().
|
|
94
|
+
"""
|
|
95
|
+
error_code = _extract_support_error_code(exc)
|
|
96
|
+
logger.debug(
|
|
97
|
+
f"Unexpected sandbox filesystem exec error for operation={operation}, path={path}: {type(exc).__name__}: {exc}"
|
|
98
|
+
)
|
|
99
|
+
support_suffix = (
|
|
100
|
+
f"please contact support@modal.com (Error code: {error_code})"
|
|
101
|
+
if error_code
|
|
102
|
+
else "please contact support@modal.com"
|
|
103
|
+
)
|
|
104
|
+
return SandboxFilesystemError(f"An unexpected error occurred, {support_suffix}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def raise_write_file_error(returncode: int, stderr: Union[str, bytes], remote_path: str) -> NoReturn:
|
|
108
|
+
if payload := try_parse_error_payload(stderr):
|
|
109
|
+
if payload.error_kind == "NotDirectory" or payload.error_kind == "AlreadyExists":
|
|
110
|
+
raise SandboxFilesystemNotADirectoryError(f"{payload.message}: {remote_path}")
|
|
111
|
+
if payload.error_kind == "IsDirectory":
|
|
112
|
+
raise SandboxFilesystemIsADirectoryError(f"{payload.message}: {remote_path}")
|
|
113
|
+
if payload.error_kind == "PermissionDenied":
|
|
114
|
+
raise SandboxFilesystemPermissionError(f"{payload.message}: {remote_path}")
|
|
115
|
+
raise SandboxFilesystemError(payload.message)
|
|
116
|
+
|
|
117
|
+
if stderr_text := _stderr_to_text(stderr):
|
|
118
|
+
logger.debug(f"Unstructured modal-sandbox-fs-tools stderr: {stderr_text}")
|
|
119
|
+
raise SandboxFilesystemError(f"Operation on '{remote_path}' failed with exit code {returncode}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def make_write_file_command(remote_path: str) -> str:
|
|
123
|
+
"""Build the JSON command string for a WriteFile operation.
|
|
124
|
+
|
|
125
|
+
The returned JSON must match the `Command` enum in the modal-sandbox-fs-tools
|
|
126
|
+
Rust crate (crates/modal-sandbox-fs-tools/src/lib.rs). Treat changes to
|
|
127
|
+
this schema like protobuf changes: fields must not be removed or renamed,
|
|
128
|
+
only added with backwards-compatible defaults.
|
|
129
|
+
"""
|
|
130
|
+
return json.dumps({"WriteFile": {"path": remote_path}})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def make_read_file_command(remote_path: str) -> str:
|
|
134
|
+
"""Build the JSON command string for a ReadFile operation.
|
|
135
|
+
|
|
136
|
+
The returned JSON must match the `Command` enum in the modal-sandbox-fs-tools
|
|
137
|
+
Rust crate (crates/modal-sandbox-fs-tools/src/lib.rs). Treat changes to
|
|
138
|
+
this schema like protobuf changes: fields must not be removed or renamed,
|
|
139
|
+
only added with backwards-compatible defaults.
|
|
140
|
+
"""
|
|
141
|
+
return json.dumps({"ReadFile": {"path": remote_path}})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate_absolute_remote_path(remote_path: str, operation: str) -> None:
|
|
145
|
+
if not PurePosixPath(remote_path).is_absolute():
|
|
146
|
+
raise InvalidError(f"Sandbox.filesystem.{operation}() currently only supports absolute remote_path values")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@contextmanager
|
|
150
|
+
def translate_exec_errors(operation: str, remote_path: str):
|
|
151
|
+
"""Translate exec-level exceptions into user-facing errors."""
|
|
152
|
+
try:
|
|
153
|
+
yield
|
|
154
|
+
except _EXEC_SANDBOX_UNAVAILABLE_ERROR_TYPES as exc:
|
|
155
|
+
raise _translate_exec_sandbox_unavailable_error(operation, remote_path, exc) from None
|
|
156
|
+
except ModalError as exc:
|
|
157
|
+
raise translate_exec_unexpected_error(operation, remote_path, exc) from None
|
modal/app.py
CHANGED
|
@@ -439,6 +439,13 @@ class _App:
|
|
|
439
439
|
environment_name: Optional[str] = None, # Environment to deploy the App in
|
|
440
440
|
tag: str = "", # Optional metadata that is specific to this deployment
|
|
441
441
|
client: Optional[_Client] = None, # Alternate client to use for communication with the server
|
|
442
|
+
# Deployment strategy:
|
|
443
|
+
# - 'rolling' (default): Traffic shifts to the new deployment by starting
|
|
444
|
+
# new containers gradually. Old containers will continue to process inputs while new
|
|
445
|
+
# containers start up.
|
|
446
|
+
# - 'restart': As part of the deployment, all running containers are terminated. New inputs
|
|
447
|
+
# will start new containers.
|
|
448
|
+
strategy: str = "rolling",
|
|
442
449
|
) -> typing_extensions.Self:
|
|
443
450
|
"""Deploy the App so that it is available persistently.
|
|
444
451
|
|
|
@@ -482,7 +489,10 @@ class _App:
|
|
|
482
489
|
```
|
|
483
490
|
|
|
484
491
|
"""
|
|
485
|
-
from .runner import
|
|
492
|
+
from .runner import ( # Defer import of runner.py, which imports a lot from Rich
|
|
493
|
+
_deploy_app,
|
|
494
|
+
_validate_deployment_strategy,
|
|
495
|
+
)
|
|
486
496
|
|
|
487
497
|
if name is None and self._name is None:
|
|
488
498
|
raise InvalidError(
|
|
@@ -493,7 +503,10 @@ class _App:
|
|
|
493
503
|
"or\n"
|
|
494
504
|
'app = modal.App("some-name")'
|
|
495
505
|
)
|
|
496
|
-
|
|
506
|
+
strategy = _validate_deployment_strategy(strategy)
|
|
507
|
+
result = await _deploy_app(
|
|
508
|
+
self, name=name, environment_name=environment_name, tag=tag, client=client, deployment_strategy=strategy
|
|
509
|
+
)
|
|
497
510
|
self._app_id = result.app_id
|
|
498
511
|
return self
|
|
499
512
|
|
modal/app.pyi
CHANGED
|
@@ -310,6 +310,7 @@ class _App:
|
|
|
310
310
|
environment_name: typing.Optional[str] = None,
|
|
311
311
|
tag: str = "",
|
|
312
312
|
client: typing.Optional[modal.client._Client] = None,
|
|
313
|
+
strategy: str = "rolling",
|
|
313
314
|
) -> typing_extensions.Self:
|
|
314
315
|
"""Deploy the App so that it is available persistently.
|
|
315
316
|
|
|
@@ -986,6 +987,7 @@ class App:
|
|
|
986
987
|
environment_name: typing.Optional[str] = None,
|
|
987
988
|
tag: str = "",
|
|
988
989
|
client: typing.Optional[modal.client.Client] = None,
|
|
990
|
+
strategy: str = "rolling",
|
|
989
991
|
) -> SUPERSELF:
|
|
990
992
|
"""Deploy the App so that it is available persistently.
|
|
991
993
|
|
|
@@ -1038,6 +1040,7 @@ class App:
|
|
|
1038
1040
|
environment_name: typing.Optional[str] = None,
|
|
1039
1041
|
tag: str = "",
|
|
1040
1042
|
client: typing.Optional[modal.client.Client] = None,
|
|
1043
|
+
strategy: str = "rolling",
|
|
1041
1044
|
) -> SUPERSELF:
|
|
1042
1045
|
"""Deploy the App so that it is available persistently.
|
|
1043
1046
|
|
modal/cli/app.py
CHANGED
|
@@ -84,9 +84,10 @@ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
|
|
|
84
84
|
@app_cli.command("logs", no_args_is_help=True)
|
|
85
85
|
def logs(
|
|
86
86
|
app_identifier: str = APP_IDENTIFIER,
|
|
87
|
+
follow: bool = typer.Option(False, "-f", "--follow", help="Stream log output until App stops"),
|
|
88
|
+
timestamps: bool = typer.Option(False, "--timestamps", help="Show timestamps for each log line"),
|
|
87
89
|
*,
|
|
88
90
|
env: Optional[str] = ENV_OPTION,
|
|
89
|
-
timestamps: bool = typer.Option(False, "--timestamps", help="Show timestamps for each log line"),
|
|
90
91
|
):
|
|
91
92
|
"""Show App logs, streaming while active.
|
|
92
93
|
|
|
@@ -106,7 +107,7 @@ def logs(
|
|
|
106
107
|
|
|
107
108
|
"""
|
|
108
109
|
app_id = get_app_id(app_identifier, env)
|
|
109
|
-
stream_app_logs(app_id, show_timestamps=timestamps)
|
|
110
|
+
stream_app_logs(app_id, show_timestamps=timestamps, follow=follow)
|
|
110
111
|
|
|
111
112
|
|
|
112
113
|
@app_cli.command("rollback", no_args_is_help=True, context_settings={"ignore_unknown_options": True})
|
modal/cli/changelog.py
CHANGED
|
@@ -118,15 +118,17 @@ def changelog(
|
|
|
118
118
|
|
|
119
119
|
Examples:
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
```bash
|
|
122
|
+
modal changelog --since 1.2.0 # Show updates added after a specific version
|
|
122
123
|
|
|
123
|
-
|
|
124
|
+
modal changelog --since 2026-01-01 # Show updates added after a specific date
|
|
124
125
|
|
|
125
|
-
|
|
126
|
+
modal changelog --newer # Show updates released after the currently installed version
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
modal changelog --last 3 # Show updates included in the 3 most recent releases
|
|
128
129
|
|
|
129
|
-
|
|
130
|
+
modal changelog --for 1.3.1 # Show the changelog for a specific release
|
|
131
|
+
```
|
|
130
132
|
|
|
131
133
|
Note: when using `--since` or `--last`, only changes up to the currently installed version are shown.
|
|
132
134
|
|
modal/cli/container.py
CHANGED
|
@@ -23,13 +23,13 @@ container_cli = typer.Typer(name="container", help="Manage and connect to runnin
|
|
|
23
23
|
|
|
24
24
|
@container_cli.command("list")
|
|
25
25
|
@synchronizer.create_blocking
|
|
26
|
-
async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
|
|
26
|
+
async def list_(env: Optional[str] = ENV_OPTION, json: bool = False, app_id: str = ""):
|
|
27
27
|
"""List all containers that are currently running."""
|
|
28
28
|
env = ensure_env(env)
|
|
29
29
|
client = await _Client.from_env()
|
|
30
30
|
environment_name = _get_environment_name(env)
|
|
31
31
|
res: api_pb2.TaskListResponse = await client.stub.TaskList(
|
|
32
|
-
api_pb2.TaskListRequest(environment_name=environment_name)
|
|
32
|
+
api_pb2.TaskListRequest(environment_name=environment_name, app_id=app_id)
|
|
33
33
|
)
|
|
34
34
|
|
|
35
35
|
column_names: list[Union[Column, str]] = [
|
|
@@ -56,6 +56,7 @@ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
|
|
|
56
56
|
@container_cli.command("logs")
|
|
57
57
|
def logs(
|
|
58
58
|
container_id: str = typer.Argument(help="Container ID"),
|
|
59
|
+
follow: bool = typer.Option(False, "-f", "--follow", help="Stream log output until Container stops"),
|
|
59
60
|
timestamps: bool = typer.Option(False, "--timestamps", help="Show timestamps for each log line"),
|
|
60
61
|
):
|
|
61
62
|
"""Show logs for a specific container, streaming while active."""
|
|
@@ -66,7 +67,7 @@ def logs(
|
|
|
66
67
|
task_id = container_id
|
|
67
68
|
else:
|
|
68
69
|
raise InvalidError(f"Invalid container ID: {container_id}")
|
|
69
|
-
stream_app_logs(task_id=task_id, sandbox_id=sandbox_id, show_timestamps=timestamps)
|
|
70
|
+
stream_app_logs(task_id=task_id, sandbox_id=sandbox_id, show_timestamps=timestamps, follow=follow)
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
@container_cli.command("exec")
|
modal/cli/run.py
CHANGED
|
@@ -21,7 +21,7 @@ from ..environments import ensure_env
|
|
|
21
21
|
from ..exception import ExecutionError, InvalidError, _CliUserExecutionError
|
|
22
22
|
from ..functions import Function
|
|
23
23
|
from ..output import OutputManager
|
|
24
|
-
from ..runner import deploy_app, run_app
|
|
24
|
+
from ..runner import DEPLOYMENT_STRATEGY_TYPE, deploy_app, run_app
|
|
25
25
|
from ..serving import serve_app
|
|
26
26
|
from .import_refs import (
|
|
27
27
|
CLICommand,
|
|
@@ -504,6 +504,7 @@ def deploy(
|
|
|
504
504
|
False, "-m", help="Interpret argument as a Python module path instead of a file/script path"
|
|
505
505
|
),
|
|
506
506
|
timestamps: bool = typer.Option(False, "--timestamps", help="Show timestamps for each log line."),
|
|
507
|
+
strategy: DEPLOYMENT_STRATEGY_TYPE = typer.Option("rolling", help="Deployment strategy"),
|
|
507
508
|
):
|
|
508
509
|
"""Deploy a Modal application.
|
|
509
510
|
|
|
@@ -530,10 +531,15 @@ def deploy(
|
|
|
530
531
|
"modal deploy ... --name=some-name"
|
|
531
532
|
)
|
|
532
533
|
|
|
533
|
-
res = deploy_app(app, name=name, environment_name=env or "", tag=tag)
|
|
534
|
+
res = deploy_app(app, name=name, environment_name=env or "", tag=tag, deployment_strategy=strategy)
|
|
534
535
|
|
|
535
536
|
if stream_logs:
|
|
536
|
-
stream_app_logs(
|
|
537
|
+
stream_app_logs(
|
|
538
|
+
app_id=res.app_id,
|
|
539
|
+
app_logs_url=res.app_logs_url,
|
|
540
|
+
show_timestamps=timestamps,
|
|
541
|
+
follow=True,
|
|
542
|
+
)
|
|
537
543
|
|
|
538
544
|
|
|
539
545
|
def serve(
|
modal/cli/utils.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import io
|
|
4
4
|
from collections.abc import Sequence
|
|
5
|
+
from contextlib import nullcontext
|
|
5
6
|
from csv import writer as csv_writer
|
|
6
7
|
from json import dumps
|
|
7
8
|
from typing import Optional, Union
|
|
@@ -27,6 +28,7 @@ async def stream_app_logs(
|
|
|
27
28
|
sandbox_id: Optional[str] = None,
|
|
28
29
|
app_logs_url: Optional[str] = None,
|
|
29
30
|
show_timestamps: bool = False,
|
|
31
|
+
follow: bool = False,
|
|
30
32
|
):
|
|
31
33
|
client = await _Client.from_env()
|
|
32
34
|
output_mgr = OutputManager.get()
|
|
@@ -34,14 +36,24 @@ async def stream_app_logs(
|
|
|
34
36
|
|
|
35
37
|
# Determine the display ID for the status message
|
|
36
38
|
display_id = app_id or sandbox_id or task_id
|
|
37
|
-
|
|
39
|
+
|
|
40
|
+
if follow:
|
|
41
|
+
status_text = f"Following logs for {display_id}..." if display_id else "Following logs..."
|
|
42
|
+
log_context = output_mgr.show_status_spinner(status_text)
|
|
43
|
+
else:
|
|
44
|
+
log_context = nullcontext()
|
|
38
45
|
|
|
39
46
|
try:
|
|
40
|
-
with
|
|
41
|
-
await get_app_logs_loop(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
with log_context:
|
|
48
|
+
await get_app_logs_loop(
|
|
49
|
+
client,
|
|
50
|
+
output_mgr,
|
|
51
|
+
app_id=app_id,
|
|
52
|
+
task_id=task_id,
|
|
53
|
+
sandbox_id=sandbox_id,
|
|
54
|
+
follow=follow,
|
|
55
|
+
)
|
|
56
|
+
except (asyncio.CancelledError, KeyboardInterrupt):
|
|
45
57
|
pass
|
|
46
58
|
|
|
47
59
|
|
modal/client.pyi
CHANGED
|
@@ -35,7 +35,7 @@ class _Client:
|
|
|
35
35
|
server_url: str,
|
|
36
36
|
client_type: int,
|
|
37
37
|
credentials: typing.Optional[tuple[str, str]],
|
|
38
|
-
version: str = "1.3.
|
|
38
|
+
version: str = "1.3.6.dev1",
|
|
39
39
|
):
|
|
40
40
|
"""mdmd:hidden
|
|
41
41
|
The Modal client object is not intended to be instantiated directly by users.
|
|
@@ -171,7 +171,7 @@ class Client:
|
|
|
171
171
|
server_url: str,
|
|
172
172
|
client_type: int,
|
|
173
173
|
credentials: typing.Optional[tuple[str, str]],
|
|
174
|
-
version: str = "1.3.
|
|
174
|
+
version: str = "1.3.6.dev1",
|
|
175
175
|
):
|
|
176
176
|
"""mdmd:hidden
|
|
177
177
|
The Modal client object is not intended to be instantiated directly by users.
|
modal/exception.py
CHANGED
|
@@ -333,3 +333,39 @@ class ClientClosed(Error):
|
|
|
333
333
|
|
|
334
334
|
class FilesystemExecutionError(Error):
|
|
335
335
|
"""Raised when an unknown error is thrown during a container filesystem operation."""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class SandboxFilesystemError(Error):
|
|
339
|
+
"""Base class for sandbox filesystem errors."""
|
|
340
|
+
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class SandboxFilesystemNotFoundError(SandboxFilesystemError):
|
|
345
|
+
"""Raised when a file or directory is not found in the sandbox."""
|
|
346
|
+
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class SandboxFilesystemIsADirectoryError(SandboxFilesystemError):
|
|
351
|
+
"""Raised when a file operation in the sandbox targets a directory when it should target a non-directory file."""
|
|
352
|
+
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class SandboxFilesystemNotADirectoryError(SandboxFilesystemError):
|
|
357
|
+
"""Raised when a path component in the sandbox is not a directory."""
|
|
358
|
+
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class SandboxFilesystemPermissionError(SandboxFilesystemError):
|
|
363
|
+
"""Raised when permission is denied for a file operation in the sandbox."""
|
|
364
|
+
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class SandboxFilesystemFileTooLargeError(SandboxFilesystemError):
|
|
369
|
+
"""Raised when a file exceeds the maximum allowed size for a read operation in the sandbox."""
|
|
370
|
+
|
|
371
|
+
pass
|
modal/experimental/flash.py
CHANGED
|
@@ -149,7 +149,7 @@ class _FlashManager:
|
|
|
149
149
|
timeout=10,
|
|
150
150
|
retry=None,
|
|
151
151
|
)
|
|
152
|
-
logger.
|
|
152
|
+
logger.warning(f"Listening at {resp.url} over {self.tunnel.url} for task_id {self.task_id}")
|
|
153
153
|
return True
|
|
154
154
|
except asyncio.CancelledError:
|
|
155
155
|
logger.warning("Waited too long for port to start accepting connections. Shutting down...")
|