modal 0.71.9__py3-none-any.whl → 0.72.5__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/_utils/docker_utils.py +35 -1
- modal/_utils/function_utils.py +3 -3
- modal/app.py +1 -2
- modal/cli/launch.py +1 -1
- modal/cli/programs/run_jupyter.py +5 -10
- modal/cli/programs/vscode.py +5 -10
- modal/cli/volume.py +23 -0
- modal/client.pyi +2 -2
- modal/file_pattern_matcher.py +74 -41
- modal/functions.pyi +6 -6
- modal/image.py +166 -40
- modal/image.pyi +24 -4
- modal/mount.py +48 -2
- modal/mount.pyi +38 -0
- modal/volume.py +12 -0
- modal/volume.pyi +28 -0
- {modal-0.71.9.dist-info → modal-0.72.5.dist-info}/METADATA +1 -1
- {modal-0.71.9.dist-info → modal-0.72.5.dist-info}/RECORD +32 -32
- modal_global_objects/mounts/python_standalone.py +1 -1
- modal_proto/api.proto +6 -0
- modal_proto/api_grpc.py +16 -0
- modal_proto/api_pb2.py +83 -71
- modal_proto/api_pb2.pyi +17 -0
- modal_proto/api_pb2_grpc.py +33 -0
- modal_proto/api_pb2_grpc.pyi +10 -0
- modal_proto/modal_api_grpc.py +1 -0
- modal_version/__init__.py +1 -1
- modal_version/_version_generated.py +1 -1
- {modal-0.71.9.dist-info → modal-0.72.5.dist-info}/LICENSE +0 -0
- {modal-0.71.9.dist-info → modal-0.72.5.dist-info}/WHEEL +0 -0
- {modal-0.71.9.dist-info → modal-0.72.5.dist-info}/entry_points.txt +0 -0
- {modal-0.71.9.dist-info → modal-0.72.5.dist-info}/top_level.txt +0 -0
modal/_utils/docker_utils.py
CHANGED
@@ -1,7 +1,8 @@
|
|
1
1
|
# Copyright Modal Labs 2024
|
2
2
|
import re
|
3
3
|
import shlex
|
4
|
-
from
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Optional, Sequence
|
5
6
|
|
6
7
|
from ..exception import InvalidError
|
7
8
|
|
@@ -62,3 +63,36 @@ def extract_copy_command_patterns(dockerfile_lines: Sequence[str]) -> list[str]:
|
|
62
63
|
current_command = ""
|
63
64
|
|
64
65
|
return list(copy_source_patterns)
|
66
|
+
|
67
|
+
|
68
|
+
def find_dockerignore_file(context_directory: Path, dockerfile_path: Optional[Path] = None) -> Optional[Path]:
|
69
|
+
"""
|
70
|
+
Find dockerignore file relative to current context directory
|
71
|
+
and if dockerfile path is provided, check if specific <dockerfile_name>.dockerignore
|
72
|
+
file exists in the same directory as <dockerfile_name>
|
73
|
+
Finds the most specific dockerignore file that exists.
|
74
|
+
"""
|
75
|
+
|
76
|
+
def valid_dockerignore_file(fp):
|
77
|
+
# fp has to exist
|
78
|
+
if not fp.exists():
|
79
|
+
return False
|
80
|
+
# fp has to be subpath to current working directory
|
81
|
+
if not fp.is_relative_to(context_directory):
|
82
|
+
return False
|
83
|
+
|
84
|
+
return True
|
85
|
+
|
86
|
+
generic_name = ".dockerignore"
|
87
|
+
possible_locations = []
|
88
|
+
if dockerfile_path:
|
89
|
+
specific_name = f"{dockerfile_path.name}.dockerignore"
|
90
|
+
# 1. check if specific <dockerfile_name>.dockerignore file exists in the same directory as <dockerfile_name>
|
91
|
+
possible_locations.append(dockerfile_path.parent / specific_name)
|
92
|
+
# 2. check if generic .dockerignore file exists in the same directory as <dockerfile_name>
|
93
|
+
possible_locations.append(dockerfile_path.parent / generic_name)
|
94
|
+
|
95
|
+
# 3. check if generic .dockerignore file exists in current working directory
|
96
|
+
possible_locations.append(context_directory / generic_name)
|
97
|
+
|
98
|
+
return next((e for e in possible_locations if valid_dockerignore_file(e)), None)
|
modal/_utils/function_utils.py
CHANGED
@@ -326,11 +326,11 @@ class FunctionInfo:
|
|
326
326
|
# make sure the function's own entrypoint is included:
|
327
327
|
if self._type == FunctionInfoType.PACKAGE:
|
328
328
|
if config.get("automount"):
|
329
|
-
return [_Mount.
|
329
|
+
return [_Mount._from_local_python_packages(self.module_name)]
|
330
330
|
elif not self.is_serialized():
|
331
331
|
# mount only relevant file and __init__.py:s
|
332
332
|
return [
|
333
|
-
_Mount.
|
333
|
+
_Mount._from_local_dir(
|
334
334
|
self._base_dir,
|
335
335
|
remote_path=self._remote_dir,
|
336
336
|
recursive=True,
|
@@ -341,7 +341,7 @@ class FunctionInfo:
|
|
341
341
|
remote_path = ROOT_DIR / Path(self._file).name
|
342
342
|
if not _is_modal_path(remote_path):
|
343
343
|
return [
|
344
|
-
_Mount.
|
344
|
+
_Mount._from_local_file(
|
345
345
|
self._file,
|
346
346
|
remote_path=remote_path,
|
347
347
|
)
|
modal/app.py
CHANGED
@@ -200,10 +200,9 @@ class _App:
|
|
200
200
|
|
201
201
|
```python notest
|
202
202
|
image = modal.Image.debian_slim().pip_install(...)
|
203
|
-
mount = modal.Mount.from_local_dir("./config")
|
204
203
|
secret = modal.Secret.from_name("my-secret")
|
205
204
|
volume = modal.Volume.from_name("my-data")
|
206
|
-
app = modal.App(image=image,
|
205
|
+
app = modal.App(image=image, secrets=[secret], volumes={"/mnt/data": volume})
|
207
206
|
```
|
208
207
|
"""
|
209
208
|
if name is not None and not isinstance(name, str):
|
modal/cli/launch.py
CHANGED
@@ -55,7 +55,7 @@ def jupyter(
|
|
55
55
|
timeout: int = 3600,
|
56
56
|
image: str = "ubuntu:22.04",
|
57
57
|
add_python: Optional[str] = "3.11",
|
58
|
-
mount: Optional[str] = None, #
|
58
|
+
mount: Optional[str] = None, # Adds a local directory to the jupyter container
|
59
59
|
volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing).
|
60
60
|
detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects
|
61
61
|
):
|
@@ -10,25 +10,20 @@ import time
|
|
10
10
|
import webbrowser
|
11
11
|
from typing import Any
|
12
12
|
|
13
|
-
from modal import App, Image,
|
13
|
+
from modal import App, Image, Queue, Secret, Volume, forward
|
14
14
|
|
15
15
|
# Passed by `modal launch` locally via CLI, plumbed to remote runner through secrets.
|
16
16
|
args: dict[str, Any] = json.loads(os.environ.get("MODAL_LAUNCH_ARGS", "{}"))
|
17
17
|
|
18
|
-
|
19
18
|
app = App()
|
20
|
-
app.image = Image.from_registry(args.get("image"), add_python=args.get("add_python")).pip_install("jupyterlab")
|
21
19
|
|
20
|
+
image = Image.from_registry(args.get("image"), add_python=args.get("add_python")).pip_install("jupyterlab")
|
22
21
|
|
23
|
-
|
24
|
-
|
22
|
+
if args.get("mount"):
|
23
|
+
image = image.add_local_dir(
|
25
24
|
args.get("mount"),
|
26
25
|
remote_path="/root/lab/mount",
|
27
26
|
)
|
28
|
-
if args.get("mount")
|
29
|
-
else None
|
30
|
-
)
|
31
|
-
mounts = [mount] if mount else []
|
32
27
|
|
33
28
|
volume = (
|
34
29
|
Volume.from_name(
|
@@ -55,12 +50,12 @@ def wait_for_port(url: str, q: Queue):
|
|
55
50
|
|
56
51
|
|
57
52
|
@app.function(
|
53
|
+
image=image,
|
58
54
|
cpu=args.get("cpu"),
|
59
55
|
memory=args.get("memory"),
|
60
56
|
gpu=args.get("gpu"),
|
61
57
|
timeout=args.get("timeout"),
|
62
58
|
secrets=[Secret.from_dict({"MODAL_LAUNCH_ARGS": json.dumps(args)})],
|
63
|
-
mounts=mounts,
|
64
59
|
volumes=volumes,
|
65
60
|
concurrency_limit=1 if volume else None,
|
66
61
|
)
|
modal/cli/programs/vscode.py
CHANGED
@@ -10,7 +10,7 @@ import time
|
|
10
10
|
import webbrowser
|
11
11
|
from typing import Any
|
12
12
|
|
13
|
-
from modal import App, Image,
|
13
|
+
from modal import App, Image, Queue, Secret, Volume, forward
|
14
14
|
|
15
15
|
# Passed by `modal launch` locally via CLI, plumbed to remote runner through secrets.
|
16
16
|
args: dict[str, Any] = json.loads(os.environ.get("MODAL_LAUNCH_ARGS", "{}"))
|
@@ -23,7 +23,7 @@ FIXUD_INSTALLER = "https://github.com/boxboat/fixuid/releases/download/v0.6.0/fi
|
|
23
23
|
|
24
24
|
|
25
25
|
app = App()
|
26
|
-
|
26
|
+
image = (
|
27
27
|
Image.from_registry(args.get("image"), add_python="3.11")
|
28
28
|
.apt_install("curl", "dumb-init", "git", "git-lfs")
|
29
29
|
.run_commands(
|
@@ -44,16 +44,11 @@ app.image = (
|
|
44
44
|
.env({"ENTRYPOINTD": ""})
|
45
45
|
)
|
46
46
|
|
47
|
-
|
48
|
-
|
49
|
-
Mount.from_local_dir(
|
47
|
+
if args.get("mount"):
|
48
|
+
image = image.add_local_dir(
|
50
49
|
args.get("mount"),
|
51
50
|
remote_path="/home/coder/mount",
|
52
51
|
)
|
53
|
-
if args.get("mount")
|
54
|
-
else None
|
55
|
-
)
|
56
|
-
mounts = [mount] if mount else []
|
57
52
|
|
58
53
|
volume = (
|
59
54
|
Volume.from_name(
|
@@ -80,12 +75,12 @@ def wait_for_port(data: tuple[str, str], q: Queue):
|
|
80
75
|
|
81
76
|
|
82
77
|
@app.function(
|
78
|
+
image=image,
|
83
79
|
cpu=args.get("cpu"),
|
84
80
|
memory=args.get("memory"),
|
85
81
|
gpu=args.get("gpu"),
|
86
82
|
timeout=args.get("timeout"),
|
87
83
|
secrets=[Secret.from_dict({"MODAL_LAUNCH_ARGS": json.dumps(args)})],
|
88
|
-
mounts=mounts,
|
89
84
|
volumes=volumes,
|
90
85
|
concurrency_limit=1 if volume else None,
|
91
86
|
)
|
modal/cli/volume.py
CHANGED
@@ -287,3 +287,26 @@ async def delete(
|
|
287
287
|
)
|
288
288
|
|
289
289
|
await _Volume.delete(volume_name, environment_name=env)
|
290
|
+
|
291
|
+
|
292
|
+
@volume_cli.command(
|
293
|
+
name="rename",
|
294
|
+
help="Rename a modal.Volume.",
|
295
|
+
rich_help_panel="Management",
|
296
|
+
)
|
297
|
+
@synchronizer.create_blocking
|
298
|
+
async def rename(
|
299
|
+
old_name: str,
|
300
|
+
new_name: str,
|
301
|
+
yes: bool = YES_OPTION,
|
302
|
+
env: Optional[str] = ENV_OPTION,
|
303
|
+
):
|
304
|
+
if not yes:
|
305
|
+
typer.confirm(
|
306
|
+
f"Are you sure you want rename the modal.Volume '{old_name}'?"
|
307
|
+
" This may break any Apps currently using it.",
|
308
|
+
default=False,
|
309
|
+
abort=True,
|
310
|
+
)
|
311
|
+
|
312
|
+
await _Volume.rename(old_name, new_name, environment_name=env)
|
modal/client.pyi
CHANGED
@@ -26,7 +26,7 @@ class _Client:
|
|
26
26
|
_stub: typing.Optional[modal_proto.api_grpc.ModalClientStub]
|
27
27
|
|
28
28
|
def __init__(
|
29
|
-
self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.
|
29
|
+
self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.72.5"
|
30
30
|
): ...
|
31
31
|
def is_closed(self) -> bool: ...
|
32
32
|
@property
|
@@ -81,7 +81,7 @@ class Client:
|
|
81
81
|
_stub: typing.Optional[modal_proto.api_grpc.ModalClientStub]
|
82
82
|
|
83
83
|
def __init__(
|
84
|
-
self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.
|
84
|
+
self, server_url: str, client_type: int, credentials: typing.Optional[tuple[str, str]], version: str = "0.72.5"
|
85
85
|
): ...
|
86
86
|
def is_closed(self) -> bool: ...
|
87
87
|
@property
|
modal/file_pattern_matcher.py
CHANGED
@@ -35,7 +35,7 @@ class _AbstractPatternMatcher:
|
|
35
35
|
"""
|
36
36
|
return _CustomPatternMatcher(lambda path: not self(path))
|
37
37
|
|
38
|
-
def
|
38
|
+
def _with_repr(self, custom_repr) -> "_AbstractPatternMatcher":
|
39
39
|
# use to give an instance of a matcher a custom name - useful for visualizing default values in signatures
|
40
40
|
self._custom_repr = custom_repr
|
41
41
|
return self
|
@@ -60,7 +60,45 @@ class _CustomPatternMatcher(_AbstractPatternMatcher):
|
|
60
60
|
|
61
61
|
|
62
62
|
class FilePatternMatcher(_AbstractPatternMatcher):
|
63
|
-
"""
|
63
|
+
"""
|
64
|
+
Allows matching file Path objects against a list of patterns.
|
65
|
+
|
66
|
+
**Usage:**
|
67
|
+
```python
|
68
|
+
from pathlib import Path
|
69
|
+
from modal import FilePatternMatcher
|
70
|
+
|
71
|
+
matcher = FilePatternMatcher("*.py")
|
72
|
+
|
73
|
+
assert matcher(Path("foo.py"))
|
74
|
+
|
75
|
+
# You can also negate the matcher.
|
76
|
+
negated_matcher = ~matcher
|
77
|
+
|
78
|
+
assert not negated_matcher(Path("foo.py"))
|
79
|
+
```
|
80
|
+
"""
|
81
|
+
|
82
|
+
patterns: list[Pattern]
|
83
|
+
_delayed_init: Callable[[], None] = None
|
84
|
+
|
85
|
+
def _set_patterns(self, patterns: Sequence[str]) -> None:
|
86
|
+
self.patterns = []
|
87
|
+
for pattern in list(patterns):
|
88
|
+
pattern = pattern.strip()
|
89
|
+
if not pattern:
|
90
|
+
continue
|
91
|
+
pattern = os.path.normpath(pattern)
|
92
|
+
new_pattern = Pattern()
|
93
|
+
if pattern[0] == "!":
|
94
|
+
if len(pattern) == 1:
|
95
|
+
raise ValueError('Illegal exclusion pattern: "!"')
|
96
|
+
new_pattern.exclusion = True
|
97
|
+
pattern = pattern[1:]
|
98
|
+
# In Python, we can proceed without explicit syntax checking
|
99
|
+
new_pattern.cleaned_pattern = pattern
|
100
|
+
new_pattern.dirs = pattern.split(os.path.sep)
|
101
|
+
self.patterns.append(new_pattern)
|
64
102
|
|
65
103
|
def __init__(self, *pattern: str) -> None:
|
66
104
|
"""Initialize a new FilePatternMatcher instance.
|
@@ -71,24 +109,34 @@ class FilePatternMatcher(_AbstractPatternMatcher):
|
|
71
109
|
Raises:
|
72
110
|
ValueError: If an illegal exclusion pattern is provided.
|
73
111
|
"""
|
74
|
-
self.
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
112
|
+
self._set_patterns(pattern)
|
113
|
+
|
114
|
+
@classmethod
|
115
|
+
def from_file(cls, file_path: Path) -> "FilePatternMatcher":
|
116
|
+
"""Initialize a new FilePatternMatcher instance from a file.
|
117
|
+
|
118
|
+
The patterns in the file will be read lazily when the matcher is first used.
|
119
|
+
|
120
|
+
Args:
|
121
|
+
file_path (Path): The path to the file containing patterns.
|
122
|
+
|
123
|
+
**Usage:**
|
124
|
+
```python
|
125
|
+
from pathlib import Path
|
126
|
+
from modal import FilePatternMatcher
|
127
|
+
|
128
|
+
matcher = FilePatternMatcher.from_file(Path("/path/to/ignorefile"))
|
129
|
+
```
|
130
|
+
|
131
|
+
"""
|
132
|
+
uninitialized = cls.__new__(cls)
|
133
|
+
|
134
|
+
def _delayed_init():
|
135
|
+
uninitialized._set_patterns(file_path.read_text("utf8").splitlines())
|
136
|
+
uninitialized._delayed_init = None
|
137
|
+
|
138
|
+
uninitialized._delayed_init = _delayed_init
|
139
|
+
return uninitialized
|
92
140
|
|
93
141
|
def _matches(self, file_path: str) -> bool:
|
94
142
|
"""Check if the file path or any of its parent directories match the patterns.
|
@@ -97,6 +145,7 @@ class FilePatternMatcher(_AbstractPatternMatcher):
|
|
97
145
|
library. The reason is that `Matches()` in the original library is
|
98
146
|
deprecated due to buggy behavior.
|
99
147
|
"""
|
148
|
+
|
100
149
|
matched = False
|
101
150
|
file_path = os.path.normpath(file_path)
|
102
151
|
if file_path == ".":
|
@@ -128,31 +177,15 @@ class FilePatternMatcher(_AbstractPatternMatcher):
|
|
128
177
|
return matched
|
129
178
|
|
130
179
|
def __call__(self, file_path: Path) -> bool:
|
131
|
-
|
132
|
-
|
133
|
-
Args:
|
134
|
-
file_path (Path): The path to check.
|
135
|
-
|
136
|
-
Returns:
|
137
|
-
True if the path matches any of the patterns.
|
138
|
-
|
139
|
-
Usage:
|
140
|
-
```python
|
141
|
-
from pathlib import Path
|
142
|
-
from modal import FilePatternMatcher
|
143
|
-
|
144
|
-
matcher = FilePatternMatcher("*.py")
|
145
|
-
|
146
|
-
assert matcher(Path("foo.py"))
|
147
|
-
```
|
148
|
-
"""
|
180
|
+
if self._delayed_init:
|
181
|
+
self._delayed_init()
|
149
182
|
return self._matches(str(file_path))
|
150
183
|
|
151
184
|
|
152
|
-
#
|
185
|
+
# _with_repr allows us to use this matcher as a default value in a function signature
|
153
186
|
# and get a nice repr in the docs and auto-generated type stubs:
|
154
|
-
NON_PYTHON_FILES = (~FilePatternMatcher("**/*.py")).
|
155
|
-
_NOTHING = (~FilePatternMatcher()).
|
187
|
+
NON_PYTHON_FILES = (~FilePatternMatcher("**/*.py"))._with_repr(f"{__name__}.NON_PYTHON_FILES")
|
188
|
+
_NOTHING = (~FilePatternMatcher())._with_repr(f"{__name__}._NOTHING") # match everything = ignore nothing
|
156
189
|
|
157
190
|
|
158
191
|
def _ignore_fn(ignore: Union[Sequence[str], Callable[[Path], bool]]) -> Callable[[Path], bool]:
|
modal/functions.pyi
CHANGED
@@ -462,11 +462,11 @@ class Function(typing.Generic[P, ReturnType, OriginalReturnType], modal.object.O
|
|
462
462
|
|
463
463
|
_call_generator_nowait: ___call_generator_nowait_spec
|
464
464
|
|
465
|
-
class __remote_spec(typing_extensions.Protocol[
|
465
|
+
class __remote_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
|
466
466
|
def __call__(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER: ...
|
467
467
|
async def aio(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER: ...
|
468
468
|
|
469
|
-
remote: __remote_spec[
|
469
|
+
remote: __remote_spec[P, ReturnType]
|
470
470
|
|
471
471
|
class __remote_gen_spec(typing_extensions.Protocol):
|
472
472
|
def __call__(self, *args, **kwargs) -> typing.Generator[typing.Any, None, None]: ...
|
@@ -479,17 +479,17 @@ class Function(typing.Generic[P, ReturnType, OriginalReturnType], modal.object.O
|
|
479
479
|
def _get_obj(self) -> typing.Optional[modal.cls.Obj]: ...
|
480
480
|
def local(self, *args: P.args, **kwargs: P.kwargs) -> OriginalReturnType: ...
|
481
481
|
|
482
|
-
class ___experimental_spawn_spec(typing_extensions.Protocol[
|
482
|
+
class ___experimental_spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
|
483
483
|
def __call__(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
|
484
484
|
async def aio(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
|
485
485
|
|
486
|
-
_experimental_spawn: ___experimental_spawn_spec[
|
486
|
+
_experimental_spawn: ___experimental_spawn_spec[P, ReturnType]
|
487
487
|
|
488
|
-
class __spawn_spec(typing_extensions.Protocol[
|
488
|
+
class __spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
|
489
489
|
def __call__(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
|
490
490
|
async def aio(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
|
491
491
|
|
492
|
-
spawn: __spawn_spec[
|
492
|
+
spawn: __spawn_spec[P, ReturnType]
|
493
493
|
|
494
494
|
def get_raw_f(self) -> typing.Callable[..., typing.Any]: ...
|
495
495
|
|