modal 1.6.2.dev4__py3-none-any.whl → 1.6.2.dev5__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 (53) hide show
  1. modal/__init__.py +1 -1
  2. modal/_functions.py +6 -0
  3. modal/_logs.py +82 -54
  4. modal/_metrics.py +212 -0
  5. modal/_metrics.pyi +312 -0
  6. modal/_outbound_policy.py +3 -3
  7. modal/_outbound_policy.pyi +2 -2
  8. modal/_server.py +6 -0
  9. modal/_server.pyi +6 -0
  10. modal/_utils/time_utils.py +22 -39
  11. modal/cli/_logs.py +31 -3
  12. modal/cli/_stats.py +195 -2
  13. modal/cli/container.py +2 -1
  14. modal/cli/function.py +199 -19
  15. modal/cli/server.py +185 -14
  16. modal/cli/shell.py +2 -1
  17. modal/cli/utils.py +1 -1
  18. modal/client.pyi +2 -2
  19. modal/container_process.py +1 -1
  20. modal/container_process.pyi +3 -3
  21. modal/functions.pyi +12 -6
  22. modal/io_streams.py +1 -1
  23. modal/io_streams.pyi +9 -9
  24. modal/sandbox/_common.py +248 -0
  25. modal/{sandbox_fs.py → sandbox/_filesystem.py} +32 -27
  26. modal/sandbox/_sandbox.py +64 -852
  27. modal/sandbox/_sidecar.py +646 -0
  28. modal/{snapshot.py → sandbox/_snapshot.py} +5 -8
  29. modal/{_utils/task_command_router_client.py → sandbox/_task_command_router_client.py} +103 -46
  30. modal/sandbox/sandbox.py +5 -3
  31. modal/sandbox/sandbox.pyi +811 -30
  32. modal/server.pyi +6 -0
  33. modal/types.py +53 -1
  34. {modal-1.6.2.dev4.dist-info → modal-1.6.2.dev5.dist-info}/METADATA +1 -1
  35. {modal-1.6.2.dev4.dist-info → modal-1.6.2.dev5.dist-info}/RECORD +51 -49
  36. modal_proto/api_grpc.py +192 -0
  37. modal_proto/api_pb2.py +1496 -1000
  38. modal_proto/api_pb2.pyi +1227 -116
  39. modal_proto/api_pb2_grpc.py +400 -2
  40. modal_proto/api_pb2_grpc.pyi +130 -6
  41. modal_proto/modal_api_grpc.py +12 -0
  42. modal_proto/task_command_router_grpc.py +5 -5
  43. modal_proto/task_command_router_pb2.py +49 -49
  44. modal_proto/task_command_router_pb2.pyi +22 -22
  45. modal_proto/task_command_router_pb2_grpc.py +8 -7
  46. modal_proto/task_command_router_pb2_grpc.pyi +10 -6
  47. modal_version/__init__.py +1 -1
  48. modal/sandbox_fs.pyi +0 -1092
  49. modal/snapshot.pyi +0 -85
  50. {modal-1.6.2.dev4.dist-info → modal-1.6.2.dev5.dist-info}/WHEEL +0 -0
  51. {modal-1.6.2.dev4.dist-info → modal-1.6.2.dev5.dist-info}/entry_points.txt +0 -0
  52. {modal-1.6.2.dev4.dist-info → modal-1.6.2.dev5.dist-info}/licenses/LICENSE +0 -0
  53. {modal-1.6.2.dev4.dist-info → modal-1.6.2.dev5.dist-info}/top_level.txt +0 -0
modal/__init__.py CHANGED
@@ -43,11 +43,11 @@ try:
43
43
  from .queue import Queue
44
44
  from .retries import Retries
45
45
  from .sandbox import Probe, Sandbox
46
+ from .sandbox.sandbox import SandboxSnapshot
46
47
  from .schedule import Cron, Period
47
48
  from .scheduler_placement import SchedulerPlacement
48
49
  from .secret import Secret
49
50
  from .server import Server
50
- from .snapshot import SandboxSnapshot
51
51
  from .volume import Volume
52
52
  from .workspace import Workspace
53
53
  except Exception:
modal/_functions.py CHANGED
@@ -27,6 +27,7 @@ from ._image import _Image
27
27
  from ._load_context import LoadContext
28
28
  from ._logs import LogsFilters
29
29
  from ._logs_manager import _FunctionCallLogsManager, _FunctionLogsManager
30
+ from ._metrics import _FunctionMetricsManager
30
31
  from ._object import _Object, live_method, live_method_gen
31
32
  from ._output.pty import get_pty_info
32
33
  from ._output.status import FunctionCreationStatus
@@ -661,6 +662,11 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
661
662
  source_object_id=self.object_id,
662
663
  )
663
664
 
665
+ @property
666
+ def metrics(self) -> "_FunctionMetricsManager":
667
+ """Access metric samples with `export()` and series definitions with `schema()`."""
668
+ return _FunctionMetricsManager(self)
669
+
664
670
  @property
665
671
  def logs(self) -> _FunctionLogsManager:
666
672
  """Access logs for a `Function`.
modal/_logs.py CHANGED
@@ -3,9 +3,9 @@ from __future__ import annotations
3
3
 
4
4
  import asyncio
5
5
  import dataclasses
6
- from collections.abc import AsyncGenerator
6
+ from collections.abc import AsyncGenerator, Awaitable, Callable
7
7
  from datetime import datetime, timedelta, timezone
8
- from typing import TYPE_CHECKING
8
+ from typing import TYPE_CHECKING, Sequence, TypeVar
9
9
 
10
10
  from google.protobuf.timestamp_pb2 import Timestamp
11
11
 
@@ -13,6 +13,9 @@ from modal._utils.async_utils import TaskContext
13
13
  from modal.exception import LogsFetchError
14
14
  from modal_proto import api_pb2
15
15
 
16
+ _T = TypeVar("_T")
17
+ _CountRanges = Callable[[float, float, int], Awaitable[list[tuple[float, float, int]]]]
18
+
16
19
  if TYPE_CHECKING:
17
20
  from modal.client import _Client
18
21
 
@@ -194,6 +197,32 @@ def _next_smaller_bucket_secs(current_bucket_secs: int) -> int | None:
194
197
  return None
195
198
 
196
199
 
200
+ async def _count_log_ranges(
201
+ client: _Client,
202
+ app_id: str,
203
+ since: float,
204
+ until: float,
205
+ bucket_secs: int,
206
+ filters: LogsFilters,
207
+ ) -> list[tuple[float, float, int]]:
208
+ count_req = api_pb2.AppCountLogsRequest(
209
+ app_id=app_id,
210
+ since=_seconds_to_timestamp(since),
211
+ until=_seconds_to_timestamp(until),
212
+ bucket_secs=bucket_secs,
213
+ source=filters.source,
214
+ function_id=filters.function_id,
215
+ parametrized_function_id=filters.parametrized_function_id,
216
+ function_call_id=filters.function_call_id,
217
+ task_id=filters.task_id,
218
+ sandbox_id=filters.sandbox_id,
219
+ search_text=filters.search_text,
220
+ )
221
+ count_resp: api_pb2.AppCountLogsResponse = await client._stub.AppCountLogs(count_req)
222
+
223
+ return _buckets_to_ranges(list(count_resp.buckets), bucket_secs)
224
+
225
+
197
226
  async def _refine_dense_ranges(
198
227
  client: _Client,
199
228
  app_id: str,
@@ -201,6 +230,19 @@ async def _refine_dense_ranges(
201
230
  filters: LogsFilters,
202
231
  max_ranges: int,
203
232
  max_iterations: int,
233
+ ) -> list[tuple[float, float, int]]:
234
+ async def count_ranges(start: float, end: float, bucket_secs: int) -> list[tuple[float, float, int]]:
235
+ return await _count_log_ranges(client, app_id, start, end, bucket_secs, filters)
236
+
237
+ return await _refine_ranges(ranges, count_ranges, max_ranges, max_iterations)
238
+
239
+
240
+ async def _refine_ranges(
241
+ ranges: list[tuple[float, float, int]],
242
+ count_ranges: _CountRanges,
243
+ max_ranges: int,
244
+ max_iterations: int,
245
+ max_concurrent_counts: int = _MAX_CONCURRENT_COUNTS,
204
246
  ) -> list[tuple[float, float, int]]:
205
247
  """Refine ranges exceeding _FETCH_LIMIT by subdividing with smaller buckets.
206
248
 
@@ -239,25 +281,11 @@ async def _refine_dense_ranges(
239
281
  break
240
282
 
241
283
  # Fire off all re-count RPCs in parallel with bounded concurrency
242
- semaphore = asyncio.Semaphore(_MAX_CONCURRENT_COUNTS)
284
+ semaphore = asyncio.Semaphore(max_concurrent_counts)
243
285
 
244
286
  async def _recount(start: float, end: float, smaller_secs: int) -> list[tuple[float, float, int]]:
245
287
  async with semaphore:
246
- sub_req = api_pb2.AppCountLogsRequest(
247
- app_id=app_id,
248
- since=_seconds_to_timestamp(start),
249
- until=_seconds_to_timestamp(end),
250
- bucket_secs=smaller_secs,
251
- source=filters.source,
252
- function_id=filters.function_id,
253
- parametrized_function_id=filters.parametrized_function_id,
254
- function_call_id=filters.function_call_id,
255
- task_id=filters.task_id,
256
- sandbox_id=filters.sandbox_id,
257
- search_text=filters.search_text,
258
- )
259
- sub_resp = await client._stub.AppCountLogs(sub_req)
260
- sub_ranges = _buckets_to_ranges(list(sub_resp.buckets), smaller_secs)
288
+ sub_ranges = await count_ranges(start, end, smaller_secs)
261
289
  # Clamp the edge sub-ranges to the parent boundaries. Bucket
262
290
  # alignment can make the first bucket begin before the parent
263
291
  # (e.g. parent (6,12) with 4s buckets produces (4,8)) or make
@@ -418,26 +446,34 @@ async def fetch_logs(
418
446
 
419
447
  if until - since > _MAX_FETCH_RANGE:
420
448
  raise LogsFetchError(f"Time range cannot exceed {_MAX_FETCH_RANGE.days} days.")
421
- # Phase 1: Count logs per bucket
422
- bucket_secs = _pick_bucket_secs(since, until)
423
- count_req = api_pb2.AppCountLogsRequest(
424
- app_id=app_id,
425
- since=_datetime_to_timestamp(since),
426
- until=_datetime_to_timestamp(until),
427
- bucket_secs=bucket_secs,
428
- source=filters.source,
429
- function_id=filters.function_id,
430
- parametrized_function_id=filters.parametrized_function_id,
431
- function_call_id=filters.function_call_id,
432
- task_id=filters.task_id,
433
- sandbox_id=filters.sandbox_id,
434
- search_text=filters.search_text,
435
- )
436
- count_resp: api_pb2.AppCountLogsResponse = await client._stub.AppCountLogs(count_req)
437
449
 
438
- ranges = _buckets_to_ranges(list(count_resp.buckets), bucket_secs)
439
- total_logs = sum(count for _, _, count in ranges)
440
- if total_logs == 0:
450
+ async def count_ranges(start: float, end: float, bucket_secs: int) -> list[tuple[float, float, int]]:
451
+ return await _count_log_ranges(client, app_id, start, end, bucket_secs, filters)
452
+
453
+ async def fetch_interval(start: float, end: float, limit: int) -> list[api_pb2.TaskLogsBatch]:
454
+ return await _fetch_interval(client, app_id, start, end, limit, filters)
455
+
456
+ async for batch in _fetch_time_range(
457
+ since,
458
+ until,
459
+ count_ranges,
460
+ fetch_interval,
461
+ ):
462
+ yield batch
463
+
464
+
465
+ async def _fetch_time_range(
466
+ since: datetime,
467
+ until: datetime,
468
+ count_ranges: _CountRanges,
469
+ fetch_interval: Callable[[float, float, int], Awaitable[Sequence[_T]]],
470
+ *,
471
+ max_concurrent_counts: int = _MAX_CONCURRENT_COUNTS,
472
+ ) -> AsyncGenerator[_T]:
473
+ """Count, refine, and fetch time intervals with bounded concurrency and ordered results."""
474
+ ranges = await count_ranges(since.timestamp(), until.timestamp(), _pick_bucket_secs(since, until))
475
+ total_records = sum(count for _, _, count in ranges)
476
+ if total_records == 0:
441
477
  return
442
478
 
443
479
  # Trim leading/trailing empty buckets so they don't consume refinement
@@ -449,19 +485,17 @@ async def fetch_logs(
449
485
 
450
486
  # Phase 1b: Refine any ranges that exceed the per-fetch limit.
451
487
  # Budget: at most _MAX_FETCHES intervals, each fetching up to _FETCH_LIMIT entries.
452
- ranges = await _refine_dense_ranges(
453
- client,
454
- app_id,
488
+ ranges = await _refine_ranges(
455
489
  ranges,
456
- filters,
490
+ count_ranges,
457
491
  max_ranges=_MAX_FETCHES,
458
492
  max_iterations=_MAX_REFINE_ITERATIONS,
493
+ max_concurrent_counts=max_concurrent_counts,
459
494
  )
460
- fetch_error_message = "Too many logs to fetch in time range. Consider narrowing the range or adding filters."
461
495
  # Check that all ranges fit within the per-fetch limit.
462
496
  over_limit = [r for r in ranges if r[2] > _FETCH_LIMIT]
463
497
  if over_limit:
464
- raise LogsFetchError(fetch_error_message)
498
+ raise LogsFetchError("Too many logs to fetch in time range. Consider narrowing the range or adding filters.")
465
499
 
466
500
  # Phase 2: Build intervals from non-empty ranges and fetch in parallel
467
501
  since_secs = since.timestamp()
@@ -476,24 +510,17 @@ async def fetch_logs(
476
510
  return
477
511
 
478
512
  if len(intervals) > _MAX_FETCHES:
479
- raise LogsFetchError(fetch_error_message)
513
+ raise LogsFetchError("Too many logs to fetch in time range. Consider narrowing the range or adding filters.")
480
514
 
481
515
  # Fetch all intervals concurrently (bounded by semaphore), but yield
482
- # results in interval order so logs stay chronological. Awaiting each
516
+ # results in interval order so records stay chronological. Awaiting each
483
517
  # task in sequence lets us stream batches to the caller as soon as all
484
518
  # preceding intervals have completed.
485
519
  semaphore = asyncio.Semaphore(_MAX_CONCURRENT_FETCHES)
486
520
 
487
- async def _bounded_fetch(since_s: float, until_s: float) -> list[api_pb2.TaskLogsBatch]:
521
+ async def _bounded_fetch(since_s: float, until_s: float) -> Sequence[_T]:
488
522
  async with semaphore:
489
- return await _fetch_interval(
490
- client,
491
- app_id,
492
- since_s,
493
- until_s,
494
- _FETCH_LIMIT,
495
- filters,
496
- )
523
+ return await fetch_interval(since_s, until_s, _FETCH_LIMIT)
497
524
 
498
525
  tasks = [asyncio.create_task(_bounded_fetch(s, u)) for s, u in intervals]
499
526
 
@@ -504,3 +531,4 @@ async def fetch_logs(
504
531
  finally:
505
532
  for task in tasks:
506
533
  task.cancel()
534
+ await asyncio.gather(*tasks, return_exceptions=True)
modal/_metrics.py ADDED
@@ -0,0 +1,212 @@
1
+ # Copyright Modal Labs 2026
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ from modal_proto import api_pb2
7
+
8
+ from ._object import _Object
9
+ from ._utils.async_utils import synchronize_api
10
+ from .exception import InvalidError
11
+ from .types import MetricDefinition, MetricSeries, MetricsSchema, ObjectMetrics, TimeSeriesPoint
12
+
13
+
14
+ def _metrics_request(
15
+ since: datetime | None, until: datetime | None, bucket_size: timedelta | None, groups: list[str] | None
16
+ ) -> api_pb2.FunctionGetMetricsRequest:
17
+ until = (until or datetime.now(timezone.utc)).astimezone(timezone.utc)
18
+ since = (since or until - timedelta(hours=1)).astimezone(timezone.utc)
19
+ if since >= until:
20
+ raise InvalidError("`since` must be before `until`.")
21
+ if since.timestamp() < 0:
22
+ raise InvalidError("`since` must be at or after the Unix epoch.")
23
+ if until - since > timedelta(days=31):
24
+ raise InvalidError("Metrics time range cannot exceed 31 days.")
25
+ if groups is not None and (not isinstance(groups, list) or any(not isinstance(g, str) or not g for g in groups)):
26
+ raise InvalidError("`groups` must be a list of nonempty group names.")
27
+ request = api_pb2.FunctionGetMetricsRequest(groups=groups or [])
28
+ request.since.FromDatetime(since)
29
+ request.until.FromDatetime(until)
30
+ if bucket_size is not None:
31
+ if not isinstance(bucket_size, timedelta):
32
+ raise InvalidError("`bucket_size` must be a timedelta.")
33
+ seconds = bucket_size.total_seconds()
34
+ if not seconds.is_integer() or not 60 <= seconds <= 2**32 - 1:
35
+ raise InvalidError("`bucket_size` must be a whole number of seconds, at least one minute.")
36
+ request.bucket_secs = int(seconds)
37
+ if int(until.timestamp()) // request.bucket_secs - int(since.timestamp()) // request.bucket_secs > 500:
38
+ raise InvalidError("Metrics are limited to 500 points per series; increase `bucket_size`.")
39
+ return request
40
+
41
+
42
+ def _object_metrics(
43
+ object_id: str, response: api_pb2.FunctionGetMetricsResponse | api_pb2.ServerGetMetricsResponse
44
+ ) -> ObjectMetrics:
45
+ series = {
46
+ item.name: MetricSeries(
47
+ name=item.name,
48
+ unit=item.unit,
49
+ aggregation=item.name.rsplit("_", 1)[-1],
50
+ description=item.description,
51
+ points=[
52
+ TimeSeriesPoint(
53
+ time=point.timestamp.ToDatetime(tzinfo=timezone.utc),
54
+ value=point.value if point.HasField("value") else None,
55
+ )
56
+ for point in item.points
57
+ ],
58
+ )
59
+ for item in response.series
60
+ }
61
+ return ObjectMetrics(
62
+ object_id=object_id,
63
+ since=response.since.ToDatetime(tzinfo=timezone.utc),
64
+ until=response.until.ToDatetime(tzinfo=timezone.utc),
65
+ bucket_size=timedelta(seconds=response.bucket_secs),
66
+ point_count=sum(len(item.points) for item in series.values()),
67
+ series=series,
68
+ )
69
+
70
+
71
+ class _MetricsManager:
72
+ """mdmd:namespace"""
73
+
74
+ def __init__(self, function: _Object, *, is_server: bool):
75
+ """mdmd:hidden"""
76
+ self._function = function
77
+ self._is_server = is_server
78
+
79
+ async def schema(self, groups: list[str] | None = None) -> dict[str, MetricsSchema]:
80
+ """Return metric definitions keyed by group name without fetching samples.
81
+
82
+ Omit `groups` to list all groups. Each group contains its exact individual
83
+ series definitions.
84
+ """
85
+ response = await self._schema(groups=groups)
86
+ schema: dict[str, MetricsSchema] = {}
87
+ for row in response.groups:
88
+ if row.name not in schema:
89
+ schema[row.name] = MetricsSchema(name=row.name, definitions=[])
90
+ schema[row.name].definitions.extend(
91
+ MetricDefinition(name=m.name, unit=m.unit, description=m.description) for m in row.metrics
92
+ )
93
+ return schema
94
+
95
+ async def _schema(
96
+ self,
97
+ *,
98
+ since: datetime | None = None,
99
+ until: datetime | None = None,
100
+ bucket_size: timedelta | None = None,
101
+ groups: list[str] | None = None,
102
+ ) -> api_pb2.MetricsGetInfoResponse:
103
+ """Fetch schema rows and resolved bucket metadata for a preview window."""
104
+ window = _metrics_request(since, until, bucket_size, groups)
105
+ await self._function.hydrate()
106
+ target = api_pb2.MetricsGetInfoRequest
107
+ request = target(
108
+ target_type=target.METRICS_TARGET_TYPE_SERVER if self._is_server else target.METRICS_TARGET_TYPE_FUNCTION,
109
+ since=window.since,
110
+ until=window.until,
111
+ groups=groups,
112
+ )
113
+ if window.HasField("bucket_secs"):
114
+ request.bucket_secs = window.bucket_secs
115
+ return await self._function.client._stub.MetricsGetInfo(request)
116
+
117
+ async def _export(
118
+ self,
119
+ since: datetime | None,
120
+ until: datetime | None,
121
+ bucket_size: timedelta | None,
122
+ groups: list[str] | None,
123
+ all_variants: bool = False,
124
+ ) -> ObjectMetrics:
125
+ request = _metrics_request(since, until, bucket_size, groups)
126
+ await self._function.hydrate()
127
+ request.function_id = self._function.object_id
128
+ if self._is_server:
129
+ server_request = api_pb2.ServerGetMetricsRequest(
130
+ function_id=request.function_id,
131
+ since=request.since,
132
+ until=request.until,
133
+ groups=request.groups,
134
+ )
135
+ if request.HasField("bucket_secs"):
136
+ server_request.bucket_secs = request.bucket_secs
137
+ response = await self._function.client._stub.ServerGetMetrics(server_request)
138
+ else:
139
+ request.rollup = all_variants
140
+ response = await self._function.client._stub.FunctionGetMetrics(request)
141
+ return _object_metrics(self._function.object_id, response)
142
+
143
+
144
+ class _FunctionMetricsManager(_MetricsManager):
145
+ """mdmd:namespace"""
146
+
147
+ def __init__(self, function: _Object):
148
+ """mdmd:hidden"""
149
+ super().__init__(function, is_server=False)
150
+
151
+ async def export(
152
+ self,
153
+ *,
154
+ since: datetime | None = None,
155
+ until: datetime | None = None,
156
+ bucket_size: timedelta | None = None,
157
+ groups: list[str] | None = None,
158
+ all_variants: bool = False,
159
+ ) -> ObjectMetrics:
160
+ """Fetch metric samples for a Function.
161
+
162
+ Defaults to all groups over the last hour. `since` defaults to one hour
163
+ before `until`; naive datetimes use local time. The maximum range is 31
164
+ days, with at most 500 points per series. If `bucket_size` is omitted,
165
+ Modal selects a supported width targeting approximately 100 buckets.
166
+ Bounds round down to UTC bucket boundaries, so data before `since` may
167
+ be included and the partial bucket at `until` is excluded.
168
+
169
+ Set `all_variants=True` to include the root Function and its variants.
170
+ Missing observations have value None. Use `schema()` to discover
171
+ group names and series definitions without fetching samples.
172
+
173
+ ```python notest
174
+ metrics = function.metrics.export(bucket_size=timedelta(minutes=5), groups=["calls"])
175
+ for point in metrics["successful_calls_count"].points:
176
+ print(point.time, point.value)
177
+ ```
178
+ """
179
+ return await self._export(since, until, bucket_size, groups, all_variants)
180
+
181
+
182
+ class _ServerMetricsManager(_MetricsManager):
183
+ """mdmd:namespace"""
184
+
185
+ def __init__(self, function: _Object):
186
+ """mdmd:hidden"""
187
+ super().__init__(function, is_server=True)
188
+
189
+ async def export(
190
+ self,
191
+ *,
192
+ since: datetime | None = None,
193
+ until: datetime | None = None,
194
+ bucket_size: timedelta | None = None,
195
+ groups: list[str] | None = None,
196
+ ) -> ObjectMetrics:
197
+ """Fetch metric samples for a Server.
198
+
199
+ Defaults to all groups over the last hour. `since` defaults to one hour
200
+ before `until`; naive datetimes use local time. The maximum range is 31
201
+ days, with at most 500 points per series. Omit `bucket_size` for automatic
202
+ sizing. Bounds round down to UTC bucket boundaries; the partial bucket
203
+ at `until` is excluded. Missing observations have value None.
204
+
205
+ Use `schema()` to discover groups and series without fetching samples.
206
+ """
207
+ return await self._export(since, until, bucket_size, groups)
208
+
209
+
210
+ MetricsManager = synchronize_api(_MetricsManager, target_module=__name__)
211
+ FunctionMetricsManager = synchronize_api(_FunctionMetricsManager, target_module=__name__)
212
+ ServerMetricsManager = synchronize_api(_ServerMetricsManager, target_module=__name__)