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/cli_test.py DELETED
@@ -1,659 +0,0 @@
1
- # Copyright Modal Labs 2022-2023
2
- import asyncio
3
- import contextlib
4
- import json
5
- import os
6
- import pytest
7
- import re
8
- import subprocess
9
- import sys
10
- import tempfile
11
- import traceback
12
- from typing import List, Optional
13
- from unittest import mock
14
-
15
- import click
16
- import click.testing
17
- import pytest_asyncio
18
- import toml
19
-
20
- from modal import Client
21
- from modal.cli.entry_point import entrypoint_cli
22
- from modal_proto import api_pb2
23
-
24
- from .supports.skip import skip_windows
25
-
26
- dummy_app_file = """
27
- import modal
28
-
29
- import other_module
30
-
31
- stub = modal.Stub("my_app")
32
-
33
- # Sanity check that the module is imported properly
34
- import sys
35
- mod = sys.modules[__name__]
36
- assert mod.stub == stub
37
- """
38
-
39
- dummy_other_module_file = "x = 42"
40
-
41
-
42
- @pytest_asyncio.fixture
43
- async def set_env_client(client):
44
- try:
45
- Client.set_env_client(client)
46
- yield
47
- finally:
48
- Client.set_env_client(None)
49
-
50
-
51
- def _run(args: List[str], expected_exit_code: int = 0, expected_stderr: Optional[str] = ""):
52
- runner = click.testing.CliRunner(mix_stderr=False)
53
- with mock.patch.object(sys, "argv", args):
54
- res = runner.invoke(entrypoint_cli, args)
55
- if res.exit_code != expected_exit_code:
56
- print("stdout:", repr(res.stdout))
57
- print("stderr:", repr(res.stderr))
58
- traceback.print_tb(res.exc_info[2])
59
- print(res.exception, file=sys.stderr)
60
- assert res.exit_code == expected_exit_code
61
- if expected_stderr is not None:
62
- assert res.stderr == expected_stderr
63
- return res
64
-
65
-
66
- def test_app_deploy_success(servicer, mock_dir, set_env_client):
67
- with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
68
- # Deploy as a script in cwd
69
- _run(["deploy", "myapp.py"])
70
-
71
- # Deploy as a module
72
- _run(["deploy", "myapp"])
73
-
74
- # Deploy as a script with an absolute path
75
- _run(["deploy", os.path.abspath("myapp.py")])
76
-
77
- assert "my_app" in servicer.deployed_apps
78
-
79
-
80
- def test_app_deploy_with_name(servicer, mock_dir, set_env_client):
81
- with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
82
- _run(["deploy", "myapp.py", "--name", "my_app_foo"])
83
-
84
- assert "my_app_foo" in servicer.deployed_apps
85
-
86
-
87
- def test_secret_create(servicer, set_env_client):
88
- # fail without any keys
89
- _run(["secret", "create", "foo"], 2, None)
90
-
91
- _run(["secret", "create", "foo", "bar=baz"])
92
- assert len(servicer.secrets) == 1
93
-
94
- # Creating the same one again should fail
95
- _run(["secret", "create", "foo", "bar=baz"], expected_exit_code=1)
96
-
97
- # But it should succeed with --force
98
- _run(["secret", "create", "foo", "bar=baz", "--force"])
99
-
100
-
101
- def test_secret_list(servicer, set_env_client):
102
- res = _run(["secret", "list"])
103
- assert "dummy-secret-0" not in res.stdout
104
-
105
- _run(["secret", "create", "foo", "bar=baz"])
106
- _run(["secret", "create", "bar", "baz=buz"])
107
- _run(["secret", "create", "eric", "baz=bu 123z=b\n\t\r #(Q)JO5️⃣5️⃣😤WMLE🔧:GWam "])
108
-
109
- res = _run(["secret", "list"])
110
- assert "dummy-secret-0" in res.stdout
111
- assert "dummy-secret-1" in res.stdout
112
- assert "dummy-secret-2" in res.stdout
113
- assert "dummy-secret-3" not in res.stdout
114
-
115
-
116
- def test_app_token_new(servicer, set_env_client, server_url_env, modal_config):
117
- with modal_config() as config_file_path:
118
- _run(["token", "new", "--profile", "_test"])
119
- assert "_test" in toml.load(config_file_path)
120
-
121
-
122
- def test_app_setup(servicer, set_env_client, server_url_env, modal_config):
123
- with modal_config() as config_file_path:
124
- _run(["setup", "--profile", "_test"])
125
- assert "_test" in toml.load(config_file_path)
126
-
127
-
128
- def test_run(servicer, set_env_client, test_dir):
129
- stub_file = test_dir / "supports" / "app_run_tests" / "default_stub.py"
130
- _run(["run", stub_file.as_posix()])
131
- _run(["run", stub_file.as_posix() + "::stub"])
132
- _run(["run", stub_file.as_posix() + "::foo"])
133
- _run(["run", stub_file.as_posix() + "::bar"], expected_exit_code=1, expected_stderr=None)
134
- file_with_entrypoint = test_dir / "supports" / "app_run_tests" / "local_entrypoint.py"
135
- _run(["run", file_with_entrypoint.as_posix()])
136
- _run(["run", file_with_entrypoint.as_posix() + "::main"])
137
- _run(["run", file_with_entrypoint.as_posix() + "::stub.main"])
138
-
139
-
140
- def test_run_async(servicer, set_env_client, test_dir):
141
- sync_fn = test_dir / "supports" / "app_run_tests" / "local_entrypoint.py"
142
- res = _run(["run", sync_fn.as_posix()])
143
- assert "called locally" in res.stdout
144
-
145
- async_fn = test_dir / "supports" / "app_run_tests" / "local_entrypoint_async.py"
146
- res = _run(["run", async_fn.as_posix()])
147
- assert "called locally (async)" in res.stdout
148
-
149
-
150
- def test_run_generator(servicer, set_env_client, test_dir):
151
- stub_file = test_dir / "supports" / "app_run_tests" / "generator.py"
152
- result = _run(["run", stub_file.as_posix()], expected_exit_code=1)
153
- assert "generator functions" in str(result.exception)
154
-
155
-
156
- def test_help_message_unspecified_function(servicer, set_env_client, test_dir):
157
- stub_file = test_dir / "supports" / "app_run_tests" / "stub_with_multiple_functions.py"
158
- result = _run(["run", stub_file.as_posix()], expected_exit_code=2, expected_stderr=None)
159
-
160
- # should suggest available functions on the stub:
161
- assert "foo" in result.stderr
162
- assert "bar" in result.stderr
163
-
164
- result = _run(
165
- ["run", stub_file.as_posix(), "--help"], expected_exit_code=2, expected_stderr=None
166
- ) # TODO: help should not return non-zero
167
- # help should also available functions on the stub:
168
- assert "foo" in result.stderr
169
- assert "bar" in result.stderr
170
-
171
-
172
- def test_run_states(servicer, set_env_client, test_dir):
173
- stub_file = test_dir / "supports" / "app_run_tests" / "default_stub.py"
174
- _run(["run", stub_file.as_posix()])
175
- assert servicer.app_state_history["ap-1"] == [
176
- api_pb2.APP_STATE_INITIALIZING,
177
- api_pb2.APP_STATE_EPHEMERAL,
178
- api_pb2.APP_STATE_STOPPED,
179
- ]
180
-
181
-
182
- def test_run_detach(servicer, set_env_client, test_dir):
183
- stub_file = test_dir / "supports" / "app_run_tests" / "default_stub.py"
184
- _run(["run", "--detach", stub_file.as_posix()])
185
- assert servicer.app_state_history["ap-1"] == [api_pb2.APP_STATE_INITIALIZING, api_pb2.APP_STATE_DETACHED]
186
-
187
-
188
- def test_run_quiet(servicer, set_env_client, test_dir):
189
- stub_file = test_dir / "supports" / "app_run_tests" / "default_stub.py"
190
- # Just tests that the command runs without error for now (tests end up defaulting to `show_progress=False` anyway,
191
- # without a TTY).
192
- _run(["run", "--quiet", stub_file.as_posix()])
193
-
194
-
195
- def test_deploy(servicer, set_env_client, test_dir):
196
- stub_file = test_dir / "supports" / "app_run_tests" / "default_stub.py"
197
- _run(["deploy", "--name=deployment_name", stub_file.as_posix()])
198
- assert servicer.app_state_history["ap-1"] == [api_pb2.APP_STATE_INITIALIZING, api_pb2.APP_STATE_DEPLOYED]
199
-
200
-
201
- def test_run_custom_stub(servicer, set_env_client, test_dir):
202
- stub_file = test_dir / "supports" / "app_run_tests" / "custom_stub.py"
203
- res = _run(["run", stub_file.as_posix() + "::stub"], expected_exit_code=1, expected_stderr=None)
204
- assert "Could not find" in res.stderr
205
- res = _run(["run", stub_file.as_posix() + "::stub.foo"], expected_exit_code=1, expected_stderr=None)
206
- assert "Could not find" in res.stderr
207
-
208
- _run(["run", stub_file.as_posix() + "::foo"])
209
-
210
-
211
- def test_run_aiofunc(servicer, set_env_client, test_dir):
212
- stub_file = test_dir / "supports" / "app_run_tests" / "async_stub.py"
213
- _run(["run", stub_file.as_posix()])
214
- assert len(servicer.client_calls) == 1
215
-
216
-
217
- def test_run_local_entrypoint(servicer, set_env_client, test_dir):
218
- stub_file = test_dir / "supports" / "app_run_tests" / "local_entrypoint.py"
219
-
220
- res = _run(["run", stub_file.as_posix() + "::stub.main"]) # explicit name
221
- assert "called locally" in res.stdout
222
- assert len(servicer.client_calls) == 2
223
-
224
- res = _run(["run", stub_file.as_posix()]) # only one entry-point, no name needed
225
- assert "called locally" in res.stdout
226
- assert len(servicer.client_calls) == 4
227
-
228
-
229
- def test_run_local_entrypoint_invalid_with_stub_run(servicer, set_env_client, test_dir):
230
- stub_file = test_dir / "supports" / "app_run_tests" / "local_entrypoint_invalid.py"
231
-
232
- res = _run(["run", stub_file.as_posix()], expected_exit_code=1)
233
- assert "app is already running" in str(res.exception.__cause__).lower()
234
- assert "unreachable" not in res.stdout
235
- assert len(servicer.client_calls) == 0
236
-
237
-
238
- def test_run_parse_args_entrypoint(servicer, set_env_client, test_dir):
239
- stub_file = test_dir / "supports" / "app_run_tests" / "cli_args.py"
240
- res = _run(["run", stub_file.as_posix()], expected_exit_code=2, expected_stderr=None)
241
- assert "You need to specify a Modal function or local entrypoint to run" in res.stderr
242
-
243
- valid_call_args = [
244
- (
245
- [
246
- "run",
247
- f"{stub_file.as_posix()}::stub.dt_arg",
248
- "--dt",
249
- "2022-10-31",
250
- ],
251
- "the day is 31",
252
- ),
253
- (["run", f"{stub_file.as_posix()}::dt_arg", "--dt=2022-10-31"], "the day is 31"),
254
- (["run", f"{stub_file.as_posix()}::int_arg", "--i=200"], "200 <class 'int'>"),
255
- (["run", f"{stub_file.as_posix()}::default_arg"], "10 <class 'int'>"),
256
- (["run", f"{stub_file.as_posix()}::unannotated_arg", "--i=2022-10-31"], "'2022-10-31' <class 'str'>"),
257
- (["run", f"{stub_file.as_posix()}::unannotated_default_arg"], "10 <class 'int'>"),
258
- (["run", f"{stub_file.as_posix()}::optional_arg", "--i=20"], "20 <class 'int'>"),
259
- (["run", f"{stub_file.as_posix()}::optional_arg"], "None <class 'NoneType'>"),
260
- (["run", f"{stub_file.as_posix()}::optional_arg_postponed"], "None <class 'NoneType'>"),
261
- ]
262
- if sys.version_info >= (3, 10):
263
- valid_call_args.extend(
264
- [
265
- (["run", f"{stub_file.as_posix()}::optional_arg_pep604", "--i=20"], "20 <class 'int'>"),
266
- (["run", f"{stub_file.as_posix()}::optional_arg_pep604"], "None <class 'NoneType'>"),
267
- ]
268
- )
269
- for args, expected in valid_call_args:
270
- res = _run(args)
271
- assert expected in res.stdout
272
- assert len(servicer.client_calls) == 0
273
-
274
- if sys.version_info >= (3, 10):
275
- res = _run(["run", f"{stub_file.as_posix()}::unparseable_annot", "--i=20"], expected_exit_code=1)
276
- assert "Parameter `i` has unparseable annotation: typing.Union[int, str]" in str(res.exception)
277
-
278
- if sys.version_info <= (3, 10):
279
- res = _run(["run", f"{stub_file.as_posix()}::optional_arg_pep604"], expected_exit_code=1)
280
- assert "Unable to generate command line interface for app entrypoint." in str(res.exception)
281
-
282
-
283
- def test_run_parse_args_function(servicer, set_env_client, test_dir):
284
- stub_file = test_dir / "supports" / "app_run_tests" / "cli_args.py"
285
- res = _run(["run", stub_file.as_posix()], expected_exit_code=2, expected_stderr=None)
286
- assert "You need to specify a Modal function or local entrypoint to run" in res.stderr
287
-
288
- # HACK: all the tests use the same arg, i.
289
- @servicer.function_body
290
- def print_type(i):
291
- print(repr(i), type(i))
292
-
293
- valid_call_args = [
294
- (["run", f"{stub_file.as_posix()}::int_arg_fn", "--i=200"], "200 <class 'int'>"),
295
- (["run", f"{stub_file.as_posix()}::ALifecycle.some_method", "--i=hello"], "'hello' <class 'str'>"),
296
- (["run", f"{stub_file.as_posix()}::ALifecycle.some_method_int", "--i=42"], "42 <class 'int'>"),
297
- (["run", f"{stub_file.as_posix()}::optional_arg_fn"], "None <class 'NoneType'>"),
298
- ]
299
- for args, expected in valid_call_args:
300
- res = _run(args)
301
- assert expected in res.stdout
302
-
303
-
304
- def test_run_user_script_exception(servicer, set_env_client, test_dir):
305
- stub_file = test_dir / "supports" / "app_run_tests" / "raises_error.py"
306
- res = _run(["run", stub_file.as_posix()], expected_exit_code=1)
307
- assert res.exc_info[1].user_source == str(stub_file.resolve())
308
-
309
-
310
- @pytest.fixture
311
- def fresh_main_thread_assertion_module(test_dir):
312
- modules_to_unload = [n for n in sys.modules.keys() if "main_thread_assertion" in n]
313
- assert len(modules_to_unload) <= 1
314
- for mod in modules_to_unload:
315
- sys.modules.pop(mod)
316
- yield test_dir / "supports" / "app_run_tests" / "main_thread_assertion.py"
317
-
318
-
319
- def test_no_user_code_in_synchronicity_run(servicer, set_env_client, test_dir, fresh_main_thread_assertion_module):
320
- pytest._did_load_main_thread_assertion = False # type: ignore
321
- _run(["run", fresh_main_thread_assertion_module.as_posix()])
322
- assert pytest._did_load_main_thread_assertion # type: ignore
323
- print()
324
-
325
-
326
- def test_no_user_code_in_synchronicity_deploy(servicer, set_env_client, test_dir, fresh_main_thread_assertion_module):
327
- pytest._did_load_main_thread_assertion = False # type: ignore
328
- _run(["deploy", "--name", "foo", fresh_main_thread_assertion_module.as_posix()])
329
- assert pytest._did_load_main_thread_assertion # type: ignore
330
-
331
-
332
- def test_serve(servicer, set_env_client, server_url_env, test_dir):
333
- stub_file = test_dir / "supports" / "app_run_tests" / "webhook.py"
334
- _run(["serve", stub_file.as_posix(), "--timeout", "3"], expected_exit_code=0)
335
-
336
-
337
- @pytest.fixture
338
- def mock_shell_pty():
339
- def mock_get_pty_info(shell: bool) -> api_pb2.PTYInfo:
340
- rows, cols = (64, 128)
341
- return api_pb2.PTYInfo(
342
- enabled=True,
343
- winsz_rows=rows,
344
- winsz_cols=cols,
345
- env_term=os.environ.get("TERM"),
346
- env_colorterm=os.environ.get("COLORTERM"),
347
- env_term_program=os.environ.get("TERM_PROGRAM"),
348
- pty_type=api_pb2.PTYInfo.PTY_TYPE_SHELL,
349
- )
350
-
351
- captured_out = []
352
- fake_stdin = [b"echo foo\n", b"exit\n"]
353
-
354
- async def write_to_fd(fd: int, data: bytes):
355
- nonlocal captured_out
356
- captured_out.append((fd, data))
357
-
358
- @contextlib.asynccontextmanager
359
- async def fake_stream_from_stdin(handle_input, use_raw_terminal=False):
360
- async def _write():
361
- message_index = 0
362
- while True:
363
- if message_index == len(fake_stdin):
364
- break
365
- data = fake_stdin[message_index]
366
- await handle_input(data, message_index)
367
- message_index += 1
368
-
369
- write_task = asyncio.create_task(_write())
370
- yield
371
- write_task.cancel()
372
-
373
- with mock.patch("rich.console.Console.is_terminal", True), mock.patch(
374
- "modal._pty.get_pty_info", mock_get_pty_info
375
- ), mock.patch("modal.runner.get_pty_info", mock_get_pty_info), mock.patch(
376
- "modal._utils.shell_utils.stream_from_stdin", fake_stream_from_stdin
377
- ), mock.patch("modal._sandbox_shell.write_to_fd", write_to_fd):
378
- yield fake_stdin, captured_out
379
-
380
-
381
- @skip_windows("modal shell is not supported on Windows.")
382
- def test_shell(servicer, set_env_client, test_dir, mock_shell_pty):
383
- stub_file = test_dir / "supports" / "app_run_tests" / "default_stub.py"
384
- webhook_stub_file = test_dir / "supports" / "app_run_tests" / "webhook.py"
385
- fake_stdin, captured_out = mock_shell_pty
386
-
387
- fake_stdin.clear()
388
- fake_stdin.extend([b'echo "Hello World"\n', b"exit\n"])
389
-
390
- # Function is explicitly specified
391
- _run(["shell", stub_file.as_posix() + "::foo"])
392
-
393
- shell_prompt = servicer.sandbox_shell_prompt.encode("utf-8")
394
-
395
- # first captured message is the empty message the mock server sends
396
- assert captured_out == [(1, shell_prompt), (1, b"Hello World\n")]
397
- captured_out.clear()
398
-
399
- # Function is explicitly specified
400
- _run(["shell", webhook_stub_file.as_posix() + "::foo"])
401
- assert captured_out == [(1, shell_prompt), (1, b"Hello World\n")]
402
- captured_out.clear()
403
-
404
- # Function must be inferred
405
- _run(["shell", webhook_stub_file.as_posix()])
406
- assert captured_out == [(1, shell_prompt), (1, b"Hello World\n")]
407
- captured_out.clear()
408
-
409
-
410
- @skip_windows("modal shell is not supported on Windows.")
411
- def test_shell_cmd(servicer, set_env_client, test_dir, mock_shell_pty):
412
- stub_file = test_dir / "supports" / "app_run_tests" / "default_stub.py"
413
- _, captured_out = mock_shell_pty
414
- _run(["shell", "--cmd", "pwd", stub_file.as_posix() + "::foo"])
415
- expected_output = subprocess.run(["pwd"], capture_output=True, check=True).stdout
416
- shell_prompt = servicer.sandbox_shell_prompt.encode("utf-8")
417
- assert captured_out == [(1, shell_prompt), (1, expected_output)]
418
-
419
-
420
- def test_app_descriptions(servicer, server_url_env, test_dir):
421
- stub_file = test_dir / "supports" / "app_run_tests" / "prints_desc_stub.py"
422
- _run(["run", "--detach", stub_file.as_posix() + "::foo"])
423
-
424
- create_reqs = [s for s in servicer.requests if isinstance(s, api_pb2.AppCreateRequest)]
425
- assert len(create_reqs) == 1
426
- assert create_reqs[0].app_state == api_pb2.APP_STATE_DETACHED
427
- description = create_reqs[0].description
428
- assert "prints_desc_stub.py::foo" in description
429
- assert "run --detach " not in description
430
-
431
- _run(["serve", "--timeout", "0.0", stub_file.as_posix()])
432
- create_reqs = [s for s in servicer.requests if isinstance(s, api_pb2.AppCreateRequest)]
433
- assert len(create_reqs) == 2
434
- description = create_reqs[1].description
435
- assert "prints_desc_stub.py" in description
436
- assert "serve" not in description
437
- assert "--timeout 0.0" not in description
438
-
439
-
440
- def test_logs(servicer, server_url_env):
441
- async def app_done(self, stream):
442
- await stream.recv_message()
443
- log = api_pb2.TaskLogs(data="hello\n", file_descriptor=api_pb2.FILE_DESCRIPTOR_STDOUT)
444
- await stream.send_message(api_pb2.TaskLogsBatch(entry_id="1", items=[log]))
445
- await stream.send_message(api_pb2.TaskLogsBatch(app_done=True))
446
-
447
- with servicer.intercept() as ctx:
448
- ctx.set_responder("AppGetLogs", app_done)
449
- res = _run(["app", "logs", "ap-123"], expected_exit_code=0)
450
- assert res.stdout == "hello\n"
451
-
452
-
453
- def test_nfs_get(set_env_client):
454
- nfs_name = "my-shared-nfs"
455
- _run(["nfs", "create", nfs_name])
456
- with tempfile.TemporaryDirectory() as tmpdir:
457
- upload_path = os.path.join(tmpdir, "upload.txt")
458
- with open(upload_path, "w") as f:
459
- f.write("foo bar baz")
460
- f.flush()
461
- _run(["nfs", "put", nfs_name, upload_path, "test.txt"])
462
-
463
- _run(["nfs", "get", nfs_name, "test.txt", tmpdir])
464
- with open(os.path.join(tmpdir, "test.txt"), "r") as f:
465
- assert f.read() == "foo bar baz"
466
-
467
-
468
- def test_volume_cli(set_env_client):
469
- _run(["volume", "--help"])
470
-
471
-
472
- def test_volume_get(servicer, set_env_client):
473
- vol_name = "my-test-vol"
474
- _run(["volume", "create", vol_name])
475
- file_path = b"test.txt"
476
- file_contents = b"foo bar baz"
477
- with tempfile.TemporaryDirectory() as tmpdir:
478
- upload_path = os.path.join(tmpdir, "upload.txt")
479
- with open(upload_path, "wb") as f:
480
- f.write(file_contents)
481
- f.flush()
482
- _run(["volume", "put", vol_name, upload_path, file_path.decode()])
483
-
484
- _run(["volume", "get", vol_name, file_path.decode(), tmpdir])
485
- with open(os.path.join(tmpdir, file_path.decode()), "rb") as f:
486
- assert f.read() == file_contents
487
-
488
- with tempfile.TemporaryDirectory() as tmpdir2:
489
- _run(["volume", "get", vol_name, "**", tmpdir2])
490
- with open(os.path.join(tmpdir2, file_path.decode()), "rb") as f:
491
- assert f.read() == file_contents
492
-
493
-
494
- def test_volume_put_force(servicer, set_env_client):
495
- vol_name = "my-test-vol"
496
- _run(["volume", "create", vol_name])
497
- file_path = "test.txt"
498
- file_contents = b"foo bar baz"
499
- with tempfile.TemporaryDirectory() as tmpdir:
500
- upload_path = os.path.join(tmpdir, "upload.txt")
501
- with open(upload_path, "wb") as f:
502
- f.write(file_contents)
503
- f.flush()
504
-
505
- # File upload
506
- _run(["volume", "put", vol_name, upload_path, file_path]) # Seed the volume
507
- with servicer.intercept() as ctx:
508
- _run(["volume", "put", vol_name, upload_path, file_path], expected_exit_code=2, expected_stderr=None)
509
- assert ctx.pop_request("VolumePutFiles").disallow_overwrite_existing_files
510
-
511
- _run(["volume", "put", vol_name, upload_path, file_path, "--force"])
512
- assert not ctx.pop_request("VolumePutFiles").disallow_overwrite_existing_files
513
-
514
- # Dir upload
515
- _run(["volume", "put", vol_name, tmpdir]) # Seed the volume
516
- with servicer.intercept() as ctx:
517
- _run(["volume", "put", vol_name, tmpdir], expected_exit_code=2, expected_stderr=None)
518
- assert ctx.pop_request("VolumePutFiles").disallow_overwrite_existing_files
519
-
520
- _run(["volume", "put", vol_name, tmpdir, "--force"])
521
- assert not ctx.pop_request("VolumePutFiles").disallow_overwrite_existing_files
522
-
523
-
524
- def test_volume_rm(servicer, set_env_client):
525
- vol_name = "my-test-vol"
526
- _run(["volume", "create", vol_name])
527
- file_path = b"test.txt"
528
- file_contents = b"foo bar baz"
529
- with tempfile.TemporaryDirectory() as tmpdir:
530
- upload_path = os.path.join(tmpdir, "upload.txt")
531
- with open(upload_path, "wb") as f:
532
- f.write(file_contents)
533
- f.flush()
534
- _run(["volume", "put", vol_name, upload_path, file_path.decode()])
535
-
536
- _run(["volume", "get", vol_name, file_path.decode(), tmpdir])
537
- with open(os.path.join(tmpdir, file_path.decode()), "rb") as f:
538
- assert f.read() == file_contents
539
-
540
- _run(["volume", "rm", vol_name, file_path.decode()])
541
- _run(["volume", "get", vol_name, file_path.decode()], expected_exit_code=2, expected_stderr=None)
542
-
543
-
544
- @pytest.mark.parametrize("command", [["run"], ["deploy"], ["serve", "--timeout=1"], ["shell"]])
545
- @pytest.mark.usefixtures("set_env_client", "mock_shell_pty")
546
- @skip_windows("modal shell is not supported on Windows.")
547
- def test_environment_flag(test_dir, servicer, command):
548
- @servicer.function_body
549
- def nothing(
550
- arg=None,
551
- ): # hacky - compatible with both argless modal run and interactive mode which always sends an arg...
552
- pass
553
-
554
- stub_file = test_dir / "supports" / "app_run_tests" / "app_with_lookups.py"
555
- with servicer.intercept() as ctx:
556
- ctx.add_response(
557
- "MountGetOrCreate",
558
- api_pb2.MountGetOrCreateResponse(
559
- mount_id="mo-123",
560
- handle_metadata=api_pb2.MountHandleMetadata(content_checksum_sha256_hex="abc123"),
561
- ),
562
- request_filter=lambda req: req.deployment_name.startswith("modal-client-mount")
563
- and req.namespace == api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
564
- ) # built-in client lookup
565
- ctx.add_response(
566
- "SharedVolumeGetOrCreate",
567
- api_pb2.SharedVolumeGetOrCreateResponse(shared_volume_id="sv-123"),
568
- request_filter=lambda req: req.deployment_name == "volume_app" and req.environment_name == "staging",
569
- )
570
- _run(command + ["--env=staging", str(stub_file)])
571
-
572
- app_create: api_pb2.AppCreateRequest = ctx.pop_request("AppCreate")
573
- assert app_create.environment_name == "staging"
574
-
575
-
576
- @pytest.mark.parametrize("command", [["run"], ["deploy"], ["serve", "--timeout=1"], ["shell"]])
577
- @pytest.mark.usefixtures("set_env_client", "mock_shell_pty")
578
- @skip_windows("modal shell is not supported on Windows.")
579
- def test_environment_noflag(test_dir, servicer, command, monkeypatch):
580
- monkeypatch.setenv("MODAL_ENVIRONMENT", "some_weird_default_env")
581
-
582
- @servicer.function_body
583
- def nothing(
584
- arg=None,
585
- ): # hacky - compatible with both argless modal run and interactive mode which always sends an arg...
586
- pass
587
-
588
- stub_file = test_dir / "supports" / "app_run_tests" / "app_with_lookups.py"
589
- with servicer.intercept() as ctx:
590
- ctx.add_response(
591
- "MountGetOrCreate",
592
- api_pb2.MountGetOrCreateResponse(
593
- mount_id="mo-123",
594
- handle_metadata=api_pb2.MountHandleMetadata(content_checksum_sha256_hex="abc123"),
595
- ),
596
- request_filter=lambda req: req.deployment_name.startswith("modal-client-mount")
597
- and req.namespace == api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
598
- ) # built-in client lookup
599
- ctx.add_response(
600
- "SharedVolumeGetOrCreate",
601
- api_pb2.SharedVolumeGetOrCreateResponse(shared_volume_id="sv-123"),
602
- request_filter=lambda req: req.deployment_name == "volume_app"
603
- and req.environment_name == "some_weird_default_env",
604
- )
605
- _run(command + [str(stub_file)])
606
-
607
- app_create: api_pb2.AppCreateRequest = ctx.pop_request("AppCreate")
608
- assert app_create.environment_name == "some_weird_default_env"
609
-
610
-
611
- def test_cls(servicer, set_env_client, test_dir):
612
- stub_file = test_dir / "supports" / "app_run_tests" / "cls.py"
613
-
614
- _run(["run", stub_file.as_posix(), "--x", "42", "--y", "1000"])
615
- _run(["run", f"{stub_file.as_posix()}::AParametrized.some_method", "--x", "42", "--y", "1000"])
616
-
617
-
618
- def test_profile_list(servicer, server_url_env, modal_config):
619
- config = """
620
- [test-profile]
621
- token_id = "ak-abc"
622
- token_secret = "as-xyz"
623
-
624
- [other-profile]
625
- token_id = "ak-123"
626
- token_secret = "as-789"
627
- active = true
628
- """
629
-
630
- with modal_config(config):
631
- res = _run(["profile", "list"])
632
- table_rows = res.stdout.split("\n")
633
- assert re.search("Profile .+ Workspace", table_rows[1])
634
- assert re.search("test-profile .+ test-username", table_rows[3])
635
- assert re.search("other-profile .+ test-username", table_rows[4])
636
-
637
- res = _run(["profile", "list", "--json"])
638
- json_data = json.loads(res.stdout)
639
- assert json_data[0]["name"] == "test-profile"
640
- assert json_data[0]["workspace"] == "test-username"
641
- assert json_data[1]["name"] == "other-profile"
642
- assert json_data[1]["workspace"] == "test-username"
643
-
644
- orig_env_token_id = os.environ.get("MODAL_TOKEN_ID")
645
- orig_env_token_secret = os.environ.get("MODAL_TOKEN_SECRET")
646
- os.environ["MODAL_TOKEN_ID"] = "ak-abc"
647
- os.environ["MODAL_TOKEN_SECRET"] = "as-xyz"
648
- try:
649
- res = _run(["profile", "list"])
650
- assert "Using test-username workspace based on environment variables" in res.stdout
651
- finally:
652
- if orig_env_token_id:
653
- os.environ["MODAL_TOKEN_ID"] = orig_env_token_id
654
- else:
655
- del os.environ["MODAL_TOKEN_ID"]
656
- if orig_env_token_secret:
657
- os.environ["MODAL_TOKEN_SECRET"] = orig_env_token_secret
658
- else:
659
- del os.environ["MODAL_TOKEN_SECRET"]