modAL 1.3.5.dev0__py3-none-any.whl → 1.3.6.dev0__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.
Files changed (43) hide show
  1. modal/_functions.py +4 -1
  2. modal/_output/pty.py +3 -2
  3. modal/_output/rich.py +18 -12
  4. modal/_tunnel.py +9 -0
  5. modal/_utils/sandbox_fs_utils.py +157 -0
  6. modal/app.py +15 -2
  7. modal/app.pyi +3 -0
  8. modal/cli/app.py +3 -2
  9. modal/cli/changelog.py +7 -5
  10. modal/cli/container.py +4 -3
  11. modal/cli/run.py +9 -3
  12. modal/cli/utils.py +18 -6
  13. modal/client.pyi +2 -2
  14. modal/exception.py +36 -0
  15. modal/experimental/flash.py +1 -1
  16. modal/file_io.py +252 -165
  17. modal/file_io.pyi +39 -33
  18. modal/functions.pyi +6 -6
  19. modal/runner.py +99 -4
  20. modal/runner.pyi +14 -0
  21. modal/sandbox.py +31 -5
  22. modal/sandbox.pyi +21 -0
  23. modal/sandbox_fs.py +311 -0
  24. modal/sandbox_fs.pyi +507 -0
  25. modal/serving.py +1 -1
  26. {modal-1.3.5.dev0.dist-info → modal-1.3.6.dev0.dist-info}/METADATA +4 -3
  27. {modal-1.3.5.dev0.dist-info → modal-1.3.6.dev0.dist-info}/RECORD +43 -37
  28. modal-1.3.6.dev0.dist-info/licenses/LICENSE +176 -0
  29. modal_docs/gen_cli_docs.py +0 -1
  30. modal_docs/gen_cli_docs_main.py +8 -0
  31. modal_docs/gen_reference_docs.py +0 -1
  32. modal_docs/gen_reference_docs_main.py +8 -0
  33. modal_docs/mdmd/mdmd.py +17 -2
  34. modal_proto/api_grpc.py +48 -0
  35. modal_proto/api_pb2.py +1128 -1057
  36. modal_proto/api_pb2.pyi +197 -4
  37. modal_proto/api_pb2_grpc.py +99 -0
  38. modal_proto/api_pb2_grpc.pyi +30 -0
  39. modal_proto/modal_api_grpc.py +3 -0
  40. modal_version/__init__.py +1 -1
  41. {modal-1.3.5.dev0.dist-info → modal-1.3.6.dev0.dist-info}/WHEEL +0 -0
  42. {modal-1.3.5.dev0.dist-info → modal-1.3.6.dev0.dist-info}/entry_points.txt +0 -0
  43. {modal-1.3.5.dev0.dist-info → modal-1.3.6.dev0.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(backlog=resp.backlog, num_total_runners=resp.num_total_tasks)
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
- if not issubclass(category, (DeprecationError, PendingDeprecationError, ServerWarning)):
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
- try:
339
- with open(filename, encoding="utf-8", errors="replace") as code_file:
340
- source = code_file.readlines()[lineno - 1].strip()
341
- message = f"{message}\n\nSource: {filename}:{lineno}\n {source}"
342
- except OSError:
343
- # e.g., when filename is "<unknown>"; raises FileNotFoundError on posix but OSError on windows
344
- pass
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, ServerWarning):
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 _deploy_app # Defer import of runner.py, which imports a lot from Rich
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
- result = await _deploy_app(self, name=name, environment_name=environment_name, tag=tag, client=client)
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
- modal changelog --since 1.2.0 # Show updates added after a specific version
121
+ ```bash
122
+ modal changelog --since 1.2.0 # Show updates added after a specific version
122
123
 
123
- modal changelog --since 2026-01-01 # Show updates added after a specific date
124
+ modal changelog --since 2026-01-01 # Show updates added after a specific date
124
125
 
125
- modal changelog --newer # Show updates released after the currently installed version
126
+ modal changelog --newer # Show updates released after the currently installed version
126
127
 
127
- modal changelog --last 3 # Show updates included in the 3 most recent releases
128
+ modal changelog --last 3 # Show updates included in the 3 most recent releases
128
129
 
129
- modal changelog --for 1.3.1 # Show the changelog for a specific release
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(app_id=res.app_id, app_logs_url=res.app_logs_url, show_timestamps=timestamps)
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
- status_text = f"Tailing logs for {display_id}..." if display_id else "Tailing logs..."
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 output_mgr.show_status_spinner(status_text):
41
- await get_app_logs_loop(client, output_mgr, app_id=app_id, task_id=task_id, sandbox_id=sandbox_id)
42
- except asyncio.CancelledError:
43
- pass
44
- except KeyboardInterrupt:
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.5.dev0",
38
+ version: str = "1.3.6.dev0",
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.5.dev0",
174
+ version: str = "1.3.6.dev0",
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
@@ -149,7 +149,7 @@ class _FlashManager:
149
149
  timeout=10,
150
150
  retry=None,
151
151
  )
152
- logger.info(f"Listening at {resp.url} over {self.tunnel.url} for task_id {self.task_id}")
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...")