modal 1.1.5.dev66__py3-none-any.whl → 1.3.1.dev8__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.

Potentially problematic release.


This version of modal might be problematic. Click here for more details.

Files changed (143) hide show
  1. modal/__init__.py +4 -4
  2. modal/__main__.py +4 -29
  3. modal/_billing.py +84 -0
  4. modal/_clustered_functions.py +1 -3
  5. modal/_container_entrypoint.py +33 -208
  6. modal/_functions.py +171 -138
  7. modal/_grpc_client.py +191 -0
  8. modal/_ipython.py +16 -6
  9. modal/_load_context.py +106 -0
  10. modal/_object.py +72 -21
  11. modal/_output.py +12 -14
  12. modal/_partial_function.py +31 -4
  13. modal/_resolver.py +44 -57
  14. modal/_runtime/container_io_manager.py +30 -28
  15. modal/_runtime/container_io_manager.pyi +42 -44
  16. modal/_runtime/gpu_memory_snapshot.py +9 -7
  17. modal/_runtime/user_code_event_loop.py +80 -0
  18. modal/_runtime/user_code_imports.py +236 -10
  19. modal/_serialization.py +2 -1
  20. modal/_traceback.py +4 -13
  21. modal/_tunnel.py +16 -11
  22. modal/_tunnel.pyi +25 -3
  23. modal/_utils/async_utils.py +337 -10
  24. modal/_utils/auth_token_manager.py +1 -4
  25. modal/_utils/blob_utils.py +29 -22
  26. modal/_utils/function_utils.py +20 -21
  27. modal/_utils/grpc_testing.py +6 -3
  28. modal/_utils/grpc_utils.py +223 -64
  29. modal/_utils/mount_utils.py +26 -1
  30. modal/_utils/name_utils.py +2 -3
  31. modal/_utils/package_utils.py +0 -1
  32. modal/_utils/rand_pb_testing.py +8 -1
  33. modal/_utils/task_command_router_client.py +524 -0
  34. modal/_vendor/cloudpickle.py +144 -48
  35. modal/app.py +285 -105
  36. modal/app.pyi +216 -53
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +6 -3
  39. modal/builder/PREVIEW.txt +2 -1
  40. modal/builder/base-images.json +4 -2
  41. modal/cli/_download.py +19 -3
  42. modal/cli/cluster.py +4 -2
  43. modal/cli/config.py +3 -1
  44. modal/cli/container.py +5 -4
  45. modal/cli/dict.py +5 -2
  46. modal/cli/entry_point.py +26 -2
  47. modal/cli/environment.py +2 -16
  48. modal/cli/launch.py +1 -76
  49. modal/cli/network_file_system.py +5 -20
  50. modal/cli/programs/run_jupyter.py +1 -1
  51. modal/cli/programs/vscode.py +1 -1
  52. modal/cli/queues.py +5 -4
  53. modal/cli/run.py +24 -204
  54. modal/cli/secret.py +1 -2
  55. modal/cli/shell.py +375 -0
  56. modal/cli/utils.py +1 -13
  57. modal/cli/volume.py +11 -17
  58. modal/client.py +16 -125
  59. modal/client.pyi +94 -144
  60. modal/cloud_bucket_mount.py +3 -1
  61. modal/cloud_bucket_mount.pyi +4 -0
  62. modal/cls.py +101 -64
  63. modal/cls.pyi +9 -8
  64. modal/config.py +21 -1
  65. modal/container_process.py +288 -12
  66. modal/container_process.pyi +99 -38
  67. modal/dict.py +72 -33
  68. modal/dict.pyi +88 -57
  69. modal/environments.py +16 -8
  70. modal/environments.pyi +6 -2
  71. modal/exception.py +154 -16
  72. modal/experimental/__init__.py +24 -53
  73. modal/experimental/flash.py +161 -74
  74. modal/experimental/flash.pyi +97 -49
  75. modal/file_io.py +50 -92
  76. modal/file_io.pyi +117 -89
  77. modal/functions.pyi +70 -87
  78. modal/image.py +82 -47
  79. modal/image.pyi +51 -30
  80. modal/io_streams.py +500 -149
  81. modal/io_streams.pyi +279 -189
  82. modal/mount.py +60 -46
  83. modal/mount.pyi +41 -17
  84. modal/network_file_system.py +19 -11
  85. modal/network_file_system.pyi +72 -39
  86. modal/object.pyi +114 -22
  87. modal/parallel_map.py +42 -44
  88. modal/parallel_map.pyi +9 -17
  89. modal/partial_function.pyi +4 -2
  90. modal/proxy.py +14 -6
  91. modal/proxy.pyi +10 -2
  92. modal/queue.py +45 -38
  93. modal/queue.pyi +88 -52
  94. modal/runner.py +96 -96
  95. modal/runner.pyi +44 -27
  96. modal/sandbox.py +225 -107
  97. modal/sandbox.pyi +226 -60
  98. modal/secret.py +58 -56
  99. modal/secret.pyi +28 -13
  100. modal/serving.py +7 -11
  101. modal/serving.pyi +7 -8
  102. modal/snapshot.py +29 -15
  103. modal/snapshot.pyi +18 -10
  104. modal/token_flow.py +1 -1
  105. modal/token_flow.pyi +4 -6
  106. modal/volume.py +102 -55
  107. modal/volume.pyi +125 -66
  108. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/METADATA +10 -9
  109. modal-1.3.1.dev8.dist-info/RECORD +189 -0
  110. modal_proto/api.proto +141 -70
  111. modal_proto/api_grpc.py +42 -26
  112. modal_proto/api_pb2.py +1123 -1103
  113. modal_proto/api_pb2.pyi +331 -83
  114. modal_proto/api_pb2_grpc.py +80 -48
  115. modal_proto/api_pb2_grpc.pyi +26 -18
  116. modal_proto/modal_api_grpc.py +175 -174
  117. modal_proto/task_command_router.proto +164 -0
  118. modal_proto/task_command_router_grpc.py +138 -0
  119. modal_proto/task_command_router_pb2.py +180 -0
  120. modal_proto/{sandbox_router_pb2.pyi → task_command_router_pb2.pyi} +148 -57
  121. modal_proto/task_command_router_pb2_grpc.py +272 -0
  122. modal_proto/task_command_router_pb2_grpc.pyi +100 -0
  123. modal_version/__init__.py +1 -1
  124. modal_version/__main__.py +1 -1
  125. modal/cli/programs/launch_instance_ssh.py +0 -94
  126. modal/cli/programs/run_marimo.py +0 -95
  127. modal-1.1.5.dev66.dist-info/RECORD +0 -191
  128. modal_proto/modal_options_grpc.py +0 -3
  129. modal_proto/options.proto +0 -19
  130. modal_proto/options_grpc.py +0 -3
  131. modal_proto/options_pb2.py +0 -35
  132. modal_proto/options_pb2.pyi +0 -20
  133. modal_proto/options_pb2_grpc.py +0 -4
  134. modal_proto/options_pb2_grpc.pyi +0 -7
  135. modal_proto/sandbox_router.proto +0 -125
  136. modal_proto/sandbox_router_grpc.py +0 -89
  137. modal_proto/sandbox_router_pb2.py +0 -128
  138. modal_proto/sandbox_router_pb2_grpc.py +0 -169
  139. modal_proto/sandbox_router_pb2_grpc.pyi +0 -63
  140. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/WHEEL +0 -0
  141. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/entry_points.txt +0 -0
  142. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/licenses/LICENSE +0 -0
  143. {modal-1.1.5.dev66.dist-info → modal-1.3.1.dev8.dist-info}/top_level.txt +0 -0
modal/cls.py CHANGED
@@ -7,11 +7,11 @@ from pathlib import PurePosixPath
7
7
  from typing import Any, Callable, Optional, Sequence, TypeVar, Union
8
8
 
9
9
  from google.protobuf.message import Message
10
- from grpclib import GRPCError, Status
11
10
 
12
11
  from modal_proto import api_pb2
13
12
 
14
13
  from ._functions import _Function, _parse_retries
14
+ from ._load_context import LoadContext
15
15
  from ._object import _Object, live_method
16
16
  from ._partial_function import (
17
17
  _find_callables_for_obj,
@@ -30,14 +30,12 @@ from ._utils.deprecation import (
30
30
  warn_if_passing_namespace,
31
31
  warn_on_renamed_autoscaler_settings,
32
32
  )
33
- from ._utils.grpc_utils import retry_transient_errors
34
33
  from ._utils.mount_utils import validate_volumes
34
+ from .client import _Client
35
35
  from .cloud_bucket_mount import _CloudBucketMount
36
- from .config import config
37
36
  from .exception import ExecutionError, InvalidError, NotFoundError
38
37
  from .gpu import GPU_T
39
38
  from .retries import Retries
40
- from .scheduler_placement import SchedulerPlacement
41
39
  from .secret import _Secret
42
40
  from .volume import _Volume
43
41
 
@@ -135,7 +133,7 @@ def _bind_instance_method(cls: "_Cls", service_function: _Function, method_name:
135
133
  method_metadata = cls._method_metadata[method_name]
136
134
  new_function._hydrate(service_function.object_id, service_function.client, method_metadata)
137
135
 
138
- async def _load(fun: "_Function", resolver: Resolver, existing_object_id: Optional[str]):
136
+ async def _load(fun: "_Function", resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]):
139
137
  # there is currently no actual loading logic executed to create each method on
140
138
  # the *parametrized* instance of a class - it uses the parameter-bound service-function
141
139
  # for the instance. This load method just makes sure to set all attributes after the
@@ -154,11 +152,14 @@ def _bind_instance_method(cls: "_Cls", service_function: _Function, method_name:
154
152
 
155
153
  rep = f"Method({cls._name}.{method_name})"
156
154
 
155
+ # Bound methods should *reference* their parent Cls's LoadContext
156
+ # so that it can be modified in place on the parent and be reflected in the method
157
157
  fun = _Function._from_loader(
158
158
  _load,
159
159
  rep,
160
160
  deps=_deps,
161
161
  hydrate_lazily=True,
162
+ load_context_overrides=cls._load_context_overrides,
162
163
  )
163
164
  if service_function.is_hydrated:
164
165
  # Eager hydration (skip load) if the instance service function is already loaded
@@ -422,14 +423,13 @@ class _Obj:
422
423
 
423
424
  # Not hydrated Cls, and we don't have the class - typically a Cls.from_name that
424
425
  # has not yet been loaded. So use a special loader that loads it lazily:
425
- async def method_loader(fun, resolver: Resolver, existing_object_id):
426
- await resolver.load(self._cls) # load class so we get info about methods
426
+ async def method_loader(fun, resolver: Resolver, load_context: LoadContext, existing_object_id):
427
427
  method_function = _get_maybe_method()
428
428
  if method_function is None:
429
429
  raise NotFoundError(
430
430
  f"Class has no method {k}, and attributes can't be accessed for `Cls.from_name` instances"
431
431
  )
432
- await resolver.load(method_function) # get the appropriate method handle (lazy)
432
+ await resolver.load(method_function, load_context) # get the appropriate method handle (lazy)
433
433
  fun._hydrate_from_other(method_function)
434
434
 
435
435
  # The reason we don't *always* use this lazy loader is because it precludes attribute access
@@ -437,8 +437,9 @@ class _Obj:
437
437
  return _Function._from_loader(
438
438
  method_loader,
439
439
  rep=f"Method({self._cls._name}.{k})",
440
- deps=lambda: [], # TODO: use cls as dep instead of loading inside method_loader?
440
+ deps=lambda: [self._cls],
441
441
  hydrate_lazily=True,
442
+ load_context_overrides=self._cls._load_context_overrides,
442
443
  )
443
444
 
444
445
 
@@ -485,6 +486,7 @@ class _Cls(_Object, type_prefix="cs"):
485
486
  self._callables = other._callables
486
487
  self._name = other._name
487
488
  self._method_metadata = other._method_metadata
489
+ self._load_context_overrides = other._load_context_overrides
488
490
 
489
491
  def _get_partial_functions(self) -> dict[str, _PartialFunction]:
490
492
  if not self._user_cls:
@@ -559,7 +561,7 @@ class {user_cls.__name__}:
559
561
  More information on class parameterization can be found here: https://modal.com/docs/guide/parametrized-functions
560
562
  """,
561
563
  )
562
- annotations = user_cls.__dict__.get("__annotations__", {}) # compatible with older pythons
564
+ annotations = inspect.get_annotations(user_cls)
563
565
  missing_annotations = params.keys() - annotations.keys()
564
566
  if missing_annotations:
565
567
  raise InvalidError("All modal.parameter() specifications need to be type-annotated")
@@ -577,22 +579,15 @@ More information on class parameterization can be found here: https://modal.com/
577
579
  # validate signature
578
580
  _Cls.validate_construction_mechanism(user_cls)
579
581
 
580
- method_partials: dict[str, _PartialFunction] = _find_partial_methods_for_user_cls(
581
- user_cls, _PartialFunctionFlags.interface_flags()
582
- )
583
-
584
- for method_name, partial_function in method_partials.items():
585
- if partial_function.params.webhook_config is not None:
586
- full_name = f"{user_cls.__name__}.{method_name}"
587
- app._web_endpoints.append(full_name)
588
- partial_function.registered = True
589
-
590
582
  # Disable the warning that lifecycle methods are not wrapped
591
- for partial_function in _find_partial_methods_for_user_cls(
583
+ lifecycle_method_partials = _find_partial_methods_for_user_cls(
592
584
  user_cls, ~_PartialFunctionFlags.interface_flags()
593
- ).values():
585
+ )
586
+ for partial_function in lifecycle_method_partials.values():
594
587
  partial_function.registered = True
595
588
 
589
+ method_partials = _find_partial_methods_for_user_cls(user_cls, _PartialFunctionFlags.interface_flags())
590
+
596
591
  # Get all callables
597
592
  callables: dict[str, Callable] = {
598
593
  k: pf.raw_f
@@ -603,15 +598,18 @@ More information on class parameterization can be found here: https://modal.com/
603
598
  def _deps() -> list[_Function]:
604
599
  return [class_service_function]
605
600
 
606
- async def _load(self: "_Cls", resolver: Resolver, existing_object_id: Optional[str]):
601
+ async def _load(self: "_Cls", resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]):
607
602
  req = api_pb2.ClassCreateRequest(
608
- app_id=resolver.app_id, existing_class_id=existing_object_id, only_class_function=True
603
+ app_id=load_context.app_id, existing_class_id=existing_object_id, only_class_function=True
609
604
  )
610
- resp = await resolver.client.stub.ClassCreate(req)
611
- self._hydrate(resp.class_id, resolver.client, resp.handle_metadata)
605
+ resp = await load_context.client.stub.ClassCreate(req)
606
+ self._hydrate(resp.class_id, load_context.client, resp.handle_metadata)
612
607
 
613
608
  rep = f"Cls({user_cls.__name__})"
614
- cls: _Cls = _Cls._from_loader(_load, rep, deps=_deps)
609
+ # Pass a *reference* to the App's LoadContext - this is important since the App is
610
+ # the only way to infer a LoadContext for an `@app.cls`, and the App doesn't
611
+ # get its client until *after* the Cls is created.
612
+ cls: _Cls = _Cls._from_loader(_load, rep, deps=_deps, load_context_overrides=app._root_load_context)
615
613
  cls._app = app
616
614
  cls._user_cls = user_cls
617
615
  cls._class_service_function = class_service_function
@@ -628,6 +626,7 @@ More information on class parameterization can be found here: https://modal.com/
628
626
  *,
629
627
  namespace: Any = None, # mdmd:line-hidden
630
628
  environment_name: Optional[str] = None,
629
+ client: Optional["_Client"] = None,
631
630
  ) -> "_Cls":
632
631
  """Reference a Cls from a deployed App by its name.
633
632
 
@@ -640,42 +639,47 @@ More information on class parameterization can be found here: https://modal.com/
640
639
  ```
641
640
  """
642
641
  warn_if_passing_namespace(namespace, "modal.Cls.from_name")
643
- _environment_name = environment_name or config.get("environment")
644
642
 
645
- async def _load_remote(self: _Cls, resolver: Resolver, existing_object_id: Optional[str]):
643
+ async def _load_remote(
644
+ self: _Cls, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
645
+ ):
646
646
  request = api_pb2.ClassGetRequest(
647
647
  app_name=app_name,
648
648
  object_tag=name,
649
- environment_name=_environment_name,
649
+ environment_name=load_context.environment_name,
650
650
  only_class_function=True,
651
651
  )
652
652
  try:
653
- response = await retry_transient_errors(resolver.client.stub.ClassGet, request)
653
+ response = await load_context.client.stub.ClassGet(request)
654
654
  except NotFoundError as exc:
655
- env_context = f" (in the '{environment_name}' environment)" if environment_name else ""
655
+ env_context = (
656
+ f" (in the '{load_context.environment_name}' environment)" if load_context.environment_name else ""
657
+ )
656
658
  raise NotFoundError(
657
659
  f"Lookup failed for Cls '{name}' from the '{app_name}' app{env_context}: {exc}."
658
660
  ) from None
659
- except GRPCError as exc:
660
- if exc.status == Status.FAILED_PRECONDITION:
661
- raise InvalidError(exc.message) from None
662
- else:
663
- raise
664
661
 
665
662
  print_server_warnings(response.server_warnings)
666
- await resolver.load(self._class_service_function)
667
- self._hydrate(response.class_id, resolver.client, response.handle_metadata)
663
+ await resolver.load(self._class_service_function, load_context)
664
+ self._hydrate(response.class_id, load_context.client, response.handle_metadata)
668
665
 
669
666
  environment_rep = f", environment_name={environment_name!r}" if environment_name else ""
670
667
  rep = f"Cls.from_name({app_name!r}, {name!r}{environment_rep})"
671
- cls = cls._from_loader(_load_remote, rep, is_another_app=True, hydrate_lazily=True)
668
+
669
+ load_context_overrides = LoadContext(client=client, environment_name=environment_name)
670
+ cls = cls._from_loader(
671
+ _load_remote,
672
+ rep,
673
+ is_another_app=True,
674
+ hydrate_lazily=True,
675
+ load_context_overrides=load_context_overrides,
676
+ )
672
677
 
673
678
  class_service_name = f"{name}.*" # special name of the base service function for the class
674
679
  cls._class_service_function = _Function._from_name(
675
680
  app_name,
676
681
  class_service_name,
677
- namespace=namespace,
678
- environment_name=_environment_name,
682
+ load_context_overrides=load_context_overrides,
679
683
  )
680
684
  cls._name = name
681
685
  return cls
@@ -735,8 +739,6 @@ More information on class parameterization can be found here: https://modal.com/
735
739
  else:
736
740
  resources = None
737
741
 
738
- scheduler_placement = SchedulerPlacement(region=region).proto if region else None
739
-
740
742
  if allow_concurrent_inputs is not None:
741
743
  deprecation_warning(
742
744
  (2025, 5, 9),
@@ -744,7 +746,7 @@ More information on class parameterization can be found here: https://modal.com/
744
746
  " please use the `.with_concurrency` method instead.",
745
747
  )
746
748
 
747
- async def _load_from_base(new_cls, resolver, existing_object_id):
749
+ async def _load_from_base(new_cls, resolver, load_context, existing_object_id):
748
750
  # this is a bit confusing, the cls will always have the same metadata
749
751
  # since it has the same *class* service function (i.e. "template")
750
752
  # But the (instance) service function for each Obj will be different
@@ -753,14 +755,21 @@ More information on class parameterization can be found here: https://modal.com/
753
755
  if not self.is_hydrated:
754
756
  # this should only happen for Cls.from_name instances
755
757
  # other classes should already be hydrated!
756
- await resolver.load(self)
758
+ await resolver.load(self, load_context)
757
759
 
758
760
  new_cls._initialize_from_other(self)
759
761
 
760
762
  def _deps():
761
763
  return []
762
764
 
763
- cls = _Cls._from_loader(_load_from_base, rep=f"{self._name}.with_options(...)", is_another_app=True, deps=_deps)
765
+ cls = _Cls._from_loader(
766
+ _load_from_base,
767
+ rep=f"{self._name}.with_options(...)",
768
+ is_another_app=True,
769
+ deps=_deps,
770
+ load_context_overrides=self._load_context_overrides,
771
+ hydrate_lazily=True,
772
+ )
764
773
  cls._initialize_from_other(self)
765
774
 
766
775
  # Validate volumes
@@ -772,6 +781,11 @@ More information on class parameterization can be found here: https://modal.com/
772
781
  if env:
773
782
  secrets = [*secrets, _Secret.from_dict(env)]
774
783
 
784
+ scheduler_placement: Optional[api_pb2.SchedulerPlacement] = None
785
+ if region:
786
+ regions = [region] if isinstance(region, str) else list(region)
787
+ scheduler_placement = api_pb2.SchedulerPlacement(regions=regions)
788
+
775
789
  new_options = _ServiceOptions(
776
790
  secrets=secrets,
777
791
  validated_volumes=validated_volumes_no_cloud_buckets,
@@ -805,16 +819,21 @@ More information on class parameterization can be found here: https://modal.com/
805
819
  ```
806
820
  """
807
821
 
808
- async def _load_from_base(new_cls, resolver, existing_object_id):
822
+ async def _load_from_base(new_cls, resolver, load_context, existing_object_id):
809
823
  if not self.is_hydrated:
810
- await resolver.load(self)
824
+ await resolver.load(self, load_context)
811
825
  new_cls._initialize_from_other(self)
812
826
 
813
827
  def _deps():
814
828
  return []
815
829
 
816
830
  cls = _Cls._from_loader(
817
- _load_from_base, rep=f"{self._name}.with_concurrency(...)", is_another_app=True, deps=_deps
831
+ _load_from_base,
832
+ rep=f"{self._name}.with_concurrency(...)",
833
+ is_another_app=True,
834
+ deps=_deps,
835
+ load_context_overrides=self._load_context_overrides,
836
+ hydrate_lazily=True,
818
837
  )
819
838
  cls._initialize_from_other(self)
820
839
 
@@ -834,16 +853,21 @@ More information on class parameterization can be found here: https://modal.com/
834
853
  ```
835
854
  """
836
855
 
837
- async def _load_from_base(new_cls, resolver, existing_object_id):
856
+ async def _load_from_base(new_cls, resolver, load_context, existing_object_id):
838
857
  if not self.is_hydrated:
839
- await resolver.load(self)
858
+ await resolver.load(self, load_context)
840
859
  new_cls._initialize_from_other(self)
841
860
 
842
861
  def _deps():
843
862
  return []
844
863
 
845
864
  cls = _Cls._from_loader(
846
- _load_from_base, rep=f"{self._name}.with_concurrency(...)", is_another_app=True, deps=_deps
865
+ _load_from_base,
866
+ rep=f"{self._name}.with_batching(...)",
867
+ is_another_app=True,
868
+ deps=_deps,
869
+ load_context_overrides=self._load_context_overrides,
870
+ hydrate_lazily=True,
847
871
  )
848
872
  cls._initialize_from_other(self)
849
873
 
@@ -863,18 +887,31 @@ More information on class parameterization can be found here: https://modal.com/
863
887
  )
864
888
 
865
889
  def __getattr__(self, k):
866
- # TODO: remove this method - access to attributes on classes (not instances) should be discouraged
867
- if not self._is_local() or k in self._method_partials:
868
- # if not local (== k *could* be a method) or it is local and we know k is a method
869
- deprecation_warning(
870
- (2025, 1, 13),
871
- "Calling a method on an uninstantiated class will soon be deprecated; "
872
- "update your code to instantiate the class first, i.e.:\n"
873
- f"{self._name}().{k} instead of {self._name}.{k}",
890
+ if self._user_cls is not None:
891
+ # local class, we can check if there are static attributes and let the user access them
892
+ # except if they are PartialFunction (i.e. methods)
893
+ v = getattr(self._user_cls, k)
894
+ if not isinstance(v, modal.partial_function.PartialFunction):
895
+ return v
896
+
897
+ # We create a synthetic dummy Function that is guaranteed to raise an AttributeError when
898
+ # a user tries to use any of its "live methods" - this lets us raise exceptions for users
899
+ # only if they try to access methods on a Cls as if they were methods on the instance.
900
+ async def error_loader(
901
+ fun: _Function, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
902
+ ):
903
+ raise AttributeError(
904
+ "You can't access methods on a Cls directly - Did you forget to instantiate the class first?\n"
905
+ "e.g. instead of MyClass.method.remote(), do MyClass().method.remote()"
874
906
  )
875
- return getattr(self(), k)
876
- # non-method attribute access on local class - arguably shouldn't be used either:
877
- return getattr(self._user_cls, k)
907
+
908
+ return _Function._from_loader(
909
+ error_loader,
910
+ rep=f"UnboundMethod({self._name}.{k})",
911
+ deps=lambda: [],
912
+ hydrate_lazily=True,
913
+ load_context_overrides=self._load_context_overrides,
914
+ )
878
915
 
879
916
  def _is_local(self) -> bool:
880
917
  return self._user_cls is not None
modal/cls.pyi CHANGED
@@ -5,6 +5,7 @@ import modal._functions
5
5
  import modal._object
6
6
  import modal._partial_function
7
7
  import modal.app
8
+ import modal.client
8
9
  import modal.cloud_bucket_mount
9
10
  import modal.functions
10
11
  import modal.gpu
@@ -178,8 +179,6 @@ class _Obj:
178
179
  async def _aenter(self): ...
179
180
  def __getattr__(self, k): ...
180
181
 
181
- SUPERSELF = typing.TypeVar("SUPERSELF", covariant=True)
182
-
183
182
  class Obj:
184
183
  """An instance of a `Cls`, i.e. `Cls("foo", 42)` returns an `Obj`.
185
184
 
@@ -202,7 +201,7 @@ class Obj:
202
201
  def _get_parameter_values(self) -> dict[str, typing.Any]: ...
203
202
  def _new_user_cls_instance(self): ...
204
203
 
205
- class __update_autoscaler_spec(typing_extensions.Protocol[SUPERSELF]):
204
+ class __update_autoscaler_spec(typing_extensions.Protocol):
206
205
  def __call__(
207
206
  self,
208
207
  /,
@@ -273,9 +272,9 @@ class Obj:
273
272
  """
274
273
  ...
275
274
 
276
- update_autoscaler: __update_autoscaler_spec[typing_extensions.Self]
275
+ update_autoscaler: __update_autoscaler_spec
277
276
 
278
- class __keep_warm_spec(typing_extensions.Protocol[SUPERSELF]):
277
+ class __keep_warm_spec(typing_extensions.Protocol):
279
278
  def __call__(self, /, warm_pool_size: int) -> None:
280
279
  """mdmd:hidden
281
280
  Set the warm pool size for the class containers
@@ -314,7 +313,7 @@ class Obj:
314
313
  """
315
314
  ...
316
315
 
317
- keep_warm: __keep_warm_spec[typing_extensions.Self]
316
+ keep_warm: __keep_warm_spec
318
317
 
319
318
  def _cached_user_cls_instance(self):
320
319
  """Get or construct the local object
@@ -379,6 +378,7 @@ class _Cls(modal._object._Object):
379
378
  *,
380
379
  namespace: typing.Any = None,
381
380
  environment_name: typing.Optional[str] = None,
381
+ client: typing.Optional[modal.client._Client] = None,
382
382
  ) -> _Cls:
383
383
  """Reference a Cls from a deployed App by its name.
384
384
 
@@ -507,7 +507,7 @@ class Cls(modal.object.Object):
507
507
  def _get_class_service_function(self) -> modal.functions.Function: ...
508
508
  def _get_method_names(self) -> collections.abc.Collection[str]: ...
509
509
 
510
- class ___experimental_get_flash_urls_spec(typing_extensions.Protocol[SUPERSELF]):
510
+ class ___experimental_get_flash_urls_spec(typing_extensions.Protocol):
511
511
  def __call__(self, /) -> typing.Optional[list[str]]:
512
512
  """URL of the flash service for the class."""
513
513
  ...
@@ -516,7 +516,7 @@ class Cls(modal.object.Object):
516
516
  """URL of the flash service for the class."""
517
517
  ...
518
518
 
519
- _experimental_get_flash_urls: ___experimental_get_flash_urls_spec[typing_extensions.Self]
519
+ _experimental_get_flash_urls: ___experimental_get_flash_urls_spec
520
520
 
521
521
  def _hydrate_metadata(self, metadata: google.protobuf.message.Message): ...
522
522
  @staticmethod
@@ -537,6 +537,7 @@ class Cls(modal.object.Object):
537
537
  *,
538
538
  namespace: typing.Any = None,
539
539
  environment_name: typing.Optional[str] = None,
540
+ client: typing.Optional[modal.client.Client] = None,
540
541
  ) -> Cls:
541
542
  """Reference a Cls from a deployed App by its name.
542
543
 
modal/config.py CHANGED
@@ -51,6 +51,10 @@ Other possible configuration options are:
51
51
  Defaults to 10.
52
52
  Number of seconds to wait for logs to drain when closing the session,
53
53
  before giving up.
54
+ * `max_throttle_wait` (in the .toml file) / `MODAL_MAX_THROTTLE_WAIT` (as an env var).
55
+ Defaults to None (no limit).
56
+ Maximum number of seconds to wait when requests are being throttled (i.e., due
57
+ to rate limiting or other cases that can normally be resolved through backoff).
54
58
  * `force_build` (in the .toml file) / `MODAL_FORCE_BUILD` (as an env var).
55
59
  Defaults to False.
56
60
  When set, ignores the Image cache and builds all Image layers. Note that this
@@ -71,6 +75,10 @@ Other possible configuration options are:
71
75
  The log formatting pattern that will be used by the modal client itself.
72
76
  See https://docs.python.org/3/library/logging.html#logrecord-attributes for available
73
77
  log attributes.
78
+ * `dev_suffix` (in the .toml file) / `MODAL_DEV_SUFFIX` (as an env var).
79
+ Overrides the default `-dev` suffix added to URLs generated for web endpoints
80
+ when the App is ephemeral (i.e., created via `modal serve`). Must be a short
81
+ alphanumeric string.
74
82
 
75
83
  Meta-configuration
76
84
  ------------------
@@ -85,6 +93,7 @@ Some "meta-options" are set using environment variables only:
85
93
 
86
94
  import logging
87
95
  import os
96
+ import re
88
97
  import typing
89
98
  import warnings
90
99
  from typing import Any, Callable, Optional
@@ -142,7 +151,7 @@ async def _lookup_workspace(server_url: str, token_id: str, token_secret: str) -
142
151
 
143
152
  credentials = (token_id, token_secret)
144
153
  async with _Client(server_url, api_pb2.CLIENT_TYPE_CLIENT, credentials) as client:
145
- return await client.stub.WorkspaceNameLookup(Empty(), timeout=3)
154
+ return await client.stub.WorkspaceNameLookup(Empty(), retry=None, timeout=3)
146
155
 
147
156
 
148
157
  def config_profiles():
@@ -206,6 +215,12 @@ def _check_value(options: list[str]) -> Callable[[str], str]:
206
215
  return checker
207
216
 
208
217
 
218
+ def _enforce_suffix_rules(x: str) -> str:
219
+ if x and not re.match(r"^[a-zA-Z0-9]{1,8}$", x):
220
+ raise ValueError("Suffix must be an alphanumeric string of no more than 8 characters.")
221
+ return x
222
+
223
+
209
224
  class _Setting(typing.NamedTuple):
210
225
  default: typing.Any = None
211
226
  transform: typing.Callable[[str], typing.Any] = lambda x: x # noqa: E731
@@ -236,6 +251,8 @@ _SETTINGS = {
236
251
  "traceback": _Setting(False, transform=_to_boolean),
237
252
  "image_builder_version": _Setting(),
238
253
  "strict_parameters": _Setting(False, transform=_to_boolean), # For internal/experimental use
254
+ # Allow insecure TLS for the task command router when running locally (testing/dev only)
255
+ "task_command_router_insecure": _Setting(False, transform=_to_boolean),
239
256
  "snapshot_debug": _Setting(False, transform=_to_boolean),
240
257
  "cuda_checkpoint_path": _Setting("/__modal/.bin/cuda-checkpoint"), # Used for snapshotting GPU memory.
241
258
  "build_validation": _Setting("error", transform=_check_value(["error", "warn", "ignore"])),
@@ -244,6 +261,9 @@ _SETTINGS = {
244
261
  "pickle",
245
262
  transform=lambda s: _check_value(["pickle", "cbor"])(s.lower()),
246
263
  ),
264
+ "dev_suffix": _Setting("", transform=_enforce_suffix_rules),
265
+ "max_throttle_wait": _Setting(None, transform=lambda x: int(x) if x else None),
266
+ "async_warnings": _Setting(False, transform=_to_boolean), # Feature flag for async API warnings
247
267
  }
248
268
 
249
269