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