modal 0.66.39__py3-none-any.whl → 0.66.44__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.
@@ -499,7 +499,7 @@ def main(container_args: api_pb2.ContainerArguments, client: Client):
499
499
  call_lifecycle_functions(event_loop, container_io_manager, list(pre_snapshot_methods.values()))
500
500
 
501
501
  # If this container is being used to create a checkpoint, checkpoint the container after
502
- # global imports and innitialization. Checkpointed containers run from this point onwards.
502
+ # global imports and initialization. Checkpointed containers run from this point onwards.
503
503
  if is_snapshotting_function:
504
504
  container_io_manager.memory_snapshot()
505
505
 
@@ -197,7 +197,7 @@ def get_user_class_instance(
197
197
  modal_obj: modal.cls.Obj = cls(*args, **kwargs)
198
198
  modal_obj.entered = True # ugly but prevents .local() from triggering additional enter-logic
199
199
  # TODO: unify lifecycle logic between .local() and container_entrypoint
200
- user_cls_instance = modal_obj._get_user_cls_instance()
200
+ user_cls_instance = modal_obj._cached_user_cls_instance()
201
201
  else:
202
202
  # undecorated class (non-global decoration or serialized)
203
203
  user_cls_instance = cls(*args, **kwargs)
@@ -52,7 +52,7 @@ def patch_mock_servicer(cls):
52
52
  ctx = InterceptionContext()
53
53
  servicer.interception_context = ctx
54
54
  yield ctx
55
- ctx.assert_responses_consumed()
55
+ ctx._assert_responses_consumed()
56
56
  servicer.interception_context = None
57
57
 
58
58
  cls.intercept = intercept
@@ -64,7 +64,7 @@ def patch_mock_servicer(cls):
64
64
  ctx = servicer_self.interception_context
65
65
  if ctx:
66
66
  intercepted_stream = await InterceptedStream(ctx, method_name, stream).initialize()
67
- custom_responder = ctx.next_custom_responder(method_name, intercepted_stream.request_message)
67
+ custom_responder = ctx._next_custom_responder(method_name, intercepted_stream.request_message)
68
68
  if custom_responder:
69
69
  return await custom_responder(servicer_self, intercepted_stream)
70
70
  else:
@@ -105,19 +105,23 @@ class InterceptionContext:
105
105
  self.custom_responses: Dict[str, List[Tuple[Callable[[Any], bool], List[Any]]]] = defaultdict(list)
106
106
  self.custom_defaults: Dict[str, Callable[["MockClientServicer", grpclib.server.Stream], Awaitable[None]]] = {}
107
107
 
108
- def add_recv(self, method_name: str, msg):
109
- self.calls.append((method_name, msg))
110
-
111
108
  def add_response(
112
109
  self, method_name: str, first_payload, *, request_filter: Callable[[Any], bool] = lambda req: True
113
110
  ):
114
- # adds one response to a queue of responses for requests of the specified type
111
+ """Adds one response payload to an expected queue of responses for a method.
112
+
113
+ These responses will be used once each instead of calling the MockServicer's
114
+ implementation of the method.
115
+
116
+ The interception context will throw an exception on exit if not all of the added
117
+ responses have been consumed.
118
+ """
115
119
  self.custom_responses[method_name].append((request_filter, [first_payload]))
116
120
 
117
121
  def set_responder(
118
122
  self, method_name: str, responder: Callable[["MockClientServicer", grpclib.server.Stream], Awaitable[None]]
119
123
  ):
120
- """Replace the default responder method. E.g.
124
+ """Replace the default responder from the MockClientServicer with a custom implementation
121
125
 
122
126
  ```python notest
123
127
  def custom_responder(servicer, stream):
@@ -128,11 +132,28 @@ class InterceptionContext:
128
132
  ctx.set_responder("SomeMethod", custom_responder)
129
133
  ```
130
134
 
131
- Responses added via `.add_response()` take precedence.
135
+ Responses added via `.add_response()` take precedence over the use of this replacement
132
136
  """
133
137
  self.custom_defaults[method_name] = responder
134
138
 
135
- def next_custom_responder(self, method_name, request):
139
+ def pop_request(self, method_name):
140
+ # fast forward to the next request of type method_name
141
+ # dropping any preceding requests if there is a match
142
+ # returns the payload of the request
143
+ for i, (_method_name, msg) in enumerate(self.calls):
144
+ if _method_name == method_name:
145
+ self.calls = self.calls[i + 1 :]
146
+ return msg
147
+
148
+ raise KeyError(f"No message of that type in call list: {self.calls}")
149
+
150
+ def get_requests(self, method_name: str) -> List[Any]:
151
+ return [msg for _method_name, msg in self.calls if _method_name == method_name]
152
+
153
+ def _add_recv(self, method_name: str, msg):
154
+ self.calls.append((method_name, msg))
155
+
156
+ def _next_custom_responder(self, method_name, request):
136
157
  method_responses = self.custom_responses[method_name]
137
158
  for i, (request_filter, response_messages) in enumerate(method_responses):
138
159
  try:
@@ -159,7 +180,7 @@ class InterceptionContext:
159
180
 
160
181
  return responder
161
182
 
162
- def assert_responses_consumed(self):
183
+ def _assert_responses_consumed(self):
163
184
  unconsumed = []
164
185
  for method_name, queued_responses in self.custom_responses.items():
165
186
  unconsumed += [method_name] * len(queued_responses)
@@ -167,23 +188,9 @@ class InterceptionContext:
167
188
  if unconsumed:
168
189
  raise ResponseNotConsumed(unconsumed)
169
190
 
170
- def pop_request(self, method_name):
171
- # fast forward to the next request of type method_name
172
- # dropping any preceding requests if there is a match
173
- # returns the payload of the request
174
- for i, (_method_name, msg) in enumerate(self.calls):
175
- if _method_name == method_name:
176
- self.calls = self.calls[i + 1 :]
177
- return msg
178
-
179
- raise KeyError(f"No message of that type in call list: {self.calls}")
180
-
181
- def get_requests(self, method_name: str) -> List[Any]:
182
- return [msg for _method_name, msg in self.calls if _method_name == method_name]
183
-
184
191
 
185
192
  class InterceptedStream:
186
- def __init__(self, interception_context, method_name, stream):
193
+ def __init__(self, interception_context: InterceptionContext, method_name: str, stream):
187
194
  self.interception_context = interception_context
188
195
  self.method_name = method_name
189
196
  self.stream = stream
@@ -200,7 +207,7 @@ class InterceptedStream:
200
207
  return ret
201
208
 
202
209
  msg = await self.stream.recv_message()
203
- self.interception_context.add_recv(self.method_name, msg)
210
+ self.interception_context._add_recv(self.method_name, msg)
204
211
  return msg
205
212
 
206
213
  async def send_message(self, msg):
modal/client.pyi CHANGED
@@ -31,7 +31,7 @@ class _Client:
31
31
  server_url: str,
32
32
  client_type: int,
33
33
  credentials: typing.Optional[typing.Tuple[str, str]],
34
- version: str = "0.66.39",
34
+ version: str = "0.66.44",
35
35
  ): ...
36
36
  def is_closed(self) -> bool: ...
37
37
  @property
@@ -90,7 +90,7 @@ class Client:
90
90
  server_url: str,
91
91
  client_type: int,
92
92
  credentials: typing.Optional[typing.Tuple[str, str]],
93
- version: str = "0.66.39",
93
+ version: str = "0.66.44",
94
94
  ): ...
95
95
  def is_closed(self) -> bool: ...
96
96
  @property
modal/cls.py CHANGED
@@ -113,7 +113,7 @@ class _Obj:
113
113
  method = self._instance_service_function._bind_instance_method(class_bound_method)
114
114
  self._method_functions[method_name] = method
115
115
  else:
116
- # <v0.63 classes - bind each individual method to the new parameters
116
+ # looked up <v0.63 classes - bind each individual method to the new parameters
117
117
  self._instance_service_function = None
118
118
  for method_name, class_bound_method in classbound_methods.items():
119
119
  method = class_bound_method._bind_parameters(self, from_other_workspace, options, args, kwargs)
@@ -125,12 +125,14 @@ class _Obj:
125
125
  self._user_cls = user_cls
126
126
  self._construction_args = (args, kwargs) # used for lazy construction in case of explicit constructors
127
127
 
128
- def _user_cls_instance_constr(self):
128
+ def _new_user_cls_instance(self):
129
129
  args, kwargs = self._construction_args
130
130
  if not _use_annotation_parameters(self._user_cls):
131
131
  # TODO(elias): deprecate this code path eventually
132
132
  user_cls_instance = self._user_cls(*args, **kwargs)
133
133
  else:
134
+ # ignore constructor (assumes there is no custom constructor,
135
+ # which is guaranteed by _use_annotation_parameters)
134
136
  # set the attributes on the class corresponding to annotations
135
137
  # with = parameter() specifications
136
138
  sig = _get_class_constructor_signature(self._user_cls)
@@ -139,6 +141,7 @@ class _Obj:
139
141
  user_cls_instance = self._user_cls.__new__(self._user_cls) # new instance without running __init__
140
142
  user_cls_instance.__dict__.update(bound_vars.arguments)
141
143
 
144
+ # TODO: always use Obj instances instead of making modifications to user cls
142
145
  user_cls_instance._modal_functions = self._method_functions # Needed for PartialFunction.__get__
143
146
  return user_cls_instance
144
147
 
@@ -163,10 +166,12 @@ class _Obj:
163
166
  )
164
167
  await self._instance_service_function.keep_warm(warm_pool_size)
165
168
 
166
- def _get_user_cls_instance(self):
167
- """Construct local object lazily. Used for .local() calls."""
169
+ def _cached_user_cls_instance(self):
170
+ """Get or construct the local object
171
+
172
+ Used for .local() calls and getting attributes of classes"""
168
173
  if not self._user_cls_instance:
169
- self._user_cls_instance = self._user_cls_instance_constr() # Instantiate object
174
+ self._user_cls_instance = self._new_user_cls_instance() # Instantiate object
170
175
 
171
176
  return self._user_cls_instance
172
177
 
@@ -196,7 +201,7 @@ class _Obj:
196
201
  @synchronizer.nowrap
197
202
  async def aenter(self):
198
203
  if not self.entered:
199
- user_cls_instance = self._get_user_cls_instance()
204
+ user_cls_instance = self._cached_user_cls_instance()
200
205
  if hasattr(user_cls_instance, "__aenter__"):
201
206
  await user_cls_instance.__aenter__()
202
207
  elif hasattr(user_cls_instance, "__enter__"):
@@ -205,20 +210,22 @@ class _Obj:
205
210
 
206
211
  def __getattr__(self, k):
207
212
  if k in self._method_functions:
208
- # if we know the user is accessing a method, we don't have to create an instance
209
- # yet, since the user might just call `.remote()` on it which doesn't require
210
- # a local instance (in case __init__ does stuff that can't locally)
213
+ # If we know the user is accessing a *method* and not another attribute,
214
+ # we don't have to create an instance of the user class yet.
215
+ # This is because it might just be a call to `.remote()` on it which
216
+ # doesn't require a local instance.
217
+ # As long as we have the service function or params, we can do remote calls
218
+ # without calling the constructor of the class in the calling context.
211
219
  return self._method_functions[k]
212
- elif self._user_cls_instance_constr:
213
- # if it's *not* a method
214
- # TODO: To get lazy loading (from_name) of classes to work, we need to avoid
215
- # this path, otherwise local initialization will happen regardless if user
216
- # only runs .remote(), since we don't know methods for the class until we
217
- # load it
218
- user_cls_instance = self._get_user_cls_instance()
219
- return getattr(user_cls_instance, k)
220
- else:
221
- raise AttributeError(k)
220
+
221
+ # if it's *not* a method, it *might* be an attribute of the class,
222
+ # so we construct it and proxy the attribute
223
+ # TODO: To get lazy loading (from_name) of classes to work, we need to avoid
224
+ # this path, otherwise local initialization will happen regardless if user
225
+ # only runs .remote(), since we don't know methods for the class until we
226
+ # load it
227
+ user_cls_instance = self._cached_user_cls_instance()
228
+ return getattr(user_cls_instance, k)
222
229
 
223
230
 
224
231
  Obj = synchronize_api(_Obj)
modal/cls.pyi CHANGED
@@ -37,9 +37,9 @@ class _Obj:
37
37
  args,
38
38
  kwargs,
39
39
  ): ...
40
- def _user_cls_instance_constr(self): ...
40
+ def _new_user_cls_instance(self): ...
41
41
  async def keep_warm(self, warm_pool_size: int) -> None: ...
42
- def _get_user_cls_instance(self): ...
42
+ def _cached_user_cls_instance(self): ...
43
43
  def enter(self): ...
44
44
  @property
45
45
  def entered(self): ...
@@ -66,7 +66,7 @@ class Obj:
66
66
  kwargs,
67
67
  ): ...
68
68
  def _uses_common_service_function(self): ...
69
- def _user_cls_instance_constr(self): ...
69
+ def _new_user_cls_instance(self): ...
70
70
 
71
71
  class __keep_warm_spec(typing_extensions.Protocol):
72
72
  def __call__(self, warm_pool_size: int) -> None: ...
@@ -74,7 +74,7 @@ class Obj:
74
74
 
75
75
  keep_warm: __keep_warm_spec
76
76
 
77
- def _get_user_cls_instance(self): ...
77
+ def _cached_user_cls_instance(self): ...
78
78
  def enter(self): ...
79
79
  @property
80
80
  def entered(self): ...
modal/functions.py CHANGED
@@ -314,7 +314,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
314
314
  _tag: str
315
315
  _raw_f: Callable[..., Any]
316
316
  _build_args: dict
317
- _can_use_base_function: bool = False # whether we need to call FunctionBindParams
317
+
318
318
  _is_generator: Optional[bool] = None
319
319
  _cluster_size: Optional[int] = None
320
320
 
@@ -323,10 +323,6 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
323
323
  _use_function_id: str # The function to invoke
324
324
  _use_method_name: str = ""
325
325
 
326
- # TODO (elias): remove _parent. In case of instance functions, and methods bound on those,
327
- # this references the parent class-function and is used to infer the client for lazy-loaded methods
328
- _parent: Optional["_Function"] = None
329
-
330
326
  _class_parameter_info: Optional["api_pb2.ClassParameterInfo"] = None
331
327
  _method_handle_metadata: Optional[Dict[str, "api_pb2.FunctionHandleMetadata"]] = None
332
328
 
@@ -511,7 +507,6 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
511
507
  fun._info = class_bound_method._info
512
508
  fun._obj = instance_service_function._obj
513
509
  fun._is_method = True
514
- fun._parent = instance_service_function._parent
515
510
  fun._app = class_bound_method._app
516
511
  fun._spec = class_bound_method._spec
517
512
  return fun
@@ -1019,27 +1014,37 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1019
1014
  Binds a class-function to a specific instance of (init params, options) or a new workspace
1020
1015
  """
1021
1016
 
1022
- async def _load(self: _Function, resolver: Resolver, existing_object_id: Optional[str]):
1023
- if self._parent is None:
1017
+ # In some cases, reuse the base function, i.e. not create new clones of each method or the "service function"
1018
+ can_use_parent = len(args) + len(kwargs) == 0 and not from_other_workspace and options is None
1019
+ parent = self
1020
+
1021
+ async def _load(param_bound_func: _Function, resolver: Resolver, existing_object_id: Optional[str]):
1022
+ if parent is None:
1024
1023
  raise ExecutionError("Can't find the parent class' service function")
1025
1024
  try:
1026
- identity = f"{self._parent.info.function_name} class service function"
1025
+ identity = f"{parent.info.function_name} class service function"
1027
1026
  except Exception:
1028
1027
  # Can't always look up the function name that way, so fall back to generic message
1029
1028
  identity = "class service function for a parameterized class"
1030
- if not self._parent.is_hydrated:
1031
- if self._parent.app._running_app is None:
1029
+ if not parent.is_hydrated:
1030
+ if parent.app._running_app is None:
1032
1031
  reason = ", because the App it is defined on is not running"
1033
1032
  else:
1034
1033
  reason = ""
1035
1034
  raise ExecutionError(
1036
1035
  f"The {identity} has not been hydrated with the metadata it needs to run on Modal{reason}."
1037
1036
  )
1038
- assert self._parent._client.stub
1037
+
1038
+ assert parent._client.stub
1039
+
1040
+ if can_use_parent:
1041
+ # We can end up here if parent wasn't hydrated when class was instantiated, but has been since.
1042
+ param_bound_func._hydrate_from_other(parent)
1043
+ return
1044
+
1039
1045
  if (
1040
- self._parent._class_parameter_info
1041
- and self._parent._class_parameter_info.format
1042
- == api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PROTO
1046
+ parent._class_parameter_info
1047
+ and parent._class_parameter_info.format == api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PROTO
1043
1048
  ):
1044
1049
  if args:
1045
1050
  # TODO(elias) - We could potentially support positional args as well, if we want to?
@@ -1047,34 +1052,30 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1047
1052
  "Can't use positional arguments with modal.parameter-based synthetic constructors.\n"
1048
1053
  "Use (<parameter_name>=value) keyword arguments when constructing classes instead."
1049
1054
  )
1050
- serialized_params = serialize_proto_params(kwargs, self._parent._class_parameter_info.schema)
1055
+ serialized_params = serialize_proto_params(kwargs, parent._class_parameter_info.schema)
1051
1056
  else:
1052
1057
  serialized_params = serialize((args, kwargs))
1053
1058
  environment_name = _get_environment_name(None, resolver)
1054
- assert self._parent is not None
1059
+ assert parent is not None
1055
1060
  req = api_pb2.FunctionBindParamsRequest(
1056
- function_id=self._parent._object_id,
1061
+ function_id=parent._object_id,
1057
1062
  serialized_params=serialized_params,
1058
1063
  function_options=options,
1059
1064
  environment_name=environment_name
1060
1065
  or "", # TODO: investigate shouldn't environment name always be specified here?
1061
1066
  )
1062
1067
 
1063
- response = await retry_transient_errors(self._parent._client.stub.FunctionBindParams, req)
1064
- self._hydrate(response.bound_function_id, self._parent._client, response.handle_metadata)
1068
+ response = await retry_transient_errors(parent._client.stub.FunctionBindParams, req)
1069
+ param_bound_func._hydrate(response.bound_function_id, parent._client, response.handle_metadata)
1065
1070
 
1066
1071
  fun: _Function = _Function._from_loader(_load, "Function(parametrized)", hydrate_lazily=True)
1067
1072
 
1068
- # In some cases, reuse the base function, i.e. not create new clones of each method or the "service function"
1069
- fun._can_use_base_function = len(args) + len(kwargs) == 0 and not from_other_workspace and options is None
1070
- if fun._can_use_base_function and self.is_hydrated:
1071
- # Edge case that lets us hydrate all objects right away
1072
- # if the instance didn't use explicit constructor arguments
1073
- fun._hydrate_from_other(self)
1073
+ if can_use_parent and parent.is_hydrated:
1074
+ # skip the resolver altogether:
1075
+ fun._hydrate_from_other(parent)
1074
1076
 
1075
1077
  fun._info = self._info
1076
1078
  fun._obj = obj
1077
- fun._parent = self
1078
1079
  return fun
1079
1080
 
1080
1081
  @live_method
@@ -1265,8 +1266,10 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1265
1266
  + f"or call it locally: {self._function_name}.local()"
1266
1267
  )
1267
1268
 
1269
+ # TODO (live_method on properties is not great, since it could be blocking the event loop from async contexts)
1268
1270
  @property
1269
- def web_url(self) -> str:
1271
+ @live_method
1272
+ async def web_url(self) -> str:
1270
1273
  """URL of a Function running as a web endpoint."""
1271
1274
  if not self._web_url:
1272
1275
  raise ValueError(
@@ -1439,7 +1442,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1439
1442
  return fun(*args, **kwargs)
1440
1443
  else:
1441
1444
  # This is a method on a class, so bind the self to the function
1442
- user_cls_instance = obj._get_user_cls_instance()
1445
+ user_cls_instance = obj._cached_user_cls_instance()
1443
1446
 
1444
1447
  fun = info.raw_f.__get__(user_cls_instance)
1445
1448
 
modal/functions.pyi CHANGED
@@ -120,12 +120,10 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], modal.object.
120
120
  _tag: str
121
121
  _raw_f: typing.Callable[..., typing.Any]
122
122
  _build_args: dict
123
- _can_use_base_function: bool
124
123
  _is_generator: typing.Optional[bool]
125
124
  _cluster_size: typing.Optional[int]
126
125
  _use_function_id: str
127
126
  _use_method_name: str
128
- _parent: typing.Optional[_Function]
129
127
  _class_parameter_info: typing.Optional[modal_proto.api_pb2.ClassParameterInfo]
130
128
  _method_handle_metadata: typing.Optional[typing.Dict[str, modal_proto.api_pb2.FunctionHandleMetadata]]
131
129
 
@@ -218,7 +216,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], modal.object.
218
216
  def _get_metadata(self): ...
219
217
  def _check_no_web_url(self, fn_name: str): ...
220
218
  @property
221
- def web_url(self) -> str: ...
219
+ async def web_url(self) -> str: ...
222
220
  @property
223
221
  def is_generator(self) -> bool: ...
224
222
  @property
@@ -296,12 +294,10 @@ class Function(typing.Generic[P, ReturnType, OriginalReturnType], modal.object.O
296
294
  _tag: str
297
295
  _raw_f: typing.Callable[..., typing.Any]
298
296
  _build_args: dict
299
- _can_use_base_function: bool
300
297
  _is_generator: typing.Optional[bool]
301
298
  _cluster_size: typing.Optional[int]
302
299
  _use_function_id: str
303
300
  _use_method_name: str
304
- _parent: typing.Optional[Function]
305
301
  _class_parameter_info: typing.Optional[modal_proto.api_pb2.ClassParameterInfo]
306
302
  _method_handle_metadata: typing.Optional[typing.Dict[str, modal_proto.api_pb2.FunctionHandleMetadata]]
307
303
 
modal/image.py CHANGED
@@ -344,7 +344,7 @@ class _Image(_Object, type_prefix="im"):
344
344
  "\n"
345
345
  "my_image = (\n"
346
346
  " Image.debian_slim()\n"
347
- ' .add_local_python_packages("mypak", copy=True)\n'
347
+ ' .add_local_file("data.json", copy=True)\n'
348
348
  ' .run_commands("python -m mypak") # this now works!\n'
349
349
  ")\n"
350
350
  )
@@ -601,12 +601,59 @@ class _Image(_Object, type_prefix="im"):
601
601
  context_mount=mount,
602
602
  )
603
603
 
604
+ def add_local_file(self, local_path: Union[str, Path], remote_path: str, *, copy: bool = False) -> "_Image":
605
+ """Adds a local file to the image at `remote_path` within the container
606
+
607
+ By default (`copy=False`), the files are added to containers on startup and are not built into the actual Image,
608
+ which speeds up deployment.
609
+
610
+ Set `copy=True` to copy the files into an Image layer at build time instead, similar to how
611
+ [`COPY`](https://docs.docker.com/engine/reference/builder/#copy) works in a `Dockerfile`.
612
+
613
+ copy=True can slow down iteration since it requires a rebuild of the Image and any subsequent
614
+ build steps whenever the included files change, but it is required if you want to run additional
615
+ build steps after this one.
616
+ """
617
+ if not PurePosixPath(remote_path).is_absolute():
618
+ # TODO(elias): implement relative to absolute resolution using image workdir metadata
619
+ # + make default remote_path="./"
620
+ # This requires deferring the Mount creation until after "self" (the base image) has been resolved
621
+ # so we know the workdir of the operation.
622
+ raise InvalidError("image.add_local_file() currently only supports absolute remote_path values")
623
+
624
+ if remote_path.endswith("/"):
625
+ remote_path = remote_path + Path(local_path).name
626
+
627
+ mount = _Mount.from_local_file(local_path, remote_path)
628
+ return self._add_mount_layer_or_copy(mount, copy=copy)
629
+
630
+ def add_local_dir(self, local_path: Union[str, Path], remote_path: str, *, copy: bool = False) -> "_Image":
631
+ """Adds a local directory's content to the image at `remote_path` within the container
632
+
633
+ By default (`copy=False`), the files are added to containers on startup and are not built into the actual Image,
634
+ which speeds up deployment.
635
+
636
+ Set `copy=True` to copy the files into an Image layer at build time instead, similar to how
637
+ [`COPY`](https://docs.docker.com/engine/reference/builder/#copy) works in a `Dockerfile`.
638
+
639
+ copy=True can slow down iteration since it requires a rebuild of the Image and any subsequent
640
+ build steps whenever the included files change, but it is required if you want to run additional
641
+ build steps after this one.
642
+ """
643
+ if not PurePosixPath(remote_path).is_absolute():
644
+ # TODO(elias): implement relative to absolute resolution using image workdir metadata
645
+ # + make default remote_path="./"
646
+ raise InvalidError("image.add_local_dir() currently only supports absolute remote_path values")
647
+ mount = _Mount.from_local_dir(local_path, remote_path=remote_path)
648
+ return self._add_mount_layer_or_copy(mount, copy=copy)
649
+
604
650
  def copy_local_file(self, local_path: Union[str, Path], remote_path: Union[str, Path] = "./") -> "_Image":
605
651
  """Copy a file into the image as a part of building it.
606
652
 
607
653
  This works in a similar way to [`COPY`](https://docs.docker.com/engine/reference/builder/#copy)
608
654
  works in a `Dockerfile`.
609
655
  """
656
+ # TODO(elias): add pending deprecation with suggestion to use add_* instead
610
657
  basename = str(Path(local_path).name)
611
658
  mount = _Mount.from_local_file(local_path, remote_path=f"/{basename}")
612
659
 
@@ -1637,7 +1684,7 @@ class _Image(_Object, type_prefix="im"):
1637
1684
  dockerfile_function=build_dockerfile,
1638
1685
  )
1639
1686
 
1640
- def workdir(self, path: str) -> "_Image":
1687
+ def workdir(self, path: Union[str, PurePosixPath]) -> "_Image":
1641
1688
  """Set the working directory for subsequent image build steps and function execution.
1642
1689
 
1643
1690
  **Example**
modal/image.pyi CHANGED
@@ -100,6 +100,12 @@ class _Image(modal.object._Object):
100
100
  _do_assert_no_mount_layers: bool = True,
101
101
  ) -> _Image: ...
102
102
  def copy_mount(self, mount: modal.mount._Mount, remote_path: typing.Union[str, pathlib.Path] = ".") -> _Image: ...
103
+ def add_local_file(
104
+ self, local_path: typing.Union[str, pathlib.Path], remote_path: str, *, copy: bool = False
105
+ ) -> _Image: ...
106
+ def add_local_dir(
107
+ self, local_path: typing.Union[str, pathlib.Path], remote_path: str, *, copy: bool = False
108
+ ) -> _Image: ...
103
109
  def copy_local_file(
104
110
  self, local_path: typing.Union[str, pathlib.Path], remote_path: typing.Union[str, pathlib.Path] = "./"
105
111
  ) -> _Image: ...
@@ -301,7 +307,7 @@ class _Image(modal.object._Object):
301
307
  kwargs: typing.Dict[str, typing.Any] = {},
302
308
  ) -> _Image: ...
303
309
  def env(self, vars: typing.Dict[str, str]) -> _Image: ...
304
- def workdir(self, path: str) -> _Image: ...
310
+ def workdir(self, path: typing.Union[str, pathlib.PurePosixPath]) -> _Image: ...
305
311
  def imports(self): ...
306
312
  def _logs(self) -> typing.AsyncGenerator[str, None]: ...
307
313
 
@@ -351,6 +357,12 @@ class Image(modal.object.Object):
351
357
  _do_assert_no_mount_layers: bool = True,
352
358
  ) -> Image: ...
353
359
  def copy_mount(self, mount: modal.mount.Mount, remote_path: typing.Union[str, pathlib.Path] = ".") -> Image: ...
360
+ def add_local_file(
361
+ self, local_path: typing.Union[str, pathlib.Path], remote_path: str, *, copy: bool = False
362
+ ) -> Image: ...
363
+ def add_local_dir(
364
+ self, local_path: typing.Union[str, pathlib.Path], remote_path: str, *, copy: bool = False
365
+ ) -> Image: ...
354
366
  def copy_local_file(
355
367
  self, local_path: typing.Union[str, pathlib.Path], remote_path: typing.Union[str, pathlib.Path] = "./"
356
368
  ) -> Image: ...
@@ -552,7 +564,7 @@ class Image(modal.object.Object):
552
564
  kwargs: typing.Dict[str, typing.Any] = {},
553
565
  ) -> Image: ...
554
566
  def env(self, vars: typing.Dict[str, str]) -> Image: ...
555
- def workdir(self, path: str) -> Image: ...
567
+ def workdir(self, path: typing.Union[str, pathlib.PurePosixPath]) -> Image: ...
556
568
  def imports(self): ...
557
569
 
558
570
  class ___logs_spec(typing_extensions.Protocol):