modal 0.62.115__py3-none-any.whl → 0.72.13__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 (220) hide show
  1. modal/__init__.py +13 -9
  2. modal/__main__.py +41 -3
  3. modal/_clustered_functions.py +80 -0
  4. modal/_clustered_functions.pyi +22 -0
  5. modal/_container_entrypoint.py +402 -398
  6. modal/_ipython.py +3 -13
  7. modal/_location.py +17 -10
  8. modal/_output.py +243 -99
  9. modal/_pty.py +2 -2
  10. modal/_resolver.py +55 -60
  11. modal/_resources.py +26 -7
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1025 -0
  15. modal/{execution_context.py → _runtime/execution_context.py} +11 -2
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +123 -6
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +50 -14
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +386 -104
  24. modal/_utils/blob_utils.py +157 -186
  25. modal/_utils/bytes_io_segment_payload.py +97 -0
  26. modal/_utils/deprecation.py +89 -0
  27. modal/_utils/docker_utils.py +98 -0
  28. modal/_utils/function_utils.py +299 -98
  29. modal/_utils/grpc_testing.py +47 -34
  30. modal/_utils/grpc_utils.py +54 -21
  31. modal/_utils/hash_utils.py +51 -10
  32. modal/_utils/http_utils.py +39 -9
  33. modal/_utils/logger.py +2 -1
  34. modal/_utils/mount_utils.py +34 -16
  35. modal/_utils/name_utils.py +58 -0
  36. modal/_utils/package_utils.py +14 -1
  37. modal/_utils/pattern_utils.py +205 -0
  38. modal/_utils/rand_pb_testing.py +3 -3
  39. modal/_utils/shell_utils.py +15 -49
  40. modal/_vendor/a2wsgi_wsgi.py +62 -72
  41. modal/_vendor/cloudpickle.py +1 -1
  42. modal/_watcher.py +12 -10
  43. modal/app.py +561 -323
  44. modal/app.pyi +474 -262
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +22 -6
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +203 -42
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +61 -13
  51. modal/cli/dict.py +128 -0
  52. modal/cli/entry_point.py +26 -13
  53. modal/cli/environment.py +40 -9
  54. modal/cli/import_refs.py +21 -48
  55. modal/cli/launch.py +28 -14
  56. modal/cli/network_file_system.py +57 -21
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +34 -9
  59. modal/cli/programs/vscode.py +58 -8
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +199 -96
  62. modal/cli/secret.py +5 -4
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +74 -8
  65. modal/cli/volume.py +97 -56
  66. modal/client.py +248 -144
  67. modal/client.pyi +156 -124
  68. modal/cloud_bucket_mount.py +43 -30
  69. modal/cloud_bucket_mount.pyi +32 -25
  70. modal/cls.py +528 -141
  71. modal/cls.pyi +189 -145
  72. modal/config.py +32 -15
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +50 -54
  76. modal/dict.pyi +120 -164
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +30 -43
  80. modal/experimental.py +62 -2
  81. modal/file_io.py +537 -0
  82. modal/file_io.pyi +235 -0
  83. modal/file_pattern_matcher.py +196 -0
  84. modal/functions.py +846 -428
  85. modal/functions.pyi +446 -387
  86. modal/gpu.py +57 -44
  87. modal/image.py +943 -417
  88. modal/image.pyi +584 -245
  89. modal/io_streams.py +434 -0
  90. modal/io_streams.pyi +122 -0
  91. modal/mount.py +223 -90
  92. modal/mount.pyi +241 -243
  93. modal/network_file_system.py +85 -86
  94. modal/network_file_system.pyi +151 -110
  95. modal/object.py +66 -36
  96. modal/object.pyi +166 -143
  97. modal/output.py +63 -0
  98. modal/parallel_map.py +73 -47
  99. modal/parallel_map.pyi +51 -63
  100. modal/partial_function.py +272 -107
  101. modal/partial_function.pyi +219 -120
  102. modal/proxy.py +15 -12
  103. modal/proxy.pyi +3 -8
  104. modal/queue.py +96 -72
  105. modal/queue.pyi +210 -135
  106. modal/requirements/2024.04.txt +2 -1
  107. modal/requirements/2024.10.txt +16 -0
  108. modal/requirements/README.md +21 -0
  109. modal/requirements/base-images.json +22 -0
  110. modal/retries.py +45 -4
  111. modal/runner.py +325 -203
  112. modal/runner.pyi +124 -110
  113. modal/running_app.py +27 -4
  114. modal/sandbox.py +509 -231
  115. modal/sandbox.pyi +396 -169
  116. modal/schedule.py +2 -2
  117. modal/scheduler_placement.py +20 -3
  118. modal/secret.py +41 -25
  119. modal/secret.pyi +62 -42
  120. modal/serving.py +39 -49
  121. modal/serving.pyi +37 -43
  122. modal/stream_type.py +15 -0
  123. modal/token_flow.py +5 -3
  124. modal/token_flow.pyi +37 -32
  125. modal/volume.py +123 -137
  126. modal/volume.pyi +228 -221
  127. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/METADATA +5 -5
  128. modal-0.72.13.dist-info/RECORD +174 -0
  129. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/top_level.txt +0 -1
  130. modal_docs/gen_reference_docs.py +3 -1
  131. modal_docs/mdmd/mdmd.py +0 -1
  132. modal_docs/mdmd/signatures.py +1 -2
  133. modal_global_objects/images/base_images.py +28 -0
  134. modal_global_objects/mounts/python_standalone.py +2 -2
  135. modal_proto/__init__.py +1 -1
  136. modal_proto/api.proto +1231 -531
  137. modal_proto/api_grpc.py +750 -430
  138. modal_proto/api_pb2.py +2102 -1176
  139. modal_proto/api_pb2.pyi +8859 -0
  140. modal_proto/api_pb2_grpc.py +1329 -675
  141. modal_proto/api_pb2_grpc.pyi +1416 -0
  142. modal_proto/modal_api_grpc.py +149 -0
  143. modal_proto/modal_options_grpc.py +3 -0
  144. modal_proto/options_pb2.pyi +20 -0
  145. modal_proto/options_pb2_grpc.pyi +7 -0
  146. modal_proto/py.typed +0 -0
  147. modal_version/__init__.py +1 -1
  148. modal_version/_version_generated.py +2 -2
  149. modal/_asgi.py +0 -370
  150. modal/_container_exec.py +0 -128
  151. modal/_container_io_manager.py +0 -646
  152. modal/_container_io_manager.pyi +0 -412
  153. modal/_sandbox_shell.py +0 -49
  154. modal/app_utils.py +0 -20
  155. modal/app_utils.pyi +0 -17
  156. modal/execution_context.pyi +0 -37
  157. modal/shared_volume.py +0 -23
  158. modal/shared_volume.pyi +0 -24
  159. modal-0.62.115.dist-info/RECORD +0 -207
  160. modal_global_objects/images/conda.py +0 -15
  161. modal_global_objects/images/debian_slim.py +0 -15
  162. modal_global_objects/images/micromamba.py +0 -15
  163. test/__init__.py +0 -1
  164. test/aio_test.py +0 -12
  165. test/async_utils_test.py +0 -279
  166. test/blob_test.py +0 -67
  167. test/cli_imports_test.py +0 -149
  168. test/cli_test.py +0 -674
  169. test/client_test.py +0 -203
  170. test/cloud_bucket_mount_test.py +0 -22
  171. test/cls_test.py +0 -636
  172. test/config_test.py +0 -149
  173. test/conftest.py +0 -1485
  174. test/container_app_test.py +0 -50
  175. test/container_test.py +0 -1405
  176. test/cpu_test.py +0 -23
  177. test/decorator_test.py +0 -85
  178. test/deprecation_test.py +0 -34
  179. test/dict_test.py +0 -51
  180. test/e2e_test.py +0 -68
  181. test/error_test.py +0 -7
  182. test/function_serialization_test.py +0 -32
  183. test/function_test.py +0 -791
  184. test/function_utils_test.py +0 -101
  185. test/gpu_test.py +0 -159
  186. test/grpc_utils_test.py +0 -82
  187. test/helpers.py +0 -47
  188. test/image_test.py +0 -814
  189. test/live_reload_test.py +0 -80
  190. test/lookup_test.py +0 -70
  191. test/mdmd_test.py +0 -329
  192. test/mount_test.py +0 -162
  193. test/mounted_files_test.py +0 -327
  194. test/network_file_system_test.py +0 -188
  195. test/notebook_test.py +0 -66
  196. test/object_test.py +0 -41
  197. test/package_utils_test.py +0 -25
  198. test/queue_test.py +0 -115
  199. test/resolver_test.py +0 -59
  200. test/retries_test.py +0 -67
  201. test/runner_test.py +0 -85
  202. test/sandbox_test.py +0 -191
  203. test/schedule_test.py +0 -15
  204. test/scheduler_placement_test.py +0 -57
  205. test/secret_test.py +0 -89
  206. test/serialization_test.py +0 -50
  207. test/stub_composition_test.py +0 -10
  208. test/stub_test.py +0 -361
  209. test/test_asgi_wrapper.py +0 -234
  210. test/token_flow_test.py +0 -18
  211. test/traceback_test.py +0 -135
  212. test/tunnel_test.py +0 -29
  213. test/utils_test.py +0 -88
  214. test/version_test.py +0 -14
  215. test/volume_test.py +0 -397
  216. test/watcher_test.py +0 -58
  217. test/webhook_test.py +0 -145
  218. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/LICENSE +0 -0
  219. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/WHEEL +0 -0
  220. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,205 @@
1
+ # Copyright Modal Labs 2024
2
+ """Pattern matching library ported from https://github.com/moby/patternmatcher.
3
+
4
+ This is the same pattern-matching logic used by Docker, except it is written in
5
+ Python rather than Go. Also, the original Go library has a couple deprecated
6
+ functions that we don't implement in this port.
7
+
8
+ The main way to use this library is by constructing a `FilePatternMatcher` object,
9
+ then asking it whether file paths match any of its patterns.
10
+ """
11
+
12
+ import enum
13
+ import os
14
+ import re
15
+ from typing import Optional, TextIO
16
+
17
+ escape_chars = frozenset(".+()|{}$")
18
+
19
+
20
+ class MatchType(enum.IntEnum):
21
+ UNKNOWN = 0
22
+ EXACT = 1
23
+ PREFIX = 2
24
+ SUFFIX = 3
25
+ REGEXP = 4
26
+
27
+
28
+ class Pattern:
29
+ """Defines a single regex pattern used to filter file paths."""
30
+
31
+ def __init__(self) -> None:
32
+ """Initialize a new Pattern instance."""
33
+ self.match_type = MatchType.UNKNOWN
34
+ self.cleaned_pattern = ""
35
+ self.dirs: list[str] = []
36
+ self.regexp: Optional[re.Pattern] = None
37
+ self.exclusion = False
38
+
39
+ def __str__(self) -> str:
40
+ """Return the cleaned pattern as the string representation."""
41
+ return self.cleaned_pattern
42
+
43
+ def compile(self, separator: str) -> None:
44
+ """Compile the pattern into a regular expression.
45
+
46
+ Args:
47
+ separator (str): The path separator (e.g., '/' or '\\').
48
+
49
+ Raises:
50
+ ValueError: If the pattern is invalid.
51
+ """
52
+ reg_str = "^"
53
+ pattern = self.cleaned_pattern
54
+
55
+ esc_separator = separator
56
+ if separator == "\\":
57
+ esc_separator = "\\\\"
58
+
59
+ self.match_type = MatchType.EXACT
60
+ i = 0
61
+ pattern_length = len(pattern)
62
+ while i < pattern_length:
63
+ ch = pattern[i]
64
+ if ch == "*":
65
+ if (i + 1) < pattern_length and pattern[i + 1] == "*":
66
+ # Handle '**'
67
+ i += 1 # Skip the second '*'
68
+ # Treat '**/' as '**' so eat the '/'
69
+ if (i + 1) < pattern_length and pattern[i + 1] == separator:
70
+ i += 1 # Skip the '/'
71
+ if i + 1 == pattern_length:
72
+ # Pattern ends with '**'
73
+ if self.match_type == MatchType.EXACT:
74
+ self.match_type = MatchType.PREFIX
75
+ else:
76
+ reg_str += ".*"
77
+ self.match_type = MatchType.REGEXP
78
+ else:
79
+ # '**' in the middle
80
+ reg_str += f"(.*{esc_separator})?"
81
+ self.match_type = MatchType.REGEXP
82
+
83
+ if i == 1:
84
+ self.match_type = MatchType.SUFFIX
85
+ else:
86
+ # Single '*'
87
+ reg_str += f"[^{esc_separator}]*"
88
+ self.match_type = MatchType.REGEXP
89
+ elif ch == "?":
90
+ # Single '?'
91
+ reg_str += f"[^{esc_separator}]"
92
+ self.match_type = MatchType.REGEXP
93
+ elif ch in escape_chars:
94
+ reg_str += "\\" + ch
95
+ elif ch == "\\":
96
+ # Escape next character
97
+ if separator == "\\":
98
+ reg_str += esc_separator
99
+ i += 1
100
+ continue
101
+ if (i + 1) < pattern_length:
102
+ reg_str += "\\" + pattern[i + 1]
103
+ i += 1 # Skip the escaped character
104
+ self.match_type = MatchType.REGEXP
105
+ else:
106
+ reg_str += "\\"
107
+ elif ch == "[" or ch == "]":
108
+ reg_str += ch
109
+ self.match_type = MatchType.REGEXP
110
+ else:
111
+ reg_str += ch
112
+ i += 1
113
+
114
+ if self.match_type != MatchType.REGEXP:
115
+ return
116
+
117
+ reg_str += "$"
118
+
119
+ try:
120
+ self.regexp = re.compile(reg_str)
121
+ self.match_type = MatchType.REGEXP
122
+ except re.error as e:
123
+ raise ValueError(f"Bad pattern: {pattern}") from e
124
+
125
+ def match(self, path: str) -> bool:
126
+ """Check if the path matches the pattern."""
127
+ if self.match_type == MatchType.UNKNOWN:
128
+ self.compile(os.path.sep)
129
+
130
+ if self.match_type == MatchType.EXACT:
131
+ return path == self.cleaned_pattern
132
+ elif self.match_type == MatchType.PREFIX:
133
+ # Strip trailing '**'
134
+ return path.startswith(self.cleaned_pattern[:-2])
135
+ elif self.match_type == MatchType.SUFFIX:
136
+ # Strip leading '**'
137
+ suffix = self.cleaned_pattern[2:]
138
+ if path.endswith(suffix):
139
+ return True
140
+ # '**/foo' matches 'foo'
141
+ if suffix[0] == os.path.sep and path == suffix[1:]:
142
+ return True
143
+ else:
144
+ return False
145
+ elif self.match_type == MatchType.REGEXP:
146
+ return self.regexp.match(path) is not None
147
+ else:
148
+ return False
149
+
150
+
151
+ def read_ignorefile(reader: TextIO) -> list[str]:
152
+ """Read an ignore file from a reader and return the list of file patterns to
153
+ ignore, applying the following rules:
154
+
155
+ - An UTF8 BOM header (if present) is stripped. (Python does this already)
156
+ - Lines starting with "#" are considered comments and are skipped.
157
+
158
+ For remaining lines:
159
+
160
+ - Leading and trailing whitespace is removed from each ignore pattern.
161
+ - It uses `os.path.normpath` to get the shortest/cleanest path for ignore
162
+ patterns.
163
+ - Leading forward-slashes ("/") are removed from ignore patterns, so
164
+ "/some/path" and "some/path" are considered equivalent.
165
+
166
+ Args:
167
+ reader (file-like object): The input stream to read from.
168
+
169
+ Returns:
170
+ list: A list of patterns to ignore.
171
+ """
172
+ if reader is None:
173
+ return []
174
+
175
+ excludes: list[str] = []
176
+
177
+ for line in reader:
178
+ pattern = line.rstrip("\n\r")
179
+
180
+ # Lines starting with "#" are ignored
181
+ if pattern.startswith("#"):
182
+ continue
183
+
184
+ pattern = pattern.strip()
185
+ if pattern == "":
186
+ continue
187
+
188
+ # Normalize absolute paths to paths relative to the context
189
+ # (taking care of '!' prefix)
190
+ invert = pattern[0] == "!"
191
+ if invert:
192
+ pattern = pattern[1:].strip()
193
+
194
+ if len(pattern) > 0:
195
+ pattern = os.path.normpath(pattern)
196
+ pattern = pattern.replace(os.sep, "/")
197
+ if len(pattern) > 1 and pattern[0] == "/":
198
+ pattern = pattern[1:]
199
+
200
+ if invert:
201
+ pattern = "!" + pattern
202
+
203
+ excludes.append(pattern)
204
+
205
+ return excludes
@@ -7,13 +7,13 @@ Modal, with random seeds, and it supports oneofs, and Protobuf v4.
7
7
 
8
8
  import string
9
9
  from random import Random
10
- from typing import Any, Callable, Dict, Optional, Type, TypeVar
10
+ from typing import Any, Callable, Optional, TypeVar
11
11
 
12
12
  from google.protobuf.descriptor import Descriptor, FieldDescriptor
13
13
 
14
14
  T = TypeVar("T")
15
15
 
16
- _FIELD_RANDOM_GENERATOR: Dict[int, Callable[[Random], Any]] = {
16
+ _FIELD_RANDOM_GENERATOR: dict[int, Callable[[Random], Any]] = {
17
17
  FieldDescriptor.TYPE_DOUBLE: lambda rand: rand.normalvariate(0, 1),
18
18
  FieldDescriptor.TYPE_FLOAT: lambda rand: rand.normalvariate(0, 1),
19
19
  FieldDescriptor.TYPE_INT32: lambda rand: int.from_bytes(rand.randbytes(4), "little", signed=True),
@@ -71,7 +71,7 @@ def _fill(msg, desc: Descriptor, rand: Random) -> None:
71
71
  setattr(msg, field.name, generator(rand))
72
72
 
73
73
 
74
- def rand_pb(proto: Type[T], rand: Optional[Random] = None) -> T:
74
+ def rand_pb(proto: type[T], rand: Optional[Random] = None) -> T:
75
75
  """Generate a pseudorandom protobuf message.
76
76
 
77
77
  ```python notest
@@ -1,18 +1,17 @@
1
1
  # Copyright Modal Labs 2024
2
+
2
3
  import asyncio
3
4
  import contextlib
4
5
  import errno
5
6
  import os
6
7
  import select
7
8
  import sys
8
- from typing import Callable, Coroutine, Optional
9
-
10
- import rich.status
9
+ from collections.abc import Coroutine
10
+ from typing import Callable, Optional
11
11
 
12
12
  from modal._pty import raw_terminal, set_nonblocking
13
- from modal.exception import ExecutionError, InteractiveTimeoutError
14
13
 
15
- from .async_utils import TaskContext, asyncify
14
+ from .async_utils import asyncify
16
15
 
17
16
 
18
17
  def write_to_fd(fd: int, data: bytes):
@@ -20,14 +19,20 @@ def write_to_fd(fd: int, data: bytes):
20
19
  future = loop.create_future()
21
20
 
22
21
  def try_write():
22
+ nonlocal data
23
23
  try:
24
24
  nbytes = os.write(fd, data)
25
- loop.remove_writer(fd)
26
- future.set_result(nbytes)
25
+ data = data[nbytes:]
26
+ if not data:
27
+ loop.remove_writer(fd)
28
+ future.set_result(None)
27
29
  except OSError as e:
28
- if e.errno != errno.EAGAIN:
29
- future.set_exception(e)
30
- raise
30
+ if e.errno == errno.EAGAIN:
31
+ # Wait for the next write notification
32
+ return
33
+ # Fail if it's not EAGAIN
34
+ loop.remove_writer(fd)
35
+ future.set_exception(e)
31
36
 
32
37
  loop.add_writer(fd, try_write)
33
38
  return future
@@ -72,42 +77,3 @@ async def stream_from_stdin(handle_input: Callable[[bytes, int], Coroutine], use
72
77
  yield
73
78
  os.write(quit_pipe_write, b"\n")
74
79
  write_task.cancel()
75
-
76
-
77
- async def connect_to_terminal(
78
- # Handles data read from stdin. Inputs are the stdin data and message index.
79
- handle_stdin: Callable[[bytes, int], Coroutine],
80
- # Creates a coroutine that streams data to stdout/stderr. Returns the exit status.
81
- stream_to_stdio: Callable[[asyncio.Event], Coroutine[None, None, int]],
82
- pty: bool = False,
83
- connecting_status: Optional[rich.status.Status] = None,
84
- ) -> None:
85
- """
86
- Connect to the current terminal by streaming data from terminal's stdin to the running process
87
- and streaming output from running process into terminal's stdout.
88
-
89
- If connecting_status is given, this function will stop the status spinner upon connection or error.
90
- """
91
-
92
- def stop_connecting_status():
93
- if connecting_status:
94
- connecting_status.stop()
95
-
96
- on_connect = asyncio.Event()
97
- async with TaskContext() as tc:
98
- exec_output_task = tc.create_task(stream_to_stdio(on_connect))
99
- try:
100
- # time out if we can't connect to the server fast enough
101
- await asyncio.wait_for(on_connect.wait(), timeout=15)
102
- stop_connecting_status()
103
-
104
- async with stream_from_stdin(handle_stdin, use_raw_terminal=pty):
105
- exit_status = await exec_output_task
106
-
107
- if exit_status != 0:
108
- raise ExecutionError(f"Process exited with status code {exit_status}")
109
-
110
- except (asyncio.TimeoutError, TimeoutError):
111
- stop_connecting_status()
112
- exec_output_task.cancel()
113
- raise InteractiveTimeoutError("Failed to establish connection to container.")
@@ -35,10 +35,8 @@ from concurrent.futures import ThreadPoolExecutor
35
35
  from types import TracebackType
36
36
  from typing import (
37
37
  Any,
38
- Awaitable,
39
38
  Callable,
40
39
  Dict,
41
- Iterable,
42
40
  List,
43
41
  Literal,
44
42
  Optional,
@@ -48,6 +46,7 @@ from typing import (
48
46
  TypedDict,
49
47
  Union,
50
48
  )
49
+ from collections.abc import Awaitable, Iterable
51
50
 
52
51
 
53
52
  ## BEGIN a2wsgi/asgi_typing.py
@@ -73,11 +72,11 @@ class HTTPScope(TypedDict):
73
72
  raw_path: NotRequired[bytes]
74
73
  query_string: bytes
75
74
  root_path: str
76
- headers: Iterable[Tuple[bytes, bytes]]
77
- client: NotRequired[Tuple[str, int]]
78
- server: NotRequired[Tuple[str, Optional[int]]]
79
- state: NotRequired[Dict[str, Any]]
80
- extensions: NotRequired[Dict[str, Dict[object, object]]]
75
+ headers: Iterable[tuple[bytes, bytes]]
76
+ client: NotRequired[tuple[str, int]]
77
+ server: NotRequired[tuple[str, Optional[int]]]
78
+ state: NotRequired[dict[str, Any]]
79
+ extensions: NotRequired[dict[str, dict[object, object]]]
81
80
 
82
81
 
83
82
  class WebSocketScope(TypedDict):
@@ -89,18 +88,18 @@ class WebSocketScope(TypedDict):
89
88
  raw_path: bytes
90
89
  query_string: bytes
91
90
  root_path: str
92
- headers: Iterable[Tuple[bytes, bytes]]
93
- client: NotRequired[Tuple[str, int]]
94
- server: NotRequired[Tuple[str, Optional[int]]]
91
+ headers: Iterable[tuple[bytes, bytes]]
92
+ client: NotRequired[tuple[str, int]]
93
+ server: NotRequired[tuple[str, Optional[int]]]
95
94
  subprotocols: Iterable[str]
96
- state: NotRequired[Dict[str, Any]]
97
- extensions: NotRequired[Dict[str, Dict[object, object]]]
95
+ state: NotRequired[dict[str, Any]]
96
+ extensions: NotRequired[dict[str, dict[object, object]]]
98
97
 
99
98
 
100
99
  class LifespanScope(TypedDict):
101
100
  type: Literal["lifespan"]
102
101
  asgi: ASGIVersions
103
- state: NotRequired[Dict[str, Any]]
102
+ state: NotRequired[dict[str, Any]]
104
103
 
105
104
 
106
105
  WWWScope = Union[HTTPScope, WebSocketScope]
@@ -116,7 +115,7 @@ class HTTPRequestEvent(TypedDict):
116
115
  class HTTPResponseStartEvent(TypedDict):
117
116
  type: Literal["http.response.start"]
118
117
  status: int
119
- headers: NotRequired[Iterable[Tuple[bytes, bytes]]]
118
+ headers: NotRequired[Iterable[tuple[bytes, bytes]]]
120
119
  trailers: NotRequired[bool]
121
120
 
122
121
 
@@ -137,7 +136,7 @@ class WebSocketConnectEvent(TypedDict):
137
136
  class WebSocketAcceptEvent(TypedDict):
138
137
  type: Literal["websocket.accept"]
139
138
  subprotocol: NotRequired[str]
140
- headers: NotRequired[Iterable[Tuple[bytes, bytes]]]
139
+ headers: NotRequired[Iterable[tuple[bytes, bytes]]]
141
140
 
142
141
 
143
142
  class WebSocketReceiveEvent(TypedDict):
@@ -223,56 +222,47 @@ ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
223
222
 
224
223
  ## BEGIN a2wsgi/wsgi_typing.py
225
224
 
226
- CGIRequiredDefined = TypedDict(
227
- "CGIRequiredDefined",
228
- {
229
- # The HTTP request method, such as GET or POST. This cannot ever be an
230
- # empty string, and so is always required.
231
- "REQUEST_METHOD": str,
232
- # When HTTP_HOST is not set, these variables can be combined to determine
233
- # a default.
234
- # SERVER_NAME and SERVER_PORT are required strings and must never be empty.
235
- "SERVER_NAME": str,
236
- "SERVER_PORT": str,
237
- # The version of the protocol the client used to send the request.
238
- # Typically this will be something like "HTTP/1.0" or "HTTP/1.1" and
239
- # may be used by the application to determine how to treat any HTTP
240
- # request headers. (This variable should probably be called REQUEST_PROTOCOL,
241
- # since it denotes the protocol used in the request, and is not necessarily
242
- # the protocol that will be used in the server's response. However, for
243
- # compatibility with CGI we have to keep the existing name.)
244
- "SERVER_PROTOCOL": str,
245
- },
246
- )
247
-
248
- CGIOptionalDefined = TypedDict(
249
- "CGIOptionalDefined",
250
- {
251
- "REQUEST_URI": str,
252
- "REMOTE_ADDR": str,
253
- "REMOTE_PORT": str,
254
- # The initial portion of the request URL’s “path” that corresponds to the
255
- # application object, so that the application knows its virtual “location”.
256
- # This may be an empty string, if the application corresponds to the “root”
257
- # of the server.
258
- "SCRIPT_NAME": str,
259
- # The remainder of the request URL’s “path”, designating the virtual
260
- # “location” of the request’s target within the application. This may be an
261
- # empty string, if the request URL targets the application root and does
262
- # not have a trailing slash.
263
- "PATH_INFO": str,
264
- # The portion of the request URL that follows the “?”, if any. May be empty
265
- # or absent.
266
- "QUERY_STRING": str,
267
- # The contents of any Content-Type fields in the HTTP request. May be empty
268
- # or absent.
269
- "CONTENT_TYPE": str,
270
- # The contents of any Content-Length fields in the HTTP request. May be empty
271
- # or absent.
272
- "CONTENT_LENGTH": str,
273
- },
274
- total=False,
275
- )
225
+ class CGIRequiredDefined(TypedDict):
226
+ # The HTTP request method, such as GET or POST. This cannot ever be an
227
+ # empty string, and so is always required.
228
+ REQUEST_METHOD: str
229
+ # When HTTP_HOST is not set, these variables can be combined to determine
230
+ # a default.
231
+ # SERVER_NAME and SERVER_PORT are required strings and must never be empty.
232
+ SERVER_NAME: str
233
+ SERVER_PORT: str
234
+ # The version of the protocol the client used to send the request.
235
+ # Typically this will be something like "HTTP/1.0" or "HTTP/1.1" and
236
+ # may be used by the application to determine how to treat any HTTP
237
+ # request headers. (This variable should probably be called REQUEST_PROTOCOL,
238
+ # since it denotes the protocol used in the request, and is not necessarily
239
+ # the protocol that will be used in the server's response. However, for
240
+ # compatibility with CGI we have to keep the existing name.)
241
+ SERVER_PROTOCOL: str
242
+
243
+ class CGIOptionalDefined(TypedDict, total=False):
244
+ REQUEST_URI: str
245
+ REMOTE_ADDR: str
246
+ REMOTE_PORT: str
247
+ # The initial portion of the request URL’s “path” that corresponds to the
248
+ # application object, so that the application knows its virtual “location”.
249
+ # This may be an empty string, if the application corresponds to the “root”
250
+ # of the server.
251
+ SCRIPT_NAME: str
252
+ # The remainder of the request URL’s “path”, designating the virtual
253
+ # “location” of the request’s target within the application. This may be an
254
+ # empty string, if the request URL targets the application root and does
255
+ # not have a trailing slash.
256
+ PATH_INFO: str
257
+ # The portion of the request URL that follows the “?”, if any. May be empty
258
+ # or absent.
259
+ QUERY_STRING: str
260
+ # The contents of any Content-Type fields in the HTTP request. May be empty
261
+ # or absent.
262
+ CONTENT_TYPE: str
263
+ # The contents of any Content-Length fields in the HTTP request. May be empty
264
+ # or absent.
265
+ CONTENT_LENGTH: str
276
266
 
277
267
 
278
268
  class InputStream(Protocol):
@@ -308,7 +298,7 @@ class InputStream(Protocol):
308
298
  """
309
299
  raise NotImplementedError
310
300
 
311
- def readlines(self, hint: int = -1, /) -> List[bytes]:
301
+ def readlines(self, hint: int = -1, /) -> list[bytes]:
312
302
  """
313
303
  Note that the hint argument to readlines() is optional for both caller and
314
304
  implementer. The application is free not to supply it, and the server or gateway
@@ -349,14 +339,14 @@ class ErrorStream(Protocol):
349
339
  def write(self, s: str, /) -> Any:
350
340
  raise NotImplementedError
351
341
 
352
- def writelines(self, seq: List[str], /) -> Any:
342
+ def writelines(self, seq: list[str], /) -> Any:
353
343
  raise NotImplementedError
354
344
 
355
345
 
356
346
  WSGIDefined = TypedDict(
357
347
  "WSGIDefined",
358
348
  {
359
- "wsgi.version": Tuple[int, int], # e.g. (1, 0)
349
+ "wsgi.version": tuple[int, int], # e.g. (1, 0)
360
350
  "wsgi.url_scheme": str, # e.g. "http" or "https"
361
351
  "wsgi.input": InputStream,
362
352
  "wsgi.errors": ErrorStream,
@@ -381,7 +371,7 @@ class Environ(CGIRequiredDefined, CGIOptionalDefined, WSGIDefined):
381
371
  """
382
372
 
383
373
 
384
- ExceptionInfo = Tuple[Type[BaseException], BaseException, Optional[TracebackType]]
374
+ ExceptionInfo = tuple[type[BaseException], BaseException, Optional[TracebackType]]
385
375
 
386
376
  # https://peps.python.org/pep-3333/#the-write-callable
387
377
  WriteCallable = Callable[[bytes], None]
@@ -391,7 +381,7 @@ class StartResponse(Protocol):
391
381
  def __call__(
392
382
  self,
393
383
  status: str,
394
- response_headers: List[Tuple[str, str]],
384
+ response_headers: list[tuple[str, str]],
395
385
  exc_info: Optional[ExceptionInfo] = None,
396
386
  /,
397
387
  ) -> WriteCallable:
@@ -460,7 +450,7 @@ class Body:
460
450
  self.buffer.clear()
461
451
  return result
462
452
 
463
- def readlines(self, hint: int = -1) -> typing.List[bytes]:
453
+ def readlines(self, hint: int = -1) -> list[bytes]:
464
454
  if not self.has_more:
465
455
  return []
466
456
  if hint == -1:
@@ -626,7 +616,7 @@ class WSGIResponder:
626
616
  def start_response(
627
617
  self,
628
618
  status: str,
629
- response_headers: typing.List[typing.Tuple[str, str]],
619
+ response_headers: list[tuple[str, str]],
630
620
  exc_info: typing.Optional[ExceptionInfo] = None,
631
621
  ) -> WriteCallable:
632
622
  self.exc_info = exc_info
@@ -256,7 +256,7 @@ def _should_pickle_by_reference(obj, name=None):
256
256
  return False
257
257
  return obj.__name__ in sys.modules
258
258
  else:
259
- raise TypeError("cannot check importability of {} instances".format(type(obj).__name__))
259
+ raise TypeError(f"cannot check importability of {type(obj).__name__} instances")
260
260
 
261
261
 
262
262
  def _lookup_module_and_qualname(obj, name=None):
modal/_watcher.py CHANGED
@@ -1,14 +1,15 @@
1
1
  # Copyright Modal Labs 2022
2
2
  from collections import defaultdict
3
+ from collections.abc import AsyncGenerator
3
4
  from pathlib import Path
4
- from typing import AsyncGenerator, Dict, List, Optional, Set, Tuple
5
+ from typing import Optional
5
6
 
6
7
  from rich.tree import Tree
7
8
  from watchfiles import Change, DefaultFilter, awatch
8
9
 
9
10
  from modal.mount import _Mount
10
11
 
11
- from ._output import OutputManager
12
+ from .output import _get_output_manager
12
13
 
13
14
  _TIMEOUT_SENTINEL = object()
14
15
 
@@ -21,7 +22,7 @@ class AppFilesFilter(DefaultFilter):
21
22
  # Watching specific files is discouraged on Linux, so to watch a file we watch its
22
23
  # containing directory and then filter that directory's changes for relevant files.
23
24
  # https://github.com/notify-rs/notify/issues/394
24
- dir_filters: Dict[Path, Optional[Set[Path]]],
25
+ dir_filters: dict[Path, Optional[set[Path]]],
25
26
  ) -> None:
26
27
  self.dir_filters = dir_filters
27
28
  super().__init__()
@@ -54,7 +55,7 @@ class AppFilesFilter(DefaultFilter):
54
55
  return super().__call__(change, path)
55
56
 
56
57
 
57
- async def _watch_paths(paths: Set[Path], watch_filter: AppFilesFilter) -> AsyncGenerator[Set[str], None]:
58
+ async def _watch_paths(paths: set[Path], watch_filter: AppFilesFilter) -> AsyncGenerator[set[str], None]:
58
59
  try:
59
60
  async for changes in awatch(*paths, step=500, watch_filter=watch_filter):
60
61
  changed_paths = {stringpath for _, stringpath in changes}
@@ -64,7 +65,7 @@ async def _watch_paths(paths: Set[Path], watch_filter: AppFilesFilter) -> AsyncG
64
65
  pass
65
66
 
66
67
 
67
- def _print_watched_paths(paths: Set[Path], output_mgr: OutputManager):
68
+ def _print_watched_paths(paths: set[Path]):
68
69
  msg = "️️⚡️ Serving... hit Ctrl-C to stop!"
69
70
 
70
71
  output_tree = Tree(msg, guide_style="gray50")
@@ -72,12 +73,13 @@ def _print_watched_paths(paths: Set[Path], output_mgr: OutputManager):
72
73
  for path in paths:
73
74
  output_tree.add(f"Watching {path}.")
74
75
 
75
- output_mgr.print_if_visible(output_tree)
76
+ if output_mgr := _get_output_manager():
77
+ output_mgr.print(output_tree)
76
78
 
77
79
 
78
- def _watch_args_from_mounts(mounts: List[_Mount]) -> Tuple[Set[Path], AppFilesFilter]:
80
+ def _watch_args_from_mounts(mounts: list[_Mount]) -> tuple[set[Path], AppFilesFilter]:
79
81
  paths = set()
80
- dir_filters: Dict[Path, Optional[Set[Path]]] = defaultdict(set)
82
+ dir_filters: dict[Path, Optional[set[Path]]] = defaultdict(set)
81
83
  for mount in mounts:
82
84
  # TODO(elias): Make this part of the mount class instead, since it uses so much internals
83
85
  for entry in mount._entries:
@@ -93,10 +95,10 @@ def _watch_args_from_mounts(mounts: List[_Mount]) -> Tuple[Set[Path], AppFilesFi
93
95
  return paths, watch_filter
94
96
 
95
97
 
96
- async def watch(mounts: List[_Mount], output_mgr: OutputManager) -> AsyncGenerator[Set[str], None]:
98
+ async def watch(mounts: list[_Mount]) -> AsyncGenerator[set[str], None]:
97
99
  paths, watch_filter = _watch_args_from_mounts(mounts)
98
100
 
99
- _print_watched_paths(paths, output_mgr)
101
+ _print_watched_paths(paths)
100
102
 
101
103
  async for updated_paths in _watch_paths(paths, watch_filter):
102
104
  yield updated_paths