Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mounts on images [CLI-4] - image.add_local_python_package #2343

Merged
merged 49 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
8bc041c
wip
freider Sep 2, 2024
b8d399f
wip
freider Sep 23, 2024
5e34e80
wip
freider Sep 26, 2024
af9f74f
wip
freider Sep 30, 2024
0b40907
Merge remote-tracking branch 'origin/main' into freider/mount-on-image
freider Oct 15, 2024
2a52f6d
Working consolidation of mounts
freider Oct 16, 2024
e237634
Functions now attach lazy mounts to the container
freider Oct 16, 2024
787dcc6
Fix sandboxes
freider Oct 16, 2024
c89b7a4
Fix env client issue
freider Oct 16, 2024
244af57
Disable test on windows
freider Oct 16, 2024
813d3f1
types
freider Oct 16, 2024
77186d0
Merge remote-tracking branch 'origin/main' into freider/mount-on-image
freider Oct 21, 2024
36bc55e
Disable auto mounts in image tests, trims ~30s of test time
freider Oct 21, 2024
14907e5
Make materialization an explicit step
freider Oct 22, 2024
19780c7
comment
freider Oct 22, 2024
18d931f
wip refactor
freider Oct 29, 2024
01aab3b
Fixes
freider Oct 31, 2024
cab50ff
Merge remote-tracking branch 'origin/main' into freider/mount-on-image
freider Oct 31, 2024
25b388b
Merge remote-tracking branch 'origin/main' into freider/mount-on-image
freider Oct 31, 2024
0abd75b
Fixing tests
freider Oct 31, 2024
6e2db24
Adds more watcher tests, including failing one
freider Oct 31, 2024
2ffac62
wip
freider Oct 31, 2024
7e41f47
Merge remote-tracking branch 'origin/main' into freider/mount-on-image
freider Nov 6, 2024
6b107a1
Move mount extraction logic post-app-load, include image mounts
freider Nov 6, 2024
dfc627b
if
freider Nov 6, 2024
fef3f9c
Merge branch 'freider/image-mount-watch-cli-219' into freider/mount-o…
freider Nov 6, 2024
908b329
Add test + fix issue with classes
freider Nov 6, 2024
7c81d3f
Merge branch 'freider/image-mount-watch-cli-219' into freider/mount-o…
freider Nov 6, 2024
6b954c9
Fix tests
freider Nov 6, 2024
2a6001b
Merge remote-tracking branch 'origin/main' into freider/image-mount-w…
freider Nov 6, 2024
895036a
Merge branch 'freider/image-mount-watch-cli-219' into freider/mount-o…
freider Nov 6, 2024
8f81737
Cleanup
freider Nov 6, 2024
7ccb07a
Copy
freider Nov 6, 2024
3a8409c
Merge remote-tracking branch 'origin/main' into freider/mount-on-image
freider Nov 15, 2024
fd39606
Rename to attach
freider Nov 15, 2024
d3a4dec
Copy
freider Nov 15, 2024
4a4257d
Merge remote-tracking branch 'origin/main' into freider/mount-on-image
freider Nov 15, 2024
1e1bc97
More name changes
freider Nov 15, 2024
286db82
Back to add
freider Nov 19, 2024
543ee3a
Merge remote-tracking branch 'origin/main' into freider/mount-on-image
freider Nov 19, 2024
652fd7d
more
freider Nov 19, 2024
8645fbb
Adjust error message to fewer explicit line breaks
freider Nov 21, 2024
222dc25
change repr
freider Nov 21, 2024
67ed484
docstring
freider Nov 21, 2024
c0d8236
Docstring
freider Nov 21, 2024
11452a8
Renames and more copy changes
freider Nov 21, 2024
4878945
More copy
freider Nov 21, 2024
94f9843
More copy
freider Nov 21, 2024
f83f4a8
Merge remote-tracking branch 'origin/main' into freider/mount-on-image
freider Nov 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion modal/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ def from_args(

if is_local():
entrypoint_mounts = info.get_entrypoint_mount()

all_mounts = [
_get_client_mount(),
*explicit_mounts,
Expand Down Expand Up @@ -633,6 +634,7 @@ def from_args(
if proxy:
# HACK: remove this once we stop using ssh tunnels for this.
if image:
# TODO(elias): this will cause an error if users use prior `.add_local_*` commands without copy=True
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove the autossh installation - checking with @pawalt and @luiscape now

image = image.apt_install("autossh")

function_spec = _FunctionSpec(
Expand Down Expand Up @@ -849,7 +851,7 @@ async def _load(self: _Function, resolver: Resolver, existing_object_id: Optiona
)
for path, volume in validated_volumes
]
loaded_mount_ids = {m.object_id for m in all_mounts}
loaded_mount_ids = {m.object_id for m in all_mounts} | {m.object_id for m in image._mount_layers}

# Get object dependencies
object_dependencies = []
Expand Down
92 changes: 90 additions & 2 deletions modal/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
import shlex
import sys
import textwrap
import typing
import warnings
from dataclasses import dataclass
Expand Down Expand Up @@ -274,11 +275,21 @@ class _Image(_Object, type_prefix="im"):
force_build: bool
inside_exceptions: List[Exception]
_used_local_mounts: typing.FrozenSet[_Mount] # used for mounts watching
_mounts: Sequence[_Mount] # added as mounts on any container referencing the Image, see `def _mount_layers`
_metadata: Optional[api_pb2.ImageMetadata] = None # set on hydration, private for now

def _initialize_from_empty(self):
self.inside_exceptions = []
self._used_local_mounts = frozenset()
self._mounts = ()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference between _used_local_mounts and _mounts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, there is a docstring further up, but I renamed them now to make it clearer:

_used_local_mounts -> _serve_mounts # used for modal serve mount watching - also includes any context mounts for non-add operation, like copy_...
_mounts -> _deferred_mounts # these are the new "lazy" add to container on startup-mounts introduced by this pr

self.force_build = False

def _initialize_from_other(self, other: "_Image"):
# used by .clone()
self.inside_exceptions = other.inside_exceptions
self.force_build = other.force_build
self._used_local_mounts = other._used_local_mounts
self._mounts = other._mounts

def _hydrate_metadata(self, message: Optional[Message]):
env_image_id = config.get("image_id") # set as an env var in containers
Expand All @@ -292,6 +303,57 @@ def _hydrate_metadata(self, message: Optional[Message]):
assert isinstance(message, api_pb2.ImageMetadata)
self._metadata = message

def _add_mount_layer_or_copy(self, mount: _Mount, copy: bool = False):
if copy:
return self.copy_mount(mount, remote_path="/")

base_image = self

async def _load(self2: "_Image", resolver: Resolver, existing_object_id: Optional[str]):
self2._hydrate_from_other(base_image) # same image id as base image as long as it's lazy
self2._mounts = tuple(base_image._mounts) + (mount,)
self2._used_local_mounts = base_image._used_local_mounts | ({mount} if mount.is_local() else set())

return _Image._from_loader(_load, "ImageWithMounts()", deps=lambda: [base_image, mount])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is ImageWithMounts() a new class?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, they just use the regular Image class - this is the repr string. Might have changed the repr it here for debugging reasons (would be nice to fix our object reprs to actually be meaningful/using some data from the specification)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(would be nice to fix our object reprs to actually be meaningful/using some data from the specification)
+1 yeah have been wanting to do this for a while, they could be a lot more userful


@property
def _mount_layers(self) -> typing.Tuple[_Mount]:
"""Non-evaluated mount layers on the image

When the image is used by a Modal container, these mounts need to be attached as well to
represent the full image content, as they haven't yet been represented as a layer in the
image.

When the image is used as a base image for a new layer (that is not itself a mount layer)
these mounts need to first be inserted as a copy operation (.copy_mount) into the image.
"""
return self._mounts

def _assert_no_mount_layers(self):
if self._mount_layers:
raise InvalidError(
textwrap.dedent(
"""
An image tried to run a build step after using `image.add_local_*` to include local files.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mwaskom any feedback on this error message copy?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not directly related but one sort of annoying thing is that verbose error messages end up looking bad in the terminal since we print them twice:

image

Also can we error on the side of shorter lines? (Or longer lines and let rich handle the wrapping?) With narrow terminals this gets a little garbled:
image


Run `image.add_local_*` commands last in your image build to avoid rebuilding images with every local
filechange. Modal will then mount these files as a thin layer when starting your container, saving build
time.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the "thin layer" terminology used anywhere else?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, changed


If you need these files earlier in the build, set `copy=True` to copy the files directly into the image,
though this will increase build time.

Example:

my_image = (
Image.debian_slim()
.add_local_python_packages("mypak", copy=True)
.run_commands("python -m mypak") # this now works!
)
"""
)
)

@staticmethod
def _from_args(
*,
Expand All @@ -306,9 +368,11 @@ def _from_args(
force_build: bool = False,
# For internal use only.
_namespace: int = api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
_do_assert_no_mount_layers: bool = True,
):
if base_images is None:
base_images = {}

if secrets is None:
secrets = []
if gpu_config is None:
Expand All @@ -334,6 +398,11 @@ def _deps() -> List[_Object]:
return deps

async def _load(self: _Image, resolver: Resolver, existing_object_id: Optional[str]):
if _do_assert_no_mount_layers:
for image in base_images.values():
# base images can't have
image._assert_no_mount_layers()

environment = await _get_environment_cached(resolver.environment_name or "", resolver.client)
# A bit hacky,but assume that the environment provides a valid builder version
image_builder_version = cast(ImageBuilderVersion, environment._settings.image_builder_version)
Expand All @@ -349,7 +418,9 @@ async def _load(self: _Image, resolver: Resolver, existing_object_id: Optional[s
"No commands were provided for the image — have you tried using modal.Image.debian_slim()?"
)
if dockerfile.commands and build_function:
raise InvalidError("Cannot provide both a build function and Dockerfile commands!")
raise InvalidError(
"Cannot provide both build function and Dockerfile commands in the same image layer!"
)

base_images_pb2s = [
api_pb2.BaseImage(
Expand Down Expand Up @@ -487,7 +558,7 @@ async def join():
local_mounts.add(context_mount)
self._used_local_mounts = frozenset(local_mounts)

rep = "Image()"
rep = f"Image({dockerfile_function})"
obj = _Image._from_loader(_load, rep, deps=_deps)
obj.force_build = force_build
return obj
Expand Down Expand Up @@ -553,6 +624,23 @@ def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec:
context_mount=mount,
)

def add_local_python_packages(self, *packages: Union[str, Path], copy: bool = False) -> "_Image":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a Python API, would add_local_packages save users some typing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but what if the user has non-python packages locally (.deb?) that they want to add? 😬

I agree it's a bit on the long end, but this also reflects the existing Mount.from_local_python_packages

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did some more thinking... see below

"""Attaches local Python packages to the container running the image
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we avoid introducing new terminology ("attach") if we're not otherwise going to use it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something that surprised me when testing — but maybe it shouldn't have — is that adding a local package (really a third-party package; I was just reaching for something quick) didn't include its dependencies.

That makes sense from one perspective (this operation is more like copying a directory with some sugar to reference the directory via PYTHONPATH) but I wonder if it will surprise users? Since "adding a package" usually has some notion of dependency resolution.

Copy link
Contributor Author

@freider freider Nov 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Attach" was an oversight when I renamed it again, thanks for spotting!

Yeah, adding third party packages or packages that have real install recipes via this method isn't intended, but I can see how people would do that. The main intent is for it to be used for directories that are part of the user's current python projet, and the interface has been using the module name as a convenience for pointing to that directory via the current sys.path.

Basically a short form for: add_local_dir(lookup_package("my_package"), filter=modal.file_filters.PyFiles) (assuming we had lookup_package() and filters...

I have separately been thinking that it would be nice to just point to a "project directory" when setting up the user's image, which is very similar to this but using file paths, and maybe that's a better way to do this?
The problem with that is that specifying file system paths in a portable way is so un-ergonomic for even simple cases, e.g:

E.g.

Image.debian_slim().add_local_dir(
    Path(__file__).parent,
    remote_path="/root/some_root_package_name",
    condition=python_mount_condition
)

Preferably I'd want it to work like above with default arguments of using:

Image.debian_slim().add_project_dir(".")  # use directory of current file as project dir - "standard", only include python files (or .modalignore?)

And then have some very easy ways to customize this:

Image.debian_slim().add_project_dir("..")  # use parent directory as root, using relative paths from the current file's dir or cwd? (the latter probably cleanest to implement)
Image.debian_slim().add_project_dir(".", filter=modal.filters.AllFiles)  # use all files instead of just python files
# this is where we could also take advantage of new glob/modalignore support

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll rename add_local_python_packages to a "private _add_local_python_packages for now to get the rest of the changes merged in while we discuss the best way of "adding my local python files"


Packages are added to the /root directory which is on the PYTHONPATH of any
executed Modal functions.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarify that this is in the container?

Also might want to use capitalization for our proper nouns to Jono stays happy :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which nouns? Function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And Image?


By default (copy=False) the packages are layered on top of the image when
the container starts up and not built as an image layer.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having trouble parsing "packages are layered ... not built as an image layer".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrote:

Adds Python Package Files to Containers

Adds all files from the specified Python packages to containers running the image.

Packages are added to the /root directory, which is on the PYTHONPATH of any executed Modal functions.

By default (copy=False), the files are added to containers on startup and are not built into the actual image, which speeds up deployment.

Set copy=True to copy the files into an image layer at build time instead. This can slow down iteration since it requires a rebuild of the image and any subsequent build steps whenever the included files change, but it is required if you want to run additional build steps after this one.

Note: This excludes all dot-prefixed subdirectories or files and all .pyc/__pycache__ files. To add full directories with finer control, use .add_local_dir() instead.


Set copy=True to force the packages to be added as an image layer instead.
This can be slower since it requires a rebuild of the image whenever the
required package files change, but it allows you to run additional build
steps after this operation.
"""
mount = _Mount.from_local_python_packages(*packages)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we take the opportunity to break with the existing behavior of including data assets and everything else within the directory containing the Python package, at least by default? That's one of our most surprising and problem-causing behaviors IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the current behavior for explicit from_local_packages and by this code. The only things that are excluded are .-prefixed files/directories and __pycache__/.pyc.

It's the auto mounts today that exclude everything that's not a .py-file. Very consistent, I know...

Copy link
Contributor

@mwaskom mwaskom Nov 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the auto mounts today that exclude everything that's not a .py-file.

I thought auto mounting now included most files? That's how users get themselves into trouble when they automount a project directory that has a lot of data in it.

I agree that you're mostly mirroring the existing behavior of Mount.from_local_python_packages but I'm wondering should we take this opportunity to break things about that behavior that have proven problematic.

(I also think we should add the dockerignore conditions to these interfaces, which will help a bit, but my suspicion is that it's easier to debug "this add_local_python_packages command is not including my data files" than "my Image build is really slow and seems to be uploading a lot of stuff". But I'm not totally sure.)

return self._add_mount_layer_or_copy(mount, copy=copy)

def copy_local_dir(self, local_path: Union[str, Path], remote_path: Union[str, Path] = ".") -> "_Image":
"""Copy a directory into the image as a part of building the image.

Expand Down
2 changes: 1 addition & 1 deletion modal/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ async def _load(self: _Sandbox, resolver: Resolver, _existing_object_id: Optiona
definition = api_pb2.Sandbox(
entrypoint_args=entrypoint_args,
image_id=image.object_id,
mount_ids=[mount.object_id for mount in mounts],
mount_ids=[mount.object_id for mount in mounts] + [mount.object_id for mount in image._mount_layers],
secret_ids=[secret.object_id for secret in secrets],
timeout_secs=timeout,
workdir=workdir,
Expand Down
5 changes: 5 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2026,3 +2026,8 @@ def import_fail_for_rich(name: str, *args, **kwargs) -> ModuleType:
def disable_auto_mount(monkeypatch):
monkeypatch.setenv("MODAL_AUTOMOUNT", "0")
yield


@pytest.fixture()
def supports_on_path(supports_dir, monkeypatch):
monkeypatch.syspath_prepend(str(supports_dir))
151 changes: 146 additions & 5 deletions test/image_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import List, Literal, get_args
from unittest import mock

import modal
from modal import App, Image, Mount, Secret, build, environments, gpu, method
from modal._serialization import serialize
from modal._utils.async_utils import synchronizer
Expand All @@ -24,6 +25,7 @@
_validate_python_version,
)
from modal.mount import PYTHON_STANDALONE_VERSIONS
from modal.runner import deploy_app
from modal_proto import api_pb2

from .supports.skip import skip_windows
Expand All @@ -35,8 +37,16 @@
]


def dummy():
...
@pytest.fixture(autouse=True)
def no_automount(monkeypatch):
# no tests in here use automounting, but a lot of them implicitly create
# functions w/ lots of modules is sys.modules which will automount
# which takes a lot of time, so we disable it
monkeypatch.setenv("MODAL_AUTOMOUNT", "0")


def dummy() -> None:
return None


def test_supported_python_series():
Expand Down Expand Up @@ -284,8 +294,6 @@ def test_image_pip_install_pyproject(builder_version, servicer, client):
app.function(image=image)(dummy)
with app.run(client=client):
layers = get_image_layers(image.object_id, servicer)

print(layers[0].dockerfile_commands)
assert any("pip install 'banana >=1.2.0' 'potato >=0.1.0'" in cmd for cmd in layers[0].dockerfile_commands)


Expand All @@ -298,7 +306,6 @@ def test_image_pip_install_pyproject_with_optionals(builder_version, servicer, c
with app.run(client=client):
layers = get_image_layers(image.object_id, servicer)

print(layers[0].dockerfile_commands)
assert any(
"pip install 'banana >=1.2.0' 'linting-tool >=0.0.0' 'potato >=0.1.0' 'pytest >=1.2.0'" in cmd
for cmd in layers[0].dockerfile_commands
Expand Down Expand Up @@ -1209,3 +1216,137 @@ async def test_logs(servicer, client):

logs = [data async for data in image._logs.aio()]
assert logs == ["build starting\n", "build finished\n"]


def hydrate_image(img, client):
# there should be a more straight forward way to do this?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

app = App()
app.function(serialized=True, image=img)(lambda: None)
with app.run(client=client):
pass


def test_add_local_lazy_vs_copy(client, servicer, set_env_client, supports_on_path):
deb = Image.debian_slim()
image_with_mount = deb.add_local_python_packages("pkg_a")

hydrate_image(image_with_mount, client)
assert image_with_mount.object_id == deb.object_id
assert len(image_with_mount._mount_layers) == 1

image_additional_mount = image_with_mount.add_local_python_packages("pkg_b")
hydrate_image(image_additional_mount, client)
assert len(image_additional_mount._mount_layers) == 2 # another mount added to lazy layer
assert len(image_with_mount._mount_layers) == 1 # original image should not be affected

# running commands
image_non_mount = image_with_mount.run_commands("echo 'hello'")
with pytest.raises(InvalidError, match="copy=True"):
# error about using non-copy add commands before other build steps
hydrate_image(image_non_mount, client)

image_with_copy = deb.add_local_python_packages("pkg_a", copy=True)
hydrate_image(image_with_copy, client)
assert len(image_with_copy._mount_layers) == 0

# do the same exact image using copy=True
image_with_copy_and_commands = deb.add_local_python_packages("pkg_a", copy=True).run_commands("echo 'hello'")
hydrate_image(image_with_copy_and_commands, client)
assert len(image_with_copy_and_commands._mount_layers) == 0

layers = get_image_layers(image_with_copy_and_commands.object_id, servicer)

echo_layer = layers[0]
assert echo_layer.dockerfile_commands == ["FROM base", "RUN echo 'hello'"]

copy_layer = layers[1]
assert copy_layer.dockerfile_commands == ["FROM base", "COPY . /"]
copied_files = servicer.mount_contents[copy_layer.context_mount_id].keys()
assert len(copied_files) == 8
assert all(fn.startswith("/root/pkg_a/") for fn in copied_files)


def test_add_locals_are_attached_to_functions(servicer, client, supports_on_path):
deb_slim = Image.debian_slim()
img = deb_slim.add_local_python_packages("pkg_a")
app = App("my-app")
control_fun: modal.Function = app.function(serialized=True, image=deb_slim, name="control")(
dummy
) # no mounts on image
fun: modal.Function = app.function(serialized=True, image=img, name="fun")(dummy) # mounts on image
deploy_app(app, client=client)

control_func_mounts = set(servicer.app_functions[control_fun.object_id].mount_ids)
fun_def = servicer.app_functions[fun.object_id]
added_mounts = set(fun_def.mount_ids) - control_func_mounts
assert len(added_mounts) == 1
assert added_mounts == {img._mount_layers[0].object_id}


def test_add_locals_are_attached_to_classes(servicer, client, supports_on_path, set_env_client):
deb_slim = Image.debian_slim()
img = deb_slim.add_local_python_packages("pkg_a")
app = App("my-app")
control_fun: modal.Function = app.function(serialized=True, image=deb_slim, name="control")(
dummy
) # no mounts on image

class A:
some_arg: str = modal.parameter()

ACls = app.cls(serialized=True, image=img)(A) # mounts on image
deploy_app(app, client=client)

control_func_mounts = set(servicer.app_functions[control_fun.object_id].mount_ids)
fun_def = servicer.function_by_name("A.*") # class service function
added_mounts = set(fun_def.mount_ids) - control_func_mounts
assert len(added_mounts) == 1
assert added_mounts == {img._mount_layers[0].object_id}

obj = ACls(some_arg="foo") # type: ignore
# hacky way to force hydration of the *parameter bound* function (instance service function):
obj.keep_warm(0) # type: ignore

obj_fun_def = servicer.function_by_name("A.*", ((), {"some_arg": "foo"})) # instance service function
added_mounts = set(obj_fun_def.mount_ids) - control_func_mounts
assert len(added_mounts) == 1
assert added_mounts == {img._mount_layers[0].object_id}


@skip_windows("servicer sandbox implementation not working on windows")
def test_add_locals_are_attached_to_sandboxes(servicer, client, supports_on_path):
deb_slim = Image.debian_slim()
img = deb_slim.add_local_python_packages("pkg_a")
app = App("my-app")
with app.run(client=client):
modal.Sandbox.create(image=img, app=app, client=client)
sandbox_def = servicer.sandbox_defs[0]

assert sandbox_def.image_id == deb_slim.object_id
assert sandbox_def.mount_ids == [img._mount_layers[0].object_id]
copied_files = servicer.mount_contents[sandbox_def.mount_ids[0]]
assert len(copied_files) == 8
assert all(fn.startswith("/root/pkg_a/") for fn in copied_files)


def empty_fun():
pass


def test_add_locals_build_function(servicer, client, supports_on_path):
deb_slim = Image.debian_slim()
img = deb_slim.add_local_python_packages("pkg_a")
img_with_build_function = img.run_function(empty_fun)
with pytest.raises(InvalidError):
# build functions could still potentially rewrite mount contents,
# so we still require them to use copy=True
# TODO(elias): what if someone wants do use an equivalent of `run_function(..., mounts=[...]) ?
hydrate_image(img_with_build_function, client)

img_with_copy = deb_slim.add_local_python_packages("pkg_a", copy=True)
hydrate_image(img_with_copy, client) # this is fine


# TODO: test modal shell w/ lazy mounts
# this works since the image is passed on as is to a sandbox which will load it and
# transfer any virtual mount layers from the image as mounts to the sandbox
Loading
Loading