modal 1.1.5.dev91__py3-none-any.whl → 1.2.1__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 (49) hide show
  1. modal/_container_entrypoint.py +4 -1
  2. modal/_partial_function.py +28 -3
  3. modal/_utils/function_utils.py +4 -0
  4. modal/_utils/task_command_router_client.py +537 -0
  5. modal/app.py +93 -54
  6. modal/app.pyi +48 -18
  7. modal/cli/_download.py +19 -3
  8. modal/cli/cluster.py +4 -2
  9. modal/cli/container.py +4 -2
  10. modal/cli/entry_point.py +1 -0
  11. modal/cli/launch.py +1 -2
  12. modal/cli/run.py +6 -0
  13. modal/cli/volume.py +7 -1
  14. modal/client.pyi +2 -10
  15. modal/cls.py +5 -12
  16. modal/config.py +14 -0
  17. modal/container_process.py +283 -3
  18. modal/container_process.pyi +95 -32
  19. modal/exception.py +4 -0
  20. modal/experimental/flash.py +21 -47
  21. modal/experimental/flash.pyi +6 -20
  22. modal/functions.pyi +6 -6
  23. modal/io_streams.py +455 -122
  24. modal/io_streams.pyi +220 -95
  25. modal/partial_function.pyi +4 -1
  26. modal/runner.py +39 -36
  27. modal/runner.pyi +40 -24
  28. modal/sandbox.py +130 -11
  29. modal/sandbox.pyi +145 -9
  30. modal/volume.py +23 -3
  31. modal/volume.pyi +30 -0
  32. {modal-1.1.5.dev91.dist-info → modal-1.2.1.dist-info}/METADATA +5 -5
  33. {modal-1.1.5.dev91.dist-info → modal-1.2.1.dist-info}/RECORD +49 -48
  34. modal_proto/api.proto +2 -26
  35. modal_proto/api_grpc.py +0 -32
  36. modal_proto/api_pb2.py +327 -367
  37. modal_proto/api_pb2.pyi +6 -69
  38. modal_proto/api_pb2_grpc.py +0 -67
  39. modal_proto/api_pb2_grpc.pyi +0 -22
  40. modal_proto/modal_api_grpc.py +0 -2
  41. modal_proto/sandbox_router.proto +0 -4
  42. modal_proto/sandbox_router_pb2.pyi +0 -4
  43. modal_proto/task_command_router.proto +1 -1
  44. modal_proto/task_command_router_pb2.py +2 -2
  45. modal_version/__init__.py +1 -1
  46. {modal-1.1.5.dev91.dist-info → modal-1.2.1.dist-info}/WHEEL +0 -0
  47. {modal-1.1.5.dev91.dist-info → modal-1.2.1.dist-info}/entry_points.txt +0 -0
  48. {modal-1.1.5.dev91.dist-info → modal-1.2.1.dist-info}/licenses/LICENSE +0 -0
  49. {modal-1.1.5.dev91.dist-info → modal-1.2.1.dist-info}/top_level.txt +0 -0
modal/io_streams.pyi CHANGED
@@ -1,4 +1,5 @@
1
1
  import collections.abc
2
+ import modal._utils.task_command_router_client
2
3
  import modal.client
3
4
  import modal.stream_type
4
5
  import typing
@@ -17,6 +18,165 @@ def _container_process_logs_iterator(
17
18
 
18
19
  T = typing.TypeVar("T")
19
20
 
21
+ class _StreamReaderThroughServer(typing.Generic[T]):
22
+ """A StreamReader implementation that reads from the server."""
23
+
24
+ _stream: typing.Optional[collections.abc.AsyncGenerator[T, None]]
25
+
26
+ def __init__(
27
+ self,
28
+ file_descriptor: int,
29
+ object_id: str,
30
+ object_type: typing.Literal["sandbox", "container_process"],
31
+ client: modal.client._Client,
32
+ stream_type: modal.stream_type.StreamType = modal.stream_type.StreamType.PIPE,
33
+ text: bool = True,
34
+ by_line: bool = False,
35
+ deadline: typing.Optional[float] = None,
36
+ ) -> None:
37
+ """mdmd:hidden"""
38
+ ...
39
+
40
+ @property
41
+ def file_descriptor(self) -> int:
42
+ """Possible values are `1` for stdout and `2` for stderr."""
43
+ ...
44
+
45
+ async def read(self) -> T:
46
+ """Fetch the entire contents of the stream until EOF."""
47
+ ...
48
+
49
+ async def _consume_container_process_stream(self):
50
+ """Consume the container process stream and store messages in the buffer."""
51
+ ...
52
+
53
+ def _stream_container_process(self) -> collections.abc.AsyncGenerator[tuple[typing.Optional[bytes], str], None]:
54
+ """Streams the container process buffer to the reader."""
55
+ ...
56
+
57
+ def _get_logs(self, skip_empty_messages: bool = True) -> collections.abc.AsyncGenerator[bytes, None]:
58
+ """Streams sandbox or process logs from the server to the reader.
59
+
60
+ Logs returned by this method may contain partial or multiple lines at a time.
61
+
62
+ When the stream receives an EOF, it yields None. Once an EOF is received,
63
+ subsequent invocations will not yield logs.
64
+ """
65
+ ...
66
+
67
+ def _get_logs_by_line(self) -> collections.abc.AsyncGenerator[bytes, None]:
68
+ """Process logs from the server and yield complete lines only."""
69
+ ...
70
+
71
+ def _ensure_stream(self) -> collections.abc.AsyncGenerator[T, None]: ...
72
+ async def __anext__(self) -> T:
73
+ """mdmd:hidden"""
74
+ ...
75
+
76
+ async def aclose(self):
77
+ """mdmd:hidden"""
78
+ ...
79
+
80
+ def _decode_bytes_stream_to_str(
81
+ stream: collections.abc.AsyncGenerator[bytes, None],
82
+ ) -> collections.abc.AsyncGenerator[str, None]:
83
+ """Incrementally decode a bytes async generator as UTF-8 without breaking on chunk boundaries.
84
+
85
+ This function uses a streaming UTF-8 decoder so that multi-byte characters split across
86
+ chunks are handled correctly instead of raising ``UnicodeDecodeError``.
87
+ """
88
+ ...
89
+
90
+ def _stream_by_line(stream: collections.abc.AsyncGenerator[bytes, None]) -> collections.abc.AsyncGenerator[bytes, None]:
91
+ """Yield complete lines only (ending with
92
+ ), buffering partial lines until complete.
93
+ """
94
+ ...
95
+
96
+ class _StreamReaderThroughCommandRouterParams:
97
+ """_StreamReaderThroughCommandRouterParams(file_descriptor: 'api_pb2.FileDescriptor.ValueType', task_id: str, object_id: str, command_router_client: modal._utils.task_command_router_client.TaskCommandRouterClient, deadline: Optional[float])"""
98
+
99
+ file_descriptor: int
100
+ task_id: str
101
+ object_id: str
102
+ command_router_client: modal._utils.task_command_router_client.TaskCommandRouterClient
103
+ deadline: typing.Optional[float]
104
+
105
+ def __init__(
106
+ self,
107
+ file_descriptor: int,
108
+ task_id: str,
109
+ object_id: str,
110
+ command_router_client: modal._utils.task_command_router_client.TaskCommandRouterClient,
111
+ deadline: typing.Optional[float],
112
+ ) -> None:
113
+ """Initialize self. See help(type(self)) for accurate signature."""
114
+ ...
115
+
116
+ def __repr__(self):
117
+ """Return repr(self)."""
118
+ ...
119
+
120
+ def __eq__(self, other):
121
+ """Return self==value."""
122
+ ...
123
+
124
+ def _stdio_stream_from_command_router(
125
+ params: _StreamReaderThroughCommandRouterParams,
126
+ ) -> collections.abc.AsyncGenerator[bytes, None]:
127
+ """Stream raw bytes from the router client."""
128
+ ...
129
+
130
+ class _BytesStreamReaderThroughCommandRouter(typing.Generic[T]):
131
+ """StreamReader implementation that will read directly from the worker that
132
+ hosts the sandbox.
133
+
134
+ This implementation is used for non-text streams.
135
+ """
136
+ def __init__(self, params: _StreamReaderThroughCommandRouterParams) -> None:
137
+ """Initialize self. See help(type(self)) for accurate signature."""
138
+ ...
139
+
140
+ @property
141
+ def file_descriptor(self) -> int: ...
142
+ async def read(self) -> T: ...
143
+ def __aiter__(self) -> collections.abc.AsyncIterator[T]: ...
144
+ async def __anext__(self) -> T: ...
145
+ async def aclose(self): ...
146
+
147
+ class _TextStreamReaderThroughCommandRouter(typing.Generic[T]):
148
+ """StreamReader implementation that will read directly from the worker
149
+ that hosts the sandbox.
150
+
151
+ This implementation is used for text streams.
152
+ """
153
+ def __init__(self, params: _StreamReaderThroughCommandRouterParams, by_line: bool) -> None:
154
+ """Initialize self. See help(type(self)) for accurate signature."""
155
+ ...
156
+
157
+ @property
158
+ def file_descriptor(self) -> int: ...
159
+ async def read(self) -> T: ...
160
+ def __aiter__(self) -> collections.abc.AsyncIterator[T]: ...
161
+ async def __anext__(self) -> T: ...
162
+ async def aclose(self): ...
163
+
164
+ class _DevnullStreamReader(typing.Generic[T]):
165
+ """StreamReader implementation for a stream configured with
166
+ StreamType.DEVNULL. Throws an error if read or any other method is
167
+ called.
168
+ """
169
+ def __init__(self, file_descriptor: int) -> None:
170
+ """Initialize self. See help(type(self)) for accurate signature."""
171
+ ...
172
+
173
+ @property
174
+ def file_descriptor(self) -> int: ...
175
+ async def read(self) -> T: ...
176
+ def __aiter__(self) -> collections.abc.AsyncIterator[T]: ...
177
+ async def __anext__(self) -> T: ...
178
+ async def aclose(self): ...
179
+
20
180
  class _StreamReader(typing.Generic[T]):
21
181
  """Retrieve logs from a stream (`stdout` or `stderr`).
22
182
 
@@ -38,9 +198,6 @@ class _StreamReader(typing.Generic[T]):
38
198
  print(f"Message: {message}")
39
199
  ```
40
200
  """
41
-
42
- _stream: typing.Optional[collections.abc.AsyncGenerator[typing.Optional[bytes], None]]
43
-
44
201
  def __init__(
45
202
  self,
46
203
  file_descriptor: int,
@@ -51,6 +208,8 @@ class _StreamReader(typing.Generic[T]):
51
208
  text: bool = True,
52
209
  by_line: bool = False,
53
210
  deadline: typing.Optional[float] = None,
211
+ command_router_client: typing.Optional[modal._utils.task_command_router_client.TaskCommandRouterClient] = None,
212
+ task_id: typing.Optional[str] = None,
54
213
  ) -> None:
55
214
  """mdmd:hidden"""
56
215
  ...
@@ -76,52 +235,79 @@ class _StreamReader(typing.Generic[T]):
76
235
  """
77
236
  ...
78
237
 
79
- async def _consume_container_process_stream(self):
80
- """Consume the container process stream and store messages in the buffer."""
238
+ def __aiter__(self) -> collections.abc.AsyncIterator[T]:
239
+ """mdmd:hidden"""
81
240
  ...
82
241
 
83
- def _stream_container_process(self) -> collections.abc.AsyncGenerator[tuple[typing.Optional[bytes], str], None]:
84
- """Streams the container process buffer to the reader."""
242
+ async def __anext__(self) -> T:
243
+ """mdmd:hidden"""
85
244
  ...
86
245
 
87
- def _get_logs(
88
- self, skip_empty_messages: bool = True
89
- ) -> collections.abc.AsyncGenerator[typing.Optional[bytes], None]:
90
- """Streams sandbox or process logs from the server to the reader.
246
+ async def aclose(self):
247
+ """mdmd:hidden"""
248
+ ...
91
249
 
92
- Logs returned by this method may contain partial or multiple lines at a time.
250
+ class _StreamWriterThroughServer:
251
+ """Provides an interface to buffer and write logs to a sandbox or container process stream (`stdin`)."""
252
+ def __init__(
253
+ self, object_id: str, object_type: typing.Literal["sandbox", "container_process"], client: modal.client._Client
254
+ ) -> None:
255
+ """mdmd:hidden"""
256
+ ...
93
257
 
94
- When the stream receives an EOF, it yields None. Once an EOF is received,
95
- subsequent invocations will not yield logs.
258
+ def _get_next_index(self) -> int: ...
259
+ def write(self, data: typing.Union[bytes, bytearray, memoryview, str]) -> None:
260
+ """Write data to the stream but does not send it immediately.
261
+
262
+ This is non-blocking and queues the data to an internal buffer. Must be
263
+ used along with the `drain()` method, which flushes the buffer.
96
264
  """
97
265
  ...
98
266
 
99
- def _get_logs_by_line(self) -> collections.abc.AsyncGenerator[typing.Optional[bytes], None]:
100
- """Process logs from the server and yield complete lines only."""
101
- ...
267
+ def write_eof(self) -> None:
268
+ """Close the write end of the stream after the buffered data is drained.
102
269
 
103
- def _ensure_stream(self) -> collections.abc.AsyncGenerator[typing.Optional[bytes], None]: ...
104
- def __aiter__(self) -> collections.abc.AsyncIterator[T]:
105
- """mdmd:hidden"""
270
+ If the process was blocked on input, it will become unblocked after
271
+ `write_eof()`. This method needs to be used along with the `drain()`
272
+ method, which flushes the EOF to the process.
273
+ """
106
274
  ...
107
275
 
108
- async def __anext__(self) -> T:
109
- """mdmd:hidden"""
276
+ async def drain(self) -> None:
277
+ """Flush the write buffer and send data to the running process.
278
+
279
+ This is a flow control method that blocks until data is sent. It returns
280
+ when it is appropriate to continue writing data to the stream.
281
+ """
110
282
  ...
111
283
 
112
- async def aclose(self):
113
- """mdmd:hidden"""
284
+ class _StreamWriterThroughCommandRouter:
285
+ def __init__(
286
+ self,
287
+ object_id: str,
288
+ command_router_client: modal._utils.task_command_router_client.TaskCommandRouterClient,
289
+ task_id: str,
290
+ ) -> None:
291
+ """Initialize self. See help(type(self)) for accurate signature."""
114
292
  ...
115
293
 
294
+ def write(self, data: typing.Union[bytes, bytearray, memoryview, str]) -> None: ...
295
+ def write_eof(self) -> None: ...
296
+ async def drain(self) -> None: ...
297
+
116
298
  class _StreamWriter:
117
299
  """Provides an interface to buffer and write logs to a sandbox or container process stream (`stdin`)."""
118
300
  def __init__(
119
- self, object_id: str, object_type: typing.Literal["sandbox", "container_process"], client: modal.client._Client
301
+ self,
302
+ object_id: str,
303
+ object_type: typing.Literal["sandbox", "container_process"],
304
+ client: modal.client._Client,
305
+ command_router_client: typing.Optional[modal._utils.task_command_router_client.TaskCommandRouterClient] = None,
306
+ task_id: typing.Optional[str] = None,
120
307
  ) -> None:
121
308
  """mdmd:hidden"""
122
309
  ...
123
310
 
124
- def _get_next_index(self) -> int: ...
125
311
  def write(self, data: typing.Union[bytes, bytearray, memoryview, str]) -> None:
126
312
  """Write data to the stream but does not send it immediately.
127
313
 
@@ -204,9 +390,6 @@ class StreamReader(typing.Generic[T]):
204
390
  print(f"Message: {message}")
205
391
  ```
206
392
  """
207
-
208
- _stream: typing.Optional[collections.abc.AsyncGenerator[typing.Optional[bytes], None]]
209
-
210
393
  def __init__(
211
394
  self,
212
395
  file_descriptor: int,
@@ -217,6 +400,8 @@ class StreamReader(typing.Generic[T]):
217
400
  text: bool = True,
218
401
  by_line: bool = False,
219
402
  deadline: typing.Optional[float] = None,
403
+ command_router_client: typing.Optional[modal._utils.task_command_router_client.TaskCommandRouterClient] = None,
404
+ task_id: typing.Optional[str] = None,
220
405
  ) -> None:
221
406
  """mdmd:hidden"""
222
407
  ...
@@ -261,70 +446,6 @@ class StreamReader(typing.Generic[T]):
261
446
 
262
447
  read: __read_spec[T, typing_extensions.Self]
263
448
 
264
- class ___consume_container_process_stream_spec(typing_extensions.Protocol[SUPERSELF]):
265
- def __call__(self, /):
266
- """Consume the container process stream and store messages in the buffer."""
267
- ...
268
-
269
- async def aio(self, /):
270
- """Consume the container process stream and store messages in the buffer."""
271
- ...
272
-
273
- _consume_container_process_stream: ___consume_container_process_stream_spec[typing_extensions.Self]
274
-
275
- class ___stream_container_process_spec(typing_extensions.Protocol[SUPERSELF]):
276
- def __call__(self, /) -> typing.Generator[tuple[typing.Optional[bytes], str], None, None]:
277
- """Streams the container process buffer to the reader."""
278
- ...
279
-
280
- def aio(self, /) -> collections.abc.AsyncGenerator[tuple[typing.Optional[bytes], str], None]:
281
- """Streams the container process buffer to the reader."""
282
- ...
283
-
284
- _stream_container_process: ___stream_container_process_spec[typing_extensions.Self]
285
-
286
- class ___get_logs_spec(typing_extensions.Protocol[SUPERSELF]):
287
- def __call__(self, /, skip_empty_messages: bool = True) -> typing.Generator[typing.Optional[bytes], None, None]:
288
- """Streams sandbox or process logs from the server to the reader.
289
-
290
- Logs returned by this method may contain partial or multiple lines at a time.
291
-
292
- When the stream receives an EOF, it yields None. Once an EOF is received,
293
- subsequent invocations will not yield logs.
294
- """
295
- ...
296
-
297
- def aio(
298
- self, /, skip_empty_messages: bool = True
299
- ) -> collections.abc.AsyncGenerator[typing.Optional[bytes], None]:
300
- """Streams sandbox or process logs from the server to the reader.
301
-
302
- Logs returned by this method may contain partial or multiple lines at a time.
303
-
304
- When the stream receives an EOF, it yields None. Once an EOF is received,
305
- subsequent invocations will not yield logs.
306
- """
307
- ...
308
-
309
- _get_logs: ___get_logs_spec[typing_extensions.Self]
310
-
311
- class ___get_logs_by_line_spec(typing_extensions.Protocol[SUPERSELF]):
312
- def __call__(self, /) -> typing.Generator[typing.Optional[bytes], None, None]:
313
- """Process logs from the server and yield complete lines only."""
314
- ...
315
-
316
- def aio(self, /) -> collections.abc.AsyncGenerator[typing.Optional[bytes], None]:
317
- """Process logs from the server and yield complete lines only."""
318
- ...
319
-
320
- _get_logs_by_line: ___get_logs_by_line_spec[typing_extensions.Self]
321
-
322
- class ___ensure_stream_spec(typing_extensions.Protocol[SUPERSELF]):
323
- def __call__(self, /) -> typing.Generator[typing.Optional[bytes], None, None]: ...
324
- def aio(self, /) -> collections.abc.AsyncGenerator[typing.Optional[bytes], None]: ...
325
-
326
- _ensure_stream: ___ensure_stream_spec[typing_extensions.Self]
327
-
328
449
  def __iter__(self) -> typing.Iterator[T]:
329
450
  """mdmd:hidden"""
330
451
  ...
@@ -352,12 +473,16 @@ class StreamReader(typing.Generic[T]):
352
473
  class StreamWriter:
353
474
  """Provides an interface to buffer and write logs to a sandbox or container process stream (`stdin`)."""
354
475
  def __init__(
355
- self, object_id: str, object_type: typing.Literal["sandbox", "container_process"], client: modal.client.Client
476
+ self,
477
+ object_id: str,
478
+ object_type: typing.Literal["sandbox", "container_process"],
479
+ client: modal.client.Client,
480
+ command_router_client: typing.Optional[modal._utils.task_command_router_client.TaskCommandRouterClient] = None,
481
+ task_id: typing.Optional[str] = None,
356
482
  ) -> None:
357
483
  """mdmd:hidden"""
358
484
  ...
359
485
 
360
- def _get_next_index(self) -> int: ...
361
486
  def write(self, data: typing.Union[bytes, bytearray, memoryview, str]) -> None:
362
487
  """Write data to the stream but does not send it immediately.
363
488
 
@@ -329,7 +329,10 @@ def batched(
329
329
  ...
330
330
 
331
331
  def concurrent(
332
- _warn_parentheses_missing=None, *, max_inputs: int, target_inputs: typing.Optional[int] = None
332
+ _warn_parentheses_missing=None,
333
+ *,
334
+ max_inputs: typing.Optional[int] = None,
335
+ target_inputs: typing.Optional[int] = None,
333
336
  ) -> collections.abc.Callable[
334
337
  [
335
338
  typing.Union[
modal/runner.py CHANGED
@@ -35,7 +35,7 @@ from .client import HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, _Client
35
35
  from .cls import _Cls
36
36
  from .config import config, logger
37
37
  from .environments import _get_environment_cached
38
- from .exception import InteractiveTimeoutError, InvalidError, RemoteError, _CliUserExecutionError
38
+ from .exception import ConnectionError, InteractiveTimeoutError, InvalidError, RemoteError, _CliUserExecutionError
39
39
  from .output import _get_output_manager, enable_output
40
40
  from .running_app import RunningApp, running_app_from_layout
41
41
  from .sandbox import _Sandbox
@@ -43,9 +43,7 @@ from .secret import _Secret
43
43
  from .stream_type import StreamType
44
44
 
45
45
  if TYPE_CHECKING:
46
- from .app import _App
47
- else:
48
- _App = TypeVar("_App")
46
+ import modal.app
49
47
 
50
48
 
51
49
  V = TypeVar("V")
@@ -126,12 +124,11 @@ async def _init_local_app_from_name(
126
124
  async def _create_all_objects(
127
125
  client: _Client,
128
126
  running_app: RunningApp,
129
- functions: dict[str, _Function],
130
- classes: dict[str, _Cls],
127
+ local_app_state: "modal.app._LocalAppState",
131
128
  environment_name: str,
132
129
  ) -> None:
133
130
  """Create objects that have been defined but not created on the server."""
134
- indexed_objects: dict[str, _Object] = {**functions, **classes}
131
+ indexed_objects: dict[str, _Object] = {**local_app_state.functions, **local_app_state.classes}
135
132
  resolver = Resolver(
136
133
  client,
137
134
  environment_name=environment_name,
@@ -182,21 +179,19 @@ async def _publish_app(
182
179
  client: _Client,
183
180
  running_app: RunningApp,
184
181
  app_state: int, # api_pb2.AppState.value
185
- functions: dict[str, _Function],
186
- classes: dict[str, _Cls],
182
+ app_local_state: "modal.app._LocalAppState",
187
183
  name: str = "",
188
- tags: dict[str, str] = {}, # Additional App metadata
189
184
  deployment_tag: str = "", # Only relevant for deployments
190
185
  commit_info: Optional[api_pb2.CommitInfo] = None, # Git commit information
191
186
  ) -> tuple[str, list[api_pb2.Warning]]:
192
187
  """Wrapper for AppPublish RPC."""
193
-
188
+ functions = app_local_state.functions
194
189
  definition_ids = {obj.object_id: obj._get_metadata().definition_id for obj in functions.values()} # type: ignore
195
190
 
196
191
  request = api_pb2.AppPublishRequest(
197
192
  app_id=running_app.app_id,
198
193
  name=name,
199
- tags=tags,
194
+ tags=app_local_state.tags,
200
195
  deployment_tag=deployment_tag,
201
196
  commit_info=commit_info,
202
197
  app_state=app_state, # type: ignore : should be a api_pb2.AppState.value
@@ -260,13 +255,13 @@ async def _status_based_disconnect(client: _Client, app_id: str, exc_info: Optio
260
255
 
261
256
  @asynccontextmanager
262
257
  async def _run_app(
263
- app: _App,
258
+ app: "modal.app._App",
264
259
  *,
265
260
  client: Optional[_Client] = None,
266
261
  detach: bool = False,
267
262
  environment_name: Optional[str] = None,
268
263
  interactive: bool = False,
269
- ) -> AsyncGenerator[_App, None]:
264
+ ) -> AsyncGenerator["modal.app._App", None]:
270
265
  """mdmd:hidden"""
271
266
  if environment_name is None:
272
267
  environment_name = typing.cast(str, config.get("environment"))
@@ -338,12 +333,13 @@ async def _run_app(
338
333
  get_app_logs_loop(client, output_mgr, app_id=running_app.app_id, app_logs_url=running_app.app_logs_url)
339
334
  )
340
335
 
336
+ local_app_state = app._local_state
341
337
  try:
342
338
  # Create all members
343
- await _create_all_objects(client, running_app, app._functions, app._classes, environment_name)
339
+ await _create_all_objects(client, running_app, local_app_state, environment_name)
344
340
 
345
341
  # Publish the app
346
- await _publish_app(client, running_app, app_state, app._functions, app._classes, tags=app._tags)
342
+ await _publish_app(client, running_app, app_state, local_app_state)
347
343
  except asyncio.CancelledError as e:
348
344
  # this typically happens on sigint/ctrl-C during setup (the KeyboardInterrupt happens in the main thread)
349
345
  if output_mgr := _get_output_manager():
@@ -355,6 +351,15 @@ async def _run_app(
355
351
  await _status_based_disconnect(client, running_app.app_id, e)
356
352
  raise
357
353
 
354
+ detached_disconnect_msg = (
355
+ "The detached App will keep running. You can track its progress on the Dashboard: "
356
+ f"[magenta underline]{running_app.app_page_url}[/magenta underline]"
357
+ "\n\nStream App logs:\n"
358
+ f"[green]modal app logs {running_app.app_id}[/green]"
359
+ "\n\nStop the App:\n"
360
+ f"[green]modal app stop {running_app.app_id}[/green]"
361
+ )
362
+
358
363
  try:
359
364
  # Show logs from dynamically created images.
360
365
  # TODO: better way to do this
@@ -377,11 +382,7 @@ async def _run_app(
377
382
  if detach:
378
383
  if output_mgr := _get_output_manager():
379
384
  output_mgr.print(output_mgr.step_completed("Shutting down Modal client."))
380
- output_mgr.print(
381
- "The detached app keeps running. You can track its progress at: "
382
- f"[magenta]{running_app.app_page_url}[/magenta]"
383
- ""
384
- )
385
+ output_mgr.print(detached_disconnect_msg)
385
386
  if logs_loop:
386
387
  logs_loop.cancel()
387
388
  await _status_based_disconnect(client, running_app.app_id, e)
@@ -405,6 +406,14 @@ async def _run_app(
405
406
  )
406
407
  )
407
408
  return
409
+ except ConnectionError as e:
410
+ # If we lose connection to the server after a detached App has started running, it will continue
411
+ # I think we can only exit "nicely" if we are able to print output though, otherwise we should raise
412
+ if detach and (output_mgr := _get_output_manager()):
413
+ output_mgr.print(":white_exclamation_mark: Connection lost!")
414
+ output_mgr.print(detached_disconnect_msg)
415
+ return
416
+ raise
408
417
  except BaseException as e:
409
418
  logger.info("Exception during app run")
410
419
  await _status_based_disconnect(client, running_app.app_id, e)
@@ -428,7 +437,7 @@ async def _run_app(
428
437
 
429
438
 
430
439
  async def _serve_update(
431
- app: _App,
440
+ app: "modal.app._App",
432
441
  existing_app_id: str,
433
442
  is_ready: Event,
434
443
  environment_name: str,
@@ -438,13 +447,12 @@ async def _serve_update(
438
447
  client = await _Client.from_env()
439
448
  try:
440
449
  running_app: RunningApp = await _init_local_app_existing(client, existing_app_id, environment_name)
441
-
450
+ local_app_state = app._local_state
442
451
  # Create objects
443
452
  await _create_all_objects(
444
453
  client,
445
454
  running_app,
446
- app._functions,
447
- app._classes,
455
+ local_app_state,
448
456
  environment_name,
449
457
  )
450
458
 
@@ -453,9 +461,7 @@ async def _serve_update(
453
461
  client,
454
462
  running_app,
455
463
  app_state=api_pb2.APP_STATE_UNSPECIFIED,
456
- functions=app._functions,
457
- classes=app._classes,
458
- tags=app._tags,
464
+ app_local_state=local_app_state,
459
465
  )
460
466
 
461
467
  # Communicate to the parent process
@@ -476,7 +482,7 @@ class DeployResult:
476
482
 
477
483
 
478
484
  async def _deploy_app(
479
- app: _App,
485
+ app: "modal.app._App",
480
486
  name: Optional[str] = None,
481
487
  namespace: Any = None, # mdmd:line-hidden
482
488
  client: Optional[_Client] = None,
@@ -534,8 +540,7 @@ async def _deploy_app(
534
540
  await _create_all_objects(
535
541
  client,
536
542
  running_app,
537
- app._functions,
538
- app._classes,
543
+ app._local_state,
539
544
  environment_name=environment_name,
540
545
  )
541
546
 
@@ -548,11 +553,9 @@ async def _deploy_app(
548
553
  app_url, warnings = await _publish_app(
549
554
  client,
550
555
  running_app,
551
- app_state=api_pb2.APP_STATE_DEPLOYED,
552
- functions=app._functions,
553
- classes=app._classes,
556
+ api_pb2.APP_STATE_DEPLOYED,
557
+ app._local_state,
554
558
  name=name,
555
- tags=app._tags,
556
559
  deployment_tag=tag,
557
560
  commit_info=commit_info,
558
561
  )
@@ -574,7 +577,7 @@ async def _deploy_app(
574
577
 
575
578
 
576
579
  async def _interactive_shell(
577
- _app: _App, cmds: list[str], environment_name: str = "", pty: bool = True, **kwargs: Any
580
+ _app: "modal.app._App", cmds: list[str], environment_name: str = "", pty: bool = True, **kwargs: Any
578
581
  ) -> None:
579
582
  """Run an interactive shell (like `bash`) within the image for this app.
580
583