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.
- 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 +407 -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 +1036 -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 +197 -0
- modal/functions.py +846 -428
- modal/functions.pyi +446 -387
- modal/gpu.py +57 -44
- modal/image.py +946 -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.11.dist-info}/METADATA +5 -5
- modal-0.72.11.dist-info/RECORD +174 -0
- {modal-0.62.115.dist-info → modal-0.72.11.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.11.dist-info}/LICENSE +0 -0
- {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
- {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/entry_points.txt +0 -0
modal/image.py
CHANGED
@@ -1,15 +1,25 @@
|
|
1
1
|
# Copyright Modal Labs 2022
|
2
2
|
import contextlib
|
3
|
+
import json
|
3
4
|
import os
|
4
5
|
import re
|
5
6
|
import shlex
|
6
7
|
import sys
|
7
8
|
import typing
|
8
9
|
import warnings
|
10
|
+
from collections.abc import Sequence
|
9
11
|
from dataclasses import dataclass
|
10
12
|
from inspect import isfunction
|
11
13
|
from pathlib import Path, PurePosixPath
|
12
|
-
from typing import
|
14
|
+
from typing import (
|
15
|
+
Any,
|
16
|
+
Callable,
|
17
|
+
Literal,
|
18
|
+
Optional,
|
19
|
+
Union,
|
20
|
+
cast,
|
21
|
+
get_args,
|
22
|
+
)
|
13
23
|
|
14
24
|
from google.protobuf.message import Message
|
15
25
|
from grpclib.exceptions import GRPCError, StreamTerminatedError
|
@@ -20,99 +30,118 @@ from ._resolver import Resolver
|
|
20
30
|
from ._serialization import serialize
|
21
31
|
from ._utils.async_utils import synchronize_api
|
22
32
|
from ._utils.blob_utils import MAX_OBJECT_SIZE_BYTES
|
33
|
+
from ._utils.deprecation import deprecation_error, deprecation_warning
|
34
|
+
from ._utils.docker_utils import (
|
35
|
+
extract_copy_command_patterns,
|
36
|
+
find_dockerignore_file,
|
37
|
+
)
|
23
38
|
from ._utils.function_utils import FunctionInfo
|
24
|
-
from ._utils.grpc_utils import RETRYABLE_GRPC_STATUS_CODES, retry_transient_errors
|
39
|
+
from ._utils.grpc_utils import RETRYABLE_GRPC_STATUS_CODES, retry_transient_errors
|
40
|
+
from .client import _Client
|
41
|
+
from .cloud_bucket_mount import _CloudBucketMount
|
25
42
|
from .config import config, logger, user_config_path
|
26
|
-
from .
|
43
|
+
from .environments import _get_environment_cached
|
44
|
+
from .exception import InvalidError, NotFoundError, RemoteError, VersionError
|
45
|
+
from .file_pattern_matcher import NON_PYTHON_FILES, FilePatternMatcher, _ignore_fn
|
27
46
|
from .gpu import GPU_T, parse_gpu_config
|
28
47
|
from .mount import _Mount, python_standalone_mount_name
|
29
48
|
from .network_file_system import _NetworkFileSystem
|
30
|
-
from .object import _Object
|
49
|
+
from .object import _Object, live_method_gen
|
50
|
+
from .output import _get_output_manager
|
51
|
+
from .scheduler_placement import SchedulerPlacement
|
31
52
|
from .secret import _Secret
|
53
|
+
from .volume import _Volume
|
32
54
|
|
33
55
|
if typing.TYPE_CHECKING:
|
34
56
|
import modal.functions
|
35
57
|
|
36
|
-
|
37
58
|
# This is used for both type checking and runtime validation
|
38
|
-
ImageBuilderVersion = Literal["2023.12", "2024.04"]
|
59
|
+
ImageBuilderVersion = Literal["2023.12", "2024.04", "2024.10"]
|
39
60
|
|
40
61
|
# Note: we also define supported Python versions via logic at the top of the package __init__.py
|
41
62
|
# so that we fail fast / clearly in unsupported containers. Additionally, we enumerate the supported
|
42
63
|
# Python versions in mount.py where we specify the "standalone Python versions" we create mounts for.
|
43
64
|
# Consider consolidating these multiple sources of truth?
|
44
|
-
SUPPORTED_PYTHON_SERIES:
|
65
|
+
SUPPORTED_PYTHON_SERIES: dict[ImageBuilderVersion, list[str]] = {
|
66
|
+
"2024.10": ["3.9", "3.10", "3.11", "3.12", "3.13"],
|
67
|
+
"2024.04": ["3.9", "3.10", "3.11", "3.12"],
|
68
|
+
"2023.12": ["3.9", "3.10", "3.11", "3.12"],
|
69
|
+
}
|
45
70
|
|
71
|
+
LOCAL_REQUIREMENTS_DIR = Path(__file__).parent / "requirements"
|
46
72
|
CONTAINER_REQUIREMENTS_PATH = "/modal_requirements.txt"
|
47
73
|
|
48
74
|
|
49
|
-
|
50
|
-
|
75
|
+
class _AutoDockerIgnoreSentinel:
|
76
|
+
def __repr__(self) -> str:
|
77
|
+
return f"{__name__}.AUTO_DOCKERIGNORE"
|
78
|
+
|
79
|
+
def __call__(self, _: Path) -> bool:
|
80
|
+
raise NotImplementedError("This is only a placeholder. Do not call")
|
81
|
+
|
82
|
+
|
83
|
+
AUTO_DOCKERIGNORE = _AutoDockerIgnoreSentinel()
|
84
|
+
|
85
|
+
COPY_DEPRECATION_MESSAGE_PATTERN = """modal.Image.copy_* methods will soon be deprecated.
|
86
|
+
|
87
|
+
Use {replacement} instead, which is functionally and performance-wise equivalent.
|
88
|
+
"""
|
89
|
+
|
90
|
+
|
91
|
+
def _validate_python_version(
|
92
|
+
python_version: Optional[str], builder_version: ImageBuilderVersion, allow_micro_granularity: bool = True
|
93
|
+
) -> str:
|
94
|
+
if python_version is None:
|
51
95
|
# If Python version is unspecified, match the local version, up to the minor component
|
52
|
-
|
53
|
-
elif not isinstance(
|
54
|
-
raise InvalidError(f"Python version must be specified as a string, not {type(
|
55
|
-
elif not re.match(r"^3(?:\.\d{1,2}){1,2}
|
56
|
-
raise InvalidError(f"Invalid Python version: {
|
96
|
+
python_version = series_version = "{}.{}".format(*sys.version_info)
|
97
|
+
elif not isinstance(python_version, str):
|
98
|
+
raise InvalidError(f"Python version must be specified as a string, not {type(python_version).__name__}")
|
99
|
+
elif not re.match(r"^3(?:\.\d{1,2}){1,2}(rc\d*)?$", python_version):
|
100
|
+
raise InvalidError(f"Invalid Python version: {python_version!r}")
|
57
101
|
else:
|
58
|
-
components =
|
102
|
+
components = python_version.split(".")
|
59
103
|
if len(components) == 3 and not allow_micro_granularity:
|
60
104
|
raise InvalidError(
|
61
105
|
"Python version must be specified as 'major.minor' for this interface;"
|
62
|
-
f" micro-level specification ({
|
106
|
+
f" micro-level specification ({python_version!r}) is not valid."
|
63
107
|
)
|
64
|
-
series_version = "{
|
108
|
+
series_version = "{}.{}".format(*components)
|
65
109
|
|
66
|
-
|
110
|
+
supported_series = SUPPORTED_PYTHON_SERIES[builder_version]
|
111
|
+
if series_version not in supported_series:
|
67
112
|
raise InvalidError(
|
68
|
-
f"Unsupported Python version: {
|
69
|
-
f" Modal supports
|
113
|
+
f"Unsupported Python version: {python_version!r}."
|
114
|
+
f" When using the {builder_version!r} Image builder, Modal supports the following series:"
|
115
|
+
f" {supported_series!r}."
|
70
116
|
)
|
71
|
-
return
|
117
|
+
return python_version
|
72
118
|
|
73
119
|
|
74
120
|
def _dockerhub_python_version(builder_version: ImageBuilderVersion, python_version: Optional[str] = None) -> str:
|
75
|
-
python_version = _validate_python_version(python_version)
|
76
|
-
|
121
|
+
python_version = _validate_python_version(python_version, builder_version)
|
122
|
+
version_components = python_version.split(".")
|
77
123
|
|
78
124
|
# When user specifies a full Python version, use that
|
79
|
-
if len(
|
125
|
+
if len(version_components) > 2:
|
80
126
|
return python_version
|
81
127
|
|
82
128
|
# Otherwise, use the same series, but a specific micro version, corresponding to the latest
|
83
129
|
# available from https://hub.docker.com/_/python at the time of each image builder release.
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
"3.8": "15",
|
91
|
-
},
|
92
|
-
"2024.04": {
|
93
|
-
"3.12": "2",
|
94
|
-
"3.11": "8",
|
95
|
-
"3.10": "14",
|
96
|
-
"3.9": "19",
|
97
|
-
"3.8": "19",
|
98
|
-
},
|
99
|
-
}
|
100
|
-
python_series = "{0}.{1}".format(*components)
|
101
|
-
micro_version = latest_micro_version[builder_version][python_series]
|
102
|
-
python_version = f"{python_series}.{micro_version}"
|
103
|
-
return python_version
|
130
|
+
# This allows us to publish one pre-built debian-slim image per Python series.
|
131
|
+
python_versions = _base_image_config("python", builder_version)
|
132
|
+
series_to_micro_version = dict(tuple(v.rsplit(".", 1)) for v in python_versions)
|
133
|
+
python_series_requested = "{}.{}".format(*version_components)
|
134
|
+
micro_version = series_to_micro_version[python_series_requested]
|
135
|
+
return f"{python_series_requested}.{micro_version}"
|
104
136
|
|
105
137
|
|
106
|
-
def
|
107
|
-
|
138
|
+
def _base_image_config(group: str, builder_version: ImageBuilderVersion) -> Any:
|
139
|
+
with open(LOCAL_REQUIREMENTS_DIR / "base-images.json") as f:
|
140
|
+
data = json.load(f)
|
141
|
+
return data[group][builder_version]
|
108
142
|
|
109
143
|
|
110
144
|
def _get_modal_requirements_path(builder_version: ImageBuilderVersion, python_version: Optional[str] = None) -> str:
|
111
|
-
# Locate Modal client requirements data
|
112
|
-
import modal
|
113
|
-
|
114
|
-
modal_path = Path(modal.__path__[0])
|
115
|
-
|
116
145
|
# When we added Python 3.12 support, we needed to update a few dependencies but did not yet
|
117
146
|
# support versioned builds, so we put them in a separate 3.12-specific requirements file.
|
118
147
|
# When the python_version is not specified in the Image API, we fall back to the local version.
|
@@ -122,20 +151,22 @@ def _get_modal_requirements_path(builder_version: ImageBuilderVersion, python_ve
|
|
122
151
|
python_version = python_version or sys.version
|
123
152
|
suffix = ".312" if builder_version == "2023.12" and python_version.startswith("3.12") else ""
|
124
153
|
|
125
|
-
return str(
|
154
|
+
return str(LOCAL_REQUIREMENTS_DIR / f"{builder_version}{suffix}.txt")
|
126
155
|
|
127
156
|
|
128
157
|
def _get_modal_requirements_command(version: ImageBuilderVersion) -> str:
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
158
|
+
if version == "2023.12":
|
159
|
+
prefix = "pip install"
|
160
|
+
elif version == "2024.04":
|
161
|
+
prefix = "pip install --no-cache --no-deps"
|
162
|
+
else: # Currently, 2024.10+
|
163
|
+
prefix = "uv pip install --system --compile-bytecode --no-cache --no-deps"
|
135
164
|
|
165
|
+
return f"{prefix} -r {CONTAINER_REQUIREMENTS_PATH}"
|
136
166
|
|
137
|
-
|
138
|
-
|
167
|
+
|
168
|
+
def _flatten_str_args(function_name: str, arg_name: str, args: Sequence[Union[str, list[str]]]) -> list[str]:
|
169
|
+
"""Takes a sequence of strings, or string lists, and flattens it.
|
139
170
|
|
140
171
|
Raises an error if any of the elements are not strings or string lists.
|
141
172
|
"""
|
@@ -143,7 +174,7 @@ def _flatten_str_args(function_name: str, arg_name: str, args: Tuple[Union[str,
|
|
143
174
|
def is_str_list(x):
|
144
175
|
return isinstance(x, list) and all(isinstance(y, str) for y in x)
|
145
176
|
|
146
|
-
ret:
|
177
|
+
ret: list[str] = []
|
147
178
|
for x in args:
|
148
179
|
if isinstance(x, str):
|
149
180
|
ret.append(x)
|
@@ -154,11 +185,29 @@ def _flatten_str_args(function_name: str, arg_name: str, args: Tuple[Union[str,
|
|
154
185
|
return ret
|
155
186
|
|
156
187
|
|
188
|
+
def _validate_packages(packages: list[str]) -> bool:
|
189
|
+
"""Validates that a list of packages does not contain any command-line options."""
|
190
|
+
return not any(pkg.startswith("-") for pkg in packages)
|
191
|
+
|
192
|
+
|
193
|
+
def _warn_invalid_packages(old_command: str) -> None:
|
194
|
+
deprecation_warning(
|
195
|
+
(2024, 7, 3),
|
196
|
+
"Passing flags to `pip` via the `packages` argument of `pip_install` is deprecated."
|
197
|
+
" Please pass flags via the `extra_options` argument instead."
|
198
|
+
"\nNote that this will cause a rebuild of this image layer."
|
199
|
+
" To avoid rebuilding, you can pass the following to `run_commands` instead:"
|
200
|
+
f'\n`image.run_commands("{old_command}")`',
|
201
|
+
show_source=False,
|
202
|
+
)
|
203
|
+
|
204
|
+
|
157
205
|
def _make_pip_install_args(
|
158
206
|
find_links: Optional[str] = None, # Passes -f (--find-links) pip install
|
159
207
|
index_url: Optional[str] = None, # Passes -i (--index-url) to pip install
|
160
208
|
extra_index_url: Optional[str] = None, # Passes --extra-index-url to pip install
|
161
209
|
pre: bool = False, # Passes --pre (allow pre-releases) to pip install
|
210
|
+
extra_options: str = "", # Additional options to pass to pip install, e.g. "--no-build-isolation --no-clean"
|
162
211
|
) -> str:
|
163
212
|
flags = [
|
164
213
|
("--find-links", find_links), # TODO(erikbern): allow multiple?
|
@@ -168,25 +217,30 @@ def _make_pip_install_args(
|
|
168
217
|
|
169
218
|
args = " ".join(f"{flag} {shlex.quote(value)}" for flag, value in flags if value is not None)
|
170
219
|
if pre:
|
171
|
-
args += " --pre"
|
220
|
+
args += " --pre" # TODO: remove extra whitespace in future image builder version
|
221
|
+
|
222
|
+
if extra_options:
|
223
|
+
if args:
|
224
|
+
args += " "
|
225
|
+
args += f"{extra_options}"
|
172
226
|
|
173
227
|
return args
|
174
228
|
|
175
229
|
|
176
|
-
def _get_image_builder_version(
|
177
|
-
if
|
178
|
-
version =
|
230
|
+
def _get_image_builder_version(server_version: ImageBuilderVersion) -> ImageBuilderVersion:
|
231
|
+
if local_config_version := config.get("image_builder_version"):
|
232
|
+
version = local_config_version
|
179
233
|
if (env_var := "MODAL_IMAGE_BUILDER_VERSION") in os.environ:
|
180
234
|
version_source = f" (based on your `{env_var}` environment variable)"
|
181
235
|
else:
|
182
236
|
version_source = f" (based on your local config file at `{user_config_path}`)"
|
183
237
|
else:
|
184
|
-
version = client_version
|
185
238
|
version_source = ""
|
239
|
+
version = server_version
|
186
240
|
|
187
|
-
supported_versions:
|
241
|
+
supported_versions: set[ImageBuilderVersion] = set(get_args(ImageBuilderVersion))
|
188
242
|
if version not in supported_versions:
|
189
|
-
if
|
243
|
+
if local_config_version is not None:
|
190
244
|
update_suggestion = "or remove your local configuration"
|
191
245
|
elif version < min(supported_versions):
|
192
246
|
update_suggestion = "your image builder version using the Modal dashboard"
|
@@ -202,13 +256,81 @@ def _get_image_builder_version(client_version: str) -> ImageBuilderVersion:
|
|
202
256
|
return version
|
203
257
|
|
204
258
|
|
259
|
+
def _create_context_mount(
|
260
|
+
docker_commands: Sequence[str],
|
261
|
+
ignore_fn: Callable[[Path], bool],
|
262
|
+
context_dir: Path,
|
263
|
+
) -> Optional[_Mount]:
|
264
|
+
"""
|
265
|
+
Creates a context mount from a list of docker commands.
|
266
|
+
|
267
|
+
1. Paths are evaluated relative to context_dir.
|
268
|
+
2. First selects inclusions based on COPY commands in the list of commands.
|
269
|
+
3. Then ignore any files as per the ignore predicate.
|
270
|
+
"""
|
271
|
+
copy_patterns = extract_copy_command_patterns(docker_commands)
|
272
|
+
if not copy_patterns:
|
273
|
+
return None # no mount needed
|
274
|
+
include_fn = FilePatternMatcher(*copy_patterns)
|
275
|
+
|
276
|
+
def ignore_with_include(source: Path) -> bool:
|
277
|
+
relative_source = source.relative_to(context_dir)
|
278
|
+
if not include_fn(relative_source) or ignore_fn(relative_source):
|
279
|
+
return True
|
280
|
+
|
281
|
+
return False
|
282
|
+
|
283
|
+
return _Mount._add_local_dir(Path("./"), PurePosixPath("/"), ignore=ignore_with_include)
|
284
|
+
|
285
|
+
|
286
|
+
def _create_context_mount_function(
|
287
|
+
ignore: Union[Sequence[str], Callable[[Path], bool]],
|
288
|
+
dockerfile_cmds: list[str] = [],
|
289
|
+
dockerfile_path: Optional[Path] = None,
|
290
|
+
context_mount: Optional[_Mount] = None,
|
291
|
+
):
|
292
|
+
if dockerfile_path and dockerfile_cmds:
|
293
|
+
raise InvalidError("Cannot provide both dockerfile and docker commands")
|
294
|
+
|
295
|
+
if context_mount:
|
296
|
+
if ignore is not AUTO_DOCKERIGNORE:
|
297
|
+
raise InvalidError("Cannot set both `context_mount` and `ignore`")
|
298
|
+
|
299
|
+
def identity_context_mount_fn() -> Optional[_Mount]:
|
300
|
+
return context_mount
|
301
|
+
|
302
|
+
return identity_context_mount_fn
|
303
|
+
elif ignore is AUTO_DOCKERIGNORE:
|
304
|
+
|
305
|
+
def auto_created_context_mount_fn() -> Optional[_Mount]:
|
306
|
+
context_dir = Path.cwd()
|
307
|
+
dockerignore_file = find_dockerignore_file(context_dir, dockerfile_path)
|
308
|
+
ignore_fn = (
|
309
|
+
FilePatternMatcher(*dockerignore_file.read_text("utf8").splitlines())
|
310
|
+
if dockerignore_file
|
311
|
+
else _ignore_fn(())
|
312
|
+
)
|
313
|
+
|
314
|
+
cmds = dockerfile_path.read_text("utf8").splitlines() if dockerfile_path else dockerfile_cmds
|
315
|
+
return _create_context_mount(cmds, ignore_fn=ignore_fn, context_dir=context_dir)
|
316
|
+
|
317
|
+
return auto_created_context_mount_fn
|
318
|
+
|
319
|
+
def auto_created_context_mount_fn() -> Optional[_Mount]:
|
320
|
+
# use COPY commands and ignore patterns to construct implicit context mount
|
321
|
+
cmds = dockerfile_path.read_text("utf8").splitlines() if dockerfile_path else dockerfile_cmds
|
322
|
+
return _create_context_mount(cmds, ignore_fn=_ignore_fn(ignore), context_dir=Path.cwd())
|
323
|
+
|
324
|
+
return auto_created_context_mount_fn
|
325
|
+
|
326
|
+
|
205
327
|
class _ImageRegistryConfig:
|
206
328
|
"""mdmd:hidden"""
|
207
329
|
|
208
330
|
def __init__(
|
209
331
|
self,
|
210
332
|
# TODO: change to _PUBLIC after worker starts handling it.
|
211
|
-
registry_auth_type:
|
333
|
+
registry_auth_type: "api_pb2.RegistryAuthType.ValueType" = api_pb2.REGISTRY_AUTH_TYPE_UNSPECIFIED,
|
212
334
|
secret: Optional[_Secret] = None,
|
213
335
|
):
|
214
336
|
self.registry_auth_type = registry_auth_type
|
@@ -217,53 +339,160 @@ class _ImageRegistryConfig:
|
|
217
339
|
def get_proto(self) -> api_pb2.ImageRegistryConfig:
|
218
340
|
return api_pb2.ImageRegistryConfig(
|
219
341
|
registry_auth_type=self.registry_auth_type,
|
220
|
-
secret_id=(self.secret.object_id if self.secret else
|
342
|
+
secret_id=(self.secret.object_id if self.secret else ""),
|
221
343
|
)
|
222
344
|
|
223
345
|
|
224
346
|
@dataclass
|
225
347
|
class DockerfileSpec:
|
226
348
|
# Ideally we would use field() with default_factory=, but doesn't work with synchronicity type-stub gen
|
227
|
-
commands:
|
228
|
-
context_files:
|
349
|
+
commands: list[str]
|
350
|
+
context_files: dict[str, str]
|
351
|
+
|
352
|
+
|
353
|
+
async def _image_await_build_result(image_id: str, client: _Client) -> api_pb2.ImageJoinStreamingResponse:
|
354
|
+
last_entry_id: str = ""
|
355
|
+
result_response: Optional[api_pb2.ImageJoinStreamingResponse] = None
|
356
|
+
|
357
|
+
async def join():
|
358
|
+
nonlocal last_entry_id, result_response
|
359
|
+
|
360
|
+
request = api_pb2.ImageJoinStreamingRequest(image_id=image_id, timeout=55, last_entry_id=last_entry_id)
|
361
|
+
async for response in client.stub.ImageJoinStreaming.unary_stream(request):
|
362
|
+
if response.entry_id:
|
363
|
+
last_entry_id = response.entry_id
|
364
|
+
if response.result.status:
|
365
|
+
result_response = response
|
366
|
+
# can't return yet, since there may still be logs streaming back in subsequent responses
|
367
|
+
for task_log in response.task_logs:
|
368
|
+
if task_log.task_progress.pos or task_log.task_progress.len:
|
369
|
+
assert task_log.task_progress.progress_type == api_pb2.IMAGE_SNAPSHOT_UPLOAD
|
370
|
+
if output_mgr := _get_output_manager():
|
371
|
+
output_mgr.update_snapshot_progress(image_id, task_log.task_progress)
|
372
|
+
elif task_log.data:
|
373
|
+
if output_mgr := _get_output_manager():
|
374
|
+
await output_mgr.put_log_content(task_log)
|
375
|
+
if output_mgr := _get_output_manager():
|
376
|
+
output_mgr.flush_lines()
|
377
|
+
|
378
|
+
# Handle up to n exceptions while fetching logs
|
379
|
+
retry_count = 0
|
380
|
+
while result_response is None:
|
381
|
+
try:
|
382
|
+
await join()
|
383
|
+
except (StreamTerminatedError, GRPCError) as exc:
|
384
|
+
if isinstance(exc, GRPCError) and exc.status not in RETRYABLE_GRPC_STATUS_CODES:
|
385
|
+
raise exc
|
386
|
+
retry_count += 1
|
387
|
+
if retry_count >= 3:
|
388
|
+
raise exc
|
389
|
+
return result_response
|
229
390
|
|
230
391
|
|
231
392
|
class _Image(_Object, type_prefix="im"):
|
232
393
|
"""Base class for container images to run functions in.
|
233
394
|
|
234
395
|
Do not construct this class directly; instead use one of its static factory methods,
|
235
|
-
such as `modal.Image.debian_slim`, `modal.Image.from_registry`, or `modal.Image.
|
396
|
+
such as `modal.Image.debian_slim`, `modal.Image.from_registry`, or `modal.Image.micromamba`.
|
236
397
|
"""
|
237
398
|
|
238
399
|
force_build: bool
|
239
|
-
inside_exceptions:
|
400
|
+
inside_exceptions: list[Exception]
|
401
|
+
_serve_mounts: frozenset[_Mount] # used for mounts watching in `modal serve`
|
402
|
+
_deferred_mounts: Sequence[
|
403
|
+
_Mount
|
404
|
+
] # added as mounts on any container referencing the Image, see `def _mount_layers`
|
405
|
+
_metadata: Optional[api_pb2.ImageMetadata] = None # set on hydration, private for now
|
240
406
|
|
241
407
|
def _initialize_from_empty(self):
|
242
408
|
self.inside_exceptions = []
|
243
|
-
|
244
|
-
|
245
|
-
|
409
|
+
self._serve_mounts = frozenset()
|
410
|
+
self._deferred_mounts = ()
|
411
|
+
self.force_build = False
|
412
|
+
|
413
|
+
def _initialize_from_other(self, other: "_Image"):
|
414
|
+
# used by .clone()
|
415
|
+
self.inside_exceptions = other.inside_exceptions
|
416
|
+
self.force_build = other.force_build
|
417
|
+
self._serve_mounts = other._serve_mounts
|
418
|
+
self._deferred_mounts = other._deferred_mounts
|
419
|
+
|
420
|
+
def _hydrate_metadata(self, metadata: Optional[Message]):
|
421
|
+
env_image_id = config.get("image_id") # set as an env var in containers
|
246
422
|
if env_image_id == self.object_id:
|
247
423
|
for exc in self.inside_exceptions:
|
424
|
+
# This raises exceptions from `with image.imports()` blocks
|
425
|
+
# if the hydrated image is the one used by the container
|
248
426
|
raise exc
|
249
427
|
|
428
|
+
if metadata:
|
429
|
+
assert isinstance(metadata, api_pb2.ImageMetadata)
|
430
|
+
self._metadata = metadata
|
431
|
+
|
432
|
+
def _add_mount_layer_or_copy(self, mount: _Mount, copy: bool = False):
|
433
|
+
if copy:
|
434
|
+
return self.copy_mount(mount, remote_path="/")
|
435
|
+
|
436
|
+
base_image = self
|
437
|
+
|
438
|
+
async def _load(self2: "_Image", resolver: Resolver, existing_object_id: Optional[str]):
|
439
|
+
self2._hydrate_from_other(base_image) # same image id as base image as long as it's lazy
|
440
|
+
self2._deferred_mounts = tuple(base_image._deferred_mounts) + (mount,)
|
441
|
+
self2._serve_mounts = base_image._serve_mounts | ({mount} if mount.is_local() else set())
|
442
|
+
|
443
|
+
return _Image._from_loader(_load, "Image(local files)", deps=lambda: [base_image, mount])
|
444
|
+
|
445
|
+
@property
|
446
|
+
def _mount_layers(self) -> typing.Sequence[_Mount]:
|
447
|
+
"""Non-evaluated mount layers on the image
|
448
|
+
|
449
|
+
When the image is used by a Modal container, these mounts need to be attached as well to
|
450
|
+
represent the full image content, as they haven't yet been represented as a layer in the
|
451
|
+
image.
|
452
|
+
|
453
|
+
When the image is used as a base image for a new layer (that is not itself a mount layer)
|
454
|
+
these mounts need to first be inserted as a copy operation (.copy_mount) into the image.
|
455
|
+
"""
|
456
|
+
return self._deferred_mounts
|
457
|
+
|
458
|
+
def _assert_no_mount_layers(self):
|
459
|
+
if self._mount_layers:
|
460
|
+
raise InvalidError(
|
461
|
+
"An image tried to run a build step after using `image.add_local_*` to include local files.\n"
|
462
|
+
"\n"
|
463
|
+
"Run `image.add_local_*` commands last in your image build to avoid rebuilding images with every local "
|
464
|
+
"file change. Modal will then add these files to containers on startup instead, saving build time.\n"
|
465
|
+
"If you need to run other build steps after adding local files, set `copy=True` to copy the files "
|
466
|
+
"directly into the image, at the expense of some added build time.\n"
|
467
|
+
"\n"
|
468
|
+
"Example:\n"
|
469
|
+
"\n"
|
470
|
+
"my_image = (\n"
|
471
|
+
" Image.debian_slim()\n"
|
472
|
+
' .add_local_file("data.json", copy=True)\n'
|
473
|
+
' .run_commands("python -m mypak") # this now works!\n'
|
474
|
+
")\n"
|
475
|
+
)
|
476
|
+
|
250
477
|
@staticmethod
|
251
478
|
def _from_args(
|
252
479
|
*,
|
253
|
-
base_images: Optional[
|
480
|
+
base_images: Optional[dict[str, "_Image"]] = None,
|
254
481
|
dockerfile_function: Optional[Callable[[ImageBuilderVersion], DockerfileSpec]] = None,
|
255
482
|
secrets: Optional[Sequence[_Secret]] = None,
|
256
483
|
gpu_config: Optional[api_pb2.GPUConfig] = None,
|
257
484
|
build_function: Optional["modal.functions._Function"] = None,
|
258
485
|
build_function_input: Optional[api_pb2.FunctionInput] = None,
|
259
486
|
image_registry_config: Optional[_ImageRegistryConfig] = None,
|
260
|
-
|
487
|
+
context_mount_function: Optional[Callable[[], Optional[_Mount]]] = None,
|
261
488
|
force_build: bool = False,
|
262
489
|
# For internal use only.
|
263
|
-
_namespace:
|
490
|
+
_namespace: "api_pb2.DeploymentNamespace.ValueType" = api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
|
491
|
+
_do_assert_no_mount_layers: bool = True,
|
264
492
|
):
|
265
493
|
if base_images is None:
|
266
494
|
base_images = {}
|
495
|
+
|
267
496
|
if secrets is None:
|
268
497
|
secrets = []
|
269
498
|
if gpu_config is None:
|
@@ -278,18 +507,29 @@ class _Image(_Object, type_prefix="im"):
|
|
278
507
|
if build_function and len(base_images) != 1:
|
279
508
|
raise InvalidError("Cannot run a build function with multiple base images!")
|
280
509
|
|
281
|
-
def _deps() ->
|
282
|
-
deps
|
510
|
+
def _deps() -> Sequence[_Object]:
|
511
|
+
deps = tuple(base_images.values()) + tuple(secrets)
|
283
512
|
if build_function:
|
284
|
-
deps
|
285
|
-
if
|
286
|
-
deps.
|
287
|
-
if image_registry_config.secret:
|
288
|
-
deps.append(image_registry_config.secret)
|
513
|
+
deps += (build_function,)
|
514
|
+
if image_registry_config and image_registry_config.secret:
|
515
|
+
deps += (image_registry_config.secret,)
|
289
516
|
return deps
|
290
517
|
|
291
518
|
async def _load(self: _Image, resolver: Resolver, existing_object_id: Optional[str]):
|
292
|
-
|
519
|
+
context_mount = context_mount_function() if context_mount_function else None
|
520
|
+
if context_mount:
|
521
|
+
await resolver.load(context_mount)
|
522
|
+
|
523
|
+
if _do_assert_no_mount_layers:
|
524
|
+
for image in base_images.values():
|
525
|
+
# base images can't have
|
526
|
+
image._assert_no_mount_layers()
|
527
|
+
|
528
|
+
assert resolver.app_id # type narrowing
|
529
|
+
environment = await _get_environment_cached(resolver.environment_name or "", resolver.client)
|
530
|
+
# A bit hacky,but assume that the environment provides a valid builder version
|
531
|
+
image_builder_version = cast(ImageBuilderVersion, environment._settings.image_builder_version)
|
532
|
+
builder_version = _get_image_builder_version(image_builder_version)
|
293
533
|
|
294
534
|
if dockerfile_function is None:
|
295
535
|
dockerfile = DockerfileSpec(commands=[], context_files={})
|
@@ -301,7 +541,9 @@ class _Image(_Object, type_prefix="im"):
|
|
301
541
|
"No commands were provided for the image — have you tried using modal.Image.debian_slim()?"
|
302
542
|
)
|
303
543
|
if dockerfile.commands and build_function:
|
304
|
-
raise InvalidError(
|
544
|
+
raise InvalidError(
|
545
|
+
"Cannot provide both build function and Dockerfile commands in the same image layer!"
|
546
|
+
)
|
305
547
|
|
306
548
|
base_images_pb2s = [
|
307
549
|
api_pb2.BaseImage(
|
@@ -318,8 +560,9 @@ class _Image(_Object, type_prefix="im"):
|
|
318
560
|
|
319
561
|
if build_function:
|
320
562
|
build_function_id = build_function.object_id
|
321
|
-
|
322
563
|
globals = build_function._get_info().get_globals()
|
564
|
+
attrs = build_function._get_info().get_cls_var_attrs()
|
565
|
+
globals = {**globals, **attrs}
|
323
566
|
filtered_globals = {}
|
324
567
|
for k, v in globals.items():
|
325
568
|
if isfunction(v):
|
@@ -329,21 +572,23 @@ class _Image(_Object, type_prefix="im"):
|
|
329
572
|
except Exception:
|
330
573
|
# Skip unserializable values for now.
|
331
574
|
logger.warning(
|
332
|
-
f"Skipping unserializable global variable {k} for
|
575
|
+
f"Skipping unserializable global variable {k} for "
|
576
|
+
f"{build_function._get_info().function_name}. "
|
577
|
+
"Changes to this variable won't invalidate the image."
|
333
578
|
)
|
334
579
|
continue
|
335
580
|
filtered_globals[k] = v
|
336
581
|
|
337
582
|
# Cloudpickle function serialization produces unstable values.
|
338
583
|
# TODO: better way to filter out types that don't have a stable hash?
|
339
|
-
build_function_globals = serialize(filtered_globals) if filtered_globals else
|
584
|
+
build_function_globals = serialize(filtered_globals) if filtered_globals else b""
|
340
585
|
_build_function = api_pb2.BuildFunction(
|
341
586
|
definition=build_function.get_build_def(),
|
342
587
|
globals=build_function_globals,
|
343
588
|
input=build_function_input,
|
344
589
|
)
|
345
590
|
else:
|
346
|
-
build_function_id =
|
591
|
+
build_function_id = ""
|
347
592
|
_build_function = None
|
348
593
|
|
349
594
|
image_definition = api_pb2.Image(
|
@@ -352,7 +597,7 @@ class _Image(_Object, type_prefix="im"):
|
|
352
597
|
context_files=context_file_pb2s,
|
353
598
|
secret_ids=[secret.object_id for secret in secrets],
|
354
599
|
gpu=bool(gpu_config.type), # Note: as of 2023-01-27, server still uses this
|
355
|
-
context_mount_id=(context_mount.object_id if context_mount else
|
600
|
+
context_mount_id=(context_mount.object_id if context_mount else ""),
|
356
601
|
gpu_config=gpu_config, # Note: as of 2023-01-27, server ignores this
|
357
602
|
image_registry_config=image_registry_config.get_proto(),
|
358
603
|
runtime=config.get("function_runtime"),
|
@@ -363,47 +608,32 @@ class _Image(_Object, type_prefix="im"):
|
|
363
608
|
req = api_pb2.ImageGetOrCreateRequest(
|
364
609
|
app_id=resolver.app_id,
|
365
610
|
image=image_definition,
|
366
|
-
existing_image_id=existing_object_id, # TODO: ignored
|
611
|
+
existing_image_id=existing_object_id or "", # TODO: ignored
|
367
612
|
build_function_id=build_function_id,
|
368
613
|
force_build=config.get("force_build") or force_build,
|
369
614
|
namespace=_namespace,
|
370
615
|
builder_version=builder_version,
|
616
|
+
# Failsafe mechanism to prevent inadvertant updates to the global images.
|
617
|
+
# Only admins can publish to the global namespace, but they have to additionally request it.
|
618
|
+
allow_global_deployment=os.environ.get("MODAL_IMAGE_ALLOW_GLOBAL_DEPLOYMENT", "0") == "1",
|
371
619
|
)
|
372
620
|
resp = await retry_transient_errors(resolver.client.stub.ImageGetOrCreate, req)
|
373
621
|
image_id = resp.image_id
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
result:
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
if task_log.task_progress.pos or task_log.task_progress.len:
|
390
|
-
assert task_log.task_progress.progress_type == api_pb2.IMAGE_SNAPSHOT_UPLOAD
|
391
|
-
resolver.image_snapshot_update(image_id, task_log.task_progress)
|
392
|
-
elif task_log.data:
|
393
|
-
await resolver.console_write(task_log)
|
394
|
-
resolver.console_flush()
|
395
|
-
|
396
|
-
# Handle up to n exceptions while fetching logs
|
397
|
-
retry_count = 0
|
398
|
-
while result is None:
|
399
|
-
try:
|
400
|
-
await join()
|
401
|
-
except (StreamTerminatedError, GRPCError) as exc:
|
402
|
-
if isinstance(exc, GRPCError) and exc.status not in RETRYABLE_GRPC_STATUS_CODES:
|
403
|
-
raise exc
|
404
|
-
retry_count += 1
|
405
|
-
if retry_count >= 3:
|
406
|
-
raise exc
|
622
|
+
result: api_pb2.GenericResult
|
623
|
+
metadata: Optional[api_pb2.ImageMetadata] = None
|
624
|
+
|
625
|
+
if resp.result.status:
|
626
|
+
# image already built
|
627
|
+
result = resp.result
|
628
|
+
if resp.HasField("metadata"):
|
629
|
+
metadata = resp.metadata
|
630
|
+
else:
|
631
|
+
# not built or in the process of building - wait for build
|
632
|
+
logger.debug("Waiting for image %s" % image_id)
|
633
|
+
resp = await _image_await_build_result(image_id, resolver.client)
|
634
|
+
result = resp.result
|
635
|
+
if resp.HasField("metadata"):
|
636
|
+
metadata = resp.metadata
|
407
637
|
|
408
638
|
if result.status == api_pb2.GenericResult.GENERIC_STATUS_FAILURE:
|
409
639
|
raise RemoteError(f"Image build for {image_id} failed with the exception:\n{result.exception}")
|
@@ -418,28 +648,19 @@ class _Image(_Object, type_prefix="im"):
|
|
418
648
|
else:
|
419
649
|
raise RemoteError("Unknown status %s!" % result.status)
|
420
650
|
|
421
|
-
self._hydrate(image_id, resolver.client,
|
651
|
+
self._hydrate(image_id, resolver.client, metadata)
|
652
|
+
local_mounts = set()
|
653
|
+
for base in base_images.values():
|
654
|
+
local_mounts |= base._serve_mounts
|
655
|
+
if context_mount and context_mount.is_local():
|
656
|
+
local_mounts.add(context_mount)
|
657
|
+
self._serve_mounts = frozenset(local_mounts)
|
422
658
|
|
423
|
-
rep = "Image()"
|
659
|
+
rep = f"Image({dockerfile_function})"
|
424
660
|
obj = _Image._from_loader(_load, rep, deps=_deps)
|
425
661
|
obj.force_build = force_build
|
426
662
|
return obj
|
427
663
|
|
428
|
-
def extend(self, **kwargs) -> "_Image":
|
429
|
-
"""Deprecated! This is a low-level method not intended to be part of the public API."""
|
430
|
-
deprecation_warning(
|
431
|
-
(2024, 3, 7),
|
432
|
-
"`Image.extend` is deprecated; please use a higher-level method, such as `Image.dockerfile_commands`.",
|
433
|
-
)
|
434
|
-
|
435
|
-
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
436
|
-
return DockerfileSpec(
|
437
|
-
commands=kwargs.pop("dockerfile_commands", []),
|
438
|
-
context_files=kwargs.pop("context_files", {}),
|
439
|
-
)
|
440
|
-
|
441
|
-
return _Image._from_args(base_images={"base": self}, dockerfile_function=build_dockerfile, **kwargs)
|
442
|
-
|
443
664
|
def copy_mount(self, mount: _Mount, remote_path: Union[str, Path] = ".") -> "_Image":
|
444
665
|
"""Copy the entire contents of a `modal.Mount` into an image.
|
445
666
|
Useful when files only available locally are required during the image
|
@@ -465,16 +686,115 @@ class _Image(_Object, type_prefix="im"):
|
|
465
686
|
return _Image._from_args(
|
466
687
|
base_images={"base": self},
|
467
688
|
dockerfile_function=build_dockerfile,
|
468
|
-
|
689
|
+
context_mount_function=lambda: mount,
|
690
|
+
)
|
691
|
+
|
692
|
+
def add_local_file(self, local_path: Union[str, Path], remote_path: str, *, copy: bool = False) -> "_Image":
|
693
|
+
"""Adds a local file to the image at `remote_path` within the container
|
694
|
+
|
695
|
+
By default (`copy=False`), the files are added to containers on startup and are not built into the actual Image,
|
696
|
+
which speeds up deployment.
|
697
|
+
|
698
|
+
Set `copy=True` to copy the files into an Image layer at build time instead, similar to how
|
699
|
+
[`COPY`](https://docs.docker.com/engine/reference/builder/#copy) works in a `Dockerfile`.
|
700
|
+
|
701
|
+
copy=True can slow down iteration since it requires a rebuild of the Image and any subsequent
|
702
|
+
build steps whenever the included files change, but it is required if you want to run additional
|
703
|
+
build steps after this one.
|
704
|
+
"""
|
705
|
+
if not PurePosixPath(remote_path).is_absolute():
|
706
|
+
# TODO(elias): implement relative to absolute resolution using image workdir metadata
|
707
|
+
# + make default remote_path="./"
|
708
|
+
# This requires deferring the Mount creation until after "self" (the base image) has been resolved
|
709
|
+
# so we know the workdir of the operation.
|
710
|
+
raise InvalidError("image.add_local_file() currently only supports absolute remote_path values")
|
711
|
+
|
712
|
+
if remote_path.endswith("/"):
|
713
|
+
remote_path = remote_path + Path(local_path).name
|
714
|
+
|
715
|
+
mount = _Mount._from_local_file(local_path, remote_path)
|
716
|
+
return self._add_mount_layer_or_copy(mount, copy=copy)
|
717
|
+
|
718
|
+
def add_local_dir(
|
719
|
+
self,
|
720
|
+
local_path: Union[str, Path],
|
721
|
+
remote_path: str,
|
722
|
+
*,
|
723
|
+
copy: bool = False,
|
724
|
+
# Predicate filter function for file exclusion, which should accept a filepath and return `True` for exclusion.
|
725
|
+
# Defaults to excluding no files. If a Sequence is provided, it will be converted to a FilePatternMatcher.
|
726
|
+
# Which follows dockerignore syntax.
|
727
|
+
ignore: Union[Sequence[str], Callable[[Path], bool]] = [],
|
728
|
+
) -> "_Image":
|
729
|
+
"""Adds a local directory's content to the image at `remote_path` within the container
|
730
|
+
|
731
|
+
By default (`copy=False`), the files are added to containers on startup and are not built into the actual Image,
|
732
|
+
which speeds up deployment.
|
733
|
+
|
734
|
+
Set `copy=True` to copy the files into an Image layer at build time instead, similar to how
|
735
|
+
[`COPY`](https://docs.docker.com/engine/reference/builder/#copy) works in a `Dockerfile`.
|
736
|
+
|
737
|
+
copy=True can slow down iteration since it requires a rebuild of the Image and any subsequent
|
738
|
+
build steps whenever the included files change, but it is required if you want to run additional
|
739
|
+
build steps after this one.
|
740
|
+
|
741
|
+
**Usage:**
|
742
|
+
|
743
|
+
```python
|
744
|
+
from pathlib import Path
|
745
|
+
from modal import FilePatternMatcher
|
746
|
+
|
747
|
+
image = modal.Image.debian_slim().add_local_dir(
|
748
|
+
"~/assets",
|
749
|
+
remote_path="/assets",
|
750
|
+
ignore=["*.venv"],
|
751
|
+
)
|
752
|
+
|
753
|
+
image = modal.Image.debian_slim().add_local_dir(
|
754
|
+
"~/assets",
|
755
|
+
remote_path="/assets",
|
756
|
+
ignore=lambda p: p.is_relative_to(".venv"),
|
757
|
+
)
|
758
|
+
|
759
|
+
image = modal.Image.debian_slim().add_local_dir(
|
760
|
+
"~/assets",
|
761
|
+
remote_path="/assets",
|
762
|
+
ignore=FilePatternMatcher("**/*.txt"),
|
763
|
+
)
|
764
|
+
|
765
|
+
# When including files is simpler than excluding them, you can use the `~` operator to invert the matcher.
|
766
|
+
image = modal.Image.debian_slim().add_local_dir(
|
767
|
+
"~/assets",
|
768
|
+
remote_path="/assets",
|
769
|
+
ignore=~FilePatternMatcher("**/*.py"),
|
770
|
+
)
|
771
|
+
|
772
|
+
# You can also read ignore patterns from a file.
|
773
|
+
image = modal.Image.debian_slim().add_local_dir(
|
774
|
+
"~/assets",
|
775
|
+
remote_path="/assets",
|
776
|
+
ignore=FilePatternMatcher.from_file(Path("/path/to/ignorefile")),
|
469
777
|
)
|
778
|
+
```
|
779
|
+
"""
|
780
|
+
if not PurePosixPath(remote_path).is_absolute():
|
781
|
+
# TODO(elias): implement relative to absolute resolution using image workdir metadata
|
782
|
+
# + make default remote_path="./"
|
783
|
+
raise InvalidError("image.add_local_dir() currently only supports absolute remote_path values")
|
784
|
+
|
785
|
+
mount = _Mount._add_local_dir(Path(local_path), PurePosixPath(remote_path), ignore=_ignore_fn(ignore))
|
786
|
+
return self._add_mount_layer_or_copy(mount, copy=copy)
|
470
787
|
|
471
788
|
def copy_local_file(self, local_path: Union[str, Path], remote_path: Union[str, Path] = "./") -> "_Image":
|
472
789
|
"""Copy a file into the image as a part of building it.
|
473
790
|
|
474
|
-
This works in a similar way to [`COPY`](https://docs.docker.com/engine/reference/builder/#copy)
|
791
|
+
This works in a similar way to [`COPY`](https://docs.docker.com/engine/reference/builder/#copy)
|
792
|
+
works in a `Dockerfile`.
|
475
793
|
"""
|
794
|
+
deprecation_warning(
|
795
|
+
(2024, 1, 13), COPY_DEPRECATION_MESSAGE_PATTERN.format(replacement="image.add_local_file"), pending=True
|
796
|
+
)
|
476
797
|
basename = str(Path(local_path).name)
|
477
|
-
mount = _Mount.from_local_file(local_path, remote_path=f"/{basename}")
|
478
798
|
|
479
799
|
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
480
800
|
return DockerfileSpec(commands=["FROM base", f"COPY {basename} {remote_path}"], context_files={})
|
@@ -482,15 +802,103 @@ class _Image(_Object, type_prefix="im"):
|
|
482
802
|
return _Image._from_args(
|
483
803
|
base_images={"base": self},
|
484
804
|
dockerfile_function=build_dockerfile,
|
485
|
-
|
805
|
+
context_mount_function=lambda: _Mount._from_local_file(local_path, remote_path=f"/{basename}"),
|
486
806
|
)
|
487
807
|
|
488
|
-
def
|
808
|
+
def add_local_python_source(
|
809
|
+
self, *modules: str, copy: bool = False, ignore: Union[Sequence[str], Callable[[Path], bool]] = NON_PYTHON_FILES
|
810
|
+
) -> "_Image":
|
811
|
+
"""Adds locally available Python packages/modules to containers
|
812
|
+
|
813
|
+
Adds all files from the specified Python package or module to containers running the Image.
|
814
|
+
|
815
|
+
Packages are added to the `/root` directory of containers, which is on the `PYTHONPATH`
|
816
|
+
of any executed Modal Functions, enabling import of the module by that name.
|
817
|
+
|
818
|
+
By default (`copy=False`), the files are added to containers on startup and are not built into the actual Image,
|
819
|
+
which speeds up deployment.
|
820
|
+
|
821
|
+
Set `copy=True` to copy the files into an Image layer at build time instead. This can slow down iteration since
|
822
|
+
it requires a rebuild of the Image and any subsequent build steps whenever the included files change, but it is
|
823
|
+
required if you want to run additional build steps after this one.
|
824
|
+
|
825
|
+
**Note:** This excludes all dot-prefixed subdirectories or files and all `.pyc`/`__pycache__` files.
|
826
|
+
To add full directories with finer control, use `.add_local_dir()` instead and specify `/root` as
|
827
|
+
the destination directory.
|
828
|
+
|
829
|
+
By default only includes `.py`-files in the source modules. Set the `ignore` argument to a list of patterns
|
830
|
+
or a callable to override this behavior, e.g.:
|
831
|
+
|
832
|
+
```py
|
833
|
+
# includes everything except data.json
|
834
|
+
modal.Image.debian_slim().add_local_python_source("mymodule", ignore=["data.json"])
|
835
|
+
|
836
|
+
# exclude large files
|
837
|
+
modal.Image.debian_slim().add_local_python_source(
|
838
|
+
"mymodule",
|
839
|
+
ignore=lambda p: p.stat().st_size > 1e9
|
840
|
+
)
|
841
|
+
```
|
842
|
+
"""
|
843
|
+
mount = _Mount._from_local_python_packages(*modules, ignore=ignore)
|
844
|
+
return self._add_mount_layer_or_copy(mount, copy=copy)
|
845
|
+
|
846
|
+
def copy_local_dir(
|
847
|
+
self,
|
848
|
+
local_path: Union[str, Path],
|
849
|
+
remote_path: Union[str, Path] = ".",
|
850
|
+
# Predicate filter function for file exclusion, which should accept a filepath and return `True` for exclusion.
|
851
|
+
# Defaults to excluding no files. If a Sequence is provided, it will be converted to a FilePatternMatcher.
|
852
|
+
# Which follows dockerignore syntax.
|
853
|
+
ignore: Union[Sequence[str], Callable[[Path], bool]] = [],
|
854
|
+
) -> "_Image":
|
489
855
|
"""Copy a directory into the image as a part of building the image.
|
490
856
|
|
491
|
-
This works in a similar way to [`COPY`](https://docs.docker.com/engine/reference/builder/#copy)
|
857
|
+
This works in a similar way to [`COPY`](https://docs.docker.com/engine/reference/builder/#copy)
|
858
|
+
works in a `Dockerfile`.
|
859
|
+
|
860
|
+
**Usage:**
|
861
|
+
|
862
|
+
```python
|
863
|
+
from pathlib import Path
|
864
|
+
from modal import FilePatternMatcher
|
865
|
+
|
866
|
+
image = modal.Image.debian_slim().copy_local_dir(
|
867
|
+
"~/assets",
|
868
|
+
remote_path="/assets",
|
869
|
+
ignore=["**/*.venv"],
|
870
|
+
)
|
871
|
+
|
872
|
+
image = modal.Image.debian_slim().copy_local_dir(
|
873
|
+
"~/assets",
|
874
|
+
remote_path="/assets",
|
875
|
+
ignore=lambda p: p.is_relative_to(".venv"),
|
876
|
+
)
|
877
|
+
|
878
|
+
image = modal.Image.debian_slim().copy_local_dir(
|
879
|
+
"~/assets",
|
880
|
+
remote_path="/assets",
|
881
|
+
ignore=FilePatternMatcher("**/*.txt"),
|
882
|
+
)
|
883
|
+
|
884
|
+
# When including files is simpler than excluding them, you can use the `~` operator to invert the matcher.
|
885
|
+
image = modal.Image.debian_slim().copy_local_dir(
|
886
|
+
"~/assets",
|
887
|
+
remote_path="/assets",
|
888
|
+
ignore=~FilePatternMatcher("**/*.py"),
|
889
|
+
)
|
890
|
+
|
891
|
+
# You can also read ignore patterns from a file.
|
892
|
+
image = modal.Image.debian_slim().copy_local_dir(
|
893
|
+
"~/assets",
|
894
|
+
remote_path="/assets",
|
895
|
+
ignore=FilePatternMatcher.from_file(Path("/path/to/ignorefile")),
|
896
|
+
)
|
897
|
+
```
|
492
898
|
"""
|
493
|
-
|
899
|
+
deprecation_warning(
|
900
|
+
(2024, 1, 13), COPY_DEPRECATION_MESSAGE_PATTERN.format(replacement="image.add_local_dir"), pending=True
|
901
|
+
)
|
494
902
|
|
495
903
|
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
496
904
|
return DockerfileSpec(commands=["FROM base", f"COPY . {remote_path}"], context_files={})
|
@@ -498,36 +906,78 @@ class _Image(_Object, type_prefix="im"):
|
|
498
906
|
return _Image._from_args(
|
499
907
|
base_images={"base": self},
|
500
908
|
dockerfile_function=build_dockerfile,
|
501
|
-
|
909
|
+
context_mount_function=lambda: _Mount._add_local_dir(
|
910
|
+
Path(local_path), PurePosixPath("/"), ignore=_ignore_fn(ignore)
|
911
|
+
),
|
502
912
|
)
|
503
913
|
|
914
|
+
@staticmethod
|
915
|
+
async def from_id(image_id: str, client: Optional[_Client] = None) -> "_Image":
|
916
|
+
"""Construct an Image from an id and look up the Image result.
|
917
|
+
|
918
|
+
The ID of an Image object can be accessed using `.object_id`.
|
919
|
+
"""
|
920
|
+
if client is None:
|
921
|
+
client = await _Client.from_env()
|
922
|
+
|
923
|
+
async def _load(self: _Image, resolver: Resolver, existing_object_id: Optional[str]):
|
924
|
+
resp = await retry_transient_errors(client.stub.ImageFromId, api_pb2.ImageFromIdRequest(image_id=image_id))
|
925
|
+
self._hydrate(resp.image_id, resolver.client, resp.metadata)
|
926
|
+
|
927
|
+
rep = "Image()"
|
928
|
+
obj = _Image._from_loader(_load, rep)
|
929
|
+
|
930
|
+
return obj
|
931
|
+
|
504
932
|
def pip_install(
|
505
933
|
self,
|
506
|
-
*packages: Union[str,
|
934
|
+
*packages: Union[str, list[str]], # A list of Python packages, eg. ["numpy", "matplotlib>=3.5.0"]
|
507
935
|
find_links: Optional[str] = None, # Passes -f (--find-links) pip install
|
508
936
|
index_url: Optional[str] = None, # Passes -i (--index-url) to pip install
|
509
937
|
extra_index_url: Optional[str] = None, # Passes --extra-index-url to pip install
|
510
938
|
pre: bool = False, # Passes --pre (allow pre-releases) to pip install
|
511
|
-
|
939
|
+
extra_options: str = "", # Additional options to pass to pip install, e.g. "--no-build-isolation --no-clean"
|
940
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
512
941
|
secrets: Sequence[_Secret] = [],
|
513
942
|
gpu: GPU_T = None,
|
514
943
|
) -> "_Image":
|
515
944
|
"""Install a list of Python packages using pip.
|
516
945
|
|
517
|
-
**
|
946
|
+
**Examples**
|
518
947
|
|
948
|
+
Simple installation:
|
519
949
|
```python
|
520
950
|
image = modal.Image.debian_slim().pip_install("click", "httpx~=0.23.3")
|
521
951
|
```
|
952
|
+
|
953
|
+
More complex installation:
|
954
|
+
```python
|
955
|
+
image = (
|
956
|
+
modal.Image.from_registry(
|
957
|
+
"nvidia/cuda:12.2.0-devel-ubuntu22.04", add_python="3.11"
|
958
|
+
)
|
959
|
+
.pip_install(
|
960
|
+
"ninja",
|
961
|
+
"packaging",
|
962
|
+
"wheel",
|
963
|
+
"transformers==4.40.2",
|
964
|
+
)
|
965
|
+
.pip_install(
|
966
|
+
"flash-attn==2.5.8", extra_options="--no-build-isolation"
|
967
|
+
)
|
968
|
+
)
|
969
|
+
```
|
522
970
|
"""
|
523
971
|
pkgs = _flatten_str_args("pip_install", "packages", packages)
|
524
972
|
if not pkgs:
|
525
973
|
return self
|
526
974
|
|
527
975
|
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
528
|
-
package_args =
|
529
|
-
extra_args = _make_pip_install_args(find_links, index_url, extra_index_url, pre)
|
976
|
+
package_args = shlex.join(sorted(pkgs))
|
977
|
+
extra_args = _make_pip_install_args(find_links, index_url, extra_index_url, pre, extra_options)
|
530
978
|
commands = ["FROM base", f"RUN python -m pip install {package_args} {extra_args}"]
|
979
|
+
if not _validate_packages(pkgs):
|
980
|
+
_warn_invalid_packages(commands[-1].split("RUN ")[-1])
|
531
981
|
if version > "2023.12": # Back-compat for legacy trailing space with empty extra_args
|
532
982
|
commands = [cmd.strip() for cmd in commands]
|
533
983
|
return DockerfileSpec(commands=commands, context_files={})
|
@@ -549,9 +999,10 @@ class _Image(_Object, type_prefix="im"):
|
|
549
999
|
index_url: Optional[str] = None, # Passes -i (--index-url) to pip install
|
550
1000
|
extra_index_url: Optional[str] = None, # Passes --extra-index-url to pip install
|
551
1001
|
pre: bool = False, # Passes --pre (allow pre-releases) to pip install
|
1002
|
+
extra_options: str = "", # Additional options to pass to pip install, e.g. "--no-build-isolation --no-clean"
|
552
1003
|
gpu: GPU_T = None,
|
553
1004
|
secrets: Sequence[_Secret] = [],
|
554
|
-
force_build: bool = False,
|
1005
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
555
1006
|
) -> "_Image":
|
556
1007
|
"""
|
557
1008
|
Install a list of Python packages from private git repositories using pip.
|
@@ -586,7 +1037,8 @@ class _Image(_Object, type_prefix="im"):
|
|
586
1037
|
"""
|
587
1038
|
if not secrets:
|
588
1039
|
raise InvalidError(
|
589
|
-
"No secrets provided to function.
|
1040
|
+
"No secrets provided to function. "
|
1041
|
+
"Installing private packages requires tokens to be passed via modal.Secret objects."
|
590
1042
|
)
|
591
1043
|
|
592
1044
|
invalid_repos = []
|
@@ -614,14 +1066,16 @@ class _Image(_Object, type_prefix="im"):
|
|
614
1066
|
commands = ["FROM base"]
|
615
1067
|
if any(r.startswith("github") for r in repositories):
|
616
1068
|
commands.append(
|
617
|
-
|
1069
|
+
'RUN bash -c "[[ -v GITHUB_TOKEN ]] || '
|
1070
|
+
f"(echo 'GITHUB_TOKEN env var not set by provided modal.Secret(s): {secret_names}' && exit 1)\"",
|
618
1071
|
)
|
619
1072
|
if any(r.startswith("gitlab") for r in repositories):
|
620
1073
|
commands.append(
|
621
|
-
|
1074
|
+
'RUN bash -c "[[ -v GITLAB_TOKEN ]] || '
|
1075
|
+
f"(echo 'GITLAB_TOKEN env var not set by provided modal.Secret(s): {secret_names}' && exit 1)\"",
|
622
1076
|
)
|
623
1077
|
|
624
|
-
extra_args = _make_pip_install_args(find_links, index_url, extra_index_url, pre)
|
1078
|
+
extra_args = _make_pip_install_args(find_links, index_url, extra_index_url, pre, extra_options)
|
625
1079
|
commands.extend(["RUN apt-get update && apt-get install -y git"])
|
626
1080
|
commands.extend([f'RUN python3 -m pip install "{url}" {extra_args}' for url in install_urls])
|
627
1081
|
if version > "2023.12": # Back-compat for legacy trailing space with empty extra_args
|
@@ -646,7 +1100,8 @@ class _Image(_Object, type_prefix="im"):
|
|
646
1100
|
index_url: Optional[str] = None, # Passes -i (--index-url) to pip install
|
647
1101
|
extra_index_url: Optional[str] = None, # Passes --extra-index-url to pip install
|
648
1102
|
pre: bool = False, # Passes --pre (allow pre-releases) to pip install
|
649
|
-
|
1103
|
+
extra_options: str = "", # Additional options to pass to pip install, e.g. "--no-build-isolation --no-clean"
|
1104
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
650
1105
|
secrets: Sequence[_Secret] = [],
|
651
1106
|
gpu: GPU_T = None,
|
652
1107
|
) -> "_Image":
|
@@ -658,7 +1113,7 @@ class _Image(_Object, type_prefix="im"):
|
|
658
1113
|
|
659
1114
|
null_find_links_arg = " " if version == "2023.12" else ""
|
660
1115
|
find_links_arg = f" -f {find_links}" if find_links else null_find_links_arg
|
661
|
-
extra_args = _make_pip_install_args(find_links, index_url, extra_index_url, pre)
|
1116
|
+
extra_args = _make_pip_install_args(find_links, index_url, extra_index_url, pre, extra_options)
|
662
1117
|
|
663
1118
|
commands = [
|
664
1119
|
"FROM base",
|
@@ -680,13 +1135,14 @@ class _Image(_Object, type_prefix="im"):
|
|
680
1135
|
def pip_install_from_pyproject(
|
681
1136
|
self,
|
682
1137
|
pyproject_toml: str,
|
683
|
-
optional_dependencies:
|
1138
|
+
optional_dependencies: list[str] = [],
|
684
1139
|
*,
|
685
1140
|
find_links: Optional[str] = None, # Passes -f (--find-links) pip install
|
686
1141
|
index_url: Optional[str] = None, # Passes -i (--index-url) to pip install
|
687
1142
|
extra_index_url: Optional[str] = None, # Passes --extra-index-url to pip install
|
688
1143
|
pre: bool = False, # Passes --pre (allow pre-releases) to pip install
|
689
|
-
|
1144
|
+
extra_options: str = "", # Additional options to pass to pip install, e.g. "--no-build-isolation --no-clean"
|
1145
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
690
1146
|
secrets: Sequence[_Secret] = [],
|
691
1147
|
gpu: GPU_T = None,
|
692
1148
|
) -> "_Image":
|
@@ -708,8 +1164,10 @@ class _Image(_Object, type_prefix="im"):
|
|
708
1164
|
if "project" not in config or "dependencies" not in config["project"]:
|
709
1165
|
msg = (
|
710
1166
|
"No [project.dependencies] section in pyproject.toml file. "
|
711
|
-
"If your pyproject.toml instead declares [tool.poetry.dependencies],
|
712
|
-
"
|
1167
|
+
"If your pyproject.toml instead declares [tool.poetry.dependencies], "
|
1168
|
+
"use `Image.poetry_install_from_file()`. "
|
1169
|
+
"See https://packaging.python.org/en/latest/guides/writing-pyproject-toml "
|
1170
|
+
"for further file format guidelines."
|
713
1171
|
)
|
714
1172
|
raise ValueError(msg)
|
715
1173
|
else:
|
@@ -720,8 +1178,8 @@ class _Image(_Object, type_prefix="im"):
|
|
720
1178
|
if dep_group_name in optionals:
|
721
1179
|
dependencies.extend(optionals[dep_group_name])
|
722
1180
|
|
723
|
-
extra_args = _make_pip_install_args(find_links, index_url, extra_index_url, pre)
|
724
|
-
package_args =
|
1181
|
+
extra_args = _make_pip_install_args(find_links, index_url, extra_index_url, pre, extra_options)
|
1182
|
+
package_args = shlex.join(sorted(dependencies))
|
725
1183
|
commands = ["FROM base", f"RUN python -m pip install {package_args} {extra_args}"]
|
726
1184
|
if version > "2023.12": # Back-compat for legacy trailing space
|
727
1185
|
commands = [cmd.strip() for cmd in commands]
|
@@ -745,13 +1203,13 @@ class _Image(_Object, type_prefix="im"):
|
|
745
1203
|
ignore_lockfile: bool = False,
|
746
1204
|
# If set to True, use old installer. See https://github.com/python-poetry/poetry/issues/3336
|
747
1205
|
old_installer: bool = False,
|
748
|
-
force_build: bool = False,
|
1206
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
749
1207
|
# Selected optional dependency groups to install (See https://python-poetry.org/docs/cli/#install)
|
750
|
-
with_:
|
1208
|
+
with_: list[str] = [],
|
751
1209
|
# Selected optional dependency groups to exclude (See https://python-poetry.org/docs/cli/#install)
|
752
|
-
without:
|
1210
|
+
without: list[str] = [],
|
753
1211
|
# Only install dependency groups specifed in this list.
|
754
|
-
only:
|
1212
|
+
only: list[str] = [],
|
755
1213
|
*,
|
756
1214
|
secrets: Sequence[_Secret] = [],
|
757
1215
|
gpu: GPU_T = None,
|
@@ -761,8 +1219,8 @@ class _Image(_Object, type_prefix="im"):
|
|
761
1219
|
If not provided as argument the path to the lockfile is inferred. However, the
|
762
1220
|
file has to exist, unless `ignore_lockfile` is set to `True`.
|
763
1221
|
|
764
|
-
Note that the root project of the poetry project is not installed,
|
765
|
-
|
1222
|
+
Note that the root project of the poetry project is not installed, only the dependencies.
|
1223
|
+
For including local python source files see `add_local_python_source`
|
766
1224
|
"""
|
767
1225
|
|
768
1226
|
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
@@ -779,7 +1237,8 @@ class _Image(_Object, type_prefix="im"):
|
|
779
1237
|
p = Path(poetry_pyproject_toml).parent / "poetry.lock"
|
780
1238
|
if not p.exists():
|
781
1239
|
raise NotFoundError(
|
782
|
-
f"poetry.lock not found at inferred location: {p.absolute()}.
|
1240
|
+
f"poetry.lock not found at inferred location: {p.absolute()}. "
|
1241
|
+
"If a lockfile is not needed, `ignore_lockfile=True` can be used."
|
783
1242
|
)
|
784
1243
|
poetry_lockfile = p.as_posix()
|
785
1244
|
context_files["/.poetry.lock"] = poetry_lockfile
|
@@ -818,15 +1277,64 @@ class _Image(_Object, type_prefix="im"):
|
|
818
1277
|
|
819
1278
|
def dockerfile_commands(
|
820
1279
|
self,
|
821
|
-
*dockerfile_commands: Union[str,
|
822
|
-
context_files:
|
1280
|
+
*dockerfile_commands: Union[str, list[str]],
|
1281
|
+
context_files: dict[str, str] = {},
|
823
1282
|
secrets: Sequence[_Secret] = [],
|
824
1283
|
gpu: GPU_T = None,
|
825
1284
|
# modal.Mount with local files to supply as build context for COPY commands
|
826
1285
|
context_mount: Optional[_Mount] = None,
|
827
|
-
force_build: bool = False,
|
1286
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
1287
|
+
ignore: Union[Sequence[str], Callable[[Path], bool]] = AUTO_DOCKERIGNORE,
|
828
1288
|
) -> "_Image":
|
829
|
-
"""
|
1289
|
+
"""
|
1290
|
+
Extend an image with arbitrary Dockerfile-like commands.
|
1291
|
+
|
1292
|
+
**Usage:**
|
1293
|
+
|
1294
|
+
```python
|
1295
|
+
from pathlib import Path
|
1296
|
+
from modal import FilePatternMatcher
|
1297
|
+
|
1298
|
+
# By default a .dockerignore file is used if present in the current working directory
|
1299
|
+
image = modal.Image.debian_slim().dockerfile_commands(
|
1300
|
+
["COPY data /data"],
|
1301
|
+
)
|
1302
|
+
|
1303
|
+
image = modal.Image.debian_slim().dockerfile_commands(
|
1304
|
+
["COPY data /data"],
|
1305
|
+
ignore=["*.venv"],
|
1306
|
+
)
|
1307
|
+
|
1308
|
+
image = modal.Image.debian_slim().dockerfile_commands(
|
1309
|
+
["COPY data /data"],
|
1310
|
+
ignore=lambda p: p.is_relative_to(".venv"),
|
1311
|
+
)
|
1312
|
+
|
1313
|
+
image = modal.Image.debian_slim().dockerfile_commands(
|
1314
|
+
["COPY data /data"],
|
1315
|
+
ignore=FilePatternMatcher("**/*.txt"),
|
1316
|
+
)
|
1317
|
+
|
1318
|
+
# When including files is simpler than excluding them, you can use the `~` operator to invert the matcher.
|
1319
|
+
image = modal.Image.debian_slim().dockerfile_commands(
|
1320
|
+
["COPY data /data"],
|
1321
|
+
ignore=~FilePatternMatcher("**/*.py"),
|
1322
|
+
)
|
1323
|
+
|
1324
|
+
# You can also read ignore patterns from a file.
|
1325
|
+
image = modal.Image.debian_slim().dockerfile_commands(
|
1326
|
+
["COPY data /data"],
|
1327
|
+
ignore=FilePatternMatcher.from_file(Path("/path/to/dockerignore")),
|
1328
|
+
)
|
1329
|
+
```
|
1330
|
+
"""
|
1331
|
+
if context_mount is not None:
|
1332
|
+
deprecation_warning(
|
1333
|
+
(2025, 1, 13),
|
1334
|
+
"`context_mount` is deprecated."
|
1335
|
+
+ " Files are now automatically added to the build context based on the commands.",
|
1336
|
+
pending=True,
|
1337
|
+
)
|
830
1338
|
cmds = _flatten_str_args("dockerfile_commands", "dockerfile_commands", dockerfile_commands)
|
831
1339
|
if not cmds:
|
832
1340
|
return self
|
@@ -838,17 +1346,41 @@ class _Image(_Object, type_prefix="im"):
|
|
838
1346
|
base_images={"base": self},
|
839
1347
|
dockerfile_function=build_dockerfile,
|
840
1348
|
secrets=secrets,
|
841
|
-
gpu_config=parse_gpu_config(gpu
|
842
|
-
|
1349
|
+
gpu_config=parse_gpu_config(gpu),
|
1350
|
+
context_mount_function=_create_context_mount_function(
|
1351
|
+
ignore=ignore, dockerfile_cmds=cmds, context_mount=context_mount
|
1352
|
+
),
|
843
1353
|
force_build=self.force_build or force_build,
|
844
1354
|
)
|
845
1355
|
|
1356
|
+
def entrypoint(
|
1357
|
+
self,
|
1358
|
+
entrypoint_commands: list[str],
|
1359
|
+
) -> "_Image":
|
1360
|
+
"""Set the entrypoint for the image."""
|
1361
|
+
args_str = _flatten_str_args("entrypoint", "entrypoint_files", entrypoint_commands)
|
1362
|
+
args_str = '"' + '", "'.join(args_str) + '"' if args_str else ""
|
1363
|
+
dockerfile_cmd = f"ENTRYPOINT [{args_str}]"
|
1364
|
+
|
1365
|
+
return self.dockerfile_commands(dockerfile_cmd)
|
1366
|
+
|
1367
|
+
def shell(
|
1368
|
+
self,
|
1369
|
+
shell_commands: list[str],
|
1370
|
+
) -> "_Image":
|
1371
|
+
"""Overwrite default shell for the image."""
|
1372
|
+
args_str = _flatten_str_args("shell", "shell_commands", shell_commands)
|
1373
|
+
args_str = '"' + '", "'.join(args_str) + '"' if args_str else ""
|
1374
|
+
dockerfile_cmd = f"SHELL [{args_str}]"
|
1375
|
+
|
1376
|
+
return self.dockerfile_commands(dockerfile_cmd)
|
1377
|
+
|
846
1378
|
def run_commands(
|
847
1379
|
self,
|
848
|
-
*commands: Union[str,
|
1380
|
+
*commands: Union[str, list[str]],
|
849
1381
|
secrets: Sequence[_Secret] = [],
|
850
1382
|
gpu: GPU_T = None,
|
851
|
-
force_build: bool = False,
|
1383
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
852
1384
|
) -> "_Image":
|
853
1385
|
"""Extend an image with a list of shell commands to run."""
|
854
1386
|
cmds = _flatten_str_args("run_commands", "commands", commands)
|
@@ -862,164 +1394,63 @@ class _Image(_Object, type_prefix="im"):
|
|
862
1394
|
base_images={"base": self},
|
863
1395
|
dockerfile_function=build_dockerfile,
|
864
1396
|
secrets=secrets,
|
865
|
-
gpu_config=parse_gpu_config(gpu
|
1397
|
+
gpu_config=parse_gpu_config(gpu),
|
866
1398
|
force_build=self.force_build or force_build,
|
867
1399
|
)
|
868
1400
|
|
869
1401
|
@staticmethod
|
870
|
-
def conda(python_version: Optional[str] = None, force_build: bool = False)
|
871
|
-
"""
|
872
|
-
|
873
|
-
|
874
|
-
|
875
|
-
|
876
|
-
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
877
|
-
nonlocal python_version
|
878
|
-
if version == "2023.12" and python_version is None:
|
879
|
-
python_version = "3.9" # Backcompat for old hardcoded default param
|
880
|
-
validated_python_version = _validate_python_version(python_version)
|
881
|
-
debian_codename = _dockerhub_debian_codename(version)
|
882
|
-
requirements_path = _get_modal_requirements_path(version, python_version)
|
883
|
-
context_files = {CONTAINER_REQUIREMENTS_PATH: requirements_path}
|
884
|
-
|
885
|
-
# Doesn't use the official continuumio/miniconda3 image as a base. That image has maintenance
|
886
|
-
# issues (https://github.com/ContinuumIO/docker-images/issues) and building our own is more flexible.
|
887
|
-
conda_install_script = "https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh"
|
888
|
-
commands = [
|
889
|
-
f"FROM debian:{debian_codename}", # the -slim images lack files required by Conda.
|
890
|
-
# Temporarily add utility packages for conda installation.
|
891
|
-
"RUN apt-get --quiet update && apt-get --quiet --yes install curl bzip2 \\",
|
892
|
-
f"&& curl --silent --show-error --location {conda_install_script} --output /tmp/miniconda.sh \\",
|
893
|
-
# Install miniconda to a filesystem location on the $PATH of Modal container tasks.
|
894
|
-
# -b = install in batch mode w/o manual intervention.
|
895
|
-
# -f = allow install prefix to already exist.
|
896
|
-
# -p = the install prefix location.
|
897
|
-
"&& bash /tmp/miniconda.sh -bfp /usr/local \\ ",
|
898
|
-
"&& rm -rf /tmp/miniconda.sh",
|
899
|
-
# Biggest and most stable community-led Conda channel.
|
900
|
-
"RUN conda config --add channels conda-forge \\ ",
|
901
|
-
# softlinking can put conda in a broken state, surfacing error on uninstall like:
|
902
|
-
# `No such device or address: '/usr/local/lib/libz.so' -> '/usr/local/lib/libz.so.c~'`
|
903
|
-
"&& conda config --set allow_softlinks false \\ ",
|
904
|
-
# Install requested Python version from conda-forge channel; base debian image has only 3.7.
|
905
|
-
f"&& conda install --yes --channel conda-forge python={validated_python_version} \\ ",
|
906
|
-
"&& conda update conda \\ ",
|
907
|
-
# Remove now unneeded packages and files.
|
908
|
-
"&& apt-get --quiet --yes remove curl bzip2 \\ ",
|
909
|
-
"&& apt-get --quiet --yes autoremove \\ ",
|
910
|
-
"&& apt-get autoclean \\ ",
|
911
|
-
"&& rm -rf /var/lib/apt/lists/* /var/log/dpkg.log \\ ",
|
912
|
-
"&& conda clean --all --yes",
|
913
|
-
# Setup .bashrc for conda.
|
914
|
-
"RUN conda init bash --verbose",
|
915
|
-
f"COPY {CONTAINER_REQUIREMENTS_PATH} {CONTAINER_REQUIREMENTS_PATH}",
|
916
|
-
# .bashrc is explicitly sourced because RUN is a non-login shell and doesn't run bash.
|
917
|
-
"RUN . /root/.bashrc && conda activate base \\ ",
|
918
|
-
# Ensure that packaging tools are up to date and install client dependenices
|
919
|
-
f"&& python -m pip install --upgrade {'pip' if version == '2023.12' else 'pip wheel'} \\ ",
|
920
|
-
f"&& python -m {_get_modal_requirements_command(version)}",
|
921
|
-
]
|
922
|
-
if version > "2023.12":
|
923
|
-
commands.append(f"RUN rm {CONTAINER_REQUIREMENTS_PATH}")
|
924
|
-
return DockerfileSpec(commands=commands, context_files=context_files)
|
925
|
-
|
926
|
-
base = _Image._from_args(
|
927
|
-
dockerfile_function=build_dockerfile,
|
928
|
-
force_build=force_build,
|
929
|
-
_namespace=api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
|
930
|
-
)
|
931
|
-
|
932
|
-
return base.dockerfile_commands(
|
933
|
-
[
|
934
|
-
"ENV CONDA_EXE=/usr/local/bin/conda",
|
935
|
-
"ENV CONDA_PREFIX=/usr/local",
|
936
|
-
"ENV CONDA_PROMPT_MODIFIER=(base)",
|
937
|
-
"ENV CONDA_SHLVL=1",
|
938
|
-
"ENV CONDA_PYTHON_EXE=/usr/local/bin/python",
|
939
|
-
"ENV CONDA_DEFAULT_ENV=base",
|
940
|
-
]
|
1402
|
+
def conda(python_version: Optional[str] = None, force_build: bool = False):
|
1403
|
+
"""mdmd:hidden"""
|
1404
|
+
message = (
|
1405
|
+
"`Image.conda` is deprecated."
|
1406
|
+
" Please use the faster and more reliable `Image.micromamba` constructor instead."
|
941
1407
|
)
|
1408
|
+
deprecation_error((2024, 5, 2), message)
|
942
1409
|
|
943
1410
|
def conda_install(
|
944
1411
|
self,
|
945
|
-
*packages: Union[str,
|
946
|
-
channels:
|
947
|
-
force_build: bool = False,
|
1412
|
+
*packages: Union[str, list[str]], # A list of Python packages, eg. ["numpy", "matplotlib>=3.5.0"]
|
1413
|
+
channels: list[str] = [], # A list of Conda channels, eg. ["conda-forge", "nvidia"]
|
1414
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
948
1415
|
secrets: Sequence[_Secret] = [],
|
949
1416
|
gpu: GPU_T = None,
|
950
|
-
)
|
951
|
-
"""
|
952
|
-
|
953
|
-
|
954
|
-
|
955
|
-
if not pkgs:
|
956
|
-
return self
|
957
|
-
|
958
|
-
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
959
|
-
package_args = " ".join(shlex.quote(pkg) for pkg in pkgs)
|
960
|
-
channel_args = "".join(f" -c {channel}" for channel in channels)
|
961
|
-
|
962
|
-
commands = [
|
963
|
-
"FROM base",
|
964
|
-
f"RUN conda install {package_args}{channel_args} --yes \\ ",
|
965
|
-
"&& conda clean --yes --index-cache --tarballs --tempfiles --logfiles",
|
966
|
-
]
|
967
|
-
return DockerfileSpec(commands=commands, context_files={})
|
968
|
-
|
969
|
-
return _Image._from_args(
|
970
|
-
base_images={"base": self},
|
971
|
-
dockerfile_function=build_dockerfile,
|
972
|
-
force_build=self.force_build or force_build,
|
973
|
-
secrets=secrets,
|
974
|
-
gpu_config=parse_gpu_config(gpu),
|
1417
|
+
):
|
1418
|
+
"""mdmd:hidden"""
|
1419
|
+
message = (
|
1420
|
+
"`Image.conda_install` is deprecated."
|
1421
|
+
" Please use the faster and more reliable `Image.micromamba_install` instead."
|
975
1422
|
)
|
1423
|
+
deprecation_error((2024, 5, 2), message)
|
976
1424
|
|
977
1425
|
def conda_update_from_environment(
|
978
1426
|
self,
|
979
1427
|
environment_yml: str,
|
980
|
-
force_build: bool = False,
|
1428
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
981
1429
|
*,
|
982
1430
|
secrets: Sequence[_Secret] = [],
|
983
1431
|
gpu: GPU_T = None,
|
984
|
-
)
|
985
|
-
"""
|
986
|
-
|
987
|
-
|
988
|
-
|
989
|
-
|
990
|
-
commands = [
|
991
|
-
"FROM base",
|
992
|
-
"COPY /environment.yml /environment.yml",
|
993
|
-
"RUN conda env update --name base -f /environment.yml \\ ",
|
994
|
-
"&& conda clean --yes --index-cache --tarballs --tempfiles --logfiles",
|
995
|
-
]
|
996
|
-
return DockerfileSpec(commands=commands, context_files=context_files)
|
997
|
-
|
998
|
-
return _Image._from_args(
|
999
|
-
base_images={"base": self},
|
1000
|
-
dockerfile_function=build_dockerfile,
|
1001
|
-
force_build=self.force_build or force_build,
|
1002
|
-
secrets=secrets,
|
1003
|
-
gpu_config=parse_gpu_config(gpu),
|
1432
|
+
):
|
1433
|
+
"""mdmd:hidden"""
|
1434
|
+
message = (
|
1435
|
+
"Image.conda_update_from_environment` is deprecated."
|
1436
|
+
" Please use the `Image.micromamba_install` method (with the `spec_file` parameter) instead."
|
1004
1437
|
)
|
1438
|
+
deprecation_error((2024, 5, 2), message)
|
1005
1439
|
|
1006
1440
|
@staticmethod
|
1007
1441
|
def micromamba(
|
1008
1442
|
python_version: Optional[str] = None,
|
1009
|
-
force_build: bool = False,
|
1443
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
1010
1444
|
) -> "_Image":
|
1011
|
-
"""
|
1012
|
-
A Micromamba base image. Micromamba allows for fast building of small Conda-based containers.
|
1013
|
-
In most cases it will be faster than using [`Image.conda()`](/docs/reference/modal.Image#conda).
|
1014
|
-
"""
|
1445
|
+
"""A Micromamba base image. Micromamba allows for fast building of small Conda-based containers."""
|
1015
1446
|
|
1016
1447
|
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
1017
1448
|
nonlocal python_version
|
1018
1449
|
if version == "2023.12" and python_version is None:
|
1019
1450
|
python_version = "3.9" # Backcompat for old hardcoded default param
|
1020
|
-
validated_python_version = _validate_python_version(python_version)
|
1021
|
-
micromamba_version =
|
1022
|
-
debian_codename =
|
1451
|
+
validated_python_version = _validate_python_version(python_version, version)
|
1452
|
+
micromamba_version = _base_image_config("micromamba", version)
|
1453
|
+
debian_codename = _base_image_config("debian", version)
|
1023
1454
|
tag = f"mambaorg/micromamba:{micromamba_version}-{debian_codename}-slim"
|
1024
1455
|
setup_commands = [
|
1025
1456
|
'SHELL ["/usr/local/bin/_dockerfile_shell.sh"]',
|
@@ -1039,28 +1470,36 @@ class _Image(_Object, type_prefix="im"):
|
|
1039
1470
|
def micromamba_install(
|
1040
1471
|
self,
|
1041
1472
|
# A list of Python packages, eg. ["numpy", "matplotlib>=3.5.0"]
|
1042
|
-
*packages: Union[str,
|
1043
|
-
# A
|
1044
|
-
|
1045
|
-
|
1473
|
+
*packages: Union[str, list[str]],
|
1474
|
+
# A local path to a file containing package specifications
|
1475
|
+
spec_file: Optional[str] = None,
|
1476
|
+
# A list of Conda channels, eg. ["conda-forge", "nvidia"].
|
1477
|
+
channels: list[str] = [],
|
1478
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
1046
1479
|
secrets: Sequence[_Secret] = [],
|
1047
1480
|
gpu: GPU_T = None,
|
1048
1481
|
) -> "_Image":
|
1049
1482
|
"""Install a list of additional packages using micromamba."""
|
1050
|
-
|
1051
1483
|
pkgs = _flatten_str_args("micromamba_install", "packages", packages)
|
1052
|
-
if not pkgs:
|
1484
|
+
if not pkgs and spec_file is None:
|
1053
1485
|
return self
|
1054
1486
|
|
1055
1487
|
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
1056
|
-
package_args =
|
1488
|
+
package_args = shlex.join(pkgs)
|
1057
1489
|
channel_args = "".join(f" -c {channel}" for channel in channels)
|
1058
1490
|
|
1491
|
+
space = " " if package_args else ""
|
1492
|
+
remote_spec_file = "" if spec_file is None else f"/{os.path.basename(spec_file)}"
|
1493
|
+
file_arg = "" if spec_file is None else f"{space}-f {remote_spec_file} -n base"
|
1494
|
+
copy_commands = [] if spec_file is None else [f"COPY {remote_spec_file} {remote_spec_file}"]
|
1495
|
+
|
1059
1496
|
commands = [
|
1060
1497
|
"FROM base",
|
1061
|
-
|
1498
|
+
*copy_commands,
|
1499
|
+
f"RUN micromamba install {package_args}{file_arg}{channel_args} --yes",
|
1062
1500
|
]
|
1063
|
-
|
1501
|
+
context_files = {} if spec_file is None else {remote_spec_file: os.path.expanduser(spec_file)}
|
1502
|
+
return DockerfileSpec(commands=commands, context_files=context_files)
|
1064
1503
|
|
1065
1504
|
return _Image._from_args(
|
1066
1505
|
base_images={"base": self},
|
@@ -1074,22 +1513,30 @@ class _Image(_Object, type_prefix="im"):
|
|
1074
1513
|
def _registry_setup_commands(
|
1075
1514
|
tag: str,
|
1076
1515
|
builder_version: ImageBuilderVersion,
|
1077
|
-
setup_commands:
|
1516
|
+
setup_commands: list[str],
|
1078
1517
|
add_python: Optional[str] = None,
|
1079
|
-
) ->
|
1080
|
-
add_python_commands:
|
1518
|
+
) -> list[str]:
|
1519
|
+
add_python_commands: list[str] = []
|
1081
1520
|
if add_python:
|
1082
|
-
_validate_python_version(add_python, allow_micro_granularity=False)
|
1521
|
+
_validate_python_version(add_python, builder_version, allow_micro_granularity=False)
|
1083
1522
|
add_python_commands = [
|
1084
1523
|
"COPY /python/. /usr/local",
|
1085
|
-
"RUN ln -s /usr/local/bin/python3 /usr/local/bin/python",
|
1086
1524
|
"ENV TERMINFO_DIRS=/etc/terminfo:/lib/terminfo:/usr/share/terminfo:/usr/lib/terminfo",
|
1087
1525
|
]
|
1088
|
-
|
1526
|
+
python_minor = add_python.split(".")[1]
|
1527
|
+
if int(python_minor) < 13:
|
1528
|
+
# Previous versions did not include the `python` binary, but later ones do.
|
1529
|
+
# (The important factor is not the Python version itself, but the standalone dist version.)
|
1530
|
+
# We insert the command in the list at the position it was previously always added
|
1531
|
+
# for backwards compatibility with existing images.
|
1532
|
+
add_python_commands.insert(1, "RUN ln -s /usr/local/bin/python3 /usr/local/bin/python")
|
1533
|
+
|
1534
|
+
# Note: this change is because we install dependencies with uv in 2024.10+
|
1535
|
+
requirements_prefix = "python -m " if builder_version < "2024.10" else ""
|
1089
1536
|
modal_requirements_commands = [
|
1090
1537
|
f"COPY {CONTAINER_REQUIREMENTS_PATH} {CONTAINER_REQUIREMENTS_PATH}",
|
1091
|
-
f"RUN python -m pip install --upgrade {'
|
1092
|
-
f"RUN
|
1538
|
+
f"RUN python -m pip install --upgrade {_base_image_config('package_tools', builder_version)}",
|
1539
|
+
f"RUN {requirements_prefix}{_get_modal_requirements_command(builder_version)}",
|
1093
1540
|
]
|
1094
1541
|
if builder_version > "2023.12":
|
1095
1542
|
modal_requirements_commands.append(f"RUN rm {CONTAINER_REQUIREMENTS_PATH}")
|
@@ -1106,8 +1553,8 @@ class _Image(_Object, type_prefix="im"):
|
|
1106
1553
|
tag: str,
|
1107
1554
|
*,
|
1108
1555
|
secret: Optional[_Secret] = None,
|
1109
|
-
setup_dockerfile_commands:
|
1110
|
-
force_build: bool = False,
|
1556
|
+
setup_dockerfile_commands: list[str] = [],
|
1557
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
1111
1558
|
add_python: Optional[str] = None,
|
1112
1559
|
**kwargs,
|
1113
1560
|
) -> "_Image":
|
@@ -1116,19 +1563,19 @@ class _Image(_Object, type_prefix="im"):
|
|
1116
1563
|
The image must be built for the `linux/amd64` platform.
|
1117
1564
|
|
1118
1565
|
If your image does not come with Python installed, you can use the `add_python` parameter
|
1119
|
-
to specify a version of Python to add to the image.
|
1120
|
-
|
1121
|
-
on PATH as `python`, along with `pip`.
|
1566
|
+
to specify a version of Python to add to the image. Otherwise, the image is expected to
|
1567
|
+
have Python on PATH as `python`, along with `pip`.
|
1122
1568
|
|
1123
1569
|
You may also use `setup_dockerfile_commands` to run Dockerfile commands before the
|
1124
1570
|
remaining commands run. This might be useful if you want a custom Python installation or to
|
1125
1571
|
set a `SHELL`. Prefer `run_commands()` when possible though.
|
1126
1572
|
|
1127
1573
|
To authenticate against a private registry with static credentials, you must set the `secret` parameter to
|
1128
|
-
a `modal.Secret` containing a username (`REGISTRY_USERNAME`) and
|
1574
|
+
a `modal.Secret` containing a username (`REGISTRY_USERNAME`) and
|
1575
|
+
an access token or password (`REGISTRY_PASSWORD`).
|
1129
1576
|
|
1130
|
-
To authenticate against private registries with credentials from a cloud provider,
|
1131
|
-
or `Image.from_aws_ecr()`.
|
1577
|
+
To authenticate against private registries with credentials from a cloud provider,
|
1578
|
+
use `Image.from_gcp_artifact_registry()` or `Image.from_aws_ecr()`.
|
1132
1579
|
|
1133
1580
|
**Examples**
|
1134
1581
|
|
@@ -1138,11 +1585,15 @@ class _Image(_Object, type_prefix="im"):
|
|
1138
1585
|
modal.Image.from_registry("nvcr.io/nvidia/pytorch:22.12-py3")
|
1139
1586
|
```
|
1140
1587
|
"""
|
1141
|
-
|
1142
|
-
|
1143
|
-
|
1144
|
-
|
1145
|
-
|
1588
|
+
|
1589
|
+
def context_mount_function() -> Optional[_Mount]:
|
1590
|
+
return (
|
1591
|
+
_Mount.from_name(
|
1592
|
+
python_standalone_mount_name(add_python),
|
1593
|
+
namespace=api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
|
1594
|
+
)
|
1595
|
+
if add_python
|
1596
|
+
else None
|
1146
1597
|
)
|
1147
1598
|
|
1148
1599
|
if "image_registry_config" not in kwargs and secret is not None:
|
@@ -1155,7 +1606,7 @@ class _Image(_Object, type_prefix="im"):
|
|
1155
1606
|
|
1156
1607
|
return _Image._from_args(
|
1157
1608
|
dockerfile_function=build_dockerfile,
|
1158
|
-
|
1609
|
+
context_mount_function=context_mount_function,
|
1159
1610
|
force_build=force_build,
|
1160
1611
|
**kwargs,
|
1161
1612
|
)
|
@@ -1165,21 +1616,24 @@ class _Image(_Object, type_prefix="im"):
|
|
1165
1616
|
tag: str,
|
1166
1617
|
secret: Optional[_Secret] = None,
|
1167
1618
|
*,
|
1168
|
-
setup_dockerfile_commands:
|
1169
|
-
force_build: bool = False,
|
1619
|
+
setup_dockerfile_commands: list[str] = [],
|
1620
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
1170
1621
|
add_python: Optional[str] = None,
|
1171
1622
|
**kwargs,
|
1172
1623
|
) -> "_Image":
|
1173
1624
|
"""Build a Modal image from a private image in Google Cloud Platform (GCP) Artifact Registry.
|
1174
1625
|
|
1175
1626
|
You will need to pass a `modal.Secret` containing [your GCP service account key data](https://cloud.google.com/iam/docs/keys-create-delete#creating)
|
1176
|
-
as `SERVICE_ACCOUNT_JSON`. This can be done from the [Secrets](/secrets) page.
|
1177
|
-
role depending on the GCP registry used:
|
1627
|
+
as `SERVICE_ACCOUNT_JSON`. This can be done from the [Secrets](/secrets) page.
|
1628
|
+
Your service account should be granted a specific role depending on the GCP registry used:
|
1178
1629
|
|
1179
|
-
- For Artifact Registry images (`pkg.dev` domains) use
|
1180
|
-
|
1630
|
+
- For Artifact Registry images (`pkg.dev` domains) use
|
1631
|
+
the ["Artifact Registry Reader"](https://cloud.google.com/artifact-registry/docs/access-control#roles) role
|
1632
|
+
- For Container Registry images (`gcr.io` domains) use
|
1633
|
+
the ["Storage Object Viewer"](https://cloud.google.com/artifact-registry/docs/transition/setup-gcr-repo) role
|
1181
1634
|
|
1182
|
-
**Note:** This method does not use `GOOGLE_APPLICATION_CREDENTIALS` as that
|
1635
|
+
**Note:** This method does not use `GOOGLE_APPLICATION_CREDENTIALS` as that
|
1636
|
+
variable accepts a path to a JSON file, not the actual JSON string.
|
1183
1637
|
|
1184
1638
|
See `Image.from_registry()` for information about the other parameters.
|
1185
1639
|
|
@@ -1188,7 +1642,10 @@ class _Image(_Object, type_prefix="im"):
|
|
1188
1642
|
```python
|
1189
1643
|
modal.Image.from_gcp_artifact_registry(
|
1190
1644
|
"us-east1-docker.pkg.dev/my-project-1234/my-repo/my-image:my-version",
|
1191
|
-
secret=modal.Secret.from_name(
|
1645
|
+
secret=modal.Secret.from_name(
|
1646
|
+
"my-gcp-secret",
|
1647
|
+
required_keys=["SERVICE_ACCOUNT_JSON"],
|
1648
|
+
),
|
1192
1649
|
add_python="3.11",
|
1193
1650
|
)
|
1194
1651
|
```
|
@@ -1210,15 +1667,15 @@ class _Image(_Object, type_prefix="im"):
|
|
1210
1667
|
tag: str,
|
1211
1668
|
secret: Optional[_Secret] = None,
|
1212
1669
|
*,
|
1213
|
-
setup_dockerfile_commands:
|
1214
|
-
force_build: bool = False,
|
1670
|
+
setup_dockerfile_commands: list[str] = [],
|
1671
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
1215
1672
|
add_python: Optional[str] = None,
|
1216
1673
|
**kwargs,
|
1217
1674
|
) -> "_Image":
|
1218
1675
|
"""Build a Modal image from a private image in AWS Elastic Container Registry (ECR).
|
1219
1676
|
|
1220
|
-
You will need to pass a `modal.Secret` containing
|
1221
|
-
|
1677
|
+
You will need to pass a `modal.Secret` containing `AWS_ACCESS_KEY_ID`,
|
1678
|
+
`AWS_SECRET_ACCESS_KEY`, and `AWS_REGION` to access the target ECR registry.
|
1222
1679
|
|
1223
1680
|
IAM configuration details can be found in the AWS documentation for
|
1224
1681
|
["Private repository policies"](https://docs.aws.amazon.com/AmazonECR/latest/userguide/repository-policies.html).
|
@@ -1230,7 +1687,10 @@ class _Image(_Object, type_prefix="im"):
|
|
1230
1687
|
```python
|
1231
1688
|
modal.Image.from_aws_ecr(
|
1232
1689
|
"000000000000.dkr.ecr.us-east-1.amazonaws.com/my-private-registry:my-version",
|
1233
|
-
secret=modal.Secret.from_name(
|
1690
|
+
secret=modal.Secret.from_name(
|
1691
|
+
"aws",
|
1692
|
+
required_keys=["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
|
1693
|
+
),
|
1234
1694
|
add_python="3.11",
|
1235
1695
|
)
|
1236
1696
|
```
|
@@ -1249,28 +1709,76 @@ class _Image(_Object, type_prefix="im"):
|
|
1249
1709
|
|
1250
1710
|
@staticmethod
|
1251
1711
|
def from_dockerfile(
|
1712
|
+
# Filepath to Dockerfile.
|
1252
1713
|
path: Union[str, Path],
|
1253
|
-
|
1254
|
-
|
1255
|
-
] = None,
|
1714
|
+
# modal.Mount with local files to supply as build context for COPY commands.
|
1715
|
+
# NOTE: The remote_path of the Mount should match the Dockerfile's WORKDIR.
|
1716
|
+
context_mount: Optional[_Mount] = None,
|
1717
|
+
# Ignore cached builds, similar to 'docker build --no-cache'
|
1256
1718
|
force_build: bool = False,
|
1257
1719
|
*,
|
1258
1720
|
secrets: Sequence[_Secret] = [],
|
1259
1721
|
gpu: GPU_T = None,
|
1260
1722
|
add_python: Optional[str] = None,
|
1723
|
+
ignore: Union[Sequence[str], Callable[[Path], bool]] = AUTO_DOCKERIGNORE,
|
1261
1724
|
) -> "_Image":
|
1262
1725
|
"""Build a Modal image from a local Dockerfile.
|
1263
1726
|
|
1264
1727
|
If your Dockerfile does not have Python installed, you can use the `add_python` parameter
|
1265
|
-
to specify a version of Python to add to the image.
|
1266
|
-
`3.10`, `3.11`, and `3.12`.
|
1728
|
+
to specify a version of Python to add to the image.
|
1267
1729
|
|
1268
|
-
**
|
1730
|
+
**Usage:**
|
1269
1731
|
|
1270
1732
|
```python
|
1271
|
-
|
1733
|
+
from pathlib import Path
|
1734
|
+
from modal import FilePatternMatcher
|
1735
|
+
|
1736
|
+
# By default a .dockerignore file is used if present in the current working directory
|
1737
|
+
image = modal.Image.from_dockerfile(
|
1738
|
+
"./Dockerfile",
|
1739
|
+
add_python="3.12",
|
1740
|
+
)
|
1741
|
+
|
1742
|
+
image = modal.Image.from_dockerfile(
|
1743
|
+
"./Dockerfile",
|
1744
|
+
add_python="3.12",
|
1745
|
+
ignore=["*.venv"],
|
1746
|
+
)
|
1747
|
+
|
1748
|
+
image = modal.Image.from_dockerfile(
|
1749
|
+
"./Dockerfile",
|
1750
|
+
add_python="3.12",
|
1751
|
+
ignore=lambda p: p.is_relative_to(".venv"),
|
1752
|
+
)
|
1753
|
+
|
1754
|
+
image = modal.Image.from_dockerfile(
|
1755
|
+
"./Dockerfile",
|
1756
|
+
add_python="3.12",
|
1757
|
+
ignore=FilePatternMatcher("**/*.txt"),
|
1758
|
+
)
|
1759
|
+
|
1760
|
+
# When including files is simpler than excluding them, you can use the `~` operator to invert the matcher.
|
1761
|
+
image = modal.Image.from_dockerfile(
|
1762
|
+
"./Dockerfile",
|
1763
|
+
add_python="3.12",
|
1764
|
+
ignore=~FilePatternMatcher("**/*.py"),
|
1765
|
+
)
|
1766
|
+
|
1767
|
+
# You can also read ignore patterns from a file.
|
1768
|
+
image = modal.Image.from_dockerfile(
|
1769
|
+
"./Dockerfile",
|
1770
|
+
add_python="3.12",
|
1771
|
+
ignore=FilePatternMatcher.from_file(Path("/path/to/dockerignore")),
|
1772
|
+
)
|
1272
1773
|
```
|
1273
1774
|
"""
|
1775
|
+
if context_mount is not None:
|
1776
|
+
deprecation_warning(
|
1777
|
+
(2025, 1, 13),
|
1778
|
+
"`context_mount` is deprecated."
|
1779
|
+
+ " Files are now automatically added to the build context based on the commands in the Dockerfile.",
|
1780
|
+
pending=True,
|
1781
|
+
)
|
1274
1782
|
|
1275
1783
|
# --- Build the base dockerfile
|
1276
1784
|
|
@@ -1282,7 +1790,9 @@ class _Image(_Object, type_prefix="im"):
|
|
1282
1790
|
gpu_config = parse_gpu_config(gpu)
|
1283
1791
|
base_image = _Image._from_args(
|
1284
1792
|
dockerfile_function=build_dockerfile_base,
|
1285
|
-
|
1793
|
+
context_mount_function=_create_context_mount_function(
|
1794
|
+
ignore=ignore, dockerfile_path=Path(path), context_mount=context_mount
|
1795
|
+
),
|
1286
1796
|
gpu_config=gpu_config,
|
1287
1797
|
secrets=secrets,
|
1288
1798
|
)
|
@@ -1291,13 +1801,15 @@ class _Image(_Object, type_prefix="im"):
|
|
1291
1801
|
# This happening in two steps is probably a vestigial consequence of previous limitations,
|
1292
1802
|
# but it will be difficult to merge them without forcing rebuilds of images.
|
1293
1803
|
|
1294
|
-
|
1295
|
-
|
1296
|
-
|
1297
|
-
|
1804
|
+
def add_python_mount():
|
1805
|
+
return (
|
1806
|
+
_Mount.from_name(
|
1807
|
+
python_standalone_mount_name(add_python),
|
1808
|
+
namespace=api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL,
|
1809
|
+
)
|
1810
|
+
if add_python
|
1811
|
+
else None
|
1298
1812
|
)
|
1299
|
-
else:
|
1300
|
-
context_mount = None
|
1301
1813
|
|
1302
1814
|
def build_dockerfile_python(version: ImageBuilderVersion) -> DockerfileSpec:
|
1303
1815
|
commands = _Image._registry_setup_commands("base", version, [], add_python)
|
@@ -1308,26 +1820,28 @@ class _Image(_Object, type_prefix="im"):
|
|
1308
1820
|
return _Image._from_args(
|
1309
1821
|
base_images={"base": base_image},
|
1310
1822
|
dockerfile_function=build_dockerfile_python,
|
1311
|
-
|
1823
|
+
context_mount_function=add_python_mount,
|
1312
1824
|
force_build=force_build,
|
1313
1825
|
)
|
1314
1826
|
|
1315
1827
|
@staticmethod
|
1316
1828
|
def debian_slim(python_version: Optional[str] = None, force_build: bool = False) -> "_Image":
|
1317
1829
|
"""Default image, based on the official `python` Docker images."""
|
1830
|
+
if isinstance(python_version, float):
|
1831
|
+
raise TypeError("The `python_version` argument should be a string, not a float.")
|
1318
1832
|
|
1319
1833
|
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
1320
1834
|
requirements_path = _get_modal_requirements_path(version, python_version)
|
1321
1835
|
context_files = {CONTAINER_REQUIREMENTS_PATH: requirements_path}
|
1322
1836
|
full_python_version = _dockerhub_python_version(version, python_version)
|
1323
|
-
debian_codename =
|
1837
|
+
debian_codename = _base_image_config("debian", version)
|
1324
1838
|
|
1325
1839
|
commands = [
|
1326
1840
|
f"FROM python:{full_python_version}-slim-{debian_codename}",
|
1327
1841
|
f"COPY {CONTAINER_REQUIREMENTS_PATH} {CONTAINER_REQUIREMENTS_PATH}",
|
1328
1842
|
"RUN apt-get update",
|
1329
1843
|
"RUN apt-get install -y gcc gfortran build-essential",
|
1330
|
-
f"RUN pip install --upgrade {'
|
1844
|
+
f"RUN pip install --upgrade {_base_image_config('package_tools', version)}",
|
1331
1845
|
f"RUN {_get_modal_requirements_command(version)}",
|
1332
1846
|
# Set debian front-end to non-interactive to avoid users getting stuck with input prompts.
|
1333
1847
|
"RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections",
|
@@ -1344,8 +1858,8 @@ class _Image(_Object, type_prefix="im"):
|
|
1344
1858
|
|
1345
1859
|
def apt_install(
|
1346
1860
|
self,
|
1347
|
-
*packages: Union[str,
|
1348
|
-
force_build: bool = False,
|
1861
|
+
*packages: Union[str, list[str]], # A list of packages, e.g. ["ssh", "libpq-dev"]
|
1862
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
1349
1863
|
secrets: Sequence[_Secret] = [],
|
1350
1864
|
gpu: GPU_T = None,
|
1351
1865
|
) -> "_Image":
|
@@ -1361,7 +1875,7 @@ class _Image(_Object, type_prefix="im"):
|
|
1361
1875
|
if not pkgs:
|
1362
1876
|
return self
|
1363
1877
|
|
1364
|
-
package_args =
|
1878
|
+
package_args = shlex.join(pkgs)
|
1365
1879
|
|
1366
1880
|
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
1367
1881
|
commands = [
|
@@ -1381,28 +1895,33 @@ class _Image(_Object, type_prefix="im"):
|
|
1381
1895
|
|
1382
1896
|
def run_function(
|
1383
1897
|
self,
|
1384
|
-
raw_f: Callable,
|
1898
|
+
raw_f: Callable[..., Any],
|
1385
1899
|
secrets: Sequence[_Secret] = (), # Optional Modal Secret objects with environment variables for the container
|
1386
|
-
gpu:
|
1387
|
-
|
1388
|
-
|
1389
|
-
|
1900
|
+
gpu: Union[
|
1901
|
+
GPU_T, list[GPU_T]
|
1902
|
+
] = None, # GPU request as string ("any", "T4", ...), object (`modal.GPU.A100()`, ...), or a list of either
|
1903
|
+
mounts: Sequence[_Mount] = (), # Mounts attached to the function
|
1904
|
+
volumes: dict[Union[str, PurePosixPath], Union[_Volume, _CloudBucketMount]] = {}, # Volume mount paths
|
1905
|
+
network_file_systems: dict[Union[str, PurePosixPath], _NetworkFileSystem] = {}, # NFS mount paths
|
1390
1906
|
cpu: Optional[float] = None, # How many CPU cores to request. This is a soft limit.
|
1391
1907
|
memory: Optional[int] = None, # How much memory to request, in MiB. This is a soft limit.
|
1392
|
-
timeout: Optional[int] =
|
1393
|
-
force_build: bool = False,
|
1394
|
-
|
1908
|
+
timeout: Optional[int] = 60 * 60, # Maximum execution time of the function in seconds.
|
1909
|
+
force_build: bool = False, # Ignore cached builds, similar to 'docker build --no-cache'
|
1910
|
+
cloud: Optional[str] = None, # Cloud provider to run the function on. Possible values are aws, gcp, oci, auto.
|
1911
|
+
region: Optional[Union[str, Sequence[str]]] = None, # Region or regions to run the function on.
|
1395
1912
|
args: Sequence[Any] = (), # Positional arguments to the function.
|
1396
|
-
kwargs:
|
1913
|
+
kwargs: dict[str, Any] = {}, # Keyword arguments to the function.
|
1397
1914
|
) -> "_Image":
|
1398
1915
|
"""Run user-defined function `raw_f` as an image build step. The function runs just like an ordinary Modal
|
1399
|
-
function, and any kwargs accepted by `@app.function` (such as `Mount`s, `NetworkFileSystem`s,
|
1400
|
-
be supplied to it.
|
1916
|
+
function, and any kwargs accepted by `@app.function` (such as `Mount`s, `NetworkFileSystem`s,
|
1917
|
+
and resource requests) can be supplied to it.
|
1918
|
+
After it finishes execution, a snapshot of the resulting container file system is saved as an image.
|
1401
1919
|
|
1402
1920
|
**Note**
|
1403
1921
|
|
1404
|
-
Only the source code of `raw_f`, the contents of `**kwargs`, and any referenced *global* variables
|
1405
|
-
|
1922
|
+
Only the source code of `raw_f`, the contents of `**kwargs`, and any referenced *global* variables
|
1923
|
+
are used to determine whether the image has changed and needs to be rebuilt.
|
1924
|
+
If this function references other functions or variables, the image will not be rebuilt if you
|
1406
1925
|
make changes to them. You can force a rebuild by changing the function's source code itself.
|
1407
1926
|
|
1408
1927
|
**Example**
|
@@ -1422,23 +1941,27 @@ class _Image(_Object, type_prefix="im"):
|
|
1422
1941
|
"""
|
1423
1942
|
from .functions import _Function
|
1424
1943
|
|
1425
|
-
|
1944
|
+
if not callable(raw_f):
|
1945
|
+
raise InvalidError(f"Argument to Image.run_function must be a function, not {type(raw_f).__name__}.")
|
1946
|
+
elif raw_f.__name__ == "<lambda>":
|
1947
|
+
# It may be possible to support lambdas eventually, but for now we don't handle them well, so reject quickly
|
1948
|
+
raise InvalidError("Image.run_function does not support lambda functions.")
|
1426
1949
|
|
1427
|
-
if
|
1428
|
-
|
1429
|
-
|
1430
|
-
" If you are trying to download model weights, downloading it to the image itself is recommended and sufficient."
|
1431
|
-
)
|
1950
|
+
scheduler_placement = SchedulerPlacement(region=region) if region else None
|
1951
|
+
|
1952
|
+
info = FunctionInfo(raw_f)
|
1432
1953
|
|
1433
1954
|
function = _Function.from_args(
|
1434
1955
|
info,
|
1435
1956
|
app=None,
|
1436
|
-
image=self,
|
1437
|
-
secret=secret,
|
1957
|
+
image=self, # type: ignore[reportArgumentType] # TODO: probably conflict with type stub?
|
1438
1958
|
secrets=secrets,
|
1439
1959
|
gpu=gpu,
|
1440
1960
|
mounts=mounts,
|
1961
|
+
volumes=volumes,
|
1441
1962
|
network_file_systems=network_file_systems,
|
1963
|
+
cloud=cloud,
|
1964
|
+
scheduler_placement=scheduler_placement,
|
1442
1965
|
memory=memory,
|
1443
1966
|
timeout=timeout,
|
1444
1967
|
cpu=cpu,
|
@@ -1461,17 +1984,15 @@ class _Image(_Object, type_prefix="im"):
|
|
1461
1984
|
force_build=self.force_build or force_build,
|
1462
1985
|
)
|
1463
1986
|
|
1464
|
-
def env(self, vars:
|
1465
|
-
"""Sets the
|
1987
|
+
def env(self, vars: dict[str, str]) -> "_Image":
|
1988
|
+
"""Sets the environment variables in an Image.
|
1466
1989
|
|
1467
1990
|
**Example**
|
1468
1991
|
|
1469
1992
|
```python
|
1470
1993
|
image = (
|
1471
|
-
modal.Image.
|
1472
|
-
|
1473
|
-
.conda_install("jax", "cuda-nvcc", channels=["conda-forge", "nvidia"])
|
1474
|
-
.pip_install("dm-haiku", "optax")
|
1994
|
+
modal.Image.debian_slim()
|
1995
|
+
.env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
|
1475
1996
|
)
|
1476
1997
|
```
|
1477
1998
|
"""
|
@@ -1485,7 +2006,7 @@ class _Image(_Object, type_prefix="im"):
|
|
1485
2006
|
dockerfile_function=build_dockerfile,
|
1486
2007
|
)
|
1487
2008
|
|
1488
|
-
def workdir(self, path: str) -> "_Image":
|
2009
|
+
def workdir(self, path: Union[str, PurePosixPath]) -> "_Image":
|
1489
2010
|
"""Set the working directory for subsequent image build steps and function execution.
|
1490
2011
|
|
1491
2012
|
**Example**
|
@@ -1501,7 +2022,7 @@ class _Image(_Object, type_prefix="im"):
|
|
1501
2022
|
"""
|
1502
2023
|
|
1503
2024
|
def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
|
1504
|
-
commands = ["FROM base", f"WORKDIR {shlex.quote(path)}"]
|
2025
|
+
commands = ["FROM base", f"WORKDIR {shlex.quote(str(path))}"]
|
1505
2026
|
return DockerfileSpec(commands=commands, context_files={})
|
1506
2027
|
|
1507
2028
|
return _Image._from_args(
|
@@ -1539,17 +2060,25 @@ class _Image(_Object, type_prefix="im"):
|
|
1539
2060
|
if not isinstance(exc, ImportError):
|
1540
2061
|
warnings.warn(f"Warning: caught a non-ImportError exception in an `imports()` block: {repr(exc)}")
|
1541
2062
|
|
1542
|
-
|
1543
|
-
|
1544
|
-
|
1545
|
-
**Usage:**
|
2063
|
+
@live_method_gen
|
2064
|
+
async def _logs(self) -> typing.AsyncGenerator[str, None]:
|
2065
|
+
"""Streams logs from an image, or returns logs from an already completed image.
|
1546
2066
|
|
1547
|
-
|
1548
|
-
with image.imports():
|
1549
|
-
import torch
|
1550
|
-
```
|
2067
|
+
This method is considered private since its interface may change - use it at your own risk!
|
1551
2068
|
"""
|
1552
|
-
|
2069
|
+
last_entry_id: str = ""
|
2070
|
+
|
2071
|
+
request = api_pb2.ImageJoinStreamingRequest(
|
2072
|
+
image_id=self._object_id, timeout=55, last_entry_id=last_entry_id, include_logs_for_finished=True
|
2073
|
+
)
|
2074
|
+
async for response in self._client.stub.ImageJoinStreaming.unary_stream(request):
|
2075
|
+
if response.result.status:
|
2076
|
+
return
|
2077
|
+
if response.entry_id:
|
2078
|
+
last_entry_id = response.entry_id
|
2079
|
+
for task_log in response.task_logs:
|
2080
|
+
if task_log.data:
|
2081
|
+
yield task_log.data
|
1553
2082
|
|
1554
2083
|
|
1555
2084
|
Image = synchronize_api(_Image)
|