modal 0.62.115__py3-none-any.whl → 0.72.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modal/__init__.py +13 -9
- modal/__main__.py +41 -3
- modal/_clustered_functions.py +80 -0
- modal/_clustered_functions.pyi +22 -0
- modal/_container_entrypoint.py +402 -398
- modal/_ipython.py +3 -13
- modal/_location.py +17 -10
- modal/_output.py +243 -99
- modal/_pty.py +2 -2
- modal/_resolver.py +55 -60
- modal/_resources.py +26 -7
- modal/_runtime/__init__.py +1 -0
- modal/_runtime/asgi.py +519 -0
- modal/_runtime/container_io_manager.py +1025 -0
- modal/{execution_context.py → _runtime/execution_context.py} +11 -2
- modal/_runtime/telemetry.py +169 -0
- modal/_runtime/user_code_imports.py +356 -0
- modal/_serialization.py +123 -6
- modal/_traceback.py +47 -187
- modal/_tunnel.py +50 -14
- modal/_tunnel.pyi +19 -36
- modal/_utils/app_utils.py +3 -17
- modal/_utils/async_utils.py +386 -104
- modal/_utils/blob_utils.py +157 -186
- modal/_utils/bytes_io_segment_payload.py +97 -0
- modal/_utils/deprecation.py +89 -0
- modal/_utils/docker_utils.py +98 -0
- modal/_utils/function_utils.py +299 -98
- modal/_utils/grpc_testing.py +47 -34
- modal/_utils/grpc_utils.py +54 -21
- modal/_utils/hash_utils.py +51 -10
- modal/_utils/http_utils.py +39 -9
- modal/_utils/logger.py +2 -1
- modal/_utils/mount_utils.py +34 -16
- modal/_utils/name_utils.py +58 -0
- modal/_utils/package_utils.py +14 -1
- modal/_utils/pattern_utils.py +205 -0
- modal/_utils/rand_pb_testing.py +3 -3
- modal/_utils/shell_utils.py +15 -49
- modal/_vendor/a2wsgi_wsgi.py +62 -72
- modal/_vendor/cloudpickle.py +1 -1
- modal/_watcher.py +12 -10
- modal/app.py +561 -323
- modal/app.pyi +474 -262
- modal/call_graph.py +7 -6
- modal/cli/_download.py +22 -6
- modal/cli/_traceback.py +200 -0
- modal/cli/app.py +203 -42
- modal/cli/config.py +12 -5
- modal/cli/container.py +61 -13
- modal/cli/dict.py +128 -0
- modal/cli/entry_point.py +26 -13
- modal/cli/environment.py +40 -9
- modal/cli/import_refs.py +21 -48
- modal/cli/launch.py +28 -14
- modal/cli/network_file_system.py +57 -21
- modal/cli/profile.py +1 -1
- modal/cli/programs/run_jupyter.py +34 -9
- modal/cli/programs/vscode.py +58 -8
- modal/cli/queues.py +131 -0
- modal/cli/run.py +199 -96
- modal/cli/secret.py +5 -4
- modal/cli/token.py +7 -2
- modal/cli/utils.py +74 -8
- modal/cli/volume.py +97 -56
- modal/client.py +248 -144
- modal/client.pyi +156 -124
- modal/cloud_bucket_mount.py +43 -30
- modal/cloud_bucket_mount.pyi +32 -25
- modal/cls.py +528 -141
- modal/cls.pyi +189 -145
- modal/config.py +32 -15
- modal/container_process.py +177 -0
- modal/container_process.pyi +82 -0
- modal/dict.py +50 -54
- modal/dict.pyi +120 -164
- modal/environments.py +106 -5
- modal/environments.pyi +77 -25
- modal/exception.py +30 -43
- modal/experimental.py +62 -2
- modal/file_io.py +537 -0
- modal/file_io.pyi +235 -0
- modal/file_pattern_matcher.py +196 -0
- modal/functions.py +846 -428
- modal/functions.pyi +446 -387
- modal/gpu.py +57 -44
- modal/image.py +943 -417
- modal/image.pyi +584 -245
- modal/io_streams.py +434 -0
- modal/io_streams.pyi +122 -0
- modal/mount.py +223 -90
- modal/mount.pyi +241 -243
- modal/network_file_system.py +85 -86
- modal/network_file_system.pyi +151 -110
- modal/object.py +66 -36
- modal/object.pyi +166 -143
- modal/output.py +63 -0
- modal/parallel_map.py +73 -47
- modal/parallel_map.pyi +51 -63
- modal/partial_function.py +272 -107
- modal/partial_function.pyi +219 -120
- modal/proxy.py +15 -12
- modal/proxy.pyi +3 -8
- modal/queue.py +96 -72
- modal/queue.pyi +210 -135
- modal/requirements/2024.04.txt +2 -1
- modal/requirements/2024.10.txt +16 -0
- modal/requirements/README.md +21 -0
- modal/requirements/base-images.json +22 -0
- modal/retries.py +45 -4
- modal/runner.py +325 -203
- modal/runner.pyi +124 -110
- modal/running_app.py +27 -4
- modal/sandbox.py +509 -231
- modal/sandbox.pyi +396 -169
- modal/schedule.py +2 -2
- modal/scheduler_placement.py +20 -3
- modal/secret.py +41 -25
- modal/secret.pyi +62 -42
- modal/serving.py +39 -49
- modal/serving.pyi +37 -43
- modal/stream_type.py +15 -0
- modal/token_flow.py +5 -3
- modal/token_flow.pyi +37 -32
- modal/volume.py +123 -137
- modal/volume.pyi +228 -221
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/METADATA +5 -5
- modal-0.72.13.dist-info/RECORD +174 -0
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/top_level.txt +0 -1
- modal_docs/gen_reference_docs.py +3 -1
- modal_docs/mdmd/mdmd.py +0 -1
- modal_docs/mdmd/signatures.py +1 -2
- modal_global_objects/images/base_images.py +28 -0
- modal_global_objects/mounts/python_standalone.py +2 -2
- modal_proto/__init__.py +1 -1
- modal_proto/api.proto +1231 -531
- modal_proto/api_grpc.py +750 -430
- modal_proto/api_pb2.py +2102 -1176
- modal_proto/api_pb2.pyi +8859 -0
- modal_proto/api_pb2_grpc.py +1329 -675
- modal_proto/api_pb2_grpc.pyi +1416 -0
- modal_proto/modal_api_grpc.py +149 -0
- modal_proto/modal_options_grpc.py +3 -0
- modal_proto/options_pb2.pyi +20 -0
- modal_proto/options_pb2_grpc.pyi +7 -0
- modal_proto/py.typed +0 -0
- modal_version/__init__.py +1 -1
- modal_version/_version_generated.py +2 -2
- modal/_asgi.py +0 -370
- modal/_container_exec.py +0 -128
- modal/_container_io_manager.py +0 -646
- modal/_container_io_manager.pyi +0 -412
- modal/_sandbox_shell.py +0 -49
- modal/app_utils.py +0 -20
- modal/app_utils.pyi +0 -17
- modal/execution_context.pyi +0 -37
- modal/shared_volume.py +0 -23
- modal/shared_volume.pyi +0 -24
- modal-0.62.115.dist-info/RECORD +0 -207
- modal_global_objects/images/conda.py +0 -15
- modal_global_objects/images/debian_slim.py +0 -15
- modal_global_objects/images/micromamba.py +0 -15
- test/__init__.py +0 -1
- test/aio_test.py +0 -12
- test/async_utils_test.py +0 -279
- test/blob_test.py +0 -67
- test/cli_imports_test.py +0 -149
- test/cli_test.py +0 -674
- test/client_test.py +0 -203
- test/cloud_bucket_mount_test.py +0 -22
- test/cls_test.py +0 -636
- test/config_test.py +0 -149
- test/conftest.py +0 -1485
- test/container_app_test.py +0 -50
- test/container_test.py +0 -1405
- test/cpu_test.py +0 -23
- test/decorator_test.py +0 -85
- test/deprecation_test.py +0 -34
- test/dict_test.py +0 -51
- test/e2e_test.py +0 -68
- test/error_test.py +0 -7
- test/function_serialization_test.py +0 -32
- test/function_test.py +0 -791
- test/function_utils_test.py +0 -101
- test/gpu_test.py +0 -159
- test/grpc_utils_test.py +0 -82
- test/helpers.py +0 -47
- test/image_test.py +0 -814
- test/live_reload_test.py +0 -80
- test/lookup_test.py +0 -70
- test/mdmd_test.py +0 -329
- test/mount_test.py +0 -162
- test/mounted_files_test.py +0 -327
- test/network_file_system_test.py +0 -188
- test/notebook_test.py +0 -66
- test/object_test.py +0 -41
- test/package_utils_test.py +0 -25
- test/queue_test.py +0 -115
- test/resolver_test.py +0 -59
- test/retries_test.py +0 -67
- test/runner_test.py +0 -85
- test/sandbox_test.py +0 -191
- test/schedule_test.py +0 -15
- test/scheduler_placement_test.py +0 -57
- test/secret_test.py +0 -89
- test/serialization_test.py +0 -50
- test/stub_composition_test.py +0 -10
- test/stub_test.py +0 -361
- test/test_asgi_wrapper.py +0 -234
- test/token_flow_test.py +0 -18
- test/traceback_test.py +0 -135
- test/tunnel_test.py +0 -29
- test/utils_test.py +0 -88
- test/version_test.py +0 -14
- test/volume_test.py +0 -397
- test/watcher_test.py +0 -58
- test/webhook_test.py +0 -145
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/LICENSE +0 -0
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/WHEEL +0 -0
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/entry_points.txt +0 -0
test/serialization_test.py
DELETED
@@ -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)
|
test/stub_composition_test.py
DELETED
@@ -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
|