Skip to content

Commit

Permalink
Merge branch 'main' into cathy/batch_input_output
Browse files Browse the repository at this point in the history
  • Loading branch information
cathyzbn committed Aug 5, 2024
2 parents 24b9689 + ae94f8d commit a0325be
Show file tree
Hide file tree
Showing 24 changed files with 218 additions and 90 deletions.
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ We appreciate your patience while we speedily work towards a stable release of t

<!-- NEW CONTENT GENERATED BELOW. PLEASE PRESERVE THIS COMMENT. -->

### 0.64.2 (2024-08-02)

- Volumes can now be mounted to an ad hoc modal shell session:

```
modal shell --volume my-vol-name
```
When the shell starts, the volume will be mounted at `/mnt/my-vol-name`. This may be helpful for shell-based exploration or manipulation of volume contents.

Note that the option can be used multiple times to mount additional models:
```
modal shell --volume models --volume data
```



### 0.64.0 (2024-07-29)

- App deployment events are now atomic, reducing the risk that a failed deploy will leave the App in a bad state.



### 0.63.87 (2024-07-24)

Expand Down
2 changes: 1 addition & 1 deletion modal/_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class Resolver:

def __init__(
self,
client=None,
client: _Client,
*,
environment_name: Optional[str] = None,
app_id: Optional[str] = None,
Expand Down
22 changes: 22 additions & 0 deletions modal/_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ def create_module(self, spec):
spec.loader = self
return module

def get_data(self, path: str) -> bytes:
"""
Implementation is required to support pkgutil.get_data.
> If the package cannot be located or loaded, or it uses a loader which does
> not support get_data, then None is returned.
ref: https://docs.python.org/3/library/pkgutil.html#pkgutil.get_data
"""
return self.loader.get_data(path)

def get_resource_reader(self, fullname: str):
"""
Support reading a binary artifact that is shipped within a package.
> Loaders that wish to support resource reading are expected to provide a method called
> get_resource_reader(fullname) which returns an object implementing this ABC’s interface.
ref: docs.python.org/3.10/library/importlib.html?highlight=traversableresources#importlib.abc.ResourceReader
"""
return self.loader.get_resource_reader(fullname)


class ImportInterceptor(importlib.abc.MetaPathFinder):
loading: typing.Dict[str, typing.Tuple[str, float]]
Expand Down
4 changes: 2 additions & 2 deletions modal/_tunnel.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def tcp_tunnel():
```
**SSH example:**
This assumes you have a rsa keypair in `~/.ssh/id_rsa{.pub}, this is a bare-bones example
This assumes you have a rsa keypair in `~/.ssh/id_rsa{.pub}`, this is a bare-bones example
letting you SSH into a Modal container.
```python
Expand All @@ -152,7 +152,7 @@ def some_function():
with modal.forward(port=22, unencrypted=True) as tunnel:
hostname, port = tunnel.tcp_socket
connection_cmd = f'ssh -p {port} root@{hostname}'
print(f"ssh into container using:\n{connection_cmd}")
print(f"ssh into container using: {connection_cmd}")
time.sleep(3600) # keep alive for 1 hour or until killed
```
Expand Down
21 changes: 14 additions & 7 deletions modal/_utils/function_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import os
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Any, AsyncIterator, Callable, Dict, List, Literal, Optional, Type
from typing import Any, AsyncIterator, Callable, Dict, List, Literal, Optional, Tuple, Type

from grpclib import GRPCError
from grpclib.exceptions import StreamTerminatedError
Expand All @@ -28,6 +28,12 @@ class FunctionInfoType(Enum):
NOTEBOOK = "notebook"


CLASS_PARAM_TYPE_MAP: Dict[Type, Tuple["api_pb2.ParameterType.ValueType", str]] = {
str: (api_pb2.PARAM_TYPE_STRING, "string_default"),
int: (api_pb2.PARAM_TYPE_INT, "int_default"),
}


class LocalFunctionError(InvalidError):
"""Raised if a function declared in a non-global scope is used in an impermissible way"""

Expand Down Expand Up @@ -238,13 +244,14 @@ def class_parameter_info(self) -> api_pb2.ClassParameterInfo:
modal_parameters: List[api_pb2.ClassParameterSpec] = []
signature = inspect.signature(self.user_cls)
for param in signature.parameters.values():
if param.annotation == str:
param_type = api_pb2.PARAM_TYPE_STRING
elif param.annotation == int:
param_type = api_pb2.PARAM_TYPE_INT
else:
has_default = param.default is not param.empty
if param.annotation not in CLASS_PARAM_TYPE_MAP:
raise InvalidError("Strict class parameters need to be explicitly annotated as str or int")
modal_parameters.append(api_pb2.ClassParameterSpec(name=param.name, type=param_type))
param_type, default_field = CLASS_PARAM_TYPE_MAP[param.annotation]
class_param_spec = api_pb2.ClassParameterSpec(name=param.name, has_default=has_default, type=param_type)
if has_default:
setattr(class_param_spec, default_field, param.default)
modal_parameters.append(class_param_spec)

return api_pb2.ClassParameterInfo(
format=api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PROTO, schema=modal_parameters
Expand Down
4 changes: 2 additions & 2 deletions modal/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ def registered_web_endpoints(self) -> List[str]:

def local_entrypoint(
self, _warn_parentheses_missing: Any = None, *, name: Optional[str] = None
) -> Callable[[Callable[..., Any]], None]:
) -> Callable[[Callable[..., Any]], _LocalEntrypoint]:
"""Decorate a function to be used as a CLI entrypoint for a Modal App.
These functions can be used to define code that runs locally to set up the app,
Expand Down Expand Up @@ -488,7 +488,7 @@ def main(foo: int, bar: str):
if name is not None and not isinstance(name, str):
raise InvalidError("Invalid value for `name`: Must be string.")

def wrapped(raw_f: Callable[..., Any]) -> None:
def wrapped(raw_f: Callable[..., Any]) -> _LocalEntrypoint:
info = FunctionInfo(raw_f)
tag = name if name is not None else raw_f.__qualname__
if tag in self._local_entrypoints:
Expand Down
21 changes: 17 additions & 4 deletions modal/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from ..image import Image
from ..runner import deploy_app, interactive_shell, run_app
from ..serving import serve_app
from ..volume import Volume
from .import_refs import import_app, import_function
from .utils import ENV_OPTION, ENV_OPTION_HELP, stream_app_logs

Expand Down Expand Up @@ -278,10 +279,10 @@ def run(ctx, detach, quiet, interactive, env):

def deploy(
app_ref: str = typer.Argument(..., help="Path to a Python file with an app."),
name: str = typer.Option(None, help="Name of the deployment."),
name: str = typer.Option("", help="Name of the deployment."),
env: str = ENV_OPTION,
stream_logs: bool = typer.Option(False, help="Stream logs from the app upon deployment."),
tag: str = typer.Option(None, help="Tag the deployment with a version."),
tag: str = typer.Option("", help="Tag the deployment with a version."),
):
# this ensures that `modal.lookup()` without environment specification uses the same env as specified
env = ensure_env(env)
Expand All @@ -292,7 +293,7 @@ def deploy(
name = app.name

with enable_output():
res = deploy_app(app, name=name, environment_name=env, tag=tag)
res = deploy_app(app, name=name, environment_name=env or "", tag=tag)

if stream_logs:
stream_app_logs(res.app_id)
Expand Down Expand Up @@ -332,7 +333,10 @@ def serve(
def shell(
func_ref: Optional[str] = typer.Argument(
default=None,
help="Path to a Python file with an App or Modal function whose container to run.",
help=(
"Path to a Python file with an App or Modal function with container parameters."
" Can also include a function specifier, like `module.py::func`, when the file defines multiple functions."
),
metavar="FUNC_REF",
),
cmd: str = typer.Option(default="/bin/bash", help="Command to run inside the Modal image."),
Expand All @@ -341,6 +345,13 @@ def shell(
default=None, help="Container image tag for inside the shell (if not using FUNC_REF)."
),
add_python: Optional[str] = typer.Option(default=None, help="Add Python to the image (if not using FUNC_REF)."),
volume: Optional[typing.List[str]] = typer.Option(
default=None,
help=(
"Name of a `modal.Volume` to mount inside the shell at `/mnt/{name}` (if not using FUNC_REF)."
" Can be used multiple times."
),
),
cpu: Optional[int] = typer.Option(
default=None, help="Number of CPUs to allocate to the shell (if not using FUNC_REF)."
),
Expand Down Expand Up @@ -419,13 +430,15 @@ def shell(
)
else:
modal_image = Image.from_registry(image, add_python=add_python) if image else None
volumes = {} if volume is None else {f"/mnt/{vol}": Volume.from_name(vol) for vol in volume}
start_shell = partial(
interactive_shell,
image=modal_image,
cpu=cpu,
memory=memory,
gpu=gpu,
cloud=cloud,
volumes=volumes,
region=region.split(",") if region else [],
)

Expand Down
3 changes: 2 additions & 1 deletion modal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ def __init__(
self._stub: Optional[api_grpc.ModalClientStub] = None

@property
def stub(self) -> Optional[api_grpc.ModalClientStub]:
def stub(self) -> api_grpc.ModalClientStub:
"""mdmd:hidden"""
assert self._stub
return self._stub

@property
Expand Down
10 changes: 10 additions & 0 deletions modal/cls.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,16 @@ async def _load(self: "_Cls", resolver: Resolver, existing_object_id: Optional[s
)
)
resp = await resolver.client.stub.ClassCreate(req)
# Even though we already have the function_handle_metadata for this method locally,
# The RPC is going to replace it with function_handle_metadata derived from the server.
# We need to overwrite the definition_id sent back from the server here with the definition_id
# previously stored in function metadata, which may have been sent back from FunctionCreate.
# The problem is that this metadata propagates back and overwrites the metadata on the Function
# object itself. This is really messy. Maybe better to exclusively populate the method metadata
# from the function metadata we already have locally? Really a lot to clean up here...
for method in resp.handle_metadata.methods:
f_metadata = self._method_functions[method.function_name]._get_metadata()
method.function_handle_metadata.definition_id = f_metadata.definition_id
self._hydrate(resp.class_id, resolver.client, resp.handle_metadata)

rep = f"Cls({user_cls.__name__})"
Expand Down
6 changes: 5 additions & 1 deletion modal/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ async def _load(method_bound_function: "_Function", resolver: Resolver, existing
function=function_definition,
# method_bound_function.object_id usually gets set by preload
existing_function_id=existing_object_id or method_bound_function.object_id or "",
defer_updates=True,
)
assert resolver.client.stub is not None # client should be connected when load is called
with FunctionCreationStatus(resolver, full_name) as function_creation_status:
Expand Down Expand Up @@ -834,6 +835,7 @@ async def _load(self: _Function, resolver: Resolver, existing_object_id: Optiona
function=function_definition,
schedule=schedule.proto_message if schedule is not None else None,
existing_function_id=existing_object_id or "",
defer_updates=True,
)
try:
response: api_pb2.FunctionCreateResponse = await retry_transient_errors(
Expand Down Expand Up @@ -1092,14 +1094,15 @@ def _initialize_from_empty(self):

def _hydrate_metadata(self, metadata: Optional[Message]):
# Overridden concrete implementation of base class method
assert metadata and isinstance(metadata, (api_pb2.Function, api_pb2.FunctionHandleMetadata))
assert metadata and isinstance(metadata, api_pb2.FunctionHandleMetadata)
self._is_generator = metadata.function_type == api_pb2.Function.FUNCTION_TYPE_GENERATOR
self._web_url = metadata.web_url
self._function_name = metadata.function_name
self._is_method = metadata.is_method
self._use_function_id = metadata.use_function_id
self._use_method_name = metadata.use_method_name
self._class_parameter_info = metadata.class_parameter_info
self._definition_id = metadata.definition_id

def _invocation_function_id(self) -> str:
return self._use_function_id or self.object_id
Expand All @@ -1119,6 +1122,7 @@ def _get_metadata(self):
use_function_id=self._use_function_id,
is_method=self._is_method,
class_parameter_info=self._class_parameter_info,
definition_id=self._definition_id,
)

def _set_mute_cancellation(self, value: bool = True):
Expand Down
2 changes: 1 addition & 1 deletion modal/mount.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ async def _deploy(
namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
environment_name: Optional[str] = None,
client: Optional[_Client] = None,
) -> "_Mount":
) -> None:
check_object_name(deployment_name, "Mount")
self._deployment_name = deployment_name
self._namespace = namespace
Expand Down
Loading

0 comments on commit a0325be

Please sign in to comment.