diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b399c48c..ad47137f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ We appreciate your patience while we speedily work towards a stable release of t +### 0.72.8 (2025-01-10) + +- Fixes a bug introduced in v0.72.2 when specifying `add_python="3.9"` in `Image.from_registry`. + + + ### 0.72.0 (2025-01-09) * The default behavior`Image.from_dockerfile()` and `image.dockerfile_commands()` if no parameter is passed to `ignore` will be to automatically detect if there is a valid dockerignore file in the current working directory or next to the dockerfile following the same rules as `dockerignore` does using `docker` commands. Previously no patterns were ignored. diff --git a/modal/_utils/function_utils.py b/modal/_utils/function_utils.py index d6b9aacc9..78a273797 100644 --- a/modal/_utils/function_utils.py +++ b/modal/_utils/function_utils.py @@ -326,11 +326,11 @@ def get_entrypoint_mount(self) -> list[_Mount]: # make sure the function's own entrypoint is included: if self._type == FunctionInfoType.PACKAGE: if config.get("automount"): - return [_Mount.from_local_python_packages(self.module_name)] + return [_Mount._from_local_python_packages(self.module_name)] elif not self.is_serialized(): # mount only relevant file and __init__.py:s return [ - _Mount.from_local_dir( + _Mount._from_local_dir( self._base_dir, remote_path=self._remote_dir, recursive=True, @@ -341,7 +341,7 @@ def get_entrypoint_mount(self) -> list[_Mount]: remote_path = ROOT_DIR / Path(self._file).name if not _is_modal_path(remote_path): return [ - _Mount.from_local_file( + _Mount._from_local_file( self._file, remote_path=remote_path, ) diff --git a/modal/app.py b/modal/app.py index 4d48cccbf..3c9d89186 100644 --- a/modal/app.py +++ b/modal/app.py @@ -200,10 +200,9 @@ def __init__( ```python notest image = modal.Image.debian_slim().pip_install(...) - mount = modal.Mount.from_local_dir("./config") secret = modal.Secret.from_name("my-secret") volume = modal.Volume.from_name("my-data") - app = modal.App(image=image, mounts=[mount], secrets=[secret], volumes={"/mnt/data": volume}) + app = modal.App(image=image, secrets=[secret], volumes={"/mnt/data": volume}) ``` """ if name is not None and not isinstance(name, str): diff --git a/modal/cli/launch.py b/modal/cli/launch.py index 702aa8d1b..eb034f30b 100644 --- a/modal/cli/launch.py +++ b/modal/cli/launch.py @@ -55,7 +55,7 @@ def jupyter( timeout: int = 3600, image: str = "ubuntu:22.04", add_python: Optional[str] = "3.11", - mount: Optional[str] = None, # Create a `modal.Mount` from a local directory. + mount: Optional[str] = None, # Adds a local directory to the jupyter container volume: Optional[str] = None, # Attach a persisted `modal.Volume` by name (creating if missing). detach: bool = False, # Run the app in "detached" mode to persist after local client disconnects ): diff --git a/modal/cli/programs/run_jupyter.py b/modal/cli/programs/run_jupyter.py index 25495d574..ad54e3cae 100644 --- a/modal/cli/programs/run_jupyter.py +++ b/modal/cli/programs/run_jupyter.py @@ -10,25 +10,20 @@ import webbrowser from typing import Any -from modal import App, Image, Mount, Queue, Secret, Volume, forward +from modal import App, Image, Queue, Secret, Volume, forward # Passed by `modal launch` locally via CLI, plumbed to remote runner through secrets. args: dict[str, Any] = json.loads(os.environ.get("MODAL_LAUNCH_ARGS", "{}")) - app = App() -app.image = Image.from_registry(args.get("image"), add_python=args.get("add_python")).pip_install("jupyterlab") +image = Image.from_registry(args.get("image"), add_python=args.get("add_python")).pip_install("jupyterlab") -mount = ( - Mount.from_local_dir( +if args.get("mount"): + image = image.add_local_dir( args.get("mount"), remote_path="/root/lab/mount", ) - if args.get("mount") - else None -) -mounts = [mount] if mount else [] volume = ( Volume.from_name( @@ -55,12 +50,12 @@ def wait_for_port(url: str, q: Queue): @app.function( + image=image, cpu=args.get("cpu"), memory=args.get("memory"), gpu=args.get("gpu"), timeout=args.get("timeout"), secrets=[Secret.from_dict({"MODAL_LAUNCH_ARGS": json.dumps(args)})], - mounts=mounts, volumes=volumes, concurrency_limit=1 if volume else None, ) diff --git a/modal/cli/programs/vscode.py b/modal/cli/programs/vscode.py index 15ca44d42..9d960f6e5 100644 --- a/modal/cli/programs/vscode.py +++ b/modal/cli/programs/vscode.py @@ -10,7 +10,7 @@ import webbrowser from typing import Any -from modal import App, Image, Mount, Queue, Secret, Volume, forward +from modal import App, Image, Queue, Secret, Volume, forward # Passed by `modal launch` locally via CLI, plumbed to remote runner through secrets. args: dict[str, Any] = json.loads(os.environ.get("MODAL_LAUNCH_ARGS", "{}")) @@ -23,7 +23,7 @@ app = App() -app.image = ( +image = ( Image.from_registry(args.get("image"), add_python="3.11") .apt_install("curl", "dumb-init", "git", "git-lfs") .run_commands( @@ -44,16 +44,11 @@ .env({"ENTRYPOINTD": ""}) ) - -mount = ( - Mount.from_local_dir( +if args.get("mount"): + image = image.add_local_dir( args.get("mount"), remote_path="/home/coder/mount", ) - if args.get("mount") - else None -) -mounts = [mount] if mount else [] volume = ( Volume.from_name( @@ -80,12 +75,12 @@ def wait_for_port(data: tuple[str, str], q: Queue): @app.function( + image=image, cpu=args.get("cpu"), memory=args.get("memory"), gpu=args.get("gpu"), timeout=args.get("timeout"), secrets=[Secret.from_dict({"MODAL_LAUNCH_ARGS": json.dumps(args)})], - mounts=mounts, volumes=volumes, concurrency_limit=1 if volume else None, ) diff --git a/modal/cls.py b/modal/cls.py index 24ae7ad2e..b14c5d402 100644 --- a/modal/cls.py +++ b/modal/cls.py @@ -16,7 +16,7 @@ from ._serialization import check_valid_cls_constructor_arg from ._traceback import print_server_warnings from ._utils.async_utils import synchronize_api, synchronizer -from ._utils.deprecation import renamed_parameter +from ._utils.deprecation import deprecation_warning, renamed_parameter from ._utils.grpc_utils import retry_transient_errors from ._utils.mount_utils import validate_volumes from .client import _Client @@ -374,12 +374,14 @@ class _Cls(_Object, type_prefix="cs"): _options: Optional[api_pb2.FunctionOptions] _callables: dict[str, Callable[..., Any]] _app: Optional["modal.app._App"] = None # not set for lookups + _name: Optional[str] def _initialize_from_empty(self): self._user_cls = None self._class_service_function = None self._options = None self._callables = {} + self._name = None def _initialize_from_other(self, other: "_Cls"): super()._initialize_from_other(other) @@ -388,6 +390,7 @@ def _initialize_from_other(self, other: "_Cls"): self._method_functions = other._method_functions self._options = other._options self._callables = other._callables + self._name = other._name def _get_partial_functions(self) -> dict[str, _PartialFunction]: if not self._user_cls: @@ -506,6 +509,7 @@ async def _load(self: "_Cls", resolver: Resolver, existing_object_id: Optional[s cls._class_service_function = class_service_function cls._method_functions = method_functions cls._callables = callables + cls._name = user_cls.__name__ return cls def _uses_common_service_function(self): @@ -576,6 +580,7 @@ async def _load_remote(obj: _Object, resolver: Resolver, existing_object_id: Opt rep = f"Ref({app_name})" cls = cls._from_loader(_load_remote, rep, is_another_app=True, hydrate_lazily=True) # TODO: when pre 0.63 is phased out, we can set class_service_function here instead + cls._name = name return cls def with_options( @@ -681,6 +686,13 @@ def __getattr__(self, k): # Used by CLI and container entrypoint # TODO: remove this method - access to attributes on classes should be discouraged if k in self._method_functions: + deprecation_warning( + (2025, 1, 13), + "Usage of methods directly on the class will soon be deprecated, " + "instantiate classes before using methods, e.g.:\n" + f"{self._name}().{k} instead of {self._name}.{k}", + pending=True, + ) return self._method_functions[k] return getattr(self._user_cls, k) diff --git a/modal/file_pattern_matcher.py b/modal/file_pattern_matcher.py index 8be2d6791..c2df52928 100644 --- a/modal/file_pattern_matcher.py +++ b/modal/file_pattern_matcher.py @@ -35,7 +35,7 @@ def __invert__(self) -> "_AbstractPatternMatcher": """ return _CustomPatternMatcher(lambda path: not self(path)) - def with_repr(self, custom_repr) -> "_AbstractPatternMatcher": + def _with_repr(self, custom_repr) -> "_AbstractPatternMatcher": # use to give an instance of a matcher a custom name - useful for visualizing default values in signatures self._custom_repr = custom_repr return self @@ -60,7 +60,24 @@ def __call__(self, path: Path) -> bool: class FilePatternMatcher(_AbstractPatternMatcher): - """Allows matching file paths against a list of patterns.""" + """ + Allows matching file Path objects against a list of patterns. + + **Usage:** + ```python + from pathlib import Path + from modal import FilePatternMatcher + + matcher = FilePatternMatcher("*.py") + + assert matcher(Path("foo.py")) + + # You can also negate the matcher. + negated_matcher = ~matcher + + assert not negated_matcher(Path("foo.py")) + ``` + """ patterns: list[Pattern] _delayed_init: Callable[[], None] = None @@ -102,6 +119,15 @@ def from_file(cls, file_path: Path) -> "FilePatternMatcher": Args: file_path (Path): The path to the file containing patterns. + + **Usage:** + ```python + from pathlib import Path + from modal import FilePatternMatcher + + matcher = FilePatternMatcher.from_file(Path("/path/to/ignorefile")) + ``` + """ uninitialized = cls.__new__(cls) @@ -151,33 +177,15 @@ def _matches(self, file_path: str) -> bool: return matched def __call__(self, file_path: Path) -> bool: - """Check if the path matches any of the patterns. - - Args: - file_path (Path): The path to check. - - Returns: - True if the path matches any of the patterns. - - Usage: - ```python - from pathlib import Path - from modal import FilePatternMatcher - - matcher = FilePatternMatcher("*.py") - - assert matcher(Path("foo.py")) - ``` - """ if self._delayed_init: self._delayed_init() return self._matches(str(file_path)) -# with_repr allows us to use this matcher as a default value in a function signature +# _with_repr allows us to use this matcher as a default value in a function signature # and get a nice repr in the docs and auto-generated type stubs: -NON_PYTHON_FILES = (~FilePatternMatcher("**/*.py")).with_repr(f"{__name__}.NON_PYTHON_FILES") -_NOTHING = (~FilePatternMatcher()).with_repr(f"{__name__}._NOTHING") # match everything = ignore nothing +NON_PYTHON_FILES = (~FilePatternMatcher("**/*.py"))._with_repr(f"{__name__}.NON_PYTHON_FILES") +_NOTHING = (~FilePatternMatcher())._with_repr(f"{__name__}._NOTHING") # match everything = ignore nothing def _ignore_fn(ignore: Union[Sequence[str], Callable[[Path], bool]]) -> Callable[[Path], bool]: diff --git a/modal/image.py b/modal/image.py index bc3160a19..1fa7582fc 100644 --- a/modal/image.py +++ b/modal/image.py @@ -715,7 +715,7 @@ def add_local_file(self, local_path: Union[str, Path], remote_path: str, *, copy if remote_path.endswith("/"): remote_path = remote_path + Path(local_path).name - mount = _Mount.from_local_file(local_path, remote_path) + mount = _Mount._from_local_file(local_path, remote_path) return self._add_mount_layer_or_copy(mount, copy=copy) def add_local_dir( @@ -803,7 +803,7 @@ def build_dockerfile(version: ImageBuilderVersion) -> DockerfileSpec: return _Image._from_args( base_images={"base": self}, dockerfile_function=build_dockerfile, - context_mount_function=lambda: _Mount.from_local_file(local_path, remote_path=f"/{basename}"), + context_mount_function=lambda: _Mount._from_local_file(local_path, remote_path=f"/{basename}"), ) def add_local_python_source( @@ -841,7 +841,7 @@ def add_local_python_source( ) ``` """ - mount = _Mount.from_local_python_packages(*modules, ignore=ignore) + mount = _Mount._from_local_python_packages(*modules, ignore=ignore) return self._add_mount_layer_or_copy(mount, copy=copy) def copy_local_dir( @@ -1522,7 +1522,8 @@ def _registry_setup_commands( "COPY /python/. /usr/local", "ENV TERMINFO_DIRS=/etc/terminfo:/lib/terminfo:/usr/share/terminfo:/usr/lib/terminfo", ] - if add_python < "3.13": + python_minor = add_python.split(".")[1] + if int(python_minor) < 13: # Previous versions did not include the `python` binary, but later ones do. # (The important factor is not the Python version itself, but the standalone dist version.) # We insert the command in the list at the position it was previously always added diff --git a/modal/mount.py b/modal/mount.py index a86b1aa6a..1be44d806 100644 --- a/modal/mount.py +++ b/modal/mount.py @@ -23,7 +23,7 @@ from ._resolver import Resolver from ._utils.async_utils import aclosing, async_map, synchronize_api from ._utils.blob_utils import FileUploadSpec, blob_upload_file, get_file_upload_spec_from_path -from ._utils.deprecation import renamed_parameter +from ._utils.deprecation import deprecation_warning, renamed_parameter from ._utils.grpc_utils import retry_transient_errors from ._utils.name_utils import check_object_name from ._utils.package_utils import get_module_mount_info @@ -48,6 +48,11 @@ "3.13": ("20241008", "3.13.0"), } +MOUNT_DEPRECATION_MESSAGE_PATTERN = """modal.Mount usage will soon be deprecated. + +Use {replacement} instead, which is functionally and performance-wise equivalent. +""" + def client_mount_name() -> str: """Get the deployed name of the client package mount.""" @@ -401,6 +406,23 @@ def from_local_dir( ) ``` """ + deprecation_warning( + (2024, 1, 8), MOUNT_DEPRECATION_MESSAGE_PATTERN.format(replacement="image.add_local_dir"), pending=True + ) + return _Mount._from_local_dir(local_path, remote_path=remote_path, condition=condition, recursive=recursive) + + @staticmethod + def _from_local_dir( + local_path: Union[str, Path], + *, + # Where the directory is placed within in the mount + remote_path: Union[str, PurePosixPath, None] = None, + # Predicate filter function for file selection, which should accept a filepath and return `True` for inclusion. + # Defaults to including all files. + condition: Optional[Callable[[str], bool]] = None, + # add files from subdirectories as well + recursive: bool = True, + ) -> "_Mount": return _Mount._new().add_local_dir( local_path, remote_path=remote_path, condition=condition, recursive=recursive ) @@ -439,6 +461,13 @@ def from_local_file(local_path: Union[str, Path], remote_path: Union[str, PurePo ) ``` """ + deprecation_warning( + (2024, 1, 8), MOUNT_DEPRECATION_MESSAGE_PATTERN.format(replacement="image.add_local_file"), pending=True + ) + return _Mount._from_local_file(local_path, remote_path) + + @staticmethod + def _from_local_file(local_path: Union[str, Path], remote_path: Union[str, PurePosixPath, None] = None) -> "_Mount": return _Mount._new().add_local_file(local_path, remote_path=remote_path) @staticmethod @@ -601,7 +630,24 @@ def f(): my_local_module.do_stuff() ``` """ + deprecation_warning( + (2024, 1, 8), + MOUNT_DEPRECATION_MESSAGE_PATTERN.format(replacement="image.add_local_python_source"), + pending=True, + ) + return _Mount._from_local_python_packages( + *module_names, remote_dir=remote_dir, condition=condition, ignore=ignore + ) + @staticmethod + def _from_local_python_packages( + *module_names: str, + remote_dir: Union[str, PurePosixPath] = ROOT_DIR.as_posix(), + # Predicate filter function for file selection, which should accept a filepath and return `True` for inclusion. + # Defaults to including all files. + condition: Optional[Callable[[str], bool]] = None, + ignore: Optional[Union[Sequence[str], Callable[[Path], bool]]] = None, + ) -> "_Mount": # Don't re-run inside container. if condition is not None: @@ -786,7 +832,7 @@ def get_auto_mounts() -> list[_Mount]: try: # at this point we don't know if the sys.modules module should be mounted or not - potential_mount = _Mount.from_local_python_packages(module_name) + potential_mount = _Mount._from_local_python_packages(module_name) mount_paths = potential_mount._top_level_paths() except ModuleNotMountable: # this typically happens if the module is a built-in, has binary components or doesn't exist diff --git a/modal_global_objects/mounts/python_standalone.py b/modal_global_objects/mounts/python_standalone.py index 9d75550f3..6040a6790 100644 --- a/modal_global_objects/mounts/python_standalone.py +++ b/modal_global_objects/mounts/python_standalone.py @@ -34,7 +34,7 @@ def publish_python_standalone_mount(client, version: str) -> None: urllib.request.urlretrieve(url, f"{d}/cpython.tar.gz") shutil.unpack_archive(f"{d}/cpython.tar.gz", d) print(f"🌐 Downloaded and unpacked archive to {d}.") - python_mount = Mount.from_local_dir(f"{d}/python") + python_mount = Mount._from_local_dir(f"{d}/python") python_mount._deploy( mount_name, api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL, diff --git a/modal_proto/api.proto b/modal_proto/api.proto index 59e4fabd9..98130b18f 100644 --- a/modal_proto/api.proto +++ b/modal_proto/api.proto @@ -150,6 +150,7 @@ enum GPUType { GPU_TYPE_L4 = 9; GPU_TYPE_H100 = 10; GPU_TYPE_L40S = 11; + GPU_TYPE_H200 = 12; } enum ObjectCreationType { diff --git a/modal_version/_version_generated.py b/modal_version/_version_generated.py index 75f9cfeb8..94c1bd758 100644 --- a/modal_version/_version_generated.py +++ b/modal_version/_version_generated.py @@ -1,4 +1,4 @@ # Copyright Modal Labs 2025 # Note: Reset this value to -1 whenever you make a minor `0.X` release of the client. -build_number = 2 # git: a32d5c8 +build_number = 9 # git: 45a4df9 diff --git a/test/cls_test.py b/test/cls_test.py index 68d33c5f9..9819dd54d 100644 --- a/test/cls_test.py +++ b/test/cls_test.py @@ -1041,3 +1041,29 @@ def test_modal_object_param_uses_wrapped_type(servicer, set_env_client, client): container_params = deserialize_params(req.serialized_params, function_def, _client) args, kwargs = container_params assert type(kwargs["x"]) == type(dct) + + +def test_using_method_on_uninstantiated_cls(recwarn): + app = App() + + @app.cls(serialized=True) + class C: + @method() + def method(self): + pass + + assert len(recwarn) == 0 + with pytest.raises(AttributeError): + C.blah # type: ignore # noqa + assert len(recwarn) == 0 + + assert isinstance(C().method, Function) # should be fine to access on an instance of the class + assert len(recwarn) == 0 + + # The following should warn since it's accessed on the class directly + C.method # noqa # triggers a deprecation warning + # TODO: this will be an AttributeError or return a non-modal unbound function in the future: + assert len(recwarn) == 1 + warning_string = str(recwarn[0].message) + assert "instantiate classes before using methods" in warning_string + assert "C().method instead of C.method" in warning_string diff --git a/test/conftest.py b/test/conftest.py index bf50168ba..392334bad 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -47,7 +47,7 @@ from modal.cls import _Cls from modal.functions import _Function from modal.image import ImageBuilderVersion -from modal.mount import client_mount_name +from modal.mount import PYTHON_STANDALONE_VERSIONS, client_mount_name, python_standalone_mount_name from modal_proto import api_grpc, api_pb2 @@ -233,6 +233,13 @@ def __init__(self, blob_host, blobs, credentials): self.default_published_client_mount = "mo-123" self.deployed_mounts = { (client_mount_name(), api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL): self.default_published_client_mount, + **{ + ( + python_standalone_mount_name(version), + api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL, + ): f"mo-py{version.replace('.', '')}" + for version in PYTHON_STANDALONE_VERSIONS + }, } self.deployed_nfss = {} diff --git a/test/container_test.py b/test/container_test.py index c7857913e..8a9feb2cc 100644 --- a/test/container_test.py +++ b/test/container_test.py @@ -388,7 +388,7 @@ def _unwrap_exception(ret: ContainerResult): assert len(ret.items) == 1 assert ret.items[0].result.status == api_pb2.GenericResult.GENERIC_STATUS_FAILURE assert "Traceback" in ret.items[0].result.traceback - return ret.items[0].result.exception + return deserialize(ret.items[0].result.data, ret.client) def _unwrap_batch_exception(ret: ContainerResult, batch_size): @@ -488,14 +488,18 @@ def test_async(servicer): @skip_github_non_linux def test_failure(servicer, capsys): ret = _run_container(servicer, "test.supports.functions", "raises") - assert _unwrap_exception(ret) == "Exception('Failure!')" + exc = _unwrap_exception(ret) + assert isinstance(exc, Exception) + assert repr(exc) == "Exception('Failure!')" assert 'raise Exception("Failure!")' in capsys.readouterr().err # traceback @skip_github_non_linux def test_raises_base_exception(servicer, capsys): ret = _run_container(servicer, "test.supports.functions", "raises_sysexit") - assert _unwrap_exception(ret) == "SystemExit(1)" + exc = _unwrap_exception(ret) + assert isinstance(exc, SystemExit) + assert repr(exc) == "SystemExit(1)" assert "raise SystemExit(1)" in capsys.readouterr().err # traceback @@ -2457,3 +2461,37 @@ def test_container_app_zero_matching(servicer, event_loop): @skip_github_non_linux def test_container_app_one_matching(servicer, event_loop): _run_container(servicer, "test.supports.functions", "check_container_app") + + +@skip_github_non_linux +def test_no_event_loop(servicer, event_loop): + ret = _run_container(servicer, "test.supports.functions", "get_running_loop") + exc = _unwrap_exception(ret) + assert isinstance(exc, RuntimeError) + assert repr(exc) == "RuntimeError('no running event loop')" + + +@skip_github_non_linux +def test_is_main_thread_sync(servicer, event_loop): + ret = _run_container(servicer, "test.supports.functions", "is_main_thread_sync") + assert _unwrap_scalar(ret) is True + + +@skip_github_non_linux +def test_is_main_thread_async(servicer, event_loop): + ret = _run_container(servicer, "test.supports.functions", "is_main_thread_async") + assert _unwrap_scalar(ret) is True + + +@skip_github_non_linux +def test_import_thread_is_main_thread(servicer, event_loop): + ret = _run_container(servicer, "test.supports.functions", "import_thread_is_main_thread") + assert _unwrap_scalar(ret) is True + + +@skip_github_non_linux +def test_custom_exception(servicer, capsys): + ret = _run_container(servicer, "test.supports.functions", "raises_custom_exception") + exc = _unwrap_exception(ret) + assert isinstance(exc, Exception) + assert repr(exc) == "CustomException('Failure!')" diff --git a/test/image_test.py b/test/image_test.py index 3c84702d8..060380cfd 100644 --- a/test/image_test.py +++ b/test/image_test.py @@ -323,6 +323,29 @@ def test_debian_slim_apt_install(builder_version, servicer, client): assert any("pip install numpy" in cmd for cmd in layers[2].dockerfile_commands) +def test_from_registry_add_python(builder_version, servicer, client): + app = App(image=Image.from_registry("ubuntu", add_python="3.9")) + app.function()(dummy) + + with app.run(client=client): + layers = get_image_layers(app.image.object_id, servicer) + commands = layers[0].dockerfile_commands + assert layers[0].context_mount_id == "mo-py39" + assert any("COPY /python/. /usr/local" in cmd for cmd in commands) + assert any("ln -s /usr/local/bin/python3" in cmd for cmd in commands) + + if builder_version >= "2024.10": + app = App(image=Image.from_registry("ubuntu", add_python="3.13")) + app.function()(dummy) + + with app.run(client=client): + layers = get_image_layers(app.image.object_id, servicer) + commands = layers[0].dockerfile_commands + assert layers[0].context_mount_id == "mo-py313" + assert any("COPY /python/. /usr/local" in cmd for cmd in commands) + assert not any("ln -s /usr/local/bin/python3" in cmd for cmd in commands) + + def test_image_pip_install_pyproject(builder_version, servicer, client): pyproject_toml = os.path.join(os.path.dirname(__file__), "supports/test-pyproject.toml") diff --git a/test/supports/functions.py b/test/supports/functions.py index fef797305..af7de1f60 100644 --- a/test/supports/functions.py +++ b/test/supports/functions.py @@ -1,6 +1,7 @@ # Copyright Modal Labs 2022 import asyncio import contextlib +import threading import time from modal import ( @@ -713,3 +714,35 @@ def set_input_concurrency(start: float): def check_container_app(): # The container app should be associated with the app object assert App._get_container_app() == app + + +@app.function() +def get_running_loop(x): + return asyncio.get_running_loop() + + +@app.function() +def is_main_thread_sync(x): + return threading.main_thread() == threading.current_thread() + + +@app.function() +async def is_main_thread_async(x): + return threading.main_thread() == threading.current_thread() + + +_import_thread_is_main_thread = threading.main_thread() == threading.current_thread() + + +@app.function() +def import_thread_is_main_thread(x): + return _import_thread_is_main_thread + + +class CustomException(Exception): + pass + + +@app.function() +def raises_custom_exception(x): + raise CustomException("Failure!")