modal 1.2.2.dev30__py3-none-any.whl → 1.2.2.dev36__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.
Files changed (46) hide show
  1. modal/_functions.py +77 -52
  2. modal/_load_context.py +105 -0
  3. modal/_object.py +47 -18
  4. modal/_resolver.py +21 -35
  5. modal/app.py +7 -0
  6. modal/app.pyi +3 -0
  7. modal/cli/dict.py +5 -2
  8. modal/cli/queues.py +4 -2
  9. modal/client.pyi +2 -2
  10. modal/cls.py +71 -32
  11. modal/cls.pyi +3 -0
  12. modal/dict.py +14 -5
  13. modal/dict.pyi +2 -0
  14. modal/environments.py +16 -7
  15. modal/environments.pyi +6 -2
  16. modal/functions.pyi +10 -4
  17. modal/image.py +22 -22
  18. modal/mount.py +35 -25
  19. modal/mount.pyi +33 -7
  20. modal/network_file_system.py +14 -5
  21. modal/network_file_system.pyi +12 -2
  22. modal/object.pyi +35 -8
  23. modal/proxy.py +14 -6
  24. modal/proxy.pyi +10 -2
  25. modal/queue.py +14 -5
  26. modal/queue.pyi +12 -2
  27. modal/runner.py +43 -47
  28. modal/runner.pyi +2 -2
  29. modal/sandbox.py +21 -12
  30. modal/secret.py +57 -39
  31. modal/secret.pyi +21 -4
  32. modal/serving.py +7 -11
  33. modal/serving.pyi +7 -8
  34. modal/snapshot.py +11 -5
  35. modal/volume.py +25 -7
  36. modal/volume.pyi +2 -0
  37. {modal-1.2.2.dev30.dist-info → modal-1.2.2.dev36.dist-info}/METADATA +1 -1
  38. {modal-1.2.2.dev30.dist-info → modal-1.2.2.dev36.dist-info}/RECORD +46 -45
  39. modal_proto/api.proto +4 -0
  40. modal_proto/api_pb2.py +684 -684
  41. modal_proto/api_pb2.pyi +24 -3
  42. modal_version/__init__.py +1 -1
  43. {modal-1.2.2.dev30.dist-info → modal-1.2.2.dev36.dist-info}/WHEEL +0 -0
  44. {modal-1.2.2.dev30.dist-info → modal-1.2.2.dev36.dist-info}/entry_points.txt +0 -0
  45. {modal-1.2.2.dev30.dist-info → modal-1.2.2.dev36.dist-info}/licenses/LICENSE +0 -0
  46. {modal-1.2.2.dev30.dist-info → modal-1.2.2.dev36.dist-info}/top_level.txt +0 -0
modal/queue.py CHANGED
@@ -14,6 +14,7 @@ from synchronicity.async_wrap import asynccontextmanager
14
14
 
15
15
  from modal_proto import api_pb2
16
16
 
17
+ from ._load_context import LoadContext
17
18
  from ._object import (
18
19
  EPHEMERAL_OBJECT_HEARTBEAT_SLEEP,
19
20
  _get_environment_name,
@@ -361,6 +362,7 @@ class _Queue(_Object, type_prefix="qu"):
361
362
  namespace=None, # mdmd:line-hidden
362
363
  environment_name: Optional[str] = None,
363
364
  create_if_missing: bool = False,
365
+ client: Optional[_Client] = None,
364
366
  ) -> "_Queue":
365
367
  """Reference a named Queue, creating if necessary.
366
368
 
@@ -376,17 +378,24 @@ class _Queue(_Object, type_prefix="qu"):
376
378
  check_object_name(name, "Queue")
377
379
  warn_if_passing_namespace(namespace, "modal.Queue.from_name")
378
380
 
379
- async def _load(self: _Queue, resolver: Resolver, existing_object_id: Optional[str]):
381
+ async def _load(self: _Queue, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]):
380
382
  req = api_pb2.QueueGetOrCreateRequest(
381
383
  deployment_name=name,
382
- environment_name=_get_environment_name(environment_name, resolver),
384
+ environment_name=load_context.environment_name,
383
385
  object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
384
386
  )
385
- response = await resolver.client.stub.QueueGetOrCreate(req)
386
- self._hydrate(response.queue_id, resolver.client, response.metadata)
387
+ response = await load_context.client.stub.QueueGetOrCreate(req)
388
+ self._hydrate(response.queue_id, load_context.client, response.metadata)
387
389
 
388
390
  rep = _Queue._repr(name, environment_name)
389
- return _Queue._from_loader(_load, rep, is_another_app=True, hydrate_lazily=True, name=name)
391
+ return _Queue._from_loader(
392
+ _load,
393
+ rep,
394
+ is_another_app=True,
395
+ hydrate_lazily=True,
396
+ name=name,
397
+ load_context_overrides=LoadContext(environment_name=environment_name, client=client),
398
+ )
390
399
 
391
400
  @staticmethod
392
401
  async def delete(name: str, *, client: Optional[_Client] = None, environment_name: Optional[str] = None):
modal/queue.pyi CHANGED
@@ -464,7 +464,12 @@ class _Queue(modal._object._Object):
464
464
 
465
465
  @staticmethod
466
466
  def from_name(
467
- name: str, *, namespace=None, environment_name: typing.Optional[str] = None, create_if_missing: bool = False
467
+ name: str,
468
+ *,
469
+ namespace=None,
470
+ environment_name: typing.Optional[str] = None,
471
+ create_if_missing: bool = False,
472
+ client: typing.Optional[modal.client._Client] = None,
468
473
  ) -> _Queue:
469
474
  """Reference a named Queue, creating if necessary.
470
475
 
@@ -721,7 +726,12 @@ class Queue(modal.object.Object):
721
726
 
722
727
  @staticmethod
723
728
  def from_name(
724
- name: str, *, namespace=None, environment_name: typing.Optional[str] = None, create_if_missing: bool = False
729
+ name: str,
730
+ *,
731
+ namespace=None,
732
+ environment_name: typing.Optional[str] = None,
733
+ create_if_missing: bool = False,
734
+ client: typing.Optional[modal.client.Client] = None,
725
735
  ) -> Queue:
726
736
  """Reference a named Queue, creating if necessary.
727
737
 
modal/runner.py CHANGED
@@ -8,7 +8,6 @@ import asyncio
8
8
  import dataclasses
9
9
  import os
10
10
  import time
11
- import typing
12
11
  from collections.abc import AsyncGenerator
13
12
  from contextlib import nullcontext
14
13
  from multiprocessing.synchronize import Event
@@ -19,6 +18,7 @@ from synchronicity.async_wrap import asynccontextmanager
19
18
 
20
19
  import modal._runtime.execution_context
21
20
  import modal_proto.api_pb2
21
+ from modal._load_context import LoadContext
22
22
  from modal._utils.grpc_utils import Retry
23
23
  from modal_proto import api_pb2
24
24
 
@@ -125,18 +125,14 @@ async def _init_local_app_from_name(
125
125
 
126
126
 
127
127
  async def _create_all_objects(
128
- client: _Client,
129
128
  running_app: RunningApp,
130
129
  local_app_state: "modal.app._LocalAppState",
131
- environment_name: str,
130
+ load_context: LoadContext,
132
131
  ) -> None:
133
132
  """Create objects that have been defined but not created on the server."""
134
133
  indexed_objects: dict[str, _Object] = {**local_app_state.functions, **local_app_state.classes}
135
- resolver = Resolver(
136
- client,
137
- environment_name=environment_name,
138
- app_id=running_app.app_id,
139
- )
134
+
135
+ resolver = Resolver()
140
136
  with resolver.display():
141
137
  # Get current objects, and reset all objects
142
138
  tag_to_object_id = {**running_app.function_ids, **running_app.class_ids}
@@ -159,7 +155,7 @@ async def _create_all_objects(
159
155
  # Note: preload only currently implemented for Functions, returns None otherwise
160
156
  # this is to ensure that directly referenced functions from the global scope has
161
157
  # ids associated with them when they are serialized into other functions
162
- await resolver.preload(obj, existing_object_id)
158
+ await resolver.preload(obj, load_context, existing_object_id)
163
159
  if obj.is_hydrated:
164
160
  tag_to_object_id[tag] = obj.object_id
165
161
 
@@ -167,7 +163,8 @@ async def _create_all_objects(
167
163
 
168
164
  async def _load(tag, obj):
169
165
  existing_object_id = tag_to_object_id.get(tag)
170
- await resolver.load(obj, existing_object_id)
166
+ # Pass load_context so dependencies can inherit app_id, client, etc.
167
+ await resolver.load(obj, load_context, existing_object_id=existing_object_id)
171
168
  if _Function._is_id_type(obj.object_id):
172
169
  running_app.function_ids[tag] = obj.object_id
173
170
  elif _Cls._is_id_type(obj.object_id):
@@ -266,8 +263,9 @@ async def _run_app(
266
263
  interactive: bool = False,
267
264
  ) -> AsyncGenerator["modal.app._App", None]:
268
265
  """mdmd:hidden"""
269
- if environment_name is None:
270
- environment_name = typing.cast(str, config.get("environment"))
266
+ load_context = await app._root_load_context.reset().in_place_upgrade(
267
+ client=client, environment_name=environment_name
268
+ )
271
269
 
272
270
  if modal._runtime.execution_context._is_currently_importing:
273
271
  raise InvalidError("Can not run an app in global scope within a container")
@@ -288,9 +286,6 @@ async def _run_app(
288
286
  # https://docs.python.org/3/library/__main__.html#import-main
289
287
  app.set_description(__main__.__name__)
290
288
 
291
- if client is None:
292
- client = await _Client.from_env()
293
-
294
289
  app_state = api_pb2.APP_STATE_DETACHED if detach else api_pb2.APP_STATE_EPHEMERAL
295
290
 
296
291
  output_mgr = _get_output_manager()
@@ -301,21 +296,22 @@ async def _run_app(
301
296
  local_app_state = app._local_state
302
297
 
303
298
  running_app: RunningApp = await _init_local_app_new(
304
- client,
299
+ load_context.client,
305
300
  app.description or "",
306
301
  local_app_state.tags,
307
- environment_name=environment_name or "",
302
+ environment_name=load_context.environment_name,
308
303
  app_state=app_state,
309
304
  interactive=interactive,
310
305
  )
306
+ await load_context.in_place_upgrade(app_id=running_app.app_id)
311
307
 
312
308
  logs_timeout = config["logs_timeout"]
313
- async with app._set_local_app(client, running_app), TaskContext(grace=logs_timeout) as tc:
309
+ async with app._set_local_app(load_context.client, running_app), TaskContext(grace=logs_timeout) as tc:
314
310
  # Start heartbeats loop to keep the client alive
315
311
  # we don't log heartbeat exceptions in detached mode
316
312
  # as losing the local connection will not affect the running app
317
313
  def heartbeat():
318
- return _heartbeat(client, running_app.app_id)
314
+ return _heartbeat(load_context.client, running_app.app_id)
319
315
 
320
316
  heartbeat_loop = tc.infinite_loop(heartbeat, sleep=HEARTBEAT_INTERVAL, log_exception=not detach)
321
317
  logs_loop: Optional[asyncio.Task] = None
@@ -336,24 +332,26 @@ async def _run_app(
336
332
  # Start logs loop
337
333
 
338
334
  logs_loop = tc.create_task(
339
- get_app_logs_loop(client, output_mgr, app_id=running_app.app_id, app_logs_url=running_app.app_logs_url)
335
+ get_app_logs_loop(
336
+ load_context.client, output_mgr, app_id=running_app.app_id, app_logs_url=running_app.app_logs_url
337
+ )
340
338
  )
341
339
 
342
340
  try:
343
341
  # Create all members
344
- await _create_all_objects(client, running_app, local_app_state, environment_name)
342
+ await _create_all_objects(running_app, local_app_state, load_context)
345
343
 
346
344
  # Publish the app
347
- await _publish_app(client, running_app, app_state, local_app_state)
345
+ await _publish_app(load_context.client, running_app, app_state, local_app_state)
348
346
  except asyncio.CancelledError as e:
349
347
  # this typically happens on sigint/ctrl-C during setup (the KeyboardInterrupt happens in the main thread)
350
348
  if output_mgr := _get_output_manager():
351
349
  output_mgr.print("Aborting app initialization...\n")
352
350
 
353
- await _status_based_disconnect(client, running_app.app_id, e)
351
+ await _status_based_disconnect(load_context.client, running_app.app_id, e)
354
352
  raise
355
353
  except BaseException as e:
356
- await _status_based_disconnect(client, running_app.app_id, e)
354
+ await _status_based_disconnect(load_context.client, running_app.app_id, e)
357
355
  raise
358
356
 
359
357
  detached_disconnect_msg = (
@@ -381,7 +379,7 @@ async def _run_app(
381
379
  yield app
382
380
  # successful completion!
383
381
  heartbeat_loop.cancel()
384
- await _status_based_disconnect(client, running_app.app_id, exc_info=None)
382
+ await _status_based_disconnect(load_context.client, running_app.app_id, exc_info=None)
385
383
  except KeyboardInterrupt as e:
386
384
  # this happens only if sigint comes in during the yield block above
387
385
  if detach:
@@ -390,13 +388,13 @@ async def _run_app(
390
388
  output_mgr.print(detached_disconnect_msg)
391
389
  if logs_loop:
392
390
  logs_loop.cancel()
393
- await _status_based_disconnect(client, running_app.app_id, e)
391
+ await _status_based_disconnect(load_context.client, running_app.app_id, e)
394
392
  else:
395
393
  if output_mgr := _get_output_manager():
396
394
  output_mgr.print(
397
395
  "Disconnecting from Modal - This will terminate your Modal app in a few seconds.\n"
398
396
  )
399
- await _status_based_disconnect(client, running_app.app_id, e)
397
+ await _status_based_disconnect(load_context.client, running_app.app_id, e)
400
398
  if logs_loop:
401
399
  try:
402
400
  await asyncio.wait_for(logs_loop, timeout=logs_timeout)
@@ -421,7 +419,7 @@ async def _run_app(
421
419
  raise
422
420
  except BaseException as e:
423
421
  logger.info("Exception during app run")
424
- await _status_based_disconnect(client, running_app.app_id, e)
422
+ await _status_based_disconnect(load_context.client, running_app.app_id, e)
425
423
  raise
426
424
 
427
425
  # wait for logs gracefully, even though the task context would do the same
@@ -449,21 +447,17 @@ async def _serve_update(
449
447
  ) -> None:
450
448
  """mdmd:hidden"""
451
449
  # Used by child process to reinitialize a served app
452
- client = await _Client.from_env()
450
+ load_context = await app._root_load_context.reset().in_place_upgrade(environment_name=environment_name)
453
451
  try:
454
- running_app: RunningApp = await _init_local_app_existing(client, existing_app_id, environment_name)
452
+ running_app: RunningApp = await _init_local_app_existing(load_context.client, existing_app_id, environment_name)
453
+ await load_context.in_place_upgrade(app_id=running_app.app_id)
455
454
  local_app_state = app._local_state
456
455
  # Create objects
457
- await _create_all_objects(
458
- client,
459
- running_app,
460
- local_app_state,
461
- environment_name,
462
- )
456
+ await _create_all_objects(running_app, local_app_state, load_context)
463
457
 
464
458
  # Publish the updated app
465
459
  await _publish_app(
466
- client,
460
+ load_context.client,
467
461
  running_app,
468
462
  app_state=api_pb2.APP_STATE_UNSPECIFIED,
469
463
  app_local_state=local_app_state,
@@ -498,9 +492,6 @@ async def _deploy_app(
498
492
 
499
493
  Users should prefer the `modal deploy` CLI or the `App.deploy` method.
500
494
  """
501
- if environment_name is None:
502
- environment_name = typing.cast(str, config.get("environment"))
503
-
504
495
  warn_if_passing_namespace(namespace, "modal.runner.deploy_app")
505
496
 
506
497
  name = name or app.name or ""
@@ -532,8 +523,18 @@ async def _deploy_app(
532
523
  # Get git information to track deployment history
533
524
  commit_info_task = asyncio.create_task(get_git_commit_info())
534
525
 
526
+ # We need to do in-place replacement of fields in self._root_load_context in case it has already "spread"
527
+ # to with_options() instances or similar before load
528
+ root_load_context = await app._root_load_context.reset().in_place_upgrade(
529
+ client=client,
530
+ environment_name=environment_name,
531
+ )
535
532
  running_app: RunningApp = await _init_local_app_from_name(
536
- client, name, local_app_state.tags, environment_name=environment_name
533
+ root_load_context.client, name, local_app_state.tags, environment_name=root_load_context.environment_name
534
+ )
535
+
536
+ await root_load_context.in_place_upgrade(
537
+ app_id=running_app.app_id,
537
538
  )
538
539
 
539
540
  async with TaskContext(0) as tc:
@@ -545,12 +546,7 @@ async def _deploy_app(
545
546
 
546
547
  try:
547
548
  # Create all members
548
- await _create_all_objects(
549
- client,
550
- running_app,
551
- local_app_state,
552
- environment_name=environment_name,
553
- )
549
+ await _create_all_objects(running_app, local_app_state, root_load_context)
554
550
 
555
551
  commit_info = None
556
552
  try:
modal/runner.pyi CHANGED
@@ -1,3 +1,4 @@
1
+ import modal._load_context
1
2
  import modal.app
2
3
  import modal.client
3
4
  import modal.running_app
@@ -25,10 +26,9 @@ async def _init_local_app_from_name(
25
26
  client: modal.client._Client, name: str, tags: dict[str, str], environment_name: str = ""
26
27
  ) -> modal.running_app.RunningApp: ...
27
28
  async def _create_all_objects(
28
- client: modal.client._Client,
29
29
  running_app: modal.running_app.RunningApp,
30
30
  local_app_state: modal.app._LocalAppState,
31
- environment_name: str,
31
+ load_context: modal._load_context.LoadContext,
32
32
  ) -> None:
33
33
  """Create objects that have been defined but not created on the server."""
34
34
  ...
modal/sandbox.py CHANGED
@@ -23,6 +23,7 @@ from modal.mount import _Mount
23
23
  from modal.volume import _Volume
24
24
  from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
25
25
 
26
+ from ._load_context import LoadContext
26
27
  from ._object import _get_environment_name, _Object
27
28
  from ._resolver import Resolver
28
29
  from ._resources import convert_fn_config_to_resources_config
@@ -191,7 +192,9 @@ class _Sandbox(_Object, type_prefix="sb"):
191
192
  deps.append(proxy)
192
193
  return deps
193
194
 
194
- async def _load(self: _Sandbox, resolver: Resolver, _existing_object_id: Optional[str]):
195
+ async def _load(
196
+ self: _Sandbox, resolver: Resolver, load_context: LoadContext, _existing_object_id: Optional[str]
197
+ ):
195
198
  # Relies on dicts being ordered (true as of Python 3.6).
196
199
  volume_mounts = [
197
200
  api_pb2.VolumeMount(
@@ -260,18 +263,18 @@ class _Sandbox(_Object, type_prefix="sb"):
260
263
  experimental_options=experimental_options,
261
264
  )
262
265
 
263
- create_req = api_pb2.SandboxCreateRequest(app_id=resolver.app_id, definition=definition)
266
+ create_req = api_pb2.SandboxCreateRequest(app_id=load_context.app_id, definition=definition)
264
267
  try:
265
- create_resp = await resolver.client.stub.SandboxCreate(create_req)
268
+ create_resp = await load_context.client.stub.SandboxCreate(create_req)
266
269
  except GRPCError as exc:
267
270
  if exc.status == Status.ALREADY_EXISTS:
268
271
  raise AlreadyExistsError(exc.message)
269
272
  raise exc
270
273
 
271
274
  sandbox_id = create_resp.sandbox_id
272
- self._hydrate(sandbox_id, resolver.client, None)
275
+ self._hydrate(sandbox_id, load_context.client, None)
273
276
 
274
- return _Sandbox._from_loader(_load, "Sandbox()", deps=_deps)
277
+ return _Sandbox._from_loader(_load, "Sandbox()", deps=_deps, load_context_overrides=LoadContext.empty())
275
278
 
276
279
  @staticmethod
277
280
  async def create(
@@ -486,6 +489,7 @@ class _Sandbox(_Object, type_prefix="sb"):
486
489
  app_id = app.app_id
487
490
  app_client = app._client
488
491
  elif (container_app := _App._get_container_app()) is not None:
492
+ # implicit app/client provided by running in a modal Function
489
493
  app_id = container_app.app_id
490
494
  app_client = container_app._client
491
495
  else:
@@ -498,10 +502,11 @@ class _Sandbox(_Object, type_prefix="sb"):
498
502
  "```",
499
503
  )
500
504
 
501
- client = client or app_client or await _Client.from_env()
505
+ client = client or app_client
502
506
 
503
- resolver = Resolver(client, app_id=app_id)
504
- await resolver.load(obj)
507
+ resolver = Resolver()
508
+ load_context = LoadContext(client=client, app_id=app_id)
509
+ await resolver.load(obj, load_context)
505
510
  return obj
506
511
 
507
512
  def _hydrate_metadata(self, handle_metadata: Optional[Message]):
@@ -606,12 +611,13 @@ class _Sandbox(_Object, type_prefix="sb"):
606
611
  image_id = resp.image_id
607
612
  metadata = resp.image_metadata
608
613
 
609
- async def _load(self: _Image, resolver: Resolver, existing_object_id: Optional[str]):
614
+ async def _load(self: _Image, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]):
610
615
  # no need to hydrate again since we do it eagerly below
611
616
  pass
612
617
 
613
618
  rep = "Image()"
614
- image = _Image._from_loader(_load, rep, hydrate_lazily=True)
619
+ # TODO: use ._new_hydrated instead
620
+ image = _Image._from_loader(_load, rep, hydrate_lazily=True, load_context_overrides=LoadContext.empty())
615
621
  image._hydrate(image_id, self._client, metadata) # hydrating eagerly since we have all of the data
616
622
 
617
623
  return image
@@ -990,12 +996,15 @@ class _Sandbox(_Object, type_prefix="sb"):
990
996
  if wait_resp.result.status != api_pb2.GenericResult.GENERIC_STATUS_SUCCESS:
991
997
  raise ExecutionError(wait_resp.result.exception)
992
998
 
993
- async def _load(self: _SandboxSnapshot, resolver: Resolver, existing_object_id: Optional[str]):
999
+ async def _load(
1000
+ self: _SandboxSnapshot, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
1001
+ ):
994
1002
  # we eagerly hydrate the sandbox snapshot below
995
1003
  pass
996
1004
 
997
1005
  rep = "SandboxSnapshot()"
998
- obj = _SandboxSnapshot._from_loader(_load, rep, hydrate_lazily=True)
1006
+ # TODO: use ._new_hydrated instead
1007
+ obj = _SandboxSnapshot._from_loader(_load, rep, hydrate_lazily=True, load_context_overrides=LoadContext.empty())
999
1008
  obj._hydrate(snapshot_id, self._client, None)
1000
1009
 
1001
1010
  return obj
modal/secret.py CHANGED
@@ -10,6 +10,7 @@ from synchronicity import classproperty
10
10
 
11
11
  from modal_proto import api_pb2
12
12
 
13
+ from ._load_context import LoadContext
13
14
  from ._object import _get_environment_name, _Object, live_method
14
15
  from ._resolver import Resolver
15
16
  from ._runtime.execution_context import is_local
@@ -205,6 +206,33 @@ class _SecretManager:
205
206
  SecretManager = synchronize_api(_SecretManager)
206
207
 
207
208
 
209
+ async def _load_from_env_dict(instance: "_Secret", load_context: LoadContext, env_dict: dict[str, str]):
210
+ """helper method for loaders .from_dict and .from_dotenv etc."""
211
+ if load_context.app_id is not None:
212
+ req = api_pb2.SecretGetOrCreateRequest(
213
+ object_creation_type=api_pb2.OBJECT_CREATION_TYPE_ANONYMOUS_OWNED_BY_APP,
214
+ env_dict=env_dict,
215
+ app_id=load_context.app_id,
216
+ environment_name=load_context.environment_name,
217
+ )
218
+ else:
219
+ req = api_pb2.SecretGetOrCreateRequest(
220
+ object_creation_type=api_pb2.OBJECT_CREATION_TYPE_EPHEMERAL,
221
+ env_dict=env_dict,
222
+ environment_name=load_context.environment_name,
223
+ )
224
+
225
+ try:
226
+ resp = await load_context.client.stub.SecretGetOrCreate(req)
227
+ except GRPCError as exc:
228
+ if exc.status == Status.INVALID_ARGUMENT:
229
+ raise InvalidError(exc.message)
230
+ if exc.status == Status.FAILED_PRECONDITION:
231
+ raise InvalidError(exc.message)
232
+ raise
233
+ instance._hydrate(resp.secret_id, load_context.client, resp.metadata)
234
+
235
+
208
236
  class _Secret(_Object, type_prefix="st"):
209
237
  """Secrets provide a dictionary of environment variables for images.
210
238
 
@@ -259,30 +287,14 @@ class _Secret(_Object, type_prefix="st"):
259
287
  if not all(isinstance(v, str) for v in env_dict_filtered.values()):
260
288
  raise InvalidError(ENV_DICT_WRONG_TYPE_ERR)
261
289
 
262
- async def _load(self: _Secret, resolver: Resolver, existing_object_id: Optional[str]):
263
- if resolver.app_id is not None:
264
- object_creation_type = api_pb2.OBJECT_CREATION_TYPE_ANONYMOUS_OWNED_BY_APP
265
- else:
266
- object_creation_type = api_pb2.OBJECT_CREATION_TYPE_EPHEMERAL
267
-
268
- req = api_pb2.SecretGetOrCreateRequest(
269
- object_creation_type=object_creation_type,
270
- env_dict=env_dict_filtered,
271
- app_id=resolver.app_id,
272
- environment_name=resolver.environment_name,
273
- )
274
- try:
275
- resp = await resolver.client.stub.SecretGetOrCreate(req)
276
- except GRPCError as exc:
277
- if exc.status == Status.INVALID_ARGUMENT:
278
- raise InvalidError(exc.message)
279
- if exc.status == Status.FAILED_PRECONDITION:
280
- raise InvalidError(exc.message)
281
- raise
282
- self._hydrate(resp.secret_id, resolver.client, resp.metadata)
290
+ async def _load(
291
+ self: _Secret, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
292
+ ):
293
+ await _load_from_env_dict(self, load_context, env_dict_filtered)
283
294
 
284
295
  rep = f"Secret.from_dict([{', '.join(env_dict.keys())}])"
285
- return _Secret._from_loader(_load, rep, hydrate_lazily=True)
296
+ # TODO: scoping - these should probably not be lazily hydrated without having an app and/or sandbox association
297
+ return _Secret._from_loader(_load, rep, hydrate_lazily=True, load_context_overrides=LoadContext.empty())
286
298
 
287
299
  @staticmethod
288
300
  def from_local_environ(
@@ -302,7 +314,7 @@ class _Secret(_Object, type_prefix="st"):
302
314
  return _Secret.from_dict({})
303
315
 
304
316
  @staticmethod
305
- def from_dotenv(path=None, *, filename=".env") -> "_Secret":
317
+ def from_dotenv(path=None, *, filename=".env", client: Optional[_Client] = None) -> "_Secret":
306
318
  """Create secrets from a .env file automatically.
307
319
 
308
320
  If no argument is provided, it will use the current working directory as the starting
@@ -330,7 +342,9 @@ class _Secret(_Object, type_prefix="st"):
330
342
  ```
331
343
  """
332
344
 
333
- async def _load(self: _Secret, resolver: Resolver, existing_object_id: Optional[str]):
345
+ async def _load(
346
+ self: _Secret, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
347
+ ):
334
348
  try:
335
349
  from dotenv import dotenv_values, find_dotenv
336
350
  from dotenv.main import _walk_to_root
@@ -354,18 +368,13 @@ class _Secret(_Object, type_prefix="st"):
354
368
  # To simplify this, we just support the cwd and don't do any automatic path inference.
355
369
  dotenv_path = find_dotenv(filename, usecwd=True)
356
370
 
357
- env_dict = dotenv_values(dotenv_path)
371
+ env_dict = {k: v or "" for k, v in dotenv_values(dotenv_path).items()}
358
372
 
359
- req = api_pb2.SecretGetOrCreateRequest(
360
- object_creation_type=api_pb2.OBJECT_CREATION_TYPE_ANONYMOUS_OWNED_BY_APP,
361
- env_dict=env_dict,
362
- app_id=resolver.app_id,
363
- )
364
- resp = await resolver.client.stub.SecretGetOrCreate(req)
365
-
366
- self._hydrate(resp.secret_id, resolver.client, resp.metadata)
373
+ await _load_from_env_dict(self, load_context, env_dict)
367
374
 
368
- return _Secret._from_loader(_load, "Secret.from_dotenv()", hydrate_lazily=True)
375
+ return _Secret._from_loader(
376
+ _load, "Secret.from_dotenv()", hydrate_lazily=True, load_context_overrides=LoadContext(client=client)
377
+ )
369
378
 
370
379
  @staticmethod
371
380
  def from_name(
@@ -376,6 +385,7 @@ class _Secret(_Object, type_prefix="st"):
376
385
  required_keys: list[
377
386
  str
378
387
  ] = [], # Optionally, a list of required environment variables (will be asserted server-side)
388
+ client: Optional[_Client] = None,
379
389
  ) -> "_Secret":
380
390
  """Reference a Secret by its name.
381
391
 
@@ -393,23 +403,31 @@ class _Secret(_Object, type_prefix="st"):
393
403
  """
394
404
  warn_if_passing_namespace(namespace, "modal.Secret.from_name")
395
405
 
396
- async def _load(self: _Secret, resolver: Resolver, existing_object_id: Optional[str]):
406
+ async def _load(
407
+ self: _Secret, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
408
+ ):
397
409
  req = api_pb2.SecretGetOrCreateRequest(
398
410
  deployment_name=name,
399
- environment_name=_get_environment_name(environment_name, resolver),
411
+ environment_name=load_context.environment_name,
400
412
  required_keys=required_keys,
401
413
  )
402
414
  try:
403
- response = await resolver.client.stub.SecretGetOrCreate(req)
415
+ response = await load_context.client.stub.SecretGetOrCreate(req)
404
416
  except GRPCError as exc:
405
417
  if exc.status == Status.NOT_FOUND:
406
418
  raise NotFoundError(exc.message)
407
419
  else:
408
420
  raise
409
- self._hydrate(response.secret_id, resolver.client, response.metadata)
421
+ self._hydrate(response.secret_id, load_context.client, response.metadata)
410
422
 
411
423
  rep = _Secret._repr(name, environment_name)
412
- return _Secret._from_loader(_load, rep, hydrate_lazily=True, name=name)
424
+ return _Secret._from_loader(
425
+ _load,
426
+ rep,
427
+ hydrate_lazily=True,
428
+ name=name,
429
+ load_context_overrides=LoadContext(environment_name=environment_name, client=client),
430
+ )
413
431
 
414
432
  @staticmethod
415
433
  async def create_deployed(
modal/secret.pyi CHANGED
@@ -1,5 +1,6 @@
1
1
  import datetime
2
2
  import google.protobuf.message
3
+ import modal._load_context
3
4
  import modal._object
4
5
  import modal.client
5
6
  import modal.object
@@ -355,6 +356,12 @@ class SecretManager:
355
356
 
356
357
  delete: __delete_spec
357
358
 
359
+ async def _load_from_env_dict(
360
+ instance: _Secret, load_context: modal._load_context.LoadContext, env_dict: dict[str, str]
361
+ ):
362
+ """helper method for loaders .from_dict and .from_dotenv etc."""
363
+ ...
364
+
358
365
  class _Secret(modal._object._Object):
359
366
  """Secrets provide a dictionary of environment variables for images.
360
367
 
@@ -392,7 +399,7 @@ class _Secret(modal._object._Object):
392
399
  ...
393
400
 
394
401
  @staticmethod
395
- def from_dotenv(path=None, *, filename=".env") -> _Secret:
402
+ def from_dotenv(path=None, *, filename=".env", client: typing.Optional[modal.client._Client] = None) -> _Secret:
396
403
  """Create secrets from a .env file automatically.
397
404
 
398
405
  If no argument is provided, it will use the current working directory as the starting
@@ -423,7 +430,12 @@ class _Secret(modal._object._Object):
423
430
 
424
431
  @staticmethod
425
432
  def from_name(
426
- name: str, *, namespace=None, environment_name: typing.Optional[str] = None, required_keys: list[str] = []
433
+ name: str,
434
+ *,
435
+ namespace=None,
436
+ environment_name: typing.Optional[str] = None,
437
+ required_keys: list[str] = [],
438
+ client: typing.Optional[modal.client._Client] = None,
427
439
  ) -> _Secret:
428
440
  """Reference a Secret by its name.
429
441
 
@@ -512,7 +524,7 @@ class Secret(modal.object.Object):
512
524
  ...
513
525
 
514
526
  @staticmethod
515
- def from_dotenv(path=None, *, filename=".env") -> Secret:
527
+ def from_dotenv(path=None, *, filename=".env", client: typing.Optional[modal.client.Client] = None) -> Secret:
516
528
  """Create secrets from a .env file automatically.
517
529
 
518
530
  If no argument is provided, it will use the current working directory as the starting
@@ -543,7 +555,12 @@ class Secret(modal.object.Object):
543
555
 
544
556
  @staticmethod
545
557
  def from_name(
546
- name: str, *, namespace=None, environment_name: typing.Optional[str] = None, required_keys: list[str] = []
558
+ name: str,
559
+ *,
560
+ namespace=None,
561
+ environment_name: typing.Optional[str] = None,
562
+ required_keys: list[str] = [],
563
+ client: typing.Optional[modal.client.Client] = None,
547
564
  ) -> Secret:
548
565
  """Reference a Secret by its name.
549
566