modal 1.0.3.dev10__py3-none-any.whl → 1.2.3.dev7__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 (160) hide show
  1. modal/__init__.py +0 -2
  2. modal/__main__.py +3 -4
  3. modal/_billing.py +80 -0
  4. modal/_clustered_functions.py +7 -3
  5. modal/_clustered_functions.pyi +15 -3
  6. modal/_container_entrypoint.py +51 -69
  7. modal/_functions.py +508 -240
  8. modal/_grpc_client.py +171 -0
  9. modal/_load_context.py +105 -0
  10. modal/_object.py +81 -21
  11. modal/_output.py +58 -45
  12. modal/_partial_function.py +48 -73
  13. modal/_pty.py +7 -3
  14. modal/_resolver.py +26 -46
  15. modal/_runtime/asgi.py +4 -3
  16. modal/_runtime/container_io_manager.py +358 -220
  17. modal/_runtime/container_io_manager.pyi +296 -101
  18. modal/_runtime/execution_context.py +18 -2
  19. modal/_runtime/execution_context.pyi +64 -7
  20. modal/_runtime/gpu_memory_snapshot.py +262 -57
  21. modal/_runtime/user_code_imports.py +28 -58
  22. modal/_serialization.py +90 -6
  23. modal/_traceback.py +42 -1
  24. modal/_tunnel.pyi +380 -12
  25. modal/_utils/async_utils.py +84 -29
  26. modal/_utils/auth_token_manager.py +111 -0
  27. modal/_utils/blob_utils.py +181 -58
  28. modal/_utils/deprecation.py +19 -0
  29. modal/_utils/function_utils.py +91 -47
  30. modal/_utils/grpc_utils.py +89 -66
  31. modal/_utils/mount_utils.py +26 -1
  32. modal/_utils/name_utils.py +17 -3
  33. modal/_utils/task_command_router_client.py +536 -0
  34. modal/_utils/time_utils.py +34 -6
  35. modal/app.py +256 -88
  36. modal/app.pyi +909 -92
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +18 -0
  39. modal/builder/PREVIEW.txt +18 -0
  40. modal/builder/base-images.json +58 -0
  41. modal/cli/_download.py +19 -3
  42. modal/cli/_traceback.py +3 -2
  43. modal/cli/app.py +4 -4
  44. modal/cli/cluster.py +15 -7
  45. modal/cli/config.py +5 -3
  46. modal/cli/container.py +7 -6
  47. modal/cli/dict.py +22 -16
  48. modal/cli/entry_point.py +12 -5
  49. modal/cli/environment.py +5 -4
  50. modal/cli/import_refs.py +3 -3
  51. modal/cli/launch.py +102 -5
  52. modal/cli/network_file_system.py +11 -12
  53. modal/cli/profile.py +3 -2
  54. modal/cli/programs/launch_instance_ssh.py +94 -0
  55. modal/cli/programs/run_jupyter.py +1 -1
  56. modal/cli/programs/run_marimo.py +95 -0
  57. modal/cli/programs/vscode.py +1 -1
  58. modal/cli/queues.py +57 -26
  59. modal/cli/run.py +91 -23
  60. modal/cli/secret.py +48 -22
  61. modal/cli/token.py +7 -8
  62. modal/cli/utils.py +4 -7
  63. modal/cli/volume.py +31 -25
  64. modal/client.py +15 -85
  65. modal/client.pyi +183 -62
  66. modal/cloud_bucket_mount.py +5 -3
  67. modal/cloud_bucket_mount.pyi +197 -5
  68. modal/cls.py +200 -126
  69. modal/cls.pyi +446 -68
  70. modal/config.py +29 -11
  71. modal/container_process.py +319 -19
  72. modal/container_process.pyi +190 -20
  73. modal/dict.py +290 -71
  74. modal/dict.pyi +835 -83
  75. modal/environments.py +15 -27
  76. modal/environments.pyi +46 -24
  77. modal/exception.py +14 -2
  78. modal/experimental/__init__.py +194 -40
  79. modal/experimental/flash.py +618 -0
  80. modal/experimental/flash.pyi +380 -0
  81. modal/experimental/ipython.py +11 -7
  82. modal/file_io.py +29 -36
  83. modal/file_io.pyi +251 -53
  84. modal/file_pattern_matcher.py +56 -16
  85. modal/functions.pyi +673 -92
  86. modal/gpu.py +1 -1
  87. modal/image.py +528 -176
  88. modal/image.pyi +1572 -145
  89. modal/io_streams.py +458 -128
  90. modal/io_streams.pyi +433 -52
  91. modal/mount.py +216 -151
  92. modal/mount.pyi +225 -78
  93. modal/network_file_system.py +45 -62
  94. modal/network_file_system.pyi +277 -56
  95. modal/object.pyi +93 -17
  96. modal/parallel_map.py +942 -129
  97. modal/parallel_map.pyi +294 -15
  98. modal/partial_function.py +0 -2
  99. modal/partial_function.pyi +234 -19
  100. modal/proxy.py +17 -8
  101. modal/proxy.pyi +36 -3
  102. modal/queue.py +270 -65
  103. modal/queue.pyi +817 -57
  104. modal/runner.py +115 -101
  105. modal/runner.pyi +205 -49
  106. modal/sandbox.py +512 -136
  107. modal/sandbox.pyi +845 -111
  108. modal/schedule.py +1 -1
  109. modal/secret.py +300 -70
  110. modal/secret.pyi +589 -34
  111. modal/serving.py +7 -11
  112. modal/serving.pyi +7 -8
  113. modal/snapshot.py +11 -8
  114. modal/snapshot.pyi +25 -4
  115. modal/token_flow.py +4 -4
  116. modal/token_flow.pyi +28 -8
  117. modal/volume.py +416 -158
  118. modal/volume.pyi +1117 -121
  119. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +10 -9
  120. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  121. modal_docs/mdmd/mdmd.py +17 -4
  122. modal_proto/api.proto +534 -79
  123. modal_proto/api_grpc.py +337 -1
  124. modal_proto/api_pb2.py +1522 -968
  125. modal_proto/api_pb2.pyi +1619 -134
  126. modal_proto/api_pb2_grpc.py +699 -4
  127. modal_proto/api_pb2_grpc.pyi +226 -14
  128. modal_proto/modal_api_grpc.py +175 -154
  129. modal_proto/sandbox_router.proto +145 -0
  130. modal_proto/sandbox_router_grpc.py +105 -0
  131. modal_proto/sandbox_router_pb2.py +149 -0
  132. modal_proto/sandbox_router_pb2.pyi +333 -0
  133. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  134. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  135. modal_proto/task_command_router.proto +144 -0
  136. modal_proto/task_command_router_grpc.py +105 -0
  137. modal_proto/task_command_router_pb2.py +149 -0
  138. modal_proto/task_command_router_pb2.pyi +333 -0
  139. modal_proto/task_command_router_pb2_grpc.py +203 -0
  140. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  141. modal_version/__init__.py +1 -1
  142. modal/requirements/PREVIEW.txt +0 -16
  143. modal/requirements/base-images.json +0 -26
  144. modal-1.0.3.dev10.dist-info/RECORD +0 -179
  145. modal_proto/modal_options_grpc.py +0 -3
  146. modal_proto/options.proto +0 -19
  147. modal_proto/options_grpc.py +0 -3
  148. modal_proto/options_pb2.py +0 -35
  149. modal_proto/options_pb2.pyi +0 -20
  150. modal_proto/options_pb2_grpc.py +0 -4
  151. modal_proto/options_pb2_grpc.pyi +0 -7
  152. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  153. /modal/{requirements → builder}/2023.12.txt +0 -0
  154. /modal/{requirements → builder}/2024.04.txt +0 -0
  155. /modal/{requirements → builder}/2024.10.txt +0 -0
  156. /modal/{requirements → builder}/README.md +0 -0
  157. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  158. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  159. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  160. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
modal/file_io.pyi CHANGED
@@ -1,18 +1,29 @@
1
1
  import _typeshed
2
2
  import enum
3
3
  import modal.client
4
- import modal_proto.api_pb2
5
4
  import typing
6
5
  import typing_extensions
7
6
 
8
7
  T = typing.TypeVar("T")
9
8
 
10
- async def _delete_bytes(
11
- file: _FileIO, start: typing.Optional[int] = None, end: typing.Optional[int] = None
12
- ) -> None: ...
9
+ async def _delete_bytes(file: _FileIO, start: typing.Optional[int] = None, end: typing.Optional[int] = None) -> None:
10
+ """Delete a range of bytes from the file.
11
+
12
+ `start` and `end` are byte offsets. `start` is inclusive, `end` is exclusive.
13
+ If either is None, the start or end of the file is used, respectively.
14
+ """
15
+ ...
16
+
13
17
  async def _replace_bytes(
14
18
  file: _FileIO, data: bytes, start: typing.Optional[int] = None, end: typing.Optional[int] = None
15
- ) -> None: ...
19
+ ) -> None:
20
+ """Replace a range of bytes in the file with new data. The length of the data does not
21
+ have to be the same as the length of the range being replaced.
22
+
23
+ `start` and `end` are byte offsets. `start` is inclusive, `end` is exclusive.
24
+ If either is None, the start or end of the file is used, respectively.
25
+ """
26
+ ...
16
27
 
17
28
  class FileWatchEventType(enum.Enum):
18
29
  Unknown = "Unknown"
@@ -22,23 +33,58 @@ class FileWatchEventType(enum.Enum):
22
33
  Remove = "Remove"
23
34
 
24
35
  class FileWatchEvent:
36
+ """FileWatchEvent(paths: list[str], type: modal.file_io.FileWatchEventType)"""
37
+
25
38
  paths: list[str]
26
39
  type: FileWatchEventType
27
40
 
28
- def __init__(self, paths: list[str], type: FileWatchEventType) -> None: ...
29
- def __repr__(self): ...
30
- def __eq__(self, other): ...
41
+ def __init__(self, paths: list[str], type: FileWatchEventType) -> None:
42
+ """Initialize self. See help(type(self)) for accurate signature."""
43
+ ...
44
+
45
+ def __repr__(self):
46
+ """Return repr(self)."""
47
+ ...
48
+
49
+ def __eq__(self, other):
50
+ """Return self==value."""
51
+ ...
31
52
 
32
53
  class _FileIO(typing.Generic[T]):
54
+ """[Alpha] FileIO handle, used in the Sandbox filesystem API.
55
+
56
+ The API is designed to mimic Python's io.FileIO.
57
+
58
+ Currently this API is in Alpha and is subject to change. File I/O operations
59
+ may be limited in size to 100 MiB, and the throughput of requests is
60
+ restricted in the current implementation. For our recommendations on large file transfers
61
+ see the Sandbox [filesystem access guide](https://modal.com/docs/guide/sandbox-files).
62
+
63
+ **Usage**
64
+
65
+ ```python notest
66
+ import modal
67
+
68
+ app = modal.App.lookup("my-app", create_if_missing=True)
69
+
70
+ sb = modal.Sandbox.create(app=app)
71
+ f = sb.open("/tmp/foo.txt", "w")
72
+ f.write("hello")
73
+ f.close()
74
+ ```
75
+ """
76
+
33
77
  _task_id: str
34
78
  _file_descriptor: str
35
79
  _client: modal.client._Client
36
- _watch_output_buffer: list[typing.Optional[bytes]]
80
+ _watch_output_buffer: list[typing.Union[bytes, None, Exception]]
81
+
82
+ def __init__(self, client: modal.client._Client, task_id: str) -> None:
83
+ """Initialize self. See help(type(self)) for accurate signature."""
84
+ ...
37
85
 
38
- def __init__(self, client: modal.client._Client, task_id: str) -> None: ...
39
86
  def _validate_mode(self, mode: str) -> None: ...
40
- def _handle_error(self, error: modal_proto.api_pb2.SystemErrorMessage) -> None: ...
41
- def _consume_output(self, exec_id: str) -> typing.AsyncIterator[typing.Optional[bytes]]: ...
87
+ def _consume_output(self, exec_id: str) -> typing.AsyncIterator[typing.Union[bytes, None, Exception]]: ...
42
88
  async def _consume_watch_output(self, exec_id: str) -> None: ...
43
89
  async def _parse_watch_output(self, event: bytes) -> typing.Optional[FileWatchEvent]: ...
44
90
  async def _wait(self, exec_id: str) -> bytes: ...
@@ -51,21 +97,60 @@ class _FileIO(typing.Generic[T]):
51
97
  mode: typing.Union[_typeshed.OpenTextMode, _typeshed.OpenBinaryMode],
52
98
  client: modal.client._Client,
53
99
  task_id: str,
54
- ) -> _FileIO: ...
100
+ ) -> _FileIO:
101
+ """Create a new FileIO handle."""
102
+ ...
103
+
55
104
  async def _make_read_request(self, n: typing.Optional[int]) -> bytes: ...
56
- async def read(self, n: typing.Optional[int] = None) -> T: ...
57
- async def readline(self) -> T: ...
58
- async def readlines(self) -> typing.Sequence[T]: ...
59
- async def write(self, data: typing.Union[bytes, str]) -> None: ...
60
- async def flush(self) -> None: ...
105
+ async def read(self, n: typing.Optional[int] = None) -> T:
106
+ """Read n bytes from the current position, or the entire remaining file if n is None."""
107
+ ...
108
+
109
+ async def readline(self) -> T:
110
+ """Read a single line from the current position."""
111
+ ...
112
+
113
+ async def readlines(self) -> typing.Sequence[T]:
114
+ """Read all lines from the current position."""
115
+ ...
116
+
117
+ async def write(self, data: typing.Union[bytes, str]) -> None:
118
+ """Write data to the current position.
119
+
120
+ Writes may not appear until the entire buffer is flushed, which
121
+ can be done manually with `flush()` or automatically when the file is
122
+ closed.
123
+ """
124
+ ...
125
+
126
+ async def flush(self) -> None:
127
+ """Flush the buffer to disk."""
128
+ ...
129
+
61
130
  def _get_whence(self, whence: int): ...
62
- async def seek(self, offset: int, whence: int = 0) -> None: ...
131
+ async def seek(self, offset: int, whence: int = 0) -> None:
132
+ """Move to a new position in the file.
133
+
134
+ `whence` defaults to 0 (absolute file positioning); other values are 1
135
+ (relative to the current position) and 2 (relative to the file's end).
136
+ """
137
+ ...
138
+
63
139
  @classmethod
64
- async def ls(cls, path: str, client: modal.client._Client, task_id: str) -> list[str]: ...
140
+ async def ls(cls, path: str, client: modal.client._Client, task_id: str) -> list[str]:
141
+ """List the contents of the provided directory."""
142
+ ...
143
+
65
144
  @classmethod
66
- async def mkdir(cls, path: str, client: modal.client._Client, task_id: str, parents: bool = False) -> None: ...
145
+ async def mkdir(cls, path: str, client: modal.client._Client, task_id: str, parents: bool = False) -> None:
146
+ """Create a new directory."""
147
+ ...
148
+
67
149
  @classmethod
68
- async def rm(cls, path: str, client: modal.client._Client, task_id: str, recursive: bool = False) -> None: ...
150
+ async def rm(cls, path: str, client: modal.client._Client, task_id: str, recursive: bool = False) -> None:
151
+ """Remove a file or directory in the Sandbox."""
152
+ ...
153
+
69
154
  @classmethod
70
155
  def watch(
71
156
  cls,
@@ -77,7 +162,10 @@ class _FileIO(typing.Generic[T]):
77
162
  timeout: typing.Optional[int] = None,
78
163
  ) -> typing.AsyncIterator[FileWatchEvent]: ...
79
164
  async def _close(self) -> None: ...
80
- async def close(self) -> None: ...
165
+ async def close(self) -> None:
166
+ """Flush the buffer and close the file."""
167
+ ...
168
+
81
169
  def _check_writable(self) -> None: ...
82
170
  def _check_readable(self) -> None: ...
83
171
  def _check_closed(self) -> None: ...
@@ -85,22 +173,46 @@ class _FileIO(typing.Generic[T]):
85
173
  async def __aexit__(self, exc_type, exc_value, traceback) -> None: ...
86
174
 
87
175
  class __delete_bytes_spec(typing_extensions.Protocol):
88
- def __call__(
89
- self, /, file: FileIO, start: typing.Optional[int] = None, end: typing.Optional[int] = None
90
- ) -> None: ...
91
- async def aio(
92
- self, /, file: FileIO, start: typing.Optional[int] = None, end: typing.Optional[int] = None
93
- ) -> None: ...
176
+ def __call__(self, /, file: FileIO, start: typing.Optional[int] = None, end: typing.Optional[int] = None) -> None:
177
+ """Delete a range of bytes from the file.
178
+
179
+ `start` and `end` are byte offsets. `start` is inclusive, `end` is exclusive.
180
+ If either is None, the start or end of the file is used, respectively.
181
+ """
182
+ ...
183
+
184
+ async def aio(self, /, file: FileIO, start: typing.Optional[int] = None, end: typing.Optional[int] = None) -> None:
185
+ """Delete a range of bytes from the file.
186
+
187
+ `start` and `end` are byte offsets. `start` is inclusive, `end` is exclusive.
188
+ If either is None, the start or end of the file is used, respectively.
189
+ """
190
+ ...
94
191
 
95
192
  delete_bytes: __delete_bytes_spec
96
193
 
97
194
  class __replace_bytes_spec(typing_extensions.Protocol):
98
195
  def __call__(
99
196
  self, /, file: FileIO, data: bytes, start: typing.Optional[int] = None, end: typing.Optional[int] = None
100
- ) -> None: ...
197
+ ) -> None:
198
+ """Replace a range of bytes in the file with new data. The length of the data does not
199
+ have to be the same as the length of the range being replaced.
200
+
201
+ `start` and `end` are byte offsets. `start` is inclusive, `end` is exclusive.
202
+ If either is None, the start or end of the file is used, respectively.
203
+ """
204
+ ...
205
+
101
206
  async def aio(
102
207
  self, /, file: FileIO, data: bytes, start: typing.Optional[int] = None, end: typing.Optional[int] = None
103
- ) -> None: ...
208
+ ) -> None:
209
+ """Replace a range of bytes in the file with new data. The length of the data does not
210
+ have to be the same as the length of the range being replaced.
211
+
212
+ `start` and `end` are byte offsets. `start` is inclusive, `end` is exclusive.
213
+ If either is None, the start or end of the file is used, respectively.
214
+ """
215
+ ...
104
216
 
105
217
  replace_bytes: __replace_bytes_spec
106
218
 
@@ -109,18 +221,40 @@ SUPERSELF = typing.TypeVar("SUPERSELF", covariant=True)
109
221
  T_INNER = typing.TypeVar("T_INNER", covariant=True)
110
222
 
111
223
  class FileIO(typing.Generic[T]):
224
+ """[Alpha] FileIO handle, used in the Sandbox filesystem API.
225
+
226
+ The API is designed to mimic Python's io.FileIO.
227
+
228
+ Currently this API is in Alpha and is subject to change. File I/O operations
229
+ may be limited in size to 100 MiB, and the throughput of requests is
230
+ restricted in the current implementation. For our recommendations on large file transfers
231
+ see the Sandbox [filesystem access guide](https://modal.com/docs/guide/sandbox-files).
232
+
233
+ **Usage**
234
+
235
+ ```python notest
236
+ import modal
237
+
238
+ app = modal.App.lookup("my-app", create_if_missing=True)
239
+
240
+ sb = modal.Sandbox.create(app=app)
241
+ f = sb.open("/tmp/foo.txt", "w")
242
+ f.write("hello")
243
+ f.close()
244
+ ```
245
+ """
246
+
112
247
  _task_id: str
113
248
  _file_descriptor: str
114
249
  _client: modal.client.Client
115
- _watch_output_buffer: list[typing.Optional[bytes]]
250
+ _watch_output_buffer: list[typing.Union[bytes, None, Exception]]
116
251
 
117
252
  def __init__(self, client: modal.client.Client, task_id: str) -> None: ...
118
253
  def _validate_mode(self, mode: str) -> None: ...
119
- def _handle_error(self, error: modal_proto.api_pb2.SystemErrorMessage) -> None: ...
120
254
 
121
255
  class ___consume_output_spec(typing_extensions.Protocol[SUPERSELF]):
122
- def __call__(self, /, exec_id: str) -> typing.Iterator[typing.Optional[bytes]]: ...
123
- def aio(self, /, exec_id: str) -> typing.AsyncIterator[typing.Optional[bytes]]: ...
256
+ def __call__(self, /, exec_id: str) -> typing.Iterator[typing.Union[bytes, None, Exception]]: ...
257
+ def aio(self, /, exec_id: str) -> typing.AsyncIterator[typing.Union[bytes, None, Exception]]: ...
124
258
 
125
259
  _consume_output: ___consume_output_spec[typing_extensions.Self]
126
260
 
@@ -157,7 +291,9 @@ class FileIO(typing.Generic[T]):
157
291
  mode: typing.Union[_typeshed.OpenTextMode, _typeshed.OpenBinaryMode],
158
292
  client: modal.client.Client,
159
293
  task_id: str,
160
- ) -> FileIO: ...
294
+ ) -> FileIO:
295
+ """Create a new FileIO handle."""
296
+ ...
161
297
 
162
298
  class ___make_read_request_spec(typing_extensions.Protocol[SUPERSELF]):
163
299
  def __call__(self, /, n: typing.Optional[int]) -> bytes: ...
@@ -166,49 +302,106 @@ class FileIO(typing.Generic[T]):
166
302
  _make_read_request: ___make_read_request_spec[typing_extensions.Self]
167
303
 
168
304
  class __read_spec(typing_extensions.Protocol[T_INNER, SUPERSELF]):
169
- def __call__(self, /, n: typing.Optional[int] = None) -> T_INNER: ...
170
- async def aio(self, /, n: typing.Optional[int] = None) -> T_INNER: ...
305
+ def __call__(self, /, n: typing.Optional[int] = None) -> T_INNER:
306
+ """Read n bytes from the current position, or the entire remaining file if n is None."""
307
+ ...
308
+
309
+ async def aio(self, /, n: typing.Optional[int] = None) -> T_INNER:
310
+ """Read n bytes from the current position, or the entire remaining file if n is None."""
311
+ ...
171
312
 
172
313
  read: __read_spec[T, typing_extensions.Self]
173
314
 
174
315
  class __readline_spec(typing_extensions.Protocol[T_INNER, SUPERSELF]):
175
- def __call__(self, /) -> T_INNER: ...
176
- async def aio(self, /) -> T_INNER: ...
316
+ def __call__(self, /) -> T_INNER:
317
+ """Read a single line from the current position."""
318
+ ...
319
+
320
+ async def aio(self, /) -> T_INNER:
321
+ """Read a single line from the current position."""
322
+ ...
177
323
 
178
324
  readline: __readline_spec[T, typing_extensions.Self]
179
325
 
180
326
  class __readlines_spec(typing_extensions.Protocol[T_INNER, SUPERSELF]):
181
- def __call__(self, /) -> typing.Sequence[T_INNER]: ...
182
- async def aio(self, /) -> typing.Sequence[T_INNER]: ...
327
+ def __call__(self, /) -> typing.Sequence[T_INNER]:
328
+ """Read all lines from the current position."""
329
+ ...
330
+
331
+ async def aio(self, /) -> typing.Sequence[T_INNER]:
332
+ """Read all lines from the current position."""
333
+ ...
183
334
 
184
335
  readlines: __readlines_spec[T, typing_extensions.Self]
185
336
 
186
337
  class __write_spec(typing_extensions.Protocol[SUPERSELF]):
187
- def __call__(self, /, data: typing.Union[bytes, str]) -> None: ...
188
- async def aio(self, /, data: typing.Union[bytes, str]) -> None: ...
338
+ def __call__(self, /, data: typing.Union[bytes, str]) -> None:
339
+ """Write data to the current position.
340
+
341
+ Writes may not appear until the entire buffer is flushed, which
342
+ can be done manually with `flush()` or automatically when the file is
343
+ closed.
344
+ """
345
+ ...
346
+
347
+ async def aio(self, /, data: typing.Union[bytes, str]) -> None:
348
+ """Write data to the current position.
349
+
350
+ Writes may not appear until the entire buffer is flushed, which
351
+ can be done manually with `flush()` or automatically when the file is
352
+ closed.
353
+ """
354
+ ...
189
355
 
190
356
  write: __write_spec[typing_extensions.Self]
191
357
 
192
358
  class __flush_spec(typing_extensions.Protocol[SUPERSELF]):
193
- def __call__(self, /) -> None: ...
194
- async def aio(self, /) -> None: ...
359
+ def __call__(self, /) -> None:
360
+ """Flush the buffer to disk."""
361
+ ...
362
+
363
+ async def aio(self, /) -> None:
364
+ """Flush the buffer to disk."""
365
+ ...
195
366
 
196
367
  flush: __flush_spec[typing_extensions.Self]
197
368
 
198
369
  def _get_whence(self, whence: int): ...
199
370
 
200
371
  class __seek_spec(typing_extensions.Protocol[SUPERSELF]):
201
- def __call__(self, /, offset: int, whence: int = 0) -> None: ...
202
- async def aio(self, /, offset: int, whence: int = 0) -> None: ...
372
+ def __call__(self, /, offset: int, whence: int = 0) -> None:
373
+ """Move to a new position in the file.
374
+
375
+ `whence` defaults to 0 (absolute file positioning); other values are 1
376
+ (relative to the current position) and 2 (relative to the file's end).
377
+ """
378
+ ...
379
+
380
+ async def aio(self, /, offset: int, whence: int = 0) -> None:
381
+ """Move to a new position in the file.
382
+
383
+ `whence` defaults to 0 (absolute file positioning); other values are 1
384
+ (relative to the current position) and 2 (relative to the file's end).
385
+ """
386
+ ...
203
387
 
204
388
  seek: __seek_spec[typing_extensions.Self]
205
389
 
206
390
  @classmethod
207
- def ls(cls, path: str, client: modal.client.Client, task_id: str) -> list[str]: ...
391
+ def ls(cls, path: str, client: modal.client.Client, task_id: str) -> list[str]:
392
+ """List the contents of the provided directory."""
393
+ ...
394
+
208
395
  @classmethod
209
- def mkdir(cls, path: str, client: modal.client.Client, task_id: str, parents: bool = False) -> None: ...
396
+ def mkdir(cls, path: str, client: modal.client.Client, task_id: str, parents: bool = False) -> None:
397
+ """Create a new directory."""
398
+ ...
399
+
210
400
  @classmethod
211
- def rm(cls, path: str, client: modal.client.Client, task_id: str, recursive: bool = False) -> None: ...
401
+ def rm(cls, path: str, client: modal.client.Client, task_id: str, recursive: bool = False) -> None:
402
+ """Remove a file or directory in the Sandbox."""
403
+ ...
404
+
212
405
  @classmethod
213
406
  def watch(
214
407
  cls,
@@ -227,8 +420,13 @@ class FileIO(typing.Generic[T]):
227
420
  _close: ___close_spec[typing_extensions.Self]
228
421
 
229
422
  class __close_spec(typing_extensions.Protocol[SUPERSELF]):
230
- def __call__(self, /) -> None: ...
231
- async def aio(self, /) -> None: ...
423
+ def __call__(self, /) -> None:
424
+ """Flush the buffer and close the file."""
425
+ ...
426
+
427
+ async def aio(self, /) -> None:
428
+ """Flush the buffer and close the file."""
429
+ ...
232
430
 
233
431
  close: __close_spec[typing_extensions.Self]
234
432
 
@@ -11,6 +11,7 @@ then asking it whether file paths match any of its patterns.
11
11
 
12
12
  import os
13
13
  from abc import abstractmethod
14
+ from functools import cached_property
14
15
  from pathlib import Path
15
16
  from typing import Callable, Optional, Sequence, Union
16
17
 
@@ -46,6 +47,18 @@ class _AbstractPatternMatcher:
46
47
 
47
48
  return super().__repr__()
48
49
 
50
+ @abstractmethod
51
+ def can_prune_directories(self) -> bool:
52
+ """
53
+ Returns True if this pattern matcher allows safe early directory pruning.
54
+
55
+ Directory pruning is safe when matching directories can be skipped entirely
56
+ without missing any files that should be included.
57
+
58
+ An example where pruning is not safe is for inverted patterns, like "!**/*.py".
59
+ """
60
+ ...
61
+
49
62
  @abstractmethod
50
63
  def __call__(self, path: Path) -> bool: ...
51
64
 
@@ -54,6 +67,15 @@ class _CustomPatternMatcher(_AbstractPatternMatcher):
54
67
  def __init__(self, predicate: Callable[[Path], bool]):
55
68
  self._predicate = predicate
56
69
 
70
+ def can_prune_directories(self) -> bool:
71
+ """
72
+ Custom pattern matchers (like negated matchers) cannot safely prune directories.
73
+
74
+ Since these are arbitrary predicates, we cannot determine if a directory
75
+ can be safely skipped without evaluating all files within it.
76
+ """
77
+ return False
78
+
57
79
  def __call__(self, path: Path) -> bool:
58
80
  return self._predicate(path)
59
81
 
@@ -78,11 +100,11 @@ class FilePatternMatcher(_AbstractPatternMatcher):
78
100
  ```
79
101
  """
80
102
 
81
- patterns: list[Pattern]
82
- _delayed_init: Callable[[], None] = None
103
+ _file_path: Optional[Union[str, Path]]
104
+ _pattern_strings: Optional[Sequence[str]]
83
105
 
84
- def _set_patterns(self, patterns: Sequence[str]) -> None:
85
- self.patterns = []
106
+ def _parse_patterns(self, patterns: Sequence[str]) -> list[Pattern]:
107
+ parsed_patterns = []
86
108
  for pattern in list(patterns):
87
109
  pattern = pattern.strip().strip(os.path.sep)
88
110
  if not pattern:
@@ -97,7 +119,8 @@ class FilePatternMatcher(_AbstractPatternMatcher):
97
119
  # In Python, we can proceed without explicit syntax checking
98
120
  new_pattern.cleaned_pattern = pattern
99
121
  new_pattern.dirs = pattern.split(os.path.sep)
100
- self.patterns.append(new_pattern)
122
+ parsed_patterns.append(new_pattern)
123
+ return parsed_patterns
101
124
 
102
125
  def __init__(self, *pattern: str) -> None:
103
126
  """Initialize a new FilePatternMatcher instance.
@@ -108,7 +131,8 @@ class FilePatternMatcher(_AbstractPatternMatcher):
108
131
  Raises:
109
132
  ValueError: If an illegal exclusion pattern is provided.
110
133
  """
111
- self._set_patterns(pattern)
134
+ self._pattern_strings = pattern
135
+ self._file_path = None
112
136
 
113
137
  @classmethod
114
138
  def from_file(cls, file_path: Union[str, Path]) -> "FilePatternMatcher":
@@ -127,14 +151,10 @@ class FilePatternMatcher(_AbstractPatternMatcher):
127
151
  ```
128
152
 
129
153
  """
130
- uninitialized = cls.__new__(cls)
131
-
132
- def _delayed_init():
133
- uninitialized._set_patterns(Path(file_path).read_text("utf8").splitlines())
134
- uninitialized._delayed_init = None
135
-
136
- uninitialized._delayed_init = _delayed_init
137
- return uninitialized
154
+ instance = cls.__new__(cls)
155
+ instance._file_path = file_path
156
+ instance._pattern_strings = None
157
+ return instance
138
158
 
139
159
  def _matches(self, file_path: str) -> bool:
140
160
  """Check if the file path or any of its parent directories match the patterns.
@@ -173,9 +193,29 @@ class FilePatternMatcher(_AbstractPatternMatcher):
173
193
 
174
194
  return matched
175
195
 
196
+ @cached_property
197
+ def patterns(self) -> list[Pattern]:
198
+ """Get the patterns, loading from file if necessary."""
199
+ if self._file_path is not None:
200
+ # Lazy load from file
201
+ pattern_strings = Path(self._file_path).read_text("utf8").splitlines()
202
+ else:
203
+ # Use patterns provided in __init__
204
+ pattern_strings = list(self._pattern_strings)
205
+
206
+ return self._parse_patterns(pattern_strings)
207
+
208
+ def can_prune_directories(self) -> bool:
209
+ """
210
+ Returns True if this pattern matcher allows safe early directory pruning.
211
+
212
+ Directory pruning is safe when matching directories can be skipped entirely
213
+ without missing any files that should be included. This is for example not
214
+ safe when we have inverted/negated ignore patterns (e.g. "!**/*.py").
215
+ """
216
+ return not any(pattern.exclusion for pattern in self.patterns)
217
+
176
218
  def __call__(self, file_path: Path) -> bool:
177
- if self._delayed_init:
178
- self._delayed_init()
179
219
  return self._matches(str(file_path))
180
220
 
181
221