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
test/image_test.py DELETED
@@ -1,814 +0,0 @@
1
- # Copyright Modal Labs 2022
2
- import os
3
- import pytest
4
- import re
5
- import sys
6
- import threading
7
- from hashlib import sha256
8
- from tempfile import NamedTemporaryFile
9
- from typing import List, Literal, get_args
10
- from unittest import mock
11
-
12
- from modal import App, Image, Mount, Secret, build, gpu, method
13
- from modal._serialization import serialize
14
- from modal.client import Client
15
- from modal.exception import DeprecationError, InvalidError, VersionError
16
- from modal.image import (
17
- SUPPORTED_PYTHON_SERIES,
18
- ImageBuilderVersion,
19
- _dockerhub_debian_codename,
20
- _dockerhub_python_version,
21
- _get_modal_requirements_path,
22
- _validate_python_version,
23
- )
24
- from modal.mount import PYTHON_STANDALONE_VERSIONS
25
- from modal_proto import api_pb2
26
-
27
- from .supports.skip import skip_windows
28
-
29
-
30
- def test_supported_python_series():
31
- assert SUPPORTED_PYTHON_SERIES == PYTHON_STANDALONE_VERSIONS.keys()
32
-
33
-
34
- def get_image_layers(image_id: str, servicer) -> List[api_pb2.Image]:
35
- """Follow pointers to the previous image recursively in the servicer's list of images,
36
- and return a list of image layers from top to bottom."""
37
-
38
- result = []
39
-
40
- while True:
41
- if image_id not in servicer.images:
42
- break
43
-
44
- image = servicer.images[image_id]
45
- result.append(servicer.images[image_id])
46
-
47
- if not image.base_images:
48
- break
49
-
50
- image_id = image.base_images[0].image_id
51
-
52
- return result
53
-
54
-
55
- def get_all_dockerfile_commands(image_id: str, servicer) -> str:
56
- layers = get_image_layers(image_id, servicer)
57
- return "\n".join([cmd for layer in layers for cmd in layer.dockerfile_commands])
58
-
59
-
60
- @pytest.fixture(params=get_args(ImageBuilderVersion))
61
- def builder_version(request, server_url_env, modal_config):
62
- version = request.param
63
- with modal_config():
64
- with mock.patch("test.conftest.ImageBuilderVersion", Literal[version]): # type: ignore
65
- yield version
66
-
67
-
68
- def test_python_version_validation():
69
- assert _validate_python_version(None) == "{0}.{1}".format(*sys.version_info)
70
- assert _validate_python_version("3.12") == "3.12"
71
- assert _validate_python_version("3.12.0") == "3.12.0"
72
-
73
- with pytest.raises(InvalidError, match="Unsupported Python version"):
74
- _validate_python_version("3.7")
75
-
76
- with pytest.raises(InvalidError, match="Python version must be specified as a string"):
77
- _validate_python_version(3.10) # type: ignore
78
-
79
- with pytest.raises(InvalidError, match="Invalid Python version"):
80
- _validate_python_version("3.10.2.9")
81
-
82
- with pytest.raises(InvalidError, match="Invalid Python version"):
83
- _validate_python_version("3.10.x")
84
-
85
- with pytest.raises(InvalidError, match="Python version must be specified as 'major.minor'"):
86
- _validate_python_version("3.10.5", allow_micro_granularity=False)
87
-
88
-
89
- def test_dockerhub_python_version(builder_version):
90
- assert _dockerhub_python_version(builder_version, "3.9.1") == "3.9.1"
91
-
92
- expected_39_full = {"2023.12": "3.9.15", "2024.04": "3.9.19"}[builder_version]
93
- assert _dockerhub_python_version(builder_version, "3.9") == expected_39_full
94
-
95
- v = _dockerhub_python_version(builder_version, None).split(".")
96
- assert len(v) == 3
97
- assert (int(v[0]), int(v[1])) == sys.version_info[:2]
98
-
99
-
100
- def test_image_base(builder_version, servicer, client, test_dir):
101
- app = App()
102
- constructors = [
103
- (Image.debian_slim, ()),
104
- (Image.from_registry, ("ubuntu",)),
105
- (Image.from_dockerfile, (test_dir / "supports" / "test-dockerfile",)),
106
- (Image.conda, ()),
107
- (Image.micromamba, ()),
108
- ]
109
- for meth, args in constructors:
110
- app.image = meth(*args) # type: ignore
111
- with app.run(client=client):
112
- commands = get_all_dockerfile_commands(app.image.object_id, servicer)
113
- assert "COPY /modal_requirements.txt /modal_requirements.txt" in commands
114
- if builder_version == "2023.12":
115
- assert "pip install -r /modal_requirements.txt" in commands
116
- else:
117
- assert "pip install --no-cache --no-deps -r /modal_requirements.txt" in commands
118
- assert "rm /modal_requirements.txt" in commands
119
-
120
-
121
- @pytest.mark.parametrize("python_version", [None, "3.10", "3.11.4"])
122
- def test_python_version(builder_version, servicer, client, python_version):
123
- local_python = "{0}.{1}".format(*sys.version_info)
124
- expected_python = local_python if python_version is None else python_version
125
-
126
- app = App()
127
- app.image = Image.debian_slim() if python_version is None else Image.debian_slim(python_version)
128
- expected_dockerhub_python = _dockerhub_python_version(builder_version, expected_python)
129
- expected_dockerhub_debian = _dockerhub_debian_codename(builder_version)
130
- assert expected_dockerhub_python.startswith(expected_python)
131
- with app.run(client):
132
- commands = get_all_dockerfile_commands(app.image.object_id, servicer)
133
- assert re.match(rf"FROM python:{expected_dockerhub_python}-slim-{expected_dockerhub_debian}", commands)
134
-
135
- for constructor in [Image.conda, Image.micromamba]:
136
- app.image = constructor() if python_version is None else constructor(python_version)
137
- if python_version is None and builder_version == "2023.12":
138
- expected_python = "3.9"
139
- with app.run(client):
140
- commands = get_all_dockerfile_commands(app.image.object_id, servicer)
141
- assert re.search(rf"install.* python={expected_python}", commands)
142
-
143
-
144
- def test_image_python_packages(builder_version, servicer, client):
145
- app = App()
146
- app.image = (
147
- Image.debian_slim()
148
- .pip_install("sklearn[xyz]")
149
- .pip_install("numpy", "scipy", extra_index_url="https://xyz", find_links="https://abc?q=123", pre=True)
150
- )
151
- with app.run(client=client):
152
- layers = get_image_layers(app.image.object_id, servicer)
153
- assert any("pip install 'sklearn[xyz]'" in cmd for cmd in layers[1].dockerfile_commands)
154
- assert any(
155
- "pip install numpy scipy --find-links 'https://abc?q=123' --extra-index-url https://xyz --pre" in cmd
156
- for cmd in layers[0].dockerfile_commands
157
- )
158
-
159
-
160
- def test_image_kwargs_validation(builder_version, servicer, client):
161
- app = App()
162
- app.image = Image.debian_slim().run_commands(
163
- "echo hi", secrets=[Secret.from_dict({"xyz": "123"}), Secret.from_name("foo")]
164
- )
165
- with pytest.raises(InvalidError):
166
- app.image = Image.debian_slim().run_commands(
167
- "echo hi",
168
- secrets=[
169
- Secret.from_dict({"xyz": "123"}),
170
- Secret.from_name("foo"),
171
- Mount.from_local_dir("/", remote_path="/"), # type: ignore
172
- ], # Mount is not a valid Secret
173
- )
174
-
175
- app = App()
176
- app.image = Image.debian_slim().copy_local_dir("/", remote_path="/dummy")
177
- app.image = Image.debian_slim().copy_mount(Mount.from_name("foo"), remote_path="/dummy")
178
- with pytest.raises(InvalidError):
179
- # Secret is not a valid Mount
180
- app.image = Image.debian_slim().copy_mount(Secret.from_dict({"xyz": "123"}), remote_path="/dummy") # type: ignore
181
-
182
-
183
- def test_wrong_type(builder_version, servicer, client):
184
- image = Image.debian_slim()
185
- for m in [image.pip_install, image.apt_install, image.run_commands]:
186
- m(["xyz"]) # type: ignore
187
- m("xyz") # type: ignore
188
- m("xyz", ["def", "foo"], "ghi") # type: ignore
189
- with pytest.raises(InvalidError):
190
- m(3) # type: ignore
191
- with pytest.raises(InvalidError):
192
- m([3]) # type: ignore
193
- with pytest.raises(InvalidError):
194
- m([["double-nested-package"]]) # type: ignore
195
-
196
-
197
- def test_image_requirements_txt(builder_version, servicer, client):
198
- requirements_txt = os.path.join(os.path.dirname(__file__), "supports/test-requirements.txt")
199
-
200
- app = App()
201
- app.image = Image.debian_slim().pip_install_from_requirements(requirements_txt)
202
- with app.run(client=client):
203
- layers = get_image_layers(app.image.object_id, servicer)
204
-
205
- assert any("COPY /.requirements.txt /.requirements.txt" in cmd for cmd in layers[0].dockerfile_commands)
206
- assert any("pip install -r /.requirements.txt" in cmd for cmd in layers[0].dockerfile_commands)
207
- assert any(b"banana" in f.data for f in layers[0].context_files)
208
-
209
-
210
- def test_empty_install(builder_version, servicer, client):
211
- # Install functions with no packages should be ignored.
212
- app = App(
213
- image=Image.debian_slim()
214
- .pip_install()
215
- .pip_install([], [], [], [])
216
- .apt_install([])
217
- .run_commands()
218
- .conda_install()
219
- )
220
-
221
- with app.run(client=client):
222
- layers = get_image_layers(app.image.object_id, servicer)
223
- assert len(layers) == 1
224
-
225
-
226
- def test_debian_slim_apt_install(builder_version, servicer, client):
227
- app = App(image=Image.debian_slim().pip_install("numpy").apt_install("git", "ssh").pip_install("scikit-learn"))
228
-
229
- with app.run(client=client):
230
- layers = get_image_layers(app.image.object_id, servicer)
231
-
232
- assert any("pip install scikit-learn" in cmd for cmd in layers[0].dockerfile_commands)
233
- assert any("apt-get install -y git ssh" in cmd for cmd in layers[1].dockerfile_commands)
234
- assert any("pip install numpy" in cmd for cmd in layers[2].dockerfile_commands)
235
-
236
-
237
- def test_image_pip_install_pyproject(builder_version, servicer, client):
238
- pyproject_toml = os.path.join(os.path.dirname(__file__), "supports/test-pyproject.toml")
239
-
240
- app = App()
241
- app.image = Image.debian_slim().pip_install_from_pyproject(pyproject_toml)
242
- with app.run(client=client):
243
- layers = get_image_layers(app.image.object_id, servicer)
244
-
245
- print(layers[0].dockerfile_commands)
246
- assert any("pip install 'banana >=1.2.0' 'potato >=0.1.0'" in cmd for cmd in layers[0].dockerfile_commands)
247
-
248
-
249
- def test_image_pip_install_pyproject_with_optionals(builder_version, servicer, client):
250
- pyproject_toml = os.path.join(os.path.dirname(__file__), "supports/test-pyproject.toml")
251
-
252
- app = App()
253
- app.image = Image.debian_slim().pip_install_from_pyproject(pyproject_toml, optional_dependencies=["dev", "test"])
254
- with app.run(client=client):
255
- layers = get_image_layers(app.image.object_id, servicer)
256
-
257
- print(layers[0].dockerfile_commands)
258
- assert any(
259
- "pip install 'banana >=1.2.0' 'linting-tool >=0.0.0' 'potato >=0.1.0' 'pytest >=1.2.0'" in cmd
260
- for cmd in layers[0].dockerfile_commands
261
- )
262
- assert not (any("'mkdocs >=1.4.2'" in cmd for cmd in layers[0].dockerfile_commands))
263
-
264
-
265
- def test_image_pip_install_private_repos(builder_version, servicer, client):
266
- app = App()
267
- with pytest.raises(InvalidError):
268
- app.image = Image.debian_slim().pip_install_private_repos(
269
- "github.com/ecorp/private-one@1.0.0",
270
- git_user="erikbern",
271
- secrets=[], # Invalid: missing secret
272
- )
273
-
274
- bad_repo_refs = [
275
- "ecorp/private-one@1.0.0",
276
- "gitspace.com/corp/private-one@1.0.0",
277
- ]
278
- for invalid_ref in bad_repo_refs:
279
- with pytest.raises(InvalidError):
280
- app.image = Image.debian_slim().pip_install_private_repos(
281
- invalid_ref,
282
- git_user="erikbern",
283
- secrets=[Secret.from_name("test-gh-read")],
284
- )
285
-
286
- app.image = Image.debian_slim().pip_install_private_repos(
287
- "github.com/corp/private-one@1.0.0",
288
- "gitlab.com/corp2/private-two@0.0.2",
289
- git_user="erikbern",
290
- secrets=[
291
- Secret.from_dict({"GITHUB_TOKEN": "not-a-secret"}),
292
- Secret.from_dict({"GITLAB_TOKEN": "not-a-secret"}),
293
- ],
294
- )
295
-
296
- with app.run(client=client):
297
- layers = get_image_layers(app.image.object_id, servicer)
298
- assert len(layers[0].secret_ids) == 2
299
- assert any(
300
- 'pip install "git+https://erikbern:$GITHUB_TOKEN@github.com/corp/private-one@1.0.0"' in cmd
301
- for cmd in layers[0].dockerfile_commands
302
- )
303
- assert any(
304
- 'pip install "git+https://erikbern:$GITLAB_TOKEN@gitlab.com/corp2/private-two@0.0.2"' in cmd
305
- for cmd in layers[0].dockerfile_commands
306
- )
307
-
308
-
309
- def test_conda_install(builder_version, servicer, client):
310
- app = App(image=Image.conda().pip_install("numpy").conda_install("pymc3", "theano").pip_install("scikit-learn"))
311
-
312
- with app.run(client=client):
313
- layers = get_image_layers(app.image.object_id, servicer)
314
-
315
- assert any("pip install scikit-learn" in cmd for cmd in layers[0].dockerfile_commands)
316
- assert any("conda install pymc3 theano --yes" in cmd for cmd in layers[1].dockerfile_commands)
317
- assert any("pip install numpy" in cmd for cmd in layers[2].dockerfile_commands)
318
-
319
-
320
- def test_dockerfile_image(builder_version, servicer, client):
321
- path = os.path.join(os.path.dirname(__file__), "supports/test-dockerfile")
322
-
323
- app = App(image=Image.from_dockerfile(path))
324
-
325
- with app.run(client=client):
326
- layers = get_image_layers(app.image.object_id, servicer)
327
-
328
- assert any("RUN pip install numpy" in cmd for cmd in layers[1].dockerfile_commands)
329
-
330
-
331
- def test_conda_update_from_environment(builder_version, servicer, client):
332
- path = os.path.join(os.path.dirname(__file__), "supports/test-conda-environment.yml")
333
-
334
- app = App(image=Image.conda().conda_update_from_environment(path))
335
-
336
- with app.run(client=client):
337
- layers = get_image_layers(app.image.object_id, servicer)
338
-
339
- assert any("RUN conda env update" in cmd for cmd in layers[0].dockerfile_commands)
340
- assert any(b"foo=1.0" in f.data for f in layers[0].context_files)
341
- assert any(b"bar=2.1" in f.data for f in layers[0].context_files)
342
-
343
-
344
- def test_run_commands(builder_version, servicer, client):
345
- base = Image.debian_slim()
346
-
347
- command = "echo 'Hello Modal'"
348
- app = App(image=base.run_commands(command))
349
- with app.run(client=client):
350
- layers = get_image_layers(app.image.object_id, servicer)
351
- assert layers[0].dockerfile_commands[1] == f"RUN {command}"
352
-
353
- commands = ["echo 'Hello world'", "touch agi.yaml"]
354
- for image in [base.run_commands(commands), base.run_commands(*commands)]:
355
- app = App(image=image)
356
- with app.run(client=client):
357
- layers = get_image_layers(app.image.object_id, servicer)
358
- for i, cmd in enumerate(commands, 1):
359
- assert layers[0].dockerfile_commands[i] == f"RUN {cmd}"
360
-
361
-
362
- def test_dockerhub_install(builder_version, servicer, client):
363
- app = App(image=Image.from_registry("gisops/valhalla:latest", setup_dockerfile_commands=["RUN apt-get update"]))
364
-
365
- with app.run(client=client):
366
- layers = get_image_layers(app.image.object_id, servicer)
367
-
368
- assert any("FROM gisops/valhalla:latest" in cmd for cmd in layers[0].dockerfile_commands)
369
- assert any("RUN apt-get update" in cmd for cmd in layers[0].dockerfile_commands)
370
-
371
-
372
- def test_ecr_install(builder_version, servicer, client):
373
- image_tag = "000000000000.dkr.ecr.us-east-1.amazonaws.com/my-private-registry:latest"
374
- app = App(
375
- image=Image.from_aws_ecr(
376
- image_tag,
377
- setup_dockerfile_commands=["RUN apt-get update"],
378
- secret=Secret.from_dict({"AWS_ACCESS_KEY_ID": "", "AWS_SECRET_ACCESS_KEY": ""}),
379
- )
380
- )
381
-
382
- with app.run(client=client):
383
- layers = get_image_layers(app.image.object_id, servicer)
384
-
385
- assert any(f"FROM {image_tag}" in cmd for cmd in layers[0].dockerfile_commands)
386
- assert any("RUN apt-get update" in cmd for cmd in layers[0].dockerfile_commands)
387
-
388
-
389
- def run_f():
390
- print("foo!")
391
-
392
-
393
- def test_image_run_function(builder_version, servicer, client):
394
- app = App()
395
- app.image = (
396
- Image.debian_slim().pip_install("pandas").run_function(run_f, secrets=[Secret.from_dict({"xyz": "123"})])
397
- )
398
-
399
- with app.run(client=client):
400
- image_id = app.image.object_id
401
- layers = get_image_layers(image_id, servicer)
402
- assert "foo!" in layers[0].build_function.definition
403
- assert "Secret.from_dict([xyz])" in layers[0].build_function.definition
404
- # globals is none when no globals are referenced
405
- assert layers[0].build_function.globals == b""
406
-
407
- function_id = servicer.image_build_function_ids[image_id]
408
- assert function_id
409
- assert servicer.app_functions[function_id].function_name == "run_f"
410
- assert len(servicer.app_functions[function_id].secret_ids) == 1
411
-
412
-
413
- def test_image_run_function_interactivity(builder_version, servicer, client):
414
- app = App()
415
- app.image = Image.debian_slim().pip_install("pandas").run_function(run_f)
416
-
417
- from modal.runner import run_app
418
-
419
- with run_app(app, client=client, shell=True):
420
- image_id = app.image.object_id
421
- layers = get_image_layers(image_id, servicer)
422
- assert "foo!" in layers[0].build_function.definition
423
-
424
- function_id = servicer.image_build_function_ids[image_id]
425
- assert function_id
426
- assert servicer.app_functions[function_id].function_name == "run_f"
427
- assert not servicer.app_functions[function_id].pty_info.enabled
428
-
429
-
430
- VARIABLE_1 = 1
431
- VARIABLE_2 = 3
432
-
433
-
434
- def run_f_globals():
435
- print("foo!", VARIABLE_1)
436
-
437
-
438
- def test_image_run_function_globals(builder_version, servicer, client):
439
- global VARIABLE_1, VARIABLE_2
440
-
441
- app = App()
442
- app.image = Image.debian_slim().run_function(run_f_globals)
443
-
444
- with app.run(client=client):
445
- layers = get_image_layers(app.image.object_id, servicer)
446
- old_globals = layers[0].build_function.globals
447
- assert b"VARIABLE_1" in old_globals
448
- assert b"VARIABLE_2" not in old_globals
449
-
450
- VARIABLE_1 = 3
451
- with app.run(client=client):
452
- layers = get_image_layers(app.image.object_id, servicer)
453
- assert layers[0].build_function.globals != old_globals
454
-
455
- VARIABLE_1 = 1
456
- with app.run(client=client):
457
- layers = get_image_layers(app.image.object_id, servicer)
458
- assert layers[0].build_function.globals == old_globals
459
-
460
-
461
- VARIABLE_3 = threading.Lock()
462
- VARIABLE_4 = "bar"
463
-
464
-
465
- def run_f_unserializable_globals():
466
- print("foo!", VARIABLE_3, VARIABLE_4)
467
-
468
-
469
- def test_image_run_unserializable_function(builder_version, servicer, client):
470
- app = App()
471
- app.image = Image.debian_slim().run_function(run_f_unserializable_globals)
472
-
473
- with app.run(client=client):
474
- layers = get_image_layers(app.image.object_id, servicer)
475
- old_globals = layers[0].build_function.globals
476
- assert b"VARIABLE_4" in old_globals
477
-
478
-
479
- def run_f_with_args(arg, *, kwarg):
480
- print("building!", arg, kwarg)
481
-
482
-
483
- def test_image_run_function_with_args(builder_version, servicer, client):
484
- app = App()
485
- app.image = Image.debian_slim().run_function(run_f_with_args, args=("foo",), kwargs={"kwarg": "bar"})
486
-
487
- with app.run(client=client):
488
- layers = get_image_layers(app.image.object_id, servicer)
489
- input = layers[0].build_function.input
490
- assert input.args == serialize((("foo",), {"kwarg": "bar"}))
491
-
492
-
493
- def test_poetry(builder_version, servicer, client):
494
- path = os.path.join(os.path.dirname(__file__), "supports/pyproject.toml")
495
-
496
- # No lockfile provided and there's no lockfile found
497
- # TODO we deferred the exception until _load runs, not sure how to test that here
498
- # with pytest.raises(NotFoundError):
499
- # Image.debian_slim().poetry_install_from_file(path)
500
-
501
- # Explicitly ignore lockfile - this should work
502
- Image.debian_slim().poetry_install_from_file(path, ignore_lockfile=True)
503
-
504
- # Provide lockfile explicitly - this should also work
505
- lockfile_path = os.path.join(os.path.dirname(__file__), "supports/special_poetry.lock")
506
- image = Image.debian_slim().poetry_install_from_file(path, lockfile_path)
507
-
508
- # Build iamge
509
- app = App()
510
- app.image = image
511
- with app.run(client=client):
512
- layers = get_image_layers(app.image.object_id, servicer)
513
- context_files = {f.filename for layer in layers for f in layer.context_files}
514
- assert context_files == {"/.poetry.lock", "/.pyproject.toml", "/modal_requirements.txt"}
515
-
516
-
517
- @pytest.fixture
518
- def tmp_path_with_content(tmp_path):
519
- (tmp_path / "data.txt").write_text("hello")
520
- (tmp_path / "data").mkdir()
521
- (tmp_path / "data" / "sub").write_text("world")
522
- return tmp_path
523
-
524
-
525
- def test_image_copy_local_dir(builder_version, servicer, client, tmp_path_with_content):
526
- app = App()
527
- app.image = Image.debian_slim().copy_local_dir(tmp_path_with_content, remote_path="/dummy")
528
-
529
- with app.run(client=client):
530
- layers = get_image_layers(app.image.object_id, servicer)
531
- assert "COPY . /dummy" in layers[0].dockerfile_commands
532
- assert set(servicer.mount_contents["mo-1"].keys()) == {"/data.txt", "/data/sub"}
533
-
534
-
535
- def test_image_docker_command_copy(builder_version, servicer, client, tmp_path_with_content):
536
- app = App()
537
- data_mount = Mount.from_local_dir(tmp_path_with_content, remote_path="/")
538
- app.image = Image.debian_slim().dockerfile_commands(["COPY . /dummy"], context_mount=data_mount)
539
-
540
- with app.run(client=client):
541
- layers = get_image_layers(app.image.object_id, servicer)
542
- assert "COPY . /dummy" in layers[0].dockerfile_commands
543
- files = {f.mount_filename: f.content for f in Mount._get_files(data_mount.entries)}
544
- assert files == {"/data.txt": b"hello", "/data/sub": b"world"}
545
-
546
-
547
- def test_image_dockerfile_copy(builder_version, servicer, client, tmp_path_with_content):
548
- dockerfile = NamedTemporaryFile("w", delete=False)
549
- dockerfile.write("COPY . /dummy\n")
550
- dockerfile.close()
551
-
552
- app = App()
553
- data_mount = Mount.from_local_dir(tmp_path_with_content, remote_path="/")
554
- app.image = Image.debian_slim().from_dockerfile(dockerfile.name, context_mount=data_mount)
555
-
556
- with app.run(client=client):
557
- layers = get_image_layers(app.image.object_id, servicer)
558
- assert "COPY . /dummy" in layers[1].dockerfile_commands
559
- files = {f.mount_filename: f.content for f in Mount._get_files(data_mount.entries)}
560
- assert files == {"/data.txt": b"hello", "/data/sub": b"world"}
561
-
562
-
563
- def test_image_env(builder_version, servicer, client):
564
- app = App(image=Image.debian_slim().env({"HELLO": "world!"}))
565
-
566
- with app.run(client=client):
567
- layers = get_image_layers(app.image.object_id, servicer)
568
- assert any("ENV HELLO=" in cmd and "world!" in cmd for cmd in layers[0].dockerfile_commands)
569
-
570
-
571
- def test_image_gpu(builder_version, servicer, client):
572
- app = App(image=Image.debian_slim().run_commands("echo 0"))
573
- with app.run(client=client):
574
- layers = get_image_layers(app.image.object_id, servicer)
575
- assert layers[0].gpu_config.type == api_pb2.GPU_TYPE_UNSPECIFIED
576
-
577
- with pytest.warns(DeprecationError):
578
- app = App(image=Image.debian_slim().run_commands("echo 1", gpu=True))
579
- with app.run(client=client):
580
- layers = get_image_layers(app.image.object_id, servicer)
581
- assert layers[0].gpu_config.type == api_pb2.GPU_TYPE_ANY
582
-
583
- app = App(image=Image.debian_slim().run_commands("echo 2", gpu=gpu.A10G()))
584
- with app.run(client=client):
585
- layers = get_image_layers(app.image.object_id, servicer)
586
- assert layers[0].gpu_config.type == api_pb2.GPU_TYPE_A10G
587
-
588
-
589
- def test_image_force_build(builder_version, servicer, client):
590
- app = App()
591
- app.image = Image.debian_slim().run_commands("echo 1").pip_install("foo", force_build=True).run_commands("echo 2")
592
- with app.run(client=client):
593
- assert servicer.force_built_images == ["im-3", "im-4"]
594
-
595
- app.image = (
596
- Image.from_gcp_artifact_registry("foo", force_build=True)
597
- .run_commands("python_packagesecho 1")
598
- .pip_install("foo", force_build=True)
599
- .run_commands("echo 2")
600
- )
601
- with app.run(client=client):
602
- assert servicer.force_built_images == ["im-3", "im-4", "im-5", "im-6", "im-7", "im-8"]
603
-
604
-
605
- def test_workdir(builder_version, servicer, client):
606
- app = App(image=Image.debian_slim().workdir("/foo/bar"))
607
-
608
- with app.run(client=client):
609
- layers = get_image_layers(app.image.object_id, servicer)
610
-
611
- assert any("WORKDIR /foo/bar" in cmd for cmd in layers[0].dockerfile_commands)
612
-
613
-
614
- cls_app = App()
615
-
616
- VARIABLE_5 = 1
617
- VARIABLE_6 = 1
618
-
619
-
620
- @cls_app.cls(
621
- image=Image.debian_slim().pip_install("pandas"),
622
- secrets=[Secret.from_dict({"xyz": "123"})],
623
- )
624
- class Foo:
625
- @build()
626
- def build_func(self):
627
- global VARIABLE_5
628
-
629
- print("foo!", VARIABLE_5)
630
-
631
- @method()
632
- def f(self):
633
- global VARIABLE_6
634
-
635
- print("bar!", VARIABLE_6)
636
-
637
-
638
- def test_image_build_snapshot(client, servicer):
639
- with cls_app.run(client=client):
640
- image_id = list(servicer.images.keys())[-1]
641
- layers = get_image_layers(image_id, servicer)
642
-
643
- assert "foo!" in layers[0].build_function.definition
644
- assert "Secret.from_dict([xyz])" in layers[0].build_function.definition
645
- assert any("pip install pandas" in cmd for cmd in layers[1].dockerfile_commands)
646
-
647
- globals = layers[0].build_function.globals
648
- assert b"VARIABLE_5" in globals
649
-
650
- # Globals and def for the main function should not affect build step.
651
- assert "bar!" not in layers[0].build_function.definition
652
- assert b"VARIABLE_6" not in globals
653
-
654
- function_id = servicer.image_build_function_ids[image_id]
655
- assert function_id
656
- assert servicer.app_functions[function_id].function_name == "Foo.build_func"
657
- assert len(servicer.app_functions[function_id].secret_ids) == 1
658
-
659
-
660
- def test_inside_ctx_unhydrated(client):
661
- image_1 = Image.debian_slim()
662
- image_2 = Image.debian_slim()
663
-
664
- with mock.patch.dict(os.environ, {"MODAL_IMAGE_ID": "im-123"}):
665
- # This should initially swallow the exception
666
- with image_1.imports():
667
- raise ImportError("foo")
668
-
669
- # This one too
670
- with image_2.imports():
671
- raise ImportError("bar")
672
-
673
- # non-ImportErrors should trigger a warning
674
- with pytest.warns(match="ImportError"):
675
- with image_2.imports():
676
- raise Exception("foo")
677
-
678
- # Old one raises
679
- with pytest.raises(DeprecationError, match="imports()"):
680
- image_1.run_inside()
681
-
682
- # Hydration of the image should raise the exception
683
- with pytest.raises(ImportError, match="foo"):
684
- image_1._hydrate("im-123", client, None)
685
-
686
- # Should not raise since it's a different image
687
- image_2._hydrate("im-456", client, None)
688
-
689
-
690
- def test_inside_ctx_hydrated(client):
691
- image_1 = Image.debian_slim()
692
- image_2 = Image.debian_slim()
693
-
694
- with mock.patch.dict(os.environ, {"MODAL_IMAGE_ID": "im-123"}):
695
- # Assign ids before the ctx mgr runs
696
- image_1._hydrate("im-123", client, None)
697
- image_2._hydrate("im-456", client, None)
698
-
699
- # Ctx manager should now raise right away
700
- with pytest.raises(ImportError, match="baz"):
701
- with image_1.imports():
702
- raise ImportError("baz")
703
-
704
- # We're not inside this image so this should be swallowed
705
- with image_2.imports():
706
- raise ImportError("bar")
707
-
708
-
709
- @pytest.mark.parametrize("python_version", ["3.11", "3.12", "3.12.1", "3.12.1-gnu"])
710
- def test_get_modal_requirements_path(builder_version, python_version):
711
- path = _get_modal_requirements_path(builder_version, python_version)
712
- if builder_version == "2023.12" and python_version.startswith("3.12"):
713
- assert path.endswith("2023.12.312.txt")
714
- else:
715
- assert path.endswith(f"{builder_version}.txt")
716
-
717
-
718
- def test_image_builder_version(servicer, test_dir, modal_config):
719
- app = App(image=Image.debian_slim())
720
- # TODO use a single with statement and tuple of managers when we drop Py3.8
721
- test_requirements = str(test_dir / "supports" / "test-requirements.txt")
722
- with mock.patch("modal.image._get_modal_requirements_path", lambda *_, **__: test_requirements):
723
- with mock.patch("modal.image._dockerhub_python_version", lambda *_, **__: "3.11.0"):
724
- with mock.patch("modal.image._dockerhub_debian_codename", lambda *_, **__: "bullseye"):
725
- with mock.patch("test.conftest.ImageBuilderVersion", Literal["2000.01"]):
726
- with mock.patch("modal.image.ImageBuilderVersion", Literal["2000.01"]):
727
- with Client(
728
- servicer.remote_addr, api_pb2.CLIENT_TYPE_CONTAINER, ("ak-123", "as-xyz")
729
- ) as client:
730
- with modal_config():
731
- with app.run(client=client):
732
- assert servicer.image_builder_versions
733
- for version in servicer.image_builder_versions.values():
734
- assert version == "2000.01"
735
-
736
-
737
- def test_image_builder_supported_versions(servicer):
738
- app = App(image=Image.debian_slim())
739
- # TODO use a single with statement and tuple of managers when we drop Py3.8
740
- with pytest.raises(VersionError, match=r"This version of the modal client supports.+{'2000.01'}"):
741
- with mock.patch("modal.image.ImageBuilderVersion", Literal["2000.01"]):
742
- with mock.patch("test.conftest.ImageBuilderVersion", Literal["2023.11"]):
743
- with Client(servicer.remote_addr, api_pb2.CLIENT_TYPE_CONTAINER, ("ak-123", "as-xyz")) as client:
744
- with app.run(client=client):
745
- pass
746
-
747
-
748
- @pytest.fixture
749
- def force_2023_12(modal_config):
750
- with mock.patch("test.conftest.ImageBuilderVersion", Literal["2023.12"]):
751
- with modal_config():
752
- yield
753
-
754
-
755
- @skip_windows("Different hash values for context file paths")
756
- def test_image_stability_on_2023_12(force_2023_12, servicer, client, test_dir):
757
- def get_hash(img: Image) -> str:
758
- app = App(image=img)
759
- with app.run(client=client):
760
- layers = get_image_layers(app.image.object_id, servicer)
761
- commands = [layer.dockerfile_commands for layer in layers]
762
- context_files = [[(f.filename, f.data) for f in layer.context_files] for layer in layers]
763
- return sha256(repr(list(zip(commands, context_files))).encode()).hexdigest()
764
-
765
- if sys.version_info[:2] == (3, 11):
766
- # Matches my development environment — default is to match Python version from local system
767
- img = Image.debian_slim()
768
- assert get_hash(img) == "183b86356d9eb3bd3d78adf70f16b35b63ba9bf4e1816b0cacc549541718e555"
769
-
770
- img = Image.debian_slim(python_version="3.12")
771
- assert get_hash(img) == "53b6205e1dc2a0ca7ebed862e4f3a5887367587be13e81f65a4ac8f8a1e9be91"
772
-
773
- if sys.version_info[:2] < (3, 12):
774
- # Client dependencies on 3.12 are different
775
- img = Image.from_registry("ubuntu:22.04")
776
- assert get_hash(img) == "b5f1cc544a412d1b23a5ebf9a8859ea9a86975ecbc7325b83defc0ce3fe956d3"
777
-
778
- img = Image.conda()
779
- assert get_hash(img) == "f69d6af66fb5f1a2372a61836e6166ce79ebe2cd628d12addea8e8e80cc98dc1"
780
-
781
- img = Image.micromamba()
782
- assert get_hash(img) == "fa883741544ea191ecd197c8f83a1ffe9912575faa8c107c66b3dda761b2e401"
783
-
784
- img = Image.from_dockerfile(test_dir / "supports" / "test-dockerfile")
785
- assert get_hash(img) == "0aec2f66f28ee7511c1b36604214ae7b40d9bc1fa3e6b8883001e933a966ff78"
786
-
787
- img = Image.conda(python_version="3.12")
788
- assert get_hash(img) == "c4b3f7350116d323dded29c9c9b78b62593f0fc943ccf83a09b27185bfdc2a07"
789
-
790
- img = Image.micromamba(python_version="3.12")
791
- assert get_hash(img) == "468befe16f703a3ae1a794dfe54c1a3445ca0ffda233f55f1d66c45ad608e8aa"
792
-
793
- base = Image.debian_slim(python_version="3.12")
794
-
795
- img = base.run_commands("echo 'Hello Modal'", "rm /usr/local/bin/kubectl")
796
- assert get_hash(img) == "4e1ac62eb33b44dd16940c9d2719eb79f945cee61cbf4641ca99b19cd9e0976d"
797
-
798
- img = base.pip_install("torch~=2.2", "transformers==4.23.0", pre=True, index_url="agi.se")
799
- assert get_hash(img) == "2a4fa8e3b32c70a41b3a3efd5416540b1953430543f6c27c984e7f969c2ca874"
800
-
801
- img = base.conda_install("torch=2.2", "transformers<4.23.0", channels=["conda-forge", "my-channel"])
802
- assert get_hash(img) == "dd6f27f636293996a64a98c250161d8092cb23d02629d9070493f00aad8d7266"
803
-
804
- img = base.pip_install_from_requirements(test_dir / "supports" / "test-requirements.txt")
805
- assert get_hash(img) == "69d41e699d4ecef399e51e8460f8857aa0ec57f71f00eca81c8886ec062e5c2b"
806
-
807
- img = base.conda_update_from_environment(test_dir / "supports" / "test-conda-environment.yml")
808
- assert get_hash(img) == "00940e0ee2998bfe0a337f51a5fdf5f4b29bf9d42dda3635641d44bfeb42537e"
809
-
810
- img = base.poetry_install_from_file(
811
- test_dir / "supports" / "test-pyproject.toml",
812
- poetry_lockfile=test_dir / "supports" / "special_poetry.lock",
813
- )
814
- assert get_hash(img) == "a25dd4cc2e8d88f92bfdaf2e82b9d74144d1928926bf6be2ca1cdfbbf562189e"