modal 0.62.115__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 +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 +407 -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 +1036 -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 +197 -0
  84. modal/functions.py +846 -428
  85. modal/functions.pyi +446 -387
  86. modal/gpu.py +57 -44
  87. modal/image.py +946 -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.11.dist-info}/METADATA +5 -5
  128. modal-0.72.11.dist-info/RECORD +174 -0
  129. {modal-0.62.115.dist-info → modal-0.72.11.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.11.dist-info}/LICENSE +0 -0
  219. {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
  220. {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/entry_points.txt +0 -0
@@ -1,50 +0,0 @@
1
- # Copyright Modal Labs 2022
2
- import pytest
3
- import random
4
-
5
- from modal import Queue
6
- from modal._serialization import deserialize, deserialize_data_format, serialize, serialize_data_format
7
- from modal._utils.rand_pb_testing import rand_pb
8
- from modal.exception import DeserializationError
9
- from modal_proto import api_pb2
10
-
11
- from .supports.skip import skip_old_py
12
-
13
-
14
- @pytest.mark.asyncio
15
- async def test_roundtrip(servicer, client):
16
- async with Queue.ephemeral(client=client) as q:
17
- data = serialize(q)
18
- # TODO: strip synchronizer reference from synchronicity entities!
19
- assert len(data) < 350 # Used to be 93...
20
- # Note: if this blows up significantly, it's most likely because
21
- # cloudpickle can't find a class in the global scope. When this
22
- # happens, it tries to serialize the entire class along with the
23
- # object. The reason it doesn't find the class in the global scope
24
- # is most likely because the name doesn't match. To fix this, make
25
- # sure that cls.__name__ (which is something synchronicity sets)
26
- # is the same as the symbol defined in the global scope.
27
- q_roundtrip = deserialize(data, client)
28
- assert isinstance(q_roundtrip, Queue)
29
- assert q.object_id == q_roundtrip.object_id
30
-
31
-
32
- @skip_old_py("random.randbytes() was introduced in python 3.9", (3, 9))
33
- @pytest.mark.asyncio
34
- async def test_asgi_roundtrip():
35
- rand = random.Random(42)
36
- for _ in range(10000):
37
- msg = rand_pb(api_pb2.Asgi, rand)
38
- buf = msg.SerializeToString()
39
- asgi_obj = deserialize_data_format(buf, api_pb2.DATA_FORMAT_ASGI, None)
40
- assert asgi_obj is None or (isinstance(asgi_obj, dict) and asgi_obj["type"])
41
- buf = serialize_data_format(asgi_obj, api_pb2.DATA_FORMAT_ASGI)
42
- asgi_obj_roundtrip = deserialize_data_format(buf, api_pb2.DATA_FORMAT_ASGI, None)
43
- assert asgi_obj == asgi_obj_roundtrip
44
-
45
-
46
- def test_deserialization_error(client):
47
- # Curated object that we should not be able to deserialize
48
- obj = b"\x80\x04\x95(\x00\x00\x00\x00\x00\x00\x00\x8c\x17undeserializable_module\x94\x8c\x05Dummy\x94\x93\x94)\x81\x94."
49
- with pytest.raises(DeserializationError, match="'undeserializable_module' .+ local environment"):
50
- deserialize(obj, client)
@@ -1,10 +0,0 @@
1
- # Copyright Modal Labs 2024
2
- from test.helpers import deploy_app_externally
3
-
4
-
5
- def test_app_composition_includes_all_functions(servicer, supports_dir, monkeypatch, client):
6
- print(deploy_app_externally(servicer, "main.py", cwd=supports_dir / "multifile_project"))
7
- assert servicer.n_functions == 3
8
- assert {"/root/main.py", "/root/a.py", "/root/b.py", "/root/c.py"} == set(servicer.files_name2sha.keys())
9
- assert len(servicer.secrets) == 1 # secret from B should be included
10
- assert servicer.n_mounts == 4 # mounts should not be duplicated
test/stub_test.py DELETED
@@ -1,361 +0,0 @@
1
- # Copyright Modal Labs 2022
2
- import asyncio
3
- import logging
4
- import pytest
5
-
6
- from google.protobuf.empty_pb2 import Empty
7
- from grpclib import GRPCError, Status
8
-
9
- from modal import App, Dict, Image, Mount, Queue, Secret, Volume, web_endpoint
10
- from modal.app import list_apps # type: ignore
11
- from modal.config import config
12
- from modal.exception import DeprecationError, ExecutionError, InvalidError, NotFoundError
13
- from modal.partial_function import _parse_custom_domains
14
- from modal.runner import deploy_app
15
- from modal_proto import api_pb2
16
-
17
- from .supports import module_1, module_2
18
-
19
-
20
- @pytest.mark.asyncio
21
- async def test_kwargs(servicer, client):
22
- with pytest.raises(DeprecationError):
23
- App(
24
- d=Dict.new(),
25
- q=Queue.new(),
26
- )
27
-
28
-
29
- @pytest.mark.asyncio
30
- async def test_attrs(servicer, client):
31
- app = App()
32
- with pytest.warns(DeprecationError):
33
- app.d = Dict.new()
34
- app.q = Queue.new()
35
- async with app.run(client=client):
36
- with pytest.warns(DeprecationError):
37
- await app.d.put.aio("foo", "bar") # type: ignore
38
- await app.q.put.aio("baz") # type: ignore
39
- assert await app.d.get.aio("foo") == "bar" # type: ignore
40
- assert await app.q.get.aio() == "baz" # type: ignore
41
-
42
-
43
- def square(x):
44
- return x**2
45
-
46
-
47
- @pytest.mark.asyncio
48
- async def test_redeploy(servicer, client):
49
- app = App(image=Image.debian_slim().pip_install("pandas"))
50
- app.function()(square)
51
-
52
- # Deploy app
53
- res = await deploy_app.aio(app, "my-app", client=client)
54
- assert res.app_id == "ap-1"
55
- assert servicer.app_objects["ap-1"]["square"] == "fu-1"
56
- assert servicer.app_state_history[res.app_id] == [api_pb2.APP_STATE_INITIALIZING, api_pb2.APP_STATE_DEPLOYED]
57
-
58
- # Redeploy, make sure all ids are the same
59
- res = await deploy_app.aio(app, "my-app", client=client)
60
- assert res.app_id == "ap-1"
61
- assert servicer.app_objects["ap-1"]["square"] == "fu-1"
62
- assert servicer.app_state_history[res.app_id] == [
63
- api_pb2.APP_STATE_INITIALIZING,
64
- api_pb2.APP_STATE_DEPLOYED,
65
- api_pb2.APP_STATE_DEPLOYED,
66
- ]
67
-
68
- # Deploy to a different name, ids should change
69
- res = await deploy_app.aio(app, "my-app-xyz", client=client)
70
- assert res.app_id == "ap-2"
71
- assert servicer.app_objects["ap-2"]["square"] == "fu-2"
72
- assert servicer.app_state_history[res.app_id] == [api_pb2.APP_STATE_INITIALIZING, api_pb2.APP_STATE_DEPLOYED]
73
-
74
-
75
- def dummy():
76
- pass
77
-
78
-
79
- # Should exit without waiting for the "logs_timeout" grace period.
80
- @pytest.mark.timeout(5)
81
- def test_create_object_exception(servicer, client):
82
- servicer.function_create_error = True
83
-
84
- app = App()
85
- app.function()(dummy)
86
-
87
- with pytest.raises(GRPCError) as excinfo:
88
- with app.run(client=client):
89
- pass
90
-
91
- assert excinfo.value.status == Status.INTERNAL
92
-
93
-
94
- def test_deploy_falls_back_to_app_name(servicer, client):
95
- named_app = App(name="foo_app")
96
- deploy_app(named_app, client=client)
97
- assert "foo_app" in servicer.deployed_apps
98
-
99
-
100
- def test_deploy_uses_deployment_name_if_specified(servicer, client):
101
- named_app = App(name="foo_app")
102
- deploy_app(named_app, "bar_app", client=client)
103
- assert "bar_app" in servicer.deployed_apps
104
- assert "foo_app" not in servicer.deployed_apps
105
-
106
-
107
- def test_run_function_without_app_error():
108
- app = App()
109
- dummy_modal = app.function()(dummy)
110
-
111
- with pytest.raises(ExecutionError) as excinfo:
112
- dummy_modal.remote()
113
-
114
- assert "hydrated" in str(excinfo.value)
115
-
116
-
117
- def test_is_inside_basic():
118
- app = App()
119
- with pytest.raises(DeprecationError, match="imports()"):
120
- app.is_inside()
121
-
122
-
123
- def test_missing_attr():
124
- """Trying to call a non-existent function on the App should produce
125
- an understandable error message."""
126
-
127
- app = App()
128
- with pytest.raises(AttributeError):
129
- app.fun() # type: ignore
130
-
131
-
132
- def test_same_function_name(caplog):
133
- app = App()
134
-
135
- # Add first function
136
- with caplog.at_level(logging.WARNING):
137
- app.function()(module_1.square)
138
- assert len(caplog.records) == 0
139
-
140
- # Add second function: check warning
141
- with caplog.at_level(logging.WARNING):
142
- app.function()(module_2.square)
143
- assert len(caplog.records) == 1
144
- assert "module_1" in caplog.text
145
- assert "module_2" in caplog.text
146
- assert "square" in caplog.text
147
-
148
-
149
- def test_run_state(client, servicer):
150
- app = App()
151
- with app.run(client=client):
152
- assert servicer.app_state_history[app.app_id] == [api_pb2.APP_STATE_INITIALIZING, api_pb2.APP_STATE_EPHEMERAL]
153
-
154
-
155
- def test_deploy_state(client, servicer):
156
- app = App()
157
- res = deploy_app(app, "foobar", client=client)
158
- assert servicer.app_state_history[res.app_id] == [api_pb2.APP_STATE_INITIALIZING, api_pb2.APP_STATE_DEPLOYED]
159
-
160
-
161
- def test_detach_state(client, servicer):
162
- app = App()
163
- with app.run(client=client, detach=True):
164
- assert servicer.app_state_history[app.app_id] == [api_pb2.APP_STATE_INITIALIZING, api_pb2.APP_STATE_DETACHED]
165
-
166
-
167
- @pytest.mark.asyncio
168
- async def test_grpc_protocol(client, servicer):
169
- app = App()
170
- async with app.run(client=client):
171
- await asyncio.sleep(0.01) # wait for heartbeat
172
- assert len(servicer.requests) == 4
173
- assert isinstance(servicer.requests[0], Empty) # ClientHello
174
- assert isinstance(servicer.requests[1], api_pb2.AppCreateRequest)
175
- assert isinstance(servicer.requests[2], api_pb2.AppHeartbeatRequest)
176
- assert isinstance(servicer.requests[3], api_pb2.AppClientDisconnectRequest)
177
-
178
-
179
- async def web1(x):
180
- return {"square": x**2}
181
-
182
-
183
- async def web2(x):
184
- return {"cube": x**3}
185
-
186
-
187
- def test_registered_web_endpoints(client, servicer):
188
- app = App()
189
- app.function()(square)
190
- app.function()(web_endpoint()(web1))
191
- app.function()(web_endpoint()(web2))
192
-
193
- assert app.registered_web_endpoints == ["web1", "web2"]
194
-
195
-
196
- def test_init_types():
197
- with pytest.raises(InvalidError):
198
- # singular secret to plural argument
199
- App(secrets=Secret.from_dict()) # type: ignore
200
- with pytest.raises(InvalidError):
201
- # not a Secret Object
202
- App(secrets=[{"foo": "bar"}]) # type: ignore
203
- with pytest.raises(InvalidError):
204
- # should be an Image
205
- App(image=Secret.from_dict()) # type: ignore
206
-
207
- App(
208
- image=Image.debian_slim().pip_install("pandas"),
209
- secrets=[Secret.from_dict()],
210
- mounts=[Mount.from_local_file(__file__)],
211
- )
212
-
213
-
214
- def test_set_image_on_app_as_attribute():
215
- # TODO: do we want to deprecate this syntax? It's kind of random for image to
216
- # have a reserved name in the blueprint, and being the only of the construction
217
- # arguments that can be set on the instance after construction
218
- custom_img = Image.debian_slim().apt_install("emacs")
219
- app = App(image=custom_img)
220
- assert app._get_default_image() == custom_img
221
-
222
-
223
- def test_redeploy_delete_objects(servicer, client):
224
- # Deploy an app with objects d1 and d2
225
- app = App()
226
- app.function(name="d1")(dummy)
227
- app.function(name="d2")(dummy)
228
- res = deploy_app(app, "xyz", client=client)
229
-
230
- # Check objects
231
- assert set(servicer.app_objects[res.app_id].keys()) == set(["d1", "d2"])
232
-
233
- # Deploy an app with objects d2 and d3
234
- app = App()
235
- app.function(name="d2")(dummy)
236
- app.function(name="d3")(dummy)
237
- res = deploy_app(app, "xyz", client=client)
238
-
239
- # Make sure d1 is deleted
240
- assert set(servicer.app_objects[res.app_id].keys()) == set(["d2", "d3"])
241
-
242
-
243
- @pytest.mark.asyncio
244
- async def test_unhydrate(servicer, client):
245
- app = App()
246
-
247
- f = app.function()(dummy)
248
-
249
- assert not f.is_hydrated
250
- async with app.run(client=client):
251
- assert f.is_hydrated
252
-
253
- # After app finishes, it should unhydrate
254
- assert not f.is_hydrated
255
-
256
-
257
- def test_keyboard_interrupt(servicer, client):
258
- app = App()
259
- app.function()(square)
260
- with app.run(client=client):
261
- # The exit handler should catch this interrupt and exit gracefully
262
- raise KeyboardInterrupt()
263
-
264
-
265
- def test_function_image_positional():
266
- app = App()
267
- image = Image.debian_slim()
268
-
269
- with pytest.raises(InvalidError) as excinfo:
270
-
271
- @app.function(image) # type: ignore
272
- def f():
273
- pass
274
-
275
- assert "function(image=image)" in str(excinfo.value)
276
-
277
-
278
- @pytest.mark.asyncio
279
- async def test_deploy_disconnect(servicer, client):
280
- app = App()
281
- app.function(secrets=[Secret.from_name("nonexistent-secret")])(square)
282
-
283
- with pytest.raises(NotFoundError):
284
- await deploy_app.aio(app, "my-app", client=client)
285
-
286
- assert servicer.app_state_history["ap-1"] == [
287
- api_pb2.APP_STATE_INITIALIZING,
288
- api_pb2.APP_STATE_STOPPED,
289
- ]
290
-
291
-
292
- def test_redeploy_from_name_change(servicer, client):
293
- # Deploy queue
294
- Queue.lookup("foo-queue", create_if_missing=True, client=client)
295
-
296
- # Use it from app
297
- app = App()
298
- with pytest.warns(DeprecationError):
299
- app.q = Queue.from_name("foo-queue")
300
- deploy_app(app, "my-app", client=client)
301
-
302
- # Change the object id of foo-queue
303
- k = ("foo-queue", api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE, config.get("environment"))
304
- assert servicer.deployed_queues[k]
305
- servicer.deployed_queues[k] = "qu-baz123"
306
-
307
- # Redeploy app
308
- # This should not fail because the object_id changed - it's a different app
309
- deploy_app(app, "my-app", client=client)
310
-
311
-
312
- def test_parse_custom_domains():
313
- assert len(_parse_custom_domains(None)) == 0
314
- assert len(_parse_custom_domains(["foo.com", "bar.com"])) == 2
315
- with pytest.raises(AssertionError):
316
- assert _parse_custom_domains("foo.com")
317
-
318
-
319
- def test_hydrated_other_app_object_gets_referenced(servicer, client):
320
- app = App("my-app")
321
- with servicer.intercept() as ctx:
322
- with Volume.ephemeral(client=client) as vol:
323
- app.function(volumes={"/vol": vol})(dummy) # implicitly load vol
324
- deploy_app(app, client=client)
325
- app_set_objects_req = ctx.pop_request("AppSetObjects")
326
- assert vol.object_id in app_set_objects_req.unindexed_object_ids
327
-
328
-
329
- def test_hasattr():
330
- app = App()
331
- assert not hasattr(app, "xyz")
332
-
333
-
334
- def test_app(client):
335
- app = App()
336
- square_modal = app.function()(square)
337
-
338
- with app.run(client=client):
339
- square_modal.remote(42)
340
-
341
-
342
- def test_list_apps(client):
343
- apps_0 = [app.name for app in list_apps(client=client)]
344
- app = App()
345
- deploy_app(app, "foobar", client=client)
346
- apps_1 = [app.name for app in list_apps(client=client)]
347
-
348
- assert len(apps_1) == len(apps_0) + 1
349
- assert set(apps_1) - set(apps_0) == set(["foobar"])
350
-
351
-
352
- def test_function_named_app():
353
- # Make sure we have a helpful warning when a user's function is named "app"
354
- # as it might collide with the App variable name (in particular if people
355
- # find & replace "stub" with "app").
356
- app = App()
357
-
358
- with pytest.warns(match="app"):
359
- @app.function(serialized=True)
360
- def app():
361
- ...
test/test_asgi_wrapper.py DELETED
@@ -1,234 +0,0 @@
1
- # Copyright Modal Labs 2024
2
- import asyncio
3
- import pytest
4
-
5
- import fastapi
6
- from starlette.requests import ClientDisconnect
7
-
8
- from modal._asgi import asgi_app_wrapper
9
- from modal.execution_context import _set_current_context_ids
10
-
11
-
12
- class DummyException(Exception):
13
- pass
14
-
15
-
16
- app = fastapi.FastAPI()
17
-
18
-
19
- @app.get("/")
20
- def sync_index():
21
- return {"some_result": "foo"}
22
-
23
-
24
- @app.get("/error")
25
- def sync_error():
26
- raise DummyException()
27
-
28
-
29
- @app.post("/async_reading_body")
30
- async def async_index_reading_body(req: fastapi.Request):
31
- body = await req.body()
32
- return {"some_result": body}
33
-
34
-
35
- @app.get("/async_error")
36
- async def async_error():
37
- raise DummyException()
38
-
39
-
40
- @app.get("/streaming_response")
41
- async def streaming_response():
42
- from fastapi.responses import StreamingResponse
43
-
44
- async def stream_bytes():
45
- yield b"foo"
46
- yield b"bar"
47
-
48
- return StreamingResponse(stream_bytes())
49
-
50
-
51
- def _asgi_get_scope(path, method="GET"):
52
- return {
53
- "type": "http",
54
- "method": method,
55
- "path": path,
56
- "query_string": "",
57
- "headers": [],
58
- }
59
-
60
-
61
- class MockIOManager:
62
- class get_data_in:
63
- @staticmethod
64
- async def aio(_function_call_id):
65
- yield {"type": "http.request", "body": b"some_body"}
66
- await asyncio.sleep(10)
67
-
68
-
69
- @pytest.mark.asyncio
70
- @pytest.mark.timeout(1)
71
- async def test_success():
72
- mock_manager = MockIOManager()
73
- _set_current_context_ids("in-123", "fc-123")
74
- wrapped_app = asgi_app_wrapper(app, mock_manager)
75
- asgi_scope = _asgi_get_scope("/")
76
- outputs = [output async for output in wrapped_app(asgi_scope)]
77
- assert len(outputs) == 2
78
- before_body = outputs[0]
79
- assert before_body["status"] == 200
80
- assert before_body["type"] == "http.response.start"
81
- body = outputs[1]
82
- assert body["body"] == b'{"some_result":"foo"}'
83
- assert body["type"] == "http.response.body"
84
-
85
-
86
- @pytest.mark.asyncio
87
- @pytest.mark.parametrize("endpoint_url", ["/error", "/async_error"])
88
- @pytest.mark.timeout(1)
89
- async def test_endpoint_exception(endpoint_url):
90
- mock_manager = MockIOManager()
91
- _set_current_context_ids("in-123", "fc-123")
92
- wrapped_app = asgi_app_wrapper(app, mock_manager)
93
- asgi_scope = _asgi_get_scope(endpoint_url)
94
- outputs = []
95
-
96
- with pytest.raises(DummyException):
97
- async for output in wrapped_app(asgi_scope):
98
- outputs.append(output)
99
-
100
- assert len(outputs) == 2
101
- before_body = outputs[0]
102
- assert before_body["status"] == 500
103
- assert before_body["type"] == "http.response.start"
104
- body = outputs[1]
105
- assert body["body"] == b"Internal Server Error"
106
- assert body["type"] == "http.response.body"
107
-
108
-
109
- class BrokenIOManager:
110
- class get_data_in:
111
- @staticmethod
112
- async def aio(_function_call_id):
113
- raise DummyException("error while fetching data")
114
- yield # noqa (makes this a generator)
115
-
116
-
117
- @pytest.mark.asyncio
118
- @pytest.mark.timeout(1)
119
- async def test_broken_io_unused(caplog):
120
- # if IO channel breaks, but the endpoint doesn't actually use
121
- # any of the body data, it should be allowed to output its data
122
- # and not raise an exception - but print a warning since it's unexpected
123
- mock_manager = BrokenIOManager()
124
- _set_current_context_ids("in-123", "fc-123")
125
- wrapped_app = asgi_app_wrapper(app, mock_manager)
126
- asgi_scope = _asgi_get_scope("/")
127
- outputs = []
128
-
129
- async for output in wrapped_app(asgi_scope):
130
- outputs.append(output)
131
-
132
- assert len(outputs) == 2
133
- assert outputs[0]["status"] == 200
134
- assert outputs[1]["body"] == b'{"some_result":"foo"}'
135
- assert "Internal error" in caplog.text
136
- assert "DummyException: error while fetching data" in caplog.text
137
-
138
-
139
- @pytest.mark.asyncio
140
- @pytest.mark.timeout(10)
141
- async def test_broken_io_used():
142
- mock_manager = BrokenIOManager()
143
- _set_current_context_ids("in-123", "fc-123")
144
- wrapped_app = asgi_app_wrapper(app, mock_manager)
145
- asgi_scope = _asgi_get_scope("/async_reading_body", "POST")
146
- outputs = []
147
- with pytest.raises(ClientDisconnect):
148
- async for output in wrapped_app(asgi_scope):
149
- outputs.append(output)
150
-
151
- assert len(outputs) == 2
152
- assert outputs[0]["status"] == 500
153
-
154
-
155
- class SlowIOManager:
156
- class get_data_in:
157
- @staticmethod
158
- async def aio(_function_call_id):
159
- await asyncio.sleep(5)
160
- yield # makes this an async generator
161
-
162
-
163
- @pytest.mark.asyncio
164
- @pytest.mark.timeout(2)
165
- async def test_first_message_timeout(monkeypatch):
166
- monkeypatch.setattr("modal._asgi.FIRST_MESSAGE_TIMEOUT_SECONDS", 0.1) # simulate timeout
167
- _set_current_context_ids("in-123", "fc-123")
168
- wrapped_app = asgi_app_wrapper(app, SlowIOManager())
169
- asgi_scope = _asgi_get_scope("/async_reading_body", "POST")
170
- outputs = []
171
- with pytest.raises(ClientDisconnect):
172
- async for output in wrapped_app(asgi_scope):
173
- outputs.append(output)
174
-
175
- assert outputs[0]["status"] == 502
176
- assert b"Missing request" in outputs[1]["body"]
177
-
178
-
179
- @pytest.mark.asyncio
180
- async def test_cancellation_cleanup(caplog):
181
- # this test mostly exists to get some coverage on the cancellation/error paths and ensure nothing unexpected happens there
182
- _set_current_context_ids("in-123", "fc-123")
183
- wrapped_app = asgi_app_wrapper(app, SlowIOManager())
184
- asgi_scope = _asgi_get_scope("/async_reading_body", "POST")
185
- outputs = []
186
-
187
- async def app_runner():
188
- async for output in wrapped_app(asgi_scope):
189
- outputs.append(output)
190
-
191
- app_runner_task = asyncio.create_task(app_runner())
192
- await asyncio.sleep(0.1) # let it get started
193
- app_runner_task.cancel()
194
- await asyncio.sleep(0.1) # let it shut down
195
- assert len(outputs) == 0
196
- assert caplog.text == "" # make sure there are no junk traces about dangling tasks etc.
197
-
198
-
199
- @pytest.mark.asyncio
200
- async def test_streaming_response():
201
- _set_current_context_ids("in-123", "fc-123")
202
- wrapped_app = asgi_app_wrapper(app, SlowIOManager())
203
- asgi_scope = _asgi_get_scope("/streaming_response", "GET")
204
- outputs = []
205
- async for output in wrapped_app(asgi_scope):
206
- outputs.append(output)
207
- assert outputs == [
208
- {"headers": [], "status": 200, "type": "http.response.start"},
209
- {"body": b"foo", "more_body": True, "type": "http.response.body"},
210
- {"body": b"bar", "more_body": True, "type": "http.response.body"},
211
- {"body": b"", "more_body": False, "type": "http.response.body"},
212
- ]
213
-
214
-
215
- class StreamingIOManager:
216
- class get_data_in:
217
- @staticmethod
218
- async def aio(_function_call_id):
219
- yield {"type": "http.request", "body": b"foo", "more_body": True}
220
- yield {"type": "http.request", "body": b"bar", "more_body": True}
221
- yield {"type": "http.request", "body": b"baz", "more_body": False}
222
- yield {"type": "http.request", "body": b"this should not be read", "more_body": False}
223
-
224
-
225
- @pytest.mark.asyncio
226
- async def test_streaming_body():
227
- _set_current_context_ids("in-123", "fc-123")
228
-
229
- wrapped_app = asgi_app_wrapper(app, StreamingIOManager())
230
- asgi_scope = _asgi_get_scope("/async_reading_body", "POST")
231
- outputs = []
232
- async for output in wrapped_app(asgi_scope):
233
- outputs.append(output)
234
- assert outputs[1] == {"type": "http.response.body", "body": b'{"some_result":"foobarbaz"}'}
test/token_flow_test.py DELETED
@@ -1,18 +0,0 @@
1
- # Copyright Modal Labs 2023
2
- import pytest
3
-
4
- import aiohttp
5
-
6
- from modal.token_flow import TokenFlow
7
-
8
-
9
- @pytest.mark.asyncio
10
- async def test_token_flow_server(servicer, client):
11
- tf = TokenFlow(client)
12
- async with tf.start() as (token_flow_id, _, _):
13
- # Make a request against the local web server and make sure it validates
14
- localhost_url = f"http://localhost:{servicer.token_flow_localhost_port}"
15
- async with aiohttp.ClientSession() as session:
16
- async with session.get(localhost_url) as resp:
17
- text = await resp.text()
18
- assert text == token_flow_id