modal 0.62.16__py3-none-any.whl → 0.72.11__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 +17 -13
  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 +420 -937
  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 -59
  11. modal/_resources.py +51 -0
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1036 -0
  15. modal/_runtime/execution_context.py +89 -0
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +134 -9
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +52 -16
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +479 -100
  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 +460 -171
  29. modal/_utils/grpc_testing.py +47 -31
  30. modal/_utils/grpc_utils.py +62 -109
  31. modal/_utils/hash_utils.py +61 -19
  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 +5 -7
  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 +14 -12
  43. modal/app.py +1003 -314
  44. modal/app.pyi +540 -264
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +63 -53
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +205 -45
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +62 -14
  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 +64 -58
  55. modal/cli/launch.py +32 -18
  56. modal/cli/network_file_system.py +64 -83
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +35 -10
  59. modal/cli/programs/vscode.py +60 -10
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +234 -131
  62. modal/cli/secret.py +8 -7
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +79 -10
  65. modal/cli/volume.py +110 -109
  66. modal/client.py +250 -144
  67. modal/client.pyi +157 -118
  68. modal/cloud_bucket_mount.py +108 -34
  69. modal/cloud_bucket_mount.pyi +32 -38
  70. modal/cls.py +535 -148
  71. modal/cls.pyi +190 -146
  72. modal/config.py +41 -19
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +111 -65
  76. modal/dict.pyi +136 -131
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +34 -43
  80. modal/experimental.py +61 -2
  81. modal/extensions/ipython.py +5 -5
  82. modal/file_io.py +537 -0
  83. modal/file_io.pyi +235 -0
  84. modal/file_pattern_matcher.py +197 -0
  85. modal/functions.py +906 -911
  86. modal/functions.pyi +466 -430
  87. modal/gpu.py +57 -44
  88. modal/image.py +1089 -479
  89. modal/image.pyi +584 -228
  90. modal/io_streams.py +434 -0
  91. modal/io_streams.pyi +122 -0
  92. modal/mount.py +314 -101
  93. modal/mount.pyi +241 -235
  94. modal/network_file_system.py +92 -92
  95. modal/network_file_system.pyi +152 -110
  96. modal/object.py +67 -36
  97. modal/object.pyi +166 -143
  98. modal/output.py +63 -0
  99. modal/parallel_map.py +434 -0
  100. modal/parallel_map.pyi +75 -0
  101. modal/partial_function.py +282 -117
  102. modal/partial_function.pyi +222 -129
  103. modal/proxy.py +15 -12
  104. modal/proxy.pyi +3 -8
  105. modal/queue.py +182 -65
  106. modal/queue.pyi +218 -118
  107. modal/requirements/2024.04.txt +29 -0
  108. modal/requirements/2024.10.txt +16 -0
  109. modal/requirements/README.md +21 -0
  110. modal/requirements/base-images.json +22 -0
  111. modal/retries.py +48 -7
  112. modal/runner.py +459 -156
  113. modal/runner.pyi +135 -71
  114. modal/running_app.py +38 -0
  115. modal/sandbox.py +514 -236
  116. modal/sandbox.pyi +397 -169
  117. modal/schedule.py +4 -4
  118. modal/scheduler_placement.py +20 -3
  119. modal/secret.py +56 -31
  120. modal/secret.pyi +62 -42
  121. modal/serving.py +51 -56
  122. modal/serving.pyi +44 -36
  123. modal/stream_type.py +15 -0
  124. modal/token_flow.py +5 -3
  125. modal/token_flow.pyi +37 -32
  126. modal/volume.py +285 -157
  127. modal/volume.pyi +249 -184
  128. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/METADATA +7 -7
  129. modal-0.72.11.dist-info/RECORD +174 -0
  130. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/top_level.txt +0 -1
  131. modal_docs/gen_reference_docs.py +3 -1
  132. modal_docs/mdmd/mdmd.py +0 -1
  133. modal_docs/mdmd/signatures.py +5 -2
  134. modal_global_objects/images/base_images.py +28 -0
  135. modal_global_objects/mounts/python_standalone.py +2 -2
  136. modal_proto/__init__.py +1 -1
  137. modal_proto/api.proto +1288 -533
  138. modal_proto/api_grpc.py +856 -456
  139. modal_proto/api_pb2.py +2165 -1157
  140. modal_proto/api_pb2.pyi +8859 -0
  141. modal_proto/api_pb2_grpc.py +1674 -855
  142. modal_proto/api_pb2_grpc.pyi +1416 -0
  143. modal_proto/modal_api_grpc.py +149 -0
  144. modal_proto/modal_options_grpc.py +3 -0
  145. modal_proto/options_pb2.pyi +20 -0
  146. modal_proto/options_pb2_grpc.pyi +7 -0
  147. modal_proto/py.typed +0 -0
  148. modal_version/__init__.py +1 -1
  149. modal_version/_version_generated.py +2 -2
  150. modal/_asgi.py +0 -370
  151. modal/_container_entrypoint.pyi +0 -378
  152. modal/_container_exec.py +0 -128
  153. modal/_sandbox_shell.py +0 -49
  154. modal/shared_volume.py +0 -23
  155. modal/shared_volume.pyi +0 -24
  156. modal/stub.py +0 -783
  157. modal/stub.pyi +0 -332
  158. modal-0.62.16.dist-info/RECORD +0 -198
  159. modal_global_objects/images/conda.py +0 -15
  160. modal_global_objects/images/debian_slim.py +0 -15
  161. modal_global_objects/images/micromamba.py +0 -15
  162. test/__init__.py +0 -1
  163. test/aio_test.py +0 -12
  164. test/async_utils_test.py +0 -262
  165. test/blob_test.py +0 -67
  166. test/cli_imports_test.py +0 -149
  167. test/cli_test.py +0 -659
  168. test/client_test.py +0 -194
  169. test/cls_test.py +0 -630
  170. test/config_test.py +0 -137
  171. test/conftest.py +0 -1420
  172. test/container_app_test.py +0 -32
  173. test/container_test.py +0 -1389
  174. test/cpu_test.py +0 -23
  175. test/decorator_test.py +0 -85
  176. test/deprecation_test.py +0 -34
  177. test/dict_test.py +0 -33
  178. test/e2e_test.py +0 -68
  179. test/error_test.py +0 -7
  180. test/function_serialization_test.py +0 -32
  181. test/function_test.py +0 -653
  182. test/function_utils_test.py +0 -101
  183. test/gpu_test.py +0 -159
  184. test/grpc_utils_test.py +0 -141
  185. test/helpers.py +0 -42
  186. test/image_test.py +0 -669
  187. test/live_reload_test.py +0 -80
  188. test/lookup_test.py +0 -70
  189. test/mdmd_test.py +0 -329
  190. test/mount_test.py +0 -162
  191. test/mounted_files_test.py +0 -329
  192. test/network_file_system_test.py +0 -181
  193. test/notebook_test.py +0 -66
  194. test/object_test.py +0 -41
  195. test/package_utils_test.py +0 -25
  196. test/queue_test.py +0 -97
  197. test/resolver_test.py +0 -58
  198. test/retries_test.py +0 -67
  199. test/runner_test.py +0 -85
  200. test/sandbox_test.py +0 -191
  201. test/schedule_test.py +0 -15
  202. test/scheduler_placement_test.py +0 -29
  203. test/secret_test.py +0 -78
  204. test/serialization_test.py +0 -42
  205. test/stub_composition_test.py +0 -10
  206. test/stub_test.py +0 -360
  207. test/test_asgi_wrapper.py +0 -234
  208. test/token_flow_test.py +0 -18
  209. test/traceback_test.py +0 -135
  210. test/tunnel_test.py +0 -29
  211. test/utils_test.py +0 -88
  212. test/version_test.py +0 -14
  213. test/volume_test.py +0 -341
  214. test/watcher_test.py +0 -30
  215. test/webhook_test.py +0 -146
  216. /modal/{requirements.312.txt → requirements/2023.12.312.txt} +0 -0
  217. /modal/{requirements.txt → requirements/2023.12.txt} +0 -0
  218. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
  219. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
  220. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/entry_points.txt +0 -0
@@ -1,26 +1,29 @@
1
1
  # Copyright Modal Labs 2022
2
2
  import posixpath
3
3
  import typing
4
+ from collections.abc import Mapping, Sequence
4
5
  from pathlib import PurePath, PurePosixPath
5
- from typing import TYPE_CHECKING, Dict, List, Mapping, Tuple, Union
6
+ from typing import Union
6
7
 
8
+ from ..cloud_bucket_mount import _CloudBucketMount
7
9
  from ..exception import InvalidError
10
+ from ..network_file_system import _NetworkFileSystem
8
11
  from ..volume import _Volume
9
12
 
10
- if TYPE_CHECKING:
11
- from ..cloud_bucket_mount import _CloudBucketMount
12
- from ..network_file_system import _NetworkFileSystem
13
-
14
-
15
- T = typing.TypeVar("T", bound=Union["_Volume", "_NetworkFileSystem", "_CloudBucketMount"])
13
+ T = typing.TypeVar("T", bound=Union[_Volume, _NetworkFileSystem, _CloudBucketMount])
16
14
 
17
15
 
18
16
  def validate_mount_points(
19
17
  display_name: str,
20
18
  volume_likes: Mapping[Union[str, PurePosixPath], T],
21
- ) -> List[Tuple[str, T]]:
19
+ ) -> list[tuple[str, T]]:
22
20
  """Mount point path validation for volumes and network file systems."""
23
21
 
22
+ if not isinstance(volume_likes, dict):
23
+ raise InvalidError(
24
+ f"`volume_likes` should be a dict[str | PurePosixPath, {display_name}], got {type(volume_likes)} instead"
25
+ )
26
+
24
27
  validated = []
25
28
  for path, vol in volume_likes.items():
26
29
  path = PurePath(path).as_posix()
@@ -38,17 +41,32 @@ def validate_mount_points(
38
41
  return validated
39
42
 
40
43
 
41
- def validate_volumes(
42
- volumes: Mapping[Union[str, PurePosixPath], Union["_Volume", "_CloudBucketMount"]],
43
- ) -> List[Tuple[str, Union["_Volume", "_NetworkFileSystem", "_CloudBucketMount"]]]:
44
- if not isinstance(volumes, dict):
45
- raise InvalidError("volumes must be a dict[str, Volume] where the keys are paths")
44
+ def validate_network_file_systems(
45
+ network_file_systems: Mapping[Union[str, PurePosixPath], _NetworkFileSystem],
46
+ ):
47
+ validated_network_file_systems = validate_mount_points("NetworkFileSystem", network_file_systems)
48
+
49
+ for path, network_file_system in validated_network_file_systems:
50
+ if not isinstance(network_file_system, (_NetworkFileSystem)):
51
+ raise InvalidError(
52
+ f"Object of type {type(network_file_system)} mounted at '{path}' "
53
+ + "is not useable as a network file system."
54
+ )
55
+
56
+ return validated_network_file_systems
46
57
 
58
+
59
+ def validate_volumes(
60
+ volumes: Mapping[Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]],
61
+ ) -> Sequence[tuple[str, Union[_Volume, _CloudBucketMount]]]:
47
62
  validated_volumes = validate_mount_points("Volume", volumes)
48
- # We don't support mounting a volume in more than one location
49
- volume_to_paths: Dict["_Volume", List[str]] = {}
63
+ # We don't support mounting a modal.Volume in more than one location,
64
+ # but the same CloudBucketMount object can be used in more than one location.
65
+ volume_to_paths: dict[_Volume, list[str]] = {}
50
66
  for path, volume in validated_volumes:
51
- if isinstance(volume, _Volume):
67
+ if not isinstance(volume, (_Volume, _CloudBucketMount)):
68
+ raise InvalidError(f"Object of type {type(volume)} mounted at '{path}' is not useable as a volume.")
69
+ elif isinstance(volume, (_Volume)):
52
70
  volume_to_paths.setdefault(volume, []).append(path)
53
71
  for paths in volume_to_paths.values():
54
72
  if len(paths) > 1:
@@ -0,0 +1,58 @@
1
+ # Copyright Modal Labs 2022
2
+ import re
3
+
4
+ from ..exception import InvalidError
5
+
6
+ # https://www.rfc-editor.org/rfc/rfc1035
7
+ subdomain_regex = re.compile("^(?![0-9]+$)(?!-)[a-z0-9-]{,63}(?<!-)$")
8
+
9
+
10
+ def is_valid_subdomain_label(label: str) -> bool:
11
+ return subdomain_regex.match(label) is not None
12
+
13
+
14
+ def replace_invalid_subdomain_chars(label: str) -> str:
15
+ return re.sub("[^a-z0-9-]", "-", label.lower())
16
+
17
+
18
+ def is_valid_object_name(name: str) -> bool:
19
+ return (
20
+ # Limit object name length
21
+ len(name) <= 64
22
+ # Limit character set
23
+ and re.match("^[a-zA-Z0-9-_.]+$", name) is not None
24
+ # Avoid collisions with App IDs
25
+ and re.match("^ap-[a-zA-Z0-9]{22}$", name) is None
26
+ )
27
+
28
+
29
+ def is_valid_environment_name(name: str) -> bool:
30
+ # first char is alnum, the rest allows other chars
31
+ return len(name) <= 64 and re.match(r"^[a-zA-Z0-9][a-zA-Z0-9-_.]+$", name) is not None
32
+
33
+
34
+ def is_valid_tag(tag: str) -> bool:
35
+ """Tags are alphanumeric, dashes, periods, and underscores, and must be 50 characters or less"""
36
+ pattern = r"^[a-zA-Z0-9._-]{1,50}$"
37
+ return bool(re.match(pattern, tag))
38
+
39
+
40
+ def check_object_name(name: str, object_type: str) -> None:
41
+ message = (
42
+ f"Invalid {object_type} name: '{name}'."
43
+ "\n\nNames may contain only alphanumeric characters, dashes, periods, and underscores,"
44
+ " must be shorter than 64 characters, and cannot conflict with App ID strings."
45
+ )
46
+ if not is_valid_object_name(name):
47
+ raise InvalidError(message)
48
+
49
+
50
+ def check_environment_name(name: str) -> None:
51
+ message = (
52
+ f"Invalid environment name: '{name}'."
53
+ "\n\nEnvironment names can only start with alphanumeric characters,"
54
+ " may contain only alphanumeric characters, dashes, periods, and underscores,"
55
+ " and must be shorter than 64 characters."
56
+ )
57
+ if not is_valid_environment_name(name):
58
+ raise InvalidError(message)
@@ -23,7 +23,7 @@ def get_file_formats(module):
23
23
  BINARY_FORMATS = ["so", "S", "s", "asm"] # TODO
24
24
 
25
25
 
26
- def get_module_mount_info(module_name: str) -> typing.Sequence[typing.Tuple[bool, Path]]:
26
+ def get_module_mount_info(module_name: str) -> typing.Sequence[tuple[bool, Path]]:
27
27
  """Returns a list of tuples [(is_dir, path)] describing how to mount a given module."""
28
28
  file_formats = get_file_formats(module_name)
29
29
  if set(BINARY_FORMATS) & set(file_formats):
@@ -46,3 +46,16 @@ def get_module_mount_info(module_name: str) -> typing.Sequence[typing.Tuple[bool
46
46
  if not entries:
47
47
  raise ModuleNotMountable(f"{module_name} has no mountable paths")
48
48
  return entries
49
+
50
+
51
+ def parse_major_minor_version(version_string: str) -> tuple[int, int]:
52
+ parts = version_string.split(".")
53
+ if len(parts) < 2:
54
+ raise ValueError("version_string must have at least an 'X.Y' format")
55
+ try:
56
+ major = int(parts[0])
57
+ minor = int(parts[1])
58
+ except ValueError:
59
+ raise ValueError("version_string must have at least an 'X.Y' format with integral major/minor values")
60
+
61
+ return major, minor
@@ -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),
@@ -58,12 +58,10 @@ def _fill(msg, desc: Descriptor, rand: Random) -> None:
58
58
  else:
59
59
  if field.type == FieldDescriptor.TYPE_ENUM:
60
60
  enum_values = [x.number for x in field.enum_type.values]
61
-
62
- def generator(rand):
63
- return rand.choice(enum_values)
61
+ generator = lambda rand: rand.choice(enum_values) # noqa: E731
64
62
 
65
63
  else:
66
- generator = _FIELD_RANDOM_GENERATOR.get(field.type)
64
+ generator = _FIELD_RANDOM_GENERATOR[field.type]
67
65
  if is_repeated:
68
66
  num = rand.randint(0, 2)
69
67
  msg_field = getattr(msg, field.name)
@@ -73,7 +71,7 @@ def _fill(msg, desc: Descriptor, rand: Random) -> None:
73
71
  setattr(msg, field.name, generator(rand))
74
72
 
75
73
 
76
- def rand_pb(proto: Type[T], rand: Optional[Random] = None) -> T:
74
+ def rand_pb(proto: type[T], rand: Optional[Random] = None) -> T:
77
75
  """Generate a pseudorandom protobuf message.
78
76
 
79
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.")