modal 0.73.116__py3-none-any.whl → 0.73.128__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.
@@ -15,7 +15,14 @@ from synchronicity.exceptions import UserCodeException
15
15
  import modal_proto
16
16
  from modal_proto import api_pb2
17
17
 
18
- from .._serialization import PROTO_TYPE_INFO, PYTHON_TO_PROTO_TYPE, deserialize, deserialize_data_format, serialize
18
+ from .._serialization import (
19
+ PROTO_TYPE_INFO,
20
+ PYTHON_TO_PROTO_TYPE,
21
+ deserialize,
22
+ deserialize_data_format,
23
+ get_proto_parameter_type,
24
+ serialize,
25
+ )
19
26
  from .._traceback import append_modal_tb
20
27
  from ..config import config, logger
21
28
  from ..exception import (
@@ -99,6 +106,24 @@ def get_function_type(is_generator: Optional[bool]) -> "api_pb2.Function.Functio
99
106
  return api_pb2.Function.FUNCTION_TYPE_GENERATOR if is_generator else api_pb2.Function.FUNCTION_TYPE_FUNCTION
100
107
 
101
108
 
109
+ def signature_to_protobuf_schema(signature: inspect.Signature) -> list[api_pb2.ClassParameterSpec]:
110
+ modal_parameters: list[api_pb2.ClassParameterSpec] = []
111
+ for param in signature.parameters.values():
112
+ has_default = param.default is not param.empty
113
+ class_param_spec = api_pb2.ClassParameterSpec(name=param.name, has_default=has_default)
114
+ if param.annotation not in PYTHON_TO_PROTO_TYPE:
115
+ class_param_spec.type = api_pb2.PARAM_TYPE_UNKNOWN
116
+ else:
117
+ proto_type = PYTHON_TO_PROTO_TYPE[param.annotation]
118
+ class_param_spec.type = proto_type
119
+ proto_type_info = PROTO_TYPE_INFO[proto_type]
120
+ if has_default and proto_type is not api_pb2.PARAM_TYPE_UNKNOWN:
121
+ setattr(class_param_spec, proto_type_info.default_field, param.default)
122
+
123
+ modal_parameters.append(class_param_spec)
124
+ return modal_parameters
125
+
126
+
102
127
  class FunctionInfo:
103
128
  """Utility that determines serialization/deserialization mechanisms for functions
104
129
 
@@ -277,28 +302,23 @@ class FunctionInfo:
277
302
  return api_pb2.ClassParameterInfo()
278
303
 
279
304
  # TODO(elias): Resolve circular dependencies... maybe we'll need some cls_utils module
280
- from modal.cls import _get_class_constructor_signature, _use_annotation_parameters, _validate_parameter_type
305
+ from modal.cls import _get_class_constructor_signature, _use_annotation_parameters
281
306
 
282
307
  if not _use_annotation_parameters(self.user_cls):
283
308
  return api_pb2.ClassParameterInfo(format=api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PICKLE)
284
309
 
285
310
  # annotation parameters trigger strictly typed parametrization
286
311
  # which enables web endpoint for parametrized classes
287
-
288
- modal_parameters: list[api_pb2.ClassParameterSpec] = []
289
312
  signature = _get_class_constructor_signature(self.user_cls)
313
+ # validate that the schema has no unspecified fields/unsupported class parameter types
290
314
  for param in signature.parameters.values():
291
- has_default = param.default is not param.empty
292
- _validate_parameter_type(self.user_cls.__name__, param.name, param.annotation)
293
- proto_type = PYTHON_TO_PROTO_TYPE[param.annotation]
294
- proto_type_info = PROTO_TYPE_INFO[proto_type]
295
- class_param_spec = api_pb2.ClassParameterSpec(name=param.name, has_default=has_default, type=proto_type)
296
- if has_default:
297
- setattr(class_param_spec, proto_type_info.default_field, param.default)
298
- modal_parameters.append(class_param_spec)
315
+ get_proto_parameter_type(param.annotation)
316
+
317
+ protobuf_schema = signature_to_protobuf_schema(signature)
299
318
 
300
319
  return api_pb2.ClassParameterInfo(
301
- format=api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PROTO, schema=modal_parameters
320
+ format=api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PROTO,
321
+ schema=protobuf_schema,
302
322
  )
303
323
 
304
324
  def get_entrypoint_mount(self) -> dict[str, _Mount]:
@@ -0,0 +1,38 @@
1
+ # Copyright Modal Labs 2025
2
+ import base64
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict
6
+
7
+
8
+ @dataclass
9
+ class DecodedJwt:
10
+ header: Dict[str, Any]
11
+ payload: Dict[str, Any]
12
+
13
+ @staticmethod
14
+ def decode_without_verification(token: str) -> "DecodedJwt":
15
+ # Split the JWT into its three parts
16
+ header_b64, payload_b64, _ = token.split(".")
17
+
18
+ # Decode Base64 (with padding handling)
19
+ header_json = base64.urlsafe_b64decode(header_b64 + "==").decode("utf-8")
20
+ payload_json = base64.urlsafe_b64decode(payload_b64 + "==").decode("utf-8")
21
+
22
+ # Convert JSON strings to dictionaries
23
+ header = json.loads(header_json)
24
+ payload = json.loads(payload_json)
25
+
26
+ return DecodedJwt(header, payload)
27
+
28
+ @staticmethod
29
+ def _base64url_encode(data: str) -> str:
30
+ """Encodes data to Base64 URL-safe format without padding."""
31
+ return base64.urlsafe_b64encode(data.encode()).rstrip(b"=").decode()
32
+
33
+ @staticmethod
34
+ def encode_without_signature(fields: Dict[str, Any]) -> str:
35
+ """Encodes an Unsecured JWT (without a signature)."""
36
+ header_b64 = DecodedJwt._base64url_encode(json.dumps({"alg": "none", "typ": "JWT"}))
37
+ payload_b64 = DecodedJwt._base64url_encode(json.dumps(fields))
38
+ return f"{header_b64}.{payload_b64}." # No signature
modal/app.py CHANGED
@@ -678,6 +678,12 @@ class _App:
678
678
  is_generator = f.is_generator
679
679
  batch_max_size = f.batch_max_size
680
680
  batch_wait_ms = f.batch_wait_ms
681
+ if f.max_concurrent_inputs: # Using @modal.concurrent()
682
+ max_concurrent_inputs = f.max_concurrent_inputs
683
+ target_concurrent_inputs = f.target_concurrent_inputs
684
+ else:
685
+ max_concurrent_inputs = allow_concurrent_inputs
686
+ target_concurrent_inputs = None
681
687
  else:
682
688
  if not is_global_object(f.__qualname__) and not serialized:
683
689
  raise InvalidError(
@@ -709,10 +715,12 @@ class _App:
709
715
  )
710
716
 
711
717
  info = FunctionInfo(f, serialized=serialized, name_override=name)
718
+ raw_f = f
712
719
  webhook_config = None
713
720
  batch_max_size = None
714
721
  batch_wait_ms = None
715
- raw_f = f
722
+ max_concurrent_inputs = allow_concurrent_inputs
723
+ target_concurrent_inputs = None
716
724
 
717
725
  cluster_size = None # Experimental: Clustered functions
718
726
  i6pn_enabled = i6pn
@@ -753,7 +761,8 @@ class _App:
753
761
  max_containers=max_containers,
754
762
  buffer_containers=buffer_containers,
755
763
  scaledown_window=scaledown_window,
756
- allow_concurrent_inputs=allow_concurrent_inputs,
764
+ max_concurrent_inputs=max_concurrent_inputs,
765
+ target_concurrent_inputs=target_concurrent_inputs,
757
766
  batch_max_size=batch_max_size,
758
767
  batch_wait_ms=batch_wait_ms,
759
768
  timeout=timeout,
@@ -832,7 +841,7 @@ class _App:
832
841
  concurrency_limit: Optional[int] = None, # Replaced with `max_containers`
833
842
  container_idle_timeout: Optional[int] = None, # Replaced with `scaledown_window`
834
843
  _experimental_buffer_containers: Optional[int] = None, # Now stable API with `buffer_containers`
835
- ) -> Callable[[CLS_T], CLS_T]:
844
+ ) -> Callable[[Union[CLS_T, _PartialFunction]], CLS_T]:
836
845
  """
837
846
  Decorator to register a new Modal [Cls](/docs/reference/modal.Cls) with this App.
838
847
  """
@@ -845,8 +854,21 @@ class _App:
845
854
  raise InvalidError("`region` and `_experimental_scheduler_placement` cannot be used together")
846
855
  scheduler_placement = SchedulerPlacement(region=region)
847
856
 
848
- def wrapper(user_cls: CLS_T) -> CLS_T:
857
+ def wrapper(wrapped_cls: Union[CLS_T, _PartialFunction]) -> CLS_T:
849
858
  # Check if the decorated object is a class
859
+ if isinstance(wrapped_cls, _PartialFunction):
860
+ wrapped_cls.wrapped = True
861
+ user_cls = wrapped_cls.raw_f
862
+ if wrapped_cls.max_concurrent_inputs: # Using @modal.concurrent()
863
+ max_concurrent_inputs = wrapped_cls.max_concurrent_inputs
864
+ target_concurrent_inputs = wrapped_cls.target_concurrent_inputs
865
+ else:
866
+ max_concurrent_inputs = allow_concurrent_inputs
867
+ target_concurrent_inputs = None
868
+ else:
869
+ user_cls = wrapped_cls
870
+ max_concurrent_inputs = allow_concurrent_inputs
871
+ target_concurrent_inputs = None
850
872
  if not inspect.isclass(user_cls):
851
873
  raise TypeError("The @app.cls decorator must be used on a class.")
852
874
 
@@ -871,6 +893,12 @@ class _App:
871
893
  ):
872
894
  raise InvalidError("A class must have `enable_memory_snapshot=True` to use `snap=True` on its methods.")
873
895
 
896
+ for method in _find_partial_methods_for_user_cls(user_cls, _PartialFunctionFlags.FUNCTION).values():
897
+ if method.max_concurrent_inputs:
898
+ raise InvalidError(
899
+ "The `@modal.concurrent` decorator cannot be used on methods; decorate the class instead."
900
+ )
901
+
874
902
  info = FunctionInfo(None, serialized=serialized, user_cls=user_cls)
875
903
 
876
904
  cls_func = _Function.from_local(
@@ -892,7 +920,8 @@ class _App:
892
920
  scaledown_window=scaledown_window,
893
921
  proxy=proxy,
894
922
  retries=retries,
895
- allow_concurrent_inputs=allow_concurrent_inputs,
923
+ max_concurrent_inputs=max_concurrent_inputs,
924
+ target_concurrent_inputs=target_concurrent_inputs,
896
925
  batch_max_size=batch_max_size,
897
926
  batch_wait_ms=batch_wait_ms,
898
927
  timeout=timeout,
modal/app.pyi CHANGED
@@ -1,6 +1,7 @@
1
1
  import collections.abc
2
2
  import modal._functions
3
3
  import modal._object
4
+ import modal._partial_function
4
5
  import modal._utils.function_utils
5
6
  import modal.client
6
7
  import modal.cloud_bucket_mount
@@ -247,7 +248,7 @@ class _App:
247
248
  concurrency_limit: typing.Optional[int] = None,
248
249
  container_idle_timeout: typing.Optional[int] = None,
249
250
  _experimental_buffer_containers: typing.Optional[int] = None,
250
- ) -> collections.abc.Callable[[CLS_T], CLS_T]: ...
251
+ ) -> collections.abc.Callable[[typing.Union[CLS_T, modal._partial_function._PartialFunction]], CLS_T]: ...
251
252
  async def spawn_sandbox(
252
253
  self,
253
254
  *entrypoint_args: str,
@@ -487,7 +488,7 @@ class App:
487
488
  concurrency_limit: typing.Optional[int] = None,
488
489
  container_idle_timeout: typing.Optional[int] = None,
489
490
  _experimental_buffer_containers: typing.Optional[int] = None,
490
- ) -> collections.abc.Callable[[CLS_T], CLS_T]: ...
491
+ ) -> collections.abc.Callable[[typing.Union[CLS_T, modal.partial_function.PartialFunction]], CLS_T]: ...
491
492
 
492
493
  class __spawn_sandbox_spec(typing_extensions.Protocol[SUPERSELF]):
493
494
  def __call__(
modal/cli/app.py CHANGED
@@ -227,6 +227,8 @@ async def history(
227
227
  ]
228
228
  rows = []
229
229
  deployments_with_tags = False
230
+ deployments_with_commit_info = False
231
+ deployments_with_dirty_commit = False
230
232
  for idx, app_stats in enumerate(resp.app_deployment_histories):
231
233
  style = "bold green" if idx == 0 else ""
232
234
 
@@ -241,10 +243,23 @@ async def history(
241
243
  deployments_with_tags = True
242
244
  row.append(Text(app_stats.tag, style=style))
243
245
 
246
+ if app_stats.commit_info.commit_hash:
247
+ deployments_with_commit_info = True
248
+ short_hash = app_stats.commit_info.commit_hash[:7]
249
+ if app_stats.commit_info.dirty:
250
+ deployments_with_dirty_commit = True
251
+ short_hash = f"{short_hash}*"
252
+ row.append(Text(short_hash, style=style))
253
+
244
254
  rows.append(row)
245
255
 
246
256
  if deployments_with_tags:
247
257
  columns.append("Tag")
258
+ if deployments_with_commit_info:
259
+ columns.append("Commit")
248
260
 
249
261
  rows = sorted(rows, key=lambda x: int(str(x[0])[1:]), reverse=True)
250
262
  display_table(columns, rows, json)
263
+
264
+ if deployments_with_dirty_commit and not json:
265
+ rich.print("* - repo had uncommitted changes")
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[tuple[str, str]],
34
- version: str = "0.73.116",
34
+ version: str = "0.73.128",
35
35
  ): ...
36
36
  def is_closed(self) -> bool: ...
37
37
  @property
@@ -93,7 +93,7 @@ class Client:
93
93
  server_url: str,
94
94
  client_type: int,
95
95
  credentials: typing.Optional[tuple[str, str]],
96
- version: str = "0.73.116",
96
+ version: str = "0.73.128",
97
97
  ): ...
98
98
  def is_closed(self) -> bool: ...
99
99
  @property
modal/cls.py CHANGED
@@ -21,7 +21,7 @@ from ._partial_function import (
21
21
  )
22
22
  from ._resolver import Resolver
23
23
  from ._resources import convert_fn_config_to_resources_config
24
- from ._serialization import PYTHON_TO_PROTO_TYPE, check_valid_cls_constructor_arg
24
+ from ._serialization import check_valid_cls_constructor_arg, get_proto_parameter_type
25
25
  from ._traceback import print_server_warnings
26
26
  from ._utils.async_utils import synchronize_api, synchronizer
27
27
  from ._utils.deprecation import deprecation_warning, renamed_parameter, warn_on_renamed_autoscaler_settings
@@ -362,15 +362,6 @@ class _Obj:
362
362
  Obj = synchronize_api(_Obj)
363
363
 
364
364
 
365
- def _validate_parameter_type(cls_name: str, parameter_name: str, parameter_type: type):
366
- if parameter_type not in PYTHON_TO_PROTO_TYPE:
367
- type_name = getattr(parameter_type, "__name__", repr(parameter_type))
368
- supported = ", ".join(parameter_type.__name__ for parameter_type in PYTHON_TO_PROTO_TYPE.keys())
369
- raise InvalidError(
370
- f"{cls_name}.{parameter_name}: {type_name} is not a supported parameter type. Use one of: {supported}"
371
- )
372
-
373
-
374
365
  class _Cls(_Object, type_prefix="cs"):
375
366
  """
376
367
  Cls adds method pooling and [lifecycle hook](/docs/guide/lifecycle-functions) behavior
@@ -467,12 +458,11 @@ class _Cls(_Object, type_prefix="cs"):
467
458
  annotations = user_cls.__dict__.get("__annotations__", {}) # compatible with older pythons
468
459
  missing_annotations = params.keys() - annotations.keys()
469
460
  if missing_annotations:
470
- raise InvalidError("All modal.parameter() specifications need to be type annotated")
461
+ raise InvalidError("All modal.parameter() specifications need to be type-annotated")
471
462
 
472
463
  annotated_params = {k: t for k, t in annotations.items() if k in params}
473
464
  for k, t in annotated_params.items():
474
- if t not in PYTHON_TO_PROTO_TYPE:
475
- _validate_parameter_type(user_cls.__name__, k, t)
465
+ get_proto_parameter_type(t)
476
466
 
477
467
  @staticmethod
478
468
  def from_local(user_cls, app: "modal.app._App", class_service_function: _Function) -> "_Cls":
modal/cls.pyi CHANGED
@@ -109,8 +109,6 @@ class Obj:
109
109
  async def _aenter(self): ...
110
110
  def __getattr__(self, k): ...
111
111
 
112
- def _validate_parameter_type(cls_name: str, parameter_name: str, parameter_type: type): ...
113
-
114
112
  class _Cls(modal._object._Object):
115
113
  _class_service_function: typing.Optional[modal._functions._Function]
116
114
  _options: typing.Optional[_ServiceOptions]
modal/functions.pyi CHANGED
@@ -82,7 +82,8 @@ class Function(
82
82
  max_containers: typing.Optional[int] = None,
83
83
  buffer_containers: typing.Optional[int] = None,
84
84
  scaledown_window: typing.Optional[int] = None,
85
- allow_concurrent_inputs: typing.Optional[int] = None,
85
+ max_concurrent_inputs: typing.Optional[int] = None,
86
+ target_concurrent_inputs: typing.Optional[int] = None,
86
87
  batch_max_size: typing.Optional[int] = None,
87
88
  batch_wait_ms: typing.Optional[int] = None,
88
89
  cloud: typing.Optional[str] = None,
@@ -198,11 +199,11 @@ class Function(
198
199
 
199
200
  _call_generator_nowait: ___call_generator_nowait_spec[typing_extensions.Self]
200
201
 
201
- class __remote_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER, SUPERSELF]):
202
+ class __remote_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER, SUPERSELF]):
202
203
  def __call__(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER: ...
203
204
  async def aio(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER: ...
204
205
 
205
- remote: __remote_spec[modal._functions.ReturnType, modal._functions.P, typing_extensions.Self]
206
+ remote: __remote_spec[modal._functions.P, modal._functions.ReturnType, typing_extensions.Self]
206
207
 
207
208
  class __remote_gen_spec(typing_extensions.Protocol[SUPERSELF]):
208
209
  def __call__(self, *args, **kwargs) -> typing.Generator[typing.Any, None, None]: ...
@@ -217,19 +218,19 @@ class Function(
217
218
  self, *args: modal._functions.P.args, **kwargs: modal._functions.P.kwargs
218
219
  ) -> modal._functions.OriginalReturnType: ...
219
220
 
220
- class ___experimental_spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER, SUPERSELF]):
221
+ class ___experimental_spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER, SUPERSELF]):
221
222
  def __call__(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
222
223
  async def aio(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
223
224
 
224
225
  _experimental_spawn: ___experimental_spawn_spec[
225
- modal._functions.ReturnType, modal._functions.P, typing_extensions.Self
226
+ modal._functions.P, modal._functions.ReturnType, typing_extensions.Self
226
227
  ]
227
228
 
228
- class __spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER, SUPERSELF]):
229
+ class __spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER, SUPERSELF]):
229
230
  def __call__(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
230
231
  async def aio(self, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]: ...
231
232
 
232
- spawn: __spawn_spec[modal._functions.ReturnType, modal._functions.P, typing_extensions.Self]
233
+ spawn: __spawn_spec[modal._functions.P, modal._functions.ReturnType, typing_extensions.Self]
233
234
 
234
235
  def get_raw_f(self) -> collections.abc.Callable[..., typing.Any]: ...
235
236