modAL 1.3.6.dev6__py3-none-any.whl → 1.3.6.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.
modal/_logs.py ADDED
@@ -0,0 +1,498 @@
1
+ # Copyright Modal Labs 2026
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import dataclasses
6
+ from collections.abc import AsyncGenerator
7
+ from datetime import datetime, timedelta, timezone
8
+ from typing import TYPE_CHECKING, Optional
9
+
10
+ from google.protobuf.timestamp_pb2 import Timestamp
11
+
12
+ from modal._utils.async_utils import TaskContext
13
+ from modal.exception import LogsFetchError
14
+ from modal_proto import api_pb2
15
+
16
+ if TYPE_CHECKING:
17
+ from modal.client import _Client
18
+
19
+
20
+ @dataclasses.dataclass(frozen=True)
21
+ class LogsFilters:
22
+ source: "api_pb2.FileDescriptor.ValueType" = api_pb2.FILE_DESCRIPTOR_UNSPECIFIED
23
+ function_id: str = ""
24
+ function_call_id: str = ""
25
+ task_id: str = ""
26
+ sandbox_id: str = ""
27
+ search_text: str = ""
28
+
29
+
30
+ # Maximum number of logs to request per fetch RPC call. This is a safety cap
31
+ # to avoid silently dropping logs from dense intervals, not a target size.
32
+ # Derived from a ~4 MiB target response size at ~200 bytes per log entry.
33
+ _FETCH_LIMIT = 20_000
34
+
35
+ # Minimum number of logs to accumulate per interval before splitting into
36
+ # a new one. Buckets are merged until this threshold is reached. Intervals
37
+ # with a single bucket exceeding this value are emitted as-is.
38
+ _INTERVAL_LOG_THRESHOLD = 2_000
39
+
40
+ # Maximum number of fetch RPCs per fetch_logs call. Together with _FETCH_LIMIT,
41
+ # this bounds total retrievable entries to _MAX_FETCHES * _FETCH_LIMIT = 10M.
42
+ _MAX_FETCHES = 500
43
+
44
+ # Maximum allowed range between `since` and `until`, enforced server-side.
45
+ _MAX_FETCH_RANGE = timedelta(days=35)
46
+
47
+ # Lookback windows for tailing logs, tried in order from shortest to longest.
48
+ _TAIL_LOOKBACKS = [
49
+ timedelta(hours=1),
50
+ timedelta(days=1),
51
+ timedelta(days=7),
52
+ timedelta(days=30),
53
+ ]
54
+
55
+ # Maximum number of concurrent AppFetchLogs requests.
56
+ _MAX_CONCURRENT_FETCHES = 10
57
+
58
+ # Maximum number of concurrent AppCountLogs requests (responses are small).
59
+ _MAX_CONCURRENT_COUNTS = 20
60
+
61
+ # Maximum number of refinement iterations (each fires a parallel batch of AppCountLogs RPCs).
62
+ _MAX_REFINE_ITERATIONS = 3
63
+
64
+ # Number of buckets for the initial count
65
+ _APPROX_INITIAL_BUCKETS = 100
66
+
67
+ # Predefined bucket sizes (in seconds)
68
+ # We pick the smallest size such that the total number of buckets stays <= _APPROX_INITIAL_BUCKETS.
69
+ _BUCKET_SIZES_SECS = [
70
+ 2,
71
+ 4,
72
+ 6,
73
+ 12,
74
+ 20,
75
+ 30,
76
+ 60,
77
+ 120,
78
+ 180,
79
+ 240,
80
+ 300,
81
+ 360,
82
+ 600,
83
+ 720,
84
+ 900,
85
+ 1200,
86
+ 1800,
87
+ 3600,
88
+ 7200,
89
+ 10800,
90
+ 14400,
91
+ 28800,
92
+ 43200,
93
+ 86400,
94
+ ]
95
+
96
+
97
+ def _datetime_to_timestamp(dt: datetime) -> Timestamp:
98
+ ts = Timestamp()
99
+ ts.FromDatetime(dt)
100
+ return ts
101
+
102
+
103
+ def _timestamp_to_seconds(ts: Timestamp) -> float:
104
+ return ts.seconds + ts.nanos / 1e9
105
+
106
+
107
+ def _seconds_to_timestamp(secs: float) -> Timestamp:
108
+ ts = Timestamp()
109
+ ts.seconds = int(secs)
110
+ ts.nanos = int((secs - int(secs)) * 1e9)
111
+ return ts
112
+
113
+
114
+ def _pick_bucket_secs(since: datetime, until: datetime) -> int:
115
+ """Pick the smallest bucket size that yields _APPROX_INITIAL_BUCKETS."""
116
+ duration_secs = (until - since).total_seconds()
117
+ for bucket_secs in _BUCKET_SIZES_SECS:
118
+ if duration_secs / bucket_secs <= _APPROX_INITIAL_BUCKETS:
119
+ return bucket_secs
120
+ return _BUCKET_SIZES_SECS[-1]
121
+
122
+
123
+ def _bucket_count(bucket: api_pb2.AppCountLogsResponse.LogBucket) -> int:
124
+ return bucket.stdout_logs + bucket.stderr_logs + bucket.system_logs
125
+
126
+
127
+ def _buckets_to_ranges(
128
+ buckets: list[api_pb2.AppCountLogsResponse.LogBucket],
129
+ bucket_secs: int,
130
+ ) -> list[tuple[float, float, int]]:
131
+ """Convert proto buckets to (start, end, count) tuples."""
132
+ return [
133
+ (
134
+ _timestamp_to_seconds(b.bucket_start_at),
135
+ _timestamp_to_seconds(b.bucket_start_at) + bucket_secs,
136
+ _bucket_count(b),
137
+ )
138
+ for b in buckets
139
+ ]
140
+
141
+
142
+ def _build_fetch_intervals(
143
+ ranges: list[tuple[float, float, int]],
144
+ ) -> list[tuple[float, float]]:
145
+ """Build time intervals for parallel fetching from (start, end, count) ranges.
146
+
147
+ Merges adjacent non-empty ranges into intervals, splitting when the
148
+ accumulated log count reaches _INTERVAL_LOG_THRESHOLD or when adding the
149
+ next range would push the interval over _FETCH_LIMIT.
150
+ """
151
+ intervals: list[tuple[float, float]] = []
152
+ current_start: Optional[float] = None
153
+ current_end: float = 0.0
154
+ current_count = 0
155
+
156
+ for range_start, range_end, total in ranges:
157
+ if total == 0:
158
+ if current_start is not None:
159
+ intervals.append((current_start, current_end))
160
+ current_start = None
161
+ current_count = 0
162
+ continue
163
+
164
+ if current_start is not None and current_count + total > _FETCH_LIMIT:
165
+ intervals.append((current_start, current_end))
166
+ current_start = None
167
+ current_count = 0
168
+
169
+ if current_start is None:
170
+ current_start = range_start
171
+ current_count = total
172
+ else:
173
+ current_count += total
174
+ current_end = range_end
175
+
176
+ if current_count >= _INTERVAL_LOG_THRESHOLD:
177
+ intervals.append((current_start, current_end))
178
+ current_start = None
179
+ current_count = 0
180
+
181
+ # Flush remaining interval
182
+ if current_start is not None:
183
+ intervals.append((current_start, current_end))
184
+
185
+ return intervals
186
+
187
+
188
+ def _next_smaller_bucket_secs(current_bucket_secs: int) -> Optional[int]:
189
+ """Return the next smaller bucket size, or None if already at the smallest."""
190
+ for size in reversed(_BUCKET_SIZES_SECS):
191
+ if size < current_bucket_secs:
192
+ return size
193
+ return None
194
+
195
+
196
+ async def _refine_dense_ranges(
197
+ client: _Client,
198
+ app_id: str,
199
+ ranges: list[tuple[float, float, int]],
200
+ filters: LogsFilters,
201
+ max_ranges: int,
202
+ max_iterations: int,
203
+ ) -> list[tuple[float, float, int]]:
204
+ """Refine ranges exceeding _FETCH_LIMIT by subdividing with smaller buckets.
205
+
206
+ Uses breadth-first refinement: each iteration splits ALL over-limit ranges
207
+ one level, ensuring fair treatment across dense regions. Stops when all
208
+ ranges fit within _FETCH_LIMIT, no further subdivision is possible,
209
+ the total range count would exceed max_ranges, or the iteration budget
210
+ is exhausted.
211
+ """
212
+ refined = list(ranges)
213
+ iterations = 0
214
+
215
+ while iterations < max_iterations:
216
+ # Identify dense ranges that can be subdivided
217
+ to_refine: list[tuple[int, int]] = [] # (index, smaller_secs)
218
+ for i, (start, end, count) in enumerate(refined):
219
+ if count <= _FETCH_LIMIT:
220
+ continue
221
+ duration = end - start
222
+ smaller_secs = _next_smaller_bucket_secs(int(duration))
223
+ if smaller_secs is not None and smaller_secs < duration:
224
+ to_refine.append((i, smaller_secs))
225
+
226
+ if not to_refine:
227
+ break
228
+
229
+ # Estimate new range count: each refined range of duration D
230
+ # with bucket size S produces ceil(D/S) sub-ranges, replacing 1.
231
+ estimated_new = len(refined)
232
+ for i, smaller_secs in to_refine:
233
+ start, end, _ = refined[i]
234
+ duration = end - start
235
+ sub_count = -(-int(duration) // smaller_secs) # ceil division
236
+ estimated_new += sub_count - 1 # replacing 1 range with sub_count
237
+ if estimated_new > max_ranges:
238
+ break
239
+
240
+ # Fire off all re-count RPCs in parallel with bounded concurrency
241
+ semaphore = asyncio.Semaphore(_MAX_CONCURRENT_COUNTS)
242
+
243
+ async def _recount(start: float, end: float, smaller_secs: int) -> list[tuple[float, float, int]]:
244
+ async with semaphore:
245
+ sub_req = api_pb2.AppCountLogsRequest(
246
+ app_id=app_id,
247
+ since=_seconds_to_timestamp(start),
248
+ until=_seconds_to_timestamp(end),
249
+ bucket_secs=smaller_secs,
250
+ source=filters.source,
251
+ function_id=filters.function_id,
252
+ function_call_id=filters.function_call_id,
253
+ task_id=filters.task_id,
254
+ sandbox_id=filters.sandbox_id,
255
+ search_text=filters.search_text,
256
+ )
257
+ sub_resp = await client.stub.AppCountLogs(sub_req)
258
+ sub_ranges = _buckets_to_ranges(list(sub_resp.buckets), smaller_secs)
259
+ # Clamp the last sub-range to the parent's end. When bucket_secs
260
+ # doesn't evenly divide the parent duration, the last bucket
261
+ # boundary overshoots (e.g. parent (0,30) with 20s buckets
262
+ # produces (20,40)), causing overlaps with sibling ranges.
263
+ if sub_ranges:
264
+ s, _, c = sub_ranges[-1]
265
+ sub_ranges[-1] = (s, min(s + smaller_secs, end), c)
266
+ return sub_ranges
267
+
268
+ refine_set = {i for i, _ in to_refine}
269
+ tasks = [_recount(refined[i][0], refined[i][1], s) for i, s in to_refine]
270
+ iterations += 1
271
+ sub_results = await TaskContext.gather(*tasks)
272
+
273
+ # Rebuild the list, splicing in sub-ranges for refined entries
274
+ result_iter = iter(sub_results)
275
+ new_refined: list[tuple[float, float, int]] = []
276
+ for i, r in enumerate(refined):
277
+ if i in refine_set:
278
+ new_refined.extend(next(result_iter))
279
+ else:
280
+ new_refined.append(r)
281
+
282
+ refined = new_refined
283
+
284
+ return refined
285
+
286
+
287
+ async def _fetch_interval(
288
+ client: _Client,
289
+ app_id: str,
290
+ since: float,
291
+ until: float,
292
+ limit: int,
293
+ filters: LogsFilters,
294
+ ) -> list[api_pb2.TaskLogsBatch]:
295
+ """Fetch logs for a single time interval."""
296
+ req = api_pb2.AppFetchLogsRequest(
297
+ app_id=app_id,
298
+ since=_seconds_to_timestamp(since),
299
+ until=_seconds_to_timestamp(until),
300
+ limit=limit,
301
+ source=filters.source,
302
+ function_id=filters.function_id,
303
+ function_call_id=filters.function_call_id,
304
+ task_id=filters.task_id,
305
+ sandbox_id=filters.sandbox_id,
306
+ search_text=filters.search_text,
307
+ )
308
+ resp: api_pb2.AppFetchLogsResponse = await client.stub.AppFetchLogs(req)
309
+ return list(resp.batches)
310
+
311
+
312
+ async def tail_logs(
313
+ client: _Client,
314
+ app_id: str,
315
+ n: int,
316
+ *,
317
+ since: Optional[datetime] = None,
318
+ until: Optional[datetime] = None,
319
+ filters: Optional[LogsFilters] = None,
320
+ ) -> AsyncGenerator[api_pb2.TaskLogsBatch]:
321
+ """Fetch up to `n` of the most recent log entries for an app.
322
+
323
+ When `since` is provided, it is used as a hard floor (no progressive
324
+ widening). When omitted, starts with a short lookback window and widens
325
+ until `n` results are returned or all windows are exhausted. `until`
326
+ defaults to now.
327
+
328
+ `n` must not exceed _FETCH_LIMIT.
329
+ """
330
+ if filters is None:
331
+ filters = LogsFilters()
332
+ if n > _FETCH_LIMIT:
333
+ raise LogsFetchError(f"--tail value must not exceed {_FETCH_LIMIT}.")
334
+
335
+ now = datetime.now(timezone.utc)
336
+
337
+ if since is not None and since.tzinfo is None:
338
+ raise ValueError("since must be timezone-aware")
339
+ if until is not None and until.tzinfo is None:
340
+ raise ValueError("until must be timezone-aware")
341
+
342
+ until_ts = _datetime_to_timestamp(until) if until else _datetime_to_timestamp(now)
343
+
344
+ if since is not None:
345
+ # Explicit floor — single fetch, no widening.
346
+ effective_until = until or now
347
+ if effective_until - since > _MAX_FETCH_RANGE:
348
+ raise LogsFetchError(f"Time range cannot exceed {_MAX_FETCH_RANGE.days} days.")
349
+ req = api_pb2.AppFetchLogsRequest(
350
+ app_id=app_id,
351
+ since=_datetime_to_timestamp(since),
352
+ until=until_ts,
353
+ limit=n,
354
+ source=filters.source,
355
+ function_id=filters.function_id,
356
+ function_call_id=filters.function_call_id,
357
+ task_id=filters.task_id,
358
+ sandbox_id=filters.sandbox_id,
359
+ search_text=filters.search_text,
360
+ )
361
+ resp = await client.stub.AppFetchLogs(req)
362
+ for batch in resp.batches:
363
+ yield batch
364
+ return
365
+
366
+ # No explicit since — progressively widen the lookback.
367
+ anchor = until or now
368
+ for lookback in _TAIL_LOOKBACKS:
369
+ lookback_since = anchor - lookback
370
+ req = api_pb2.AppFetchLogsRequest(
371
+ app_id=app_id,
372
+ since=_datetime_to_timestamp(lookback_since),
373
+ until=until_ts,
374
+ limit=n,
375
+ source=filters.source,
376
+ function_id=filters.function_id,
377
+ function_call_id=filters.function_call_id,
378
+ task_id=filters.task_id,
379
+ sandbox_id=filters.sandbox_id,
380
+ search_text=filters.search_text,
381
+ )
382
+ resp = await client.stub.AppFetchLogs(req)
383
+
384
+ total_items = sum(len(b.items) for b in resp.batches)
385
+ if total_items >= n or lookback == _TAIL_LOOKBACKS[-1]:
386
+ for batch in resp.batches:
387
+ yield batch
388
+ return
389
+
390
+
391
+ async def fetch_logs(
392
+ client: _Client,
393
+ app_id: str,
394
+ since: datetime,
395
+ until: datetime,
396
+ *,
397
+ filters: Optional[LogsFilters] = None,
398
+ ) -> AsyncGenerator[api_pb2.TaskLogsBatch]:
399
+ """Fetch logs for an app over a time range using count-then-fetch strategy.
400
+
401
+ Yields TaskLogsBatch objects in chronological order.
402
+ """
403
+ if filters is None:
404
+ filters = LogsFilters()
405
+
406
+ if since.tzinfo is None:
407
+ raise ValueError("since must be timezone-aware")
408
+ if until.tzinfo is None:
409
+ raise ValueError("until must be timezone-aware")
410
+
411
+ if until - since > _MAX_FETCH_RANGE:
412
+ raise LogsFetchError(f"Time range cannot exceed {_MAX_FETCH_RANGE.days} days.")
413
+
414
+ # Phase 1: Count logs per bucket
415
+ bucket_secs = _pick_bucket_secs(since, until)
416
+ count_req = api_pb2.AppCountLogsRequest(
417
+ app_id=app_id,
418
+ since=_datetime_to_timestamp(since),
419
+ until=_datetime_to_timestamp(until),
420
+ bucket_secs=bucket_secs,
421
+ source=filters.source,
422
+ function_id=filters.function_id,
423
+ function_call_id=filters.function_call_id,
424
+ task_id=filters.task_id,
425
+ sandbox_id=filters.sandbox_id,
426
+ search_text=filters.search_text,
427
+ )
428
+ count_resp: api_pb2.AppCountLogsResponse = await client.stub.AppCountLogs(count_req)
429
+
430
+ ranges = _buckets_to_ranges(list(count_resp.buckets), bucket_secs)
431
+ total_logs = sum(count for _, _, count in ranges)
432
+ if total_logs == 0:
433
+ return
434
+
435
+ # Trim leading/trailing empty buckets so they don't consume refinement
436
+ # budget. Interior zeros are kept to prevent merging across gaps.
437
+ while ranges and ranges[0][2] == 0:
438
+ ranges.pop(0)
439
+ while ranges and ranges[-1][2] == 0:
440
+ ranges.pop()
441
+
442
+ # Phase 1b: Refine any ranges that exceed the per-fetch limit.
443
+ # Budget: at most _MAX_FETCHES intervals, each fetching up to _FETCH_LIMIT entries.
444
+ ranges = await _refine_dense_ranges(
445
+ client,
446
+ app_id,
447
+ ranges,
448
+ filters,
449
+ max_ranges=_MAX_FETCHES,
450
+ max_iterations=_MAX_REFINE_ITERATIONS,
451
+ )
452
+ fetch_error_message = "Too many logs to fetch in time range. Consider narrowing the range or adding filters."
453
+ # Check that all ranges fit within the per-fetch limit.
454
+ over_limit = [r for r in ranges if r[2] > _FETCH_LIMIT]
455
+ if over_limit:
456
+ raise LogsFetchError(fetch_error_message)
457
+
458
+ # Phase 2: Build intervals from non-empty ranges and fetch in parallel
459
+ since_secs = since.timestamp()
460
+ until_secs = until.timestamp()
461
+ intervals = _build_fetch_intervals(ranges)
462
+ # Clamp intervals to the requested time range. Bucket boundaries may
463
+ # extend beyond the user's since/until (e.g. an 86400s bucket could
464
+ # include up to a full extra day).
465
+ intervals = [(max(s, since_secs), min(u, until_secs)) for s, u in intervals]
466
+ intervals = [(s, u) for s, u in intervals if s < u]
467
+ if not intervals:
468
+ return
469
+
470
+ if len(intervals) > _MAX_FETCHES:
471
+ raise LogsFetchError(fetch_error_message)
472
+
473
+ # Fetch all intervals concurrently (bounded by semaphore), but yield
474
+ # results in interval order so logs stay chronological. Awaiting each
475
+ # task in sequence lets us stream batches to the caller as soon as all
476
+ # preceding intervals have completed.
477
+ semaphore = asyncio.Semaphore(_MAX_CONCURRENT_FETCHES)
478
+
479
+ async def _bounded_fetch(since_s: float, until_s: float) -> list[api_pb2.TaskLogsBatch]:
480
+ async with semaphore:
481
+ return await _fetch_interval(
482
+ client,
483
+ app_id,
484
+ since_s,
485
+ until_s,
486
+ _FETCH_LIMIT,
487
+ filters,
488
+ )
489
+
490
+ tasks = [asyncio.create_task(_bounded_fetch(s, u)) for s, u in intervals]
491
+
492
+ try:
493
+ for task in tasks:
494
+ for batch in await task:
495
+ yield batch
496
+ finally:
497
+ for task in tasks:
498
+ task.cancel()
modal/_output/manager.py CHANGED
@@ -298,13 +298,31 @@ class OutputManager(ABC):
298
298
  ...
299
299
 
300
300
  @abstractmethod
301
- async def put_log_content(self, log: "api_pb2.TaskLogs") -> None:
302
- """Process and display log content.
301
+ async def put_streaming_log(self, log: "api_pb2.TaskLogs", prefix: str = "") -> None:
302
+ """Process and display log content with immediate output.
303
+
304
+ Use this for streaming paths (--follow, image logs) where logs arrive
305
+ in real time and should be displayed immediately.
303
306
 
304
307
  Note: In RichOutputManager, log output is always displayed even when quiet mode
305
308
  is enabled. This is intentional - quiet mode suppresses progress indicators and
306
309
  status messages, but not actual log output from running functions/images.
307
310
  In contrast, DisabledOutputManager suppresses all output including logs.
311
+
312
+ prefix: optional string to prepend to each log line (e.g. object IDs).
313
+ """
314
+ ...
315
+
316
+ @abstractmethod
317
+ async def put_fetched_log(self, log: "api_pb2.TaskLogs", prefix: str = "") -> None:
318
+ """Process and display log content with batched output.
319
+
320
+ Use this for bulk historical log fetching where thousands of entries
321
+ are processed in a tight loop. Output is buffered internally and
322
+ flushed periodically to avoid per-line stdout overhead. Call
323
+ flush_lines() after the loop to drain any remaining buffered output.
324
+
325
+ prefix: optional string to prepend to each log line (e.g. object IDs).
308
326
  """
309
327
  ...
310
328
 
@@ -382,7 +400,7 @@ class OutputManager(ABC):
382
400
  - All print() output is suppressed, including stderr
383
401
  - Progress indicators (spinners, progress bars) are suppressed
384
402
  - Warnings (via show_warning) are still displayed
385
- - Log output (via put_log_content) is still displayed
403
+ - Log output (via put_streaming_log) is still displayed
386
404
  - Interactive/raw PTY content (via put_pty_content) is still forwarded to stdout
387
405
  - Error output (via print_error) is still displayed
388
406
 
@@ -516,7 +534,10 @@ class DisabledOutputManager(OutputManager):
516
534
  ) -> None:
517
535
  pass
518
536
 
519
- async def put_log_content(self, log: "api_pb2.TaskLogs") -> None:
537
+ async def put_streaming_log(self, log: "api_pb2.TaskLogs", prefix: str = "") -> None:
538
+ pass
539
+
540
+ async def put_fetched_log(self, log: "api_pb2.TaskLogs", prefix: str = "") -> None:
520
541
  pass
521
542
 
522
543
  def flush_lines(self) -> None:
modal/_output/pty.py CHANGED
@@ -64,6 +64,30 @@ async def stream_pty_shell_input(client: "_Client", exec_id: str, finish_event:
64
64
  await finish_event.wait()
65
65
 
66
66
 
67
+ _PREFIX_FIELD_GETTERS = {
68
+ "fu": lambda batch, log: batch.function_id,
69
+ "ta": lambda batch, log: batch.task_id,
70
+ "fc": lambda batch, log: log.function_call_id,
71
+ }
72
+
73
+
74
+ def _build_log_prefix(batch: api_pb2.TaskLogsBatch, log: api_pb2.TaskLogs, prefix_fields: list[str]) -> str:
75
+ """Build a prefix string from batch/log fields based on requested prefix types.
76
+
77
+ The order of prefix_fields controls the order of the output.
78
+ """
79
+ if not prefix_fields:
80
+ return ""
81
+ parts = []
82
+ for field in prefix_fields:
83
+ getter = _PREFIX_FIELD_GETTERS.get(field)
84
+ if getter:
85
+ value = getter(batch, log)
86
+ if value:
87
+ parts.append(value)
88
+ return " ".join(parts)
89
+
90
+
67
91
  async def get_app_logs_loop(
68
92
  client: "_Client",
69
93
  output_mgr: "OutputManager",
@@ -71,8 +95,15 @@ async def get_app_logs_loop(
71
95
  task_id: Optional[str] = None,
72
96
  sandbox_id: Optional[str] = None,
73
97
  follow: bool = True,
98
+ last_entry_id: str = "",
99
+ prefix_fields: Optional[list[str]] = None,
100
+ file_descriptor: "api_pb2.FileDescriptor.ValueType" = api_pb2.FILE_DESCRIPTOR_UNSPECIFIED,
101
+ function_id: str = "",
102
+ function_call_id: str = "",
103
+ search_text: str = "",
74
104
  ):
75
- last_log_batch_entry_id = ""
105
+ last_log_batch_entry_id = last_entry_id
106
+ _prefixes = prefix_fields or []
76
107
 
77
108
  pty_shell_finish_event: Optional[asyncio.Event] = None
78
109
  pty_shell_task_id: Optional[str] = None
@@ -112,10 +143,15 @@ async def get_app_logs_loop(
112
143
  else: # Ensure forward-compatible with new types.
113
144
  logger.debug(f"Received unrecognized progress type: {log.task_progress.progress_type}")
114
145
  elif log.data:
146
+ # Client-side search filter for Redis streaming (AppGetLogs
147
+ # doesn't support server-side search)
148
+ if search_text and search_text not in log.data:
149
+ return
115
150
  if pty_shell_finish_event:
116
151
  await output_mgr.put_pty_content(log)
117
152
  else:
118
- await output_mgr.put_log_content(log)
153
+ log_prefix = _build_log_prefix(log_batch, log, _prefixes)
154
+ await output_mgr.put_streaming_log(log, prefix=log_prefix)
119
155
 
120
156
  async def _get_logs():
121
157
  nonlocal last_log_batch_entry_id
@@ -127,6 +163,9 @@ async def get_app_logs_loop(
127
163
  sandbox_id=sandbox_id or "",
128
164
  timeout=55 if follow else 0,
129
165
  last_entry_id=last_log_batch_entry_id,
166
+ file_descriptor=file_descriptor,
167
+ function_id=function_id,
168
+ function_call_id=function_call_id,
130
169
  )
131
170
  log_batch: api_pb2.TaskLogsBatch
132
171
  async for log_batch in client.stub.AppGetLogs.unary_stream(request):
modal/_output/rich.py CHANGED
@@ -10,12 +10,13 @@ from __future__ import annotations
10
10
 
11
11
  import contextlib
12
12
  import functools
13
+ import io
13
14
  import platform
14
15
  import re
15
- from collections.abc import Generator
16
+ import sys
16
17
  from datetime import timedelta
17
18
  from pathlib import Path
18
- from typing import Any, Callable
19
+ from typing import Any, Callable, Generator, NamedTuple
19
20
 
20
21
  from rich.console import Console, Group, RenderableType
21
22
  from rich.live import Live
@@ -96,18 +97,36 @@ class LineBufferedOutput:
96
97
  def __init__(self, callback: Callable[[str], None], show_timestamps: bool):
97
98
  self._callback = callback
98
99
  self._buf = ""
100
+ self._buf_prefix = ""
99
101
  self._show_timestamps = show_timestamps
100
102
 
101
- def write(self, log: api_pb2.TaskLogs):
103
+ def write(self, log: api_pb2.TaskLogs, prefix: str = ""):
104
+ line_prefix = ""
105
+ if self._show_timestamps:
106
+ ts_str = timestamp_to_localized_str(log.timestamp)
107
+ if ts_str:
108
+ line_prefix = f"{ts_str} "
109
+ if prefix:
110
+ line_prefix = f"{line_prefix}{prefix} "
111
+
112
+ # If the new prefix differs from the buffered remainder's prefix,
113
+ # flush the old remainder as its own line so it isn't emitted under
114
+ # the wrong prefix. This is rare — it means a previous log entry
115
+ # wrote partial output without a trailing newline, and a log from a
116
+ # different source arrived on the same fd.
117
+ if self._buf and line_prefix != self._buf_prefix:
118
+ self._callback(f"{self._buf_prefix}{self._buf}\n")
119
+ self._buf = ""
120
+
102
121
  chunks = self.LINE_REGEX.split(self._buf + log.data)
103
122
 
104
123
  # re.split("(<exp>)") returns the matched groups, and also the separators.
105
124
  # e.g. re.split("(+)", "a+b") returns ["a", "+", "b"].
106
125
  # This means that chunks is guaranteed to be odd in length.
107
126
 
108
- if self._show_timestamps:
127
+ if line_prefix:
109
128
  for i in range(0, len(chunks) - 1, 2):
110
- chunks[i] = f"{timestamp_to_localized_str(log.timestamp)} {chunks[i]}"
129
+ chunks[i] = f"{line_prefix}{chunks[i]}"
111
130
 
112
131
  completed_lines = "".join(chunks[:-1])
113
132
  remainder = chunks[-1]
@@ -123,14 +142,16 @@ class LineBufferedOutput:
123
142
 
124
143
  self._callback(completed_lines)
125
144
  self._buf = remainder
145
+ self._buf_prefix = line_prefix
126
146
 
127
147
  def flush(self):
128
148
  pass
129
149
 
130
150
  def finalize(self):
131
151
  if self._buf:
132
- self._callback(self._buf)
152
+ self._callback(f"{self._buf_prefix}{self._buf}")
133
153
  self._buf = ""
154
+ self._buf_prefix = ""
134
155
 
135
156
 
136
157
  class _RichStatusContext(StatusContext):
@@ -180,6 +201,22 @@ class RichStatusRow(StatusRow):
180
201
  self._step_node.label = f"🔨 {message}"
181
202
 
182
203
 
204
+ _LOG_FLUSH_THRESHOLD = 32768 # 32KB — flush batched log output roughly every ~400 lines
205
+
206
+
207
+ class AnsiColor(NamedTuple):
208
+ name: str
209
+ code: str
210
+
211
+
212
+ _FD_COLORS: dict[int, AnsiColor] = {
213
+ api_pb2.FILE_DESCRIPTOR_STDOUT: AnsiColor("blue", "\033[34m"),
214
+ api_pb2.FILE_DESCRIPTOR_STDERR: AnsiColor("red", "\033[31m"),
215
+ api_pb2.FILE_DESCRIPTOR_INFO: AnsiColor("yellow", "\033[33m"),
216
+ }
217
+ _ANSI_RESET = "\033[0m"
218
+
219
+
183
220
  class RichOutputManager(OutputManager):
184
221
  """Rich-based implementation of OutputManager.
185
222
 
@@ -195,7 +232,9 @@ class RichOutputManager(OutputManager):
195
232
  _function_progress: Progress | None
196
233
  _function_queueing_progress: Progress | None
197
234
  _snapshot_progress: Progress | None
198
- _line_buffers: dict[int, LineBufferedOutput]
235
+ _line_buffers: dict[tuple[int, str], LineBufferedOutput]
236
+ _log_buffer: io.StringIO
237
+ _use_ansi_color: bool
199
238
  _status_spinner: Spinner | None
200
239
  _app_page_url: str | None
201
240
  __show_image_logs: bool
@@ -228,6 +267,9 @@ class RichOutputManager(OutputManager):
228
267
  self._function_queueing_progress = None
229
268
  self._snapshot_progress = None
230
269
  self._line_buffers = {}
270
+ self._log_buffer = io.StringIO()
271
+ # Respect Rich's color detection (TTY, NO_COLOR, FORCE_COLOR, etc.)
272
+ self._use_ansi_color = self._console.color_system is not None and not self._console.no_color
231
273
  self._status_spinner: Spinner | None = None
232
274
  self._app_page_url = None
233
275
  self.__show_image_logs = False
@@ -453,17 +495,27 @@ class RichOutputManager(OutputManager):
453
495
 
454
496
  return update_counts
455
497
 
456
- def _print_log(self, fd: int, data: str) -> None:
457
- if fd == api_pb2.FILE_DESCRIPTOR_STDOUT:
458
- style = "blue"
459
- elif fd == api_pb2.FILE_DESCRIPTOR_STDERR:
460
- style = "red"
461
- elif fd == api_pb2.FILE_DESCRIPTOR_INFO:
462
- style = "yellow"
463
- else:
464
- raise Exception(f"Weird file descriptor {fd} for log output")
465
-
466
- self._console.out(data, style=style, end="")
498
+ def _styled_log_text(self, fd: int, data: str) -> str:
499
+ """Wrap log text in ANSI color codes, bypassing Rich's rendering pipeline."""
500
+ if self._use_ansi_color:
501
+ color = _FD_COLORS.get(fd)
502
+ if color is None:
503
+ logger.debug(f"Unknown file descriptor {fd} as log_source")
504
+ return data
505
+ return f"{color.code}{data}{_ANSI_RESET}"
506
+ return data
507
+
508
+ def _print_log_buffered(self, fd: int, data: str) -> None:
509
+ """Append styled log text to the output buffer for batched flushing."""
510
+ self._log_buffer.write(self._styled_log_text(fd, data))
511
+
512
+ def _flush_log_buffer(self) -> None:
513
+ """Write buffered log output to stdout."""
514
+ if self._log_buffer.tell() > 0:
515
+ sys.stdout.write(self._log_buffer.getvalue())
516
+ sys.stdout.flush()
517
+ self._log_buffer.seek(0)
518
+ self._log_buffer.truncate()
467
519
 
468
520
  def update_app_page_url(self, app_page_url: str) -> None:
469
521
  self._app_page_url = app_page_url
@@ -542,22 +594,56 @@ class RichOutputManager(OutputManager):
542
594
  progress_task_id = self._function_queueing_progress_bar.add_task(task_desc, start=True, total=None)
543
595
  self._task_progress_items[task_key] = progress_task_id
544
596
 
545
- async def put_log_content(self, log: api_pb2.TaskLogs) -> None:
546
- """Process and display log content.
597
+ def _get_line_buffer(self, fd: int, mode: str, callback: Callable[[str], None]) -> LineBufferedOutput:
598
+ """Get or create a LineBufferedOutput for the given file descriptor and mode.
599
+
600
+ mode should be "streaming" or "fetched" — this ensures the two output
601
+ paths maintain separate buffers even for the same file descriptor.
602
+ """
603
+ key = (fd, mode)
604
+ stream = self._line_buffers.get(key)
605
+ if stream is None:
606
+ stream = LineBufferedOutput(callback, self._show_timestamps)
607
+ self._line_buffers[key] = stream
608
+ return stream
609
+
610
+ def _console_print_log(self, fd: int, data: str) -> None:
611
+ """Write log text via Rich Console, cooperating with any active Live context."""
612
+ color = _FD_COLORS.get(fd)
613
+ if color is None:
614
+ logger.debug(f"Unknown file descriptor {fd} as log_source")
615
+ color = AnsiColor("white", "\033[37m")
616
+ self._console.out(data, style=color.name, end="")
617
+
618
+ async def put_streaming_log(self, log: api_pb2.TaskLogs, prefix: str = "") -> None:
619
+ """Process and display log content with immediate output.
620
+
621
+ Uses Rich Console.out() which cooperates with any active Live context
622
+ (e.g. the status spinner in --follow mode).
547
623
 
548
624
  Note: Log output is always displayed even when quiet mode is enabled.
549
625
  Quiet mode suppresses progress indicators and status messages, but not
550
626
  actual log output from running functions/images.
551
627
  """
552
- stream = self._line_buffers.get(log.file_descriptor)
553
- if stream is None:
554
- stream = LineBufferedOutput(functools.partial(self._print_log, log.file_descriptor), self._show_timestamps)
555
- self._line_buffers[log.file_descriptor] = stream
556
- stream.write(log)
628
+ callback = functools.partial(self._console_print_log, log.file_descriptor)
629
+ self._get_line_buffer(log.file_descriptor, "streaming", callback).write(log, prefix=prefix)
630
+
631
+ async def put_fetched_log(self, log: api_pb2.TaskLogs, prefix: str = "") -> None:
632
+ """Process and display log content with batched output.
633
+
634
+ Bypasses Rich's rendering pipeline and buffers ANSI-colored text,
635
+ flushing periodically. Use this for bulk historical log fetching
636
+ where thousands of entries are processed in a tight loop.
637
+ """
638
+ callback = functools.partial(self._print_log_buffered, log.file_descriptor)
639
+ self._get_line_buffer(log.file_descriptor, "fetched", callback).write(log, prefix=prefix)
640
+ if self._log_buffer.tell() >= _LOG_FLUSH_THRESHOLD:
641
+ self._flush_log_buffer()
557
642
 
558
643
  def flush_lines(self) -> None:
559
644
  for stream in self._line_buffers.values():
560
645
  stream.finalize()
646
+ self._flush_log_buffer()
561
647
 
562
648
  @contextlib.contextmanager
563
649
  def transfer_progress(self, type: str) -> Generator[TransferProgressContext, None, None]:
modal/cli/app.py CHANGED
@@ -1,5 +1,6 @@
1
1
  # Copyright Modal Labs 2022
2
2
  import re
3
+ from datetime import datetime, timedelta, timezone
3
4
  from typing import Optional, Union
4
5
 
5
6
  import rich
@@ -16,8 +17,9 @@ from modal.client import _Client
16
17
  from modal.environments import ensure_env
17
18
  from modal_proto import api_pb2
18
19
 
19
- from .._utils.time_utils import timestamp_to_localized_str
20
- from .utils import ENV_OPTION, display_table, get_app_id_from_name, stream_app_logs
20
+ from .._logs import _FETCH_LIMIT, _MAX_FETCH_RANGE, LogsFilters
21
+ from .._utils.time_utils import locale_tz, timestamp_to_localized_str
22
+ from .utils import ENV_OPTION, display_table, fetch_app_logs, get_app_id_from_name, stream_app_logs, tail_app_logs
21
23
 
22
24
  APP_IDENTIFIER = Argument("", help="App name or ID")
23
25
  NAME_OPTION = typer.Option("", "-n", "--name", help="Deprecated: Pass App name as a positional argument")
@@ -81,33 +83,206 @@ async def list_(env: Optional[str] = ENV_OPTION, json: bool = False):
81
83
  display_table(columns, rows, json, title=f"Apps{env_part}")
82
84
 
83
85
 
86
+ _RELATIVE_TIME_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
87
+
88
+
89
+ def _parse_time_arg(value: Optional[str], default: datetime) -> datetime:
90
+ """Parse a time argument that can be a relative duration (e.g. '2h', '30m') or ISO 8601 datetime.
91
+
92
+ Naive datetime values are interpreted in the user's local timezone.
93
+ Relative durations are always UTC-relative.
94
+ """
95
+ if value is None:
96
+ return default
97
+
98
+ # Try relative duration: digits followed by a unit letter
99
+ if len(value) >= 2 and value[-1] in _RELATIVE_TIME_UNITS and value[:-1].isdigit():
100
+ seconds = int(value[:-1]) * _RELATIVE_TIME_UNITS[value[-1]]
101
+ return datetime.now(timezone.utc) - timedelta(seconds=seconds)
102
+
103
+ # Try ISO 8601 datetime
104
+ try:
105
+ dt = datetime.fromisoformat(value)
106
+ if dt.tzinfo is None:
107
+ # Interpret naive datetimes in the user's local timezone
108
+ dt = dt.replace(tzinfo=locale_tz())
109
+ return dt
110
+ except ValueError:
111
+ raise UsageError(f"Invalid time format: '{value}'. Use a relative duration (e.g. '2h') or ISO 8601 datetime.")
112
+
113
+
114
+ _DEFAULT_LOGS_TAIL = 100
115
+
116
+
117
+ _SOURCE_OPTIONS = {
118
+ "stdout": api_pb2.FILE_DESCRIPTOR_STDOUT,
119
+ "stderr": api_pb2.FILE_DESCRIPTOR_STDERR,
120
+ "system": api_pb2.FILE_DESCRIPTOR_INFO,
121
+ }
122
+
123
+
84
124
  @app_cli.command("logs", no_args_is_help=True)
85
125
  def logs(
86
126
  app_identifier: str = APP_IDENTIFIER,
87
127
  follow: bool = typer.Option(False, "-f", "--follow", help="Stream log output until App stops"),
88
- timestamps: bool = typer.Option(False, "--timestamps", help="Show timestamps for each log line"),
128
+ since: Optional[str] = typer.Option(
129
+ None,
130
+ "--since",
131
+ help=(
132
+ "Start of time range. Accepts ISO 8601 datetime or relative time, e.g. '1d' (1 day ago), '2h', '30m', etc."
133
+ ),
134
+ ),
135
+ until: Optional[str] = typer.Option(
136
+ None,
137
+ "--until",
138
+ help="End of time range; accepts same argument types as --since",
139
+ ),
140
+ search: Optional[str] = typer.Option(None, "--search", help="Filter by search text"),
141
+ function_id: Optional[str] = typer.Option("", "--function", help="Filter by Function ID (fu-*)"),
142
+ function_call_id: Optional[str] = typer.Option("", "--function-call", help="Filter by FunctionCall ID (fc-*)"),
143
+ source: Optional[str] = typer.Option(
144
+ None, "--source", "-s", help="Filter by source: 'stdout', 'stderr', or 'system'"
145
+ ),
146
+ tail: Optional[int] = typer.Option(None, "--tail", "-n", help="Show only the last N log entries"),
147
+ timestamps: bool = typer.Option(False, "--timestamps", help="Prefix each line with its timestamp"),
148
+ show_function_id: bool = typer.Option(False, "--show-function-id", help="Prefix each line with its Function ID"),
149
+ show_function_call_id: bool = typer.Option(
150
+ False, "--show-function-call-id", help="Prefix each line with its FunctionCall ID"
151
+ ),
152
+ show_container_id: bool = typer.Option(False, "--show-container-id", help="Prefix each line with its Container ID"),
89
153
  *,
90
154
  env: Optional[str] = ENV_OPTION,
91
155
  ):
92
- """Show App logs, streaming while active.
156
+ """Fetch or stream App logs.
157
+
158
+ By default, this command fetches the last 100 log entries and exits. Use ``-f`` to
159
+ live-stream logs from a running App instead. Fetch and follow are mutually exclusive.
93
160
 
94
161
  **Examples:**
95
162
 
96
- Get the logs based on an app ID:
163
+ Get recent logs based on an app ID:
97
164
 
98
165
  ```
99
- modal app logs ap-123456
166
+ modal app logs ap-123456 --tail 100
100
167
  ```
101
168
 
102
- Get the logs for a currently deployed App based on its name:
169
+ Get recent logs for a currently deployed App based on its name:
103
170
 
104
171
  ```
105
172
  modal app logs my-app
106
173
  ```
107
174
 
175
+ Follow (stream) logs from a running App:
176
+
177
+ ```
178
+ modal app logs my-app -f
179
+ ```
180
+
181
+ Fetch logs from the last 2 hours:
182
+
183
+ ```
184
+ modal app logs my-app --since 2h
185
+ ```
186
+
187
+ Fetch logs in a specific time range:
188
+
189
+ ```
190
+ modal app logs my-app --since 2026-03-01T05:00:00 --until 2026-03-01T08:00:00
191
+ ```
192
+
193
+ Filter the logs by source and function:
194
+
195
+ ```
196
+ modal app logs my-app --source stderr --function fu-abc123
197
+ ```
198
+
199
+ Include timestamps along with Function and Container IDs on each line:
200
+
201
+ ```
202
+ modal app logs my-app --timestamps --show-function-id --show-container-id
203
+ ```
204
+
108
205
  """
206
+ if not app_identifier:
207
+ raise UsageError("Either an App ID or name must be provided.")
208
+
209
+ if follow and (since or until or tail):
210
+ raise UsageError("--follow cannot be combined with --since, --until, or --tail.")
211
+
212
+ if tail is not None and tail <= 0:
213
+ raise UsageError("--tail value must be positive.")
214
+
215
+ if tail is not None and tail > _FETCH_LIMIT:
216
+ raise UsageError(f"--tail value must not exceed {_FETCH_LIMIT}.")
217
+
109
218
  app_id = get_app_id(app_identifier, env)
110
- stream_app_logs(app_id, show_timestamps=timestamps, follow=follow)
219
+
220
+ if source is not None:
221
+ if source not in _SOURCE_OPTIONS:
222
+ raise UsageError(f"Invalid source: '{source}'. Must be 'stdout', 'stderr', or 'system'.")
223
+ source_fd = _SOURCE_OPTIONS[source]
224
+ else:
225
+ source_fd = api_pb2.FILE_DESCRIPTOR_UNSPECIFIED
226
+
227
+ prefix_fields: list[str] = []
228
+ if show_function_id:
229
+ prefix_fields.append("fu")
230
+ if show_function_call_id:
231
+ prefix_fields.append("fc")
232
+ if show_container_id:
233
+ prefix_fields.append("ta")
234
+
235
+ log_filters = LogsFilters(
236
+ source=source_fd,
237
+ function_id=function_id or "",
238
+ function_call_id=function_call_id or "",
239
+ search_text=search or "",
240
+ )
241
+
242
+ if follow:
243
+ stream_app_logs(
244
+ app_id,
245
+ show_timestamps=timestamps,
246
+ follow=True,
247
+ prefix_fields=prefix_fields,
248
+ filters=log_filters,
249
+ )
250
+ else:
251
+ now = datetime.now(timezone.utc)
252
+ since_dt = _parse_time_arg(since, default=now) if since else None
253
+ until_dt = _parse_time_arg(until, default=now) if until else None
254
+
255
+ if since_dt is not None and until_dt is not None and since_dt >= until_dt:
256
+ raise UsageError("--since must be before --until.")
257
+
258
+ if since_dt is not None:
259
+ effective_until = until_dt or now
260
+ if effective_until - since_dt > _MAX_FETCH_RANGE:
261
+ raise UsageError(f"Log fetch time range cannot exceed {_MAX_FETCH_RANGE.days} days.")
262
+
263
+ if since and tail is None:
264
+ # Range mode: --since without --tail fetches everything in the range.
265
+ fetch_app_logs(
266
+ app_id,
267
+ since_dt,
268
+ until_dt or now,
269
+ show_timestamps=timestamps,
270
+ prefix_fields=prefix_fields,
271
+ filters=log_filters,
272
+ )
273
+ else:
274
+ # Tail mode: single fetch with limit.
275
+ # --since is a hard floor, --until shifts the anchor.
276
+ effective_tail = tail if tail is not None else _DEFAULT_LOGS_TAIL
277
+ tail_app_logs(
278
+ app_id,
279
+ effective_tail,
280
+ show_timestamps=timestamps,
281
+ since=since_dt,
282
+ until=until_dt,
283
+ prefix_fields=prefix_fields,
284
+ filters=log_filters,
285
+ )
111
286
 
112
287
 
113
288
  @app_cli.command("rollback", no_args_is_help=True, context_settings={"ignore_unknown_options": True})
modal/cli/utils.py CHANGED
@@ -4,6 +4,7 @@ import io
4
4
  from collections.abc import Sequence
5
5
  from contextlib import nullcontext
6
6
  from csv import writer as csv_writer
7
+ from datetime import datetime
7
8
  from json import dumps
8
9
  from typing import Optional, Union
9
10
 
@@ -13,7 +14,8 @@ from rich.text import Text
13
14
 
14
15
  from modal_proto import api_pb2
15
16
 
16
- from .._output.pty import get_app_logs_loop
17
+ from .._logs import LogsFilters, fetch_logs, tail_logs
18
+ from .._output.pty import _build_log_prefix, get_app_logs_loop
17
19
  from .._utils.async_utils import synchronizer
18
20
  from ..client import _Client
19
21
  from ..environments import ensure_env
@@ -29,7 +31,11 @@ async def stream_app_logs(
29
31
  app_logs_url: Optional[str] = None,
30
32
  show_timestamps: bool = False,
31
33
  follow: bool = False,
34
+ prefix_fields: Optional[list[str]] = None,
35
+ filters: Optional[LogsFilters] = None,
32
36
  ):
37
+ if filters is None:
38
+ filters = LogsFilters()
33
39
  client = await _Client.from_env()
34
40
  output_mgr = OutputManager.get()
35
41
  output_mgr.set_timestamps(show_timestamps)
@@ -52,11 +58,73 @@ async def stream_app_logs(
52
58
  task_id=task_id,
53
59
  sandbox_id=sandbox_id,
54
60
  follow=follow,
61
+ prefix_fields=prefix_fields or [],
62
+ file_descriptor=filters.source,
63
+ function_id=filters.function_id,
64
+ function_call_id=filters.function_call_id,
65
+ search_text=filters.search_text,
55
66
  )
56
67
  except (asyncio.CancelledError, KeyboardInterrupt):
57
68
  pass
58
69
 
59
70
 
71
+ async def _drain_batches(output_mgr, batches, prefixes, search_text=""):
72
+ """Iterate over log batches and display them via the output manager."""
73
+ last_data = ""
74
+ async for batch in batches:
75
+ for log in batch.items:
76
+ if log.data:
77
+ # Search results may lack trailing newlines
78
+ if search_text and not log.data.endswith("\n"):
79
+ log.data += "\n"
80
+ last_data = log.data
81
+ log_prefix = _build_log_prefix(batch, log, prefixes)
82
+ await output_mgr.put_fetched_log(log, prefix=log_prefix)
83
+ output_mgr.flush_lines()
84
+ # Ensure the terminal prompt starts on a new line
85
+ if last_data and not last_data.endswith("\n"):
86
+ output_mgr.print("")
87
+
88
+
89
+ @synchronizer.create_blocking
90
+ async def tail_app_logs(
91
+ app_id: str,
92
+ n: int,
93
+ show_timestamps: bool = False,
94
+ since: Optional[datetime] = None,
95
+ until: Optional[datetime] = None,
96
+ prefix_fields: Optional[list[str]] = None,
97
+ filters: Optional[LogsFilters] = None,
98
+ ):
99
+ """Fetch up to the last n log entries for an app."""
100
+ if filters is None:
101
+ filters = LogsFilters()
102
+ client = await _Client.from_env()
103
+ output_mgr = OutputManager.get()
104
+ output_mgr.set_timestamps(show_timestamps)
105
+ batches = tail_logs(client, app_id, n, since=since, until=until, filters=filters)
106
+ await _drain_batches(output_mgr, batches, prefix_fields or [], filters.search_text)
107
+
108
+
109
+ @synchronizer.create_blocking
110
+ async def fetch_app_logs(
111
+ app_id: str,
112
+ since: datetime,
113
+ until: datetime,
114
+ show_timestamps: bool = False,
115
+ prefix_fields: Optional[list[str]] = None,
116
+ filters: Optional[LogsFilters] = None,
117
+ ):
118
+ """Fetch historical logs for an app over a time range."""
119
+ if filters is None:
120
+ filters = LogsFilters()
121
+ client = await _Client.from_env()
122
+ output_mgr = OutputManager.get()
123
+ output_mgr.set_timestamps(show_timestamps)
124
+ batches = fetch_logs(client, app_id, since, until, filters=filters)
125
+ await _drain_batches(output_mgr, batches, prefix_fields or [], filters.search_text)
126
+
127
+
60
128
  @synchronizer.create_blocking
61
129
  async def get_app_id_from_name(name: str, env: Optional[str], client: Optional[_Client] = None) -> str:
62
130
  if client is None:
modal/client.pyi CHANGED
@@ -35,7 +35,7 @@ class _Client:
35
35
  server_url: str,
36
36
  client_type: int,
37
37
  credentials: typing.Optional[tuple[str, str]],
38
- version: str = "1.3.6.dev6",
38
+ version: str = "1.3.6.dev8",
39
39
  ):
40
40
  """mdmd:hidden
41
41
  The Modal client object is not intended to be instantiated directly by users.
@@ -171,7 +171,7 @@ class Client:
171
171
  server_url: str,
172
172
  client_type: int,
173
173
  credentials: typing.Optional[tuple[str, str]],
174
- version: str = "1.3.6.dev6",
174
+ version: str = "1.3.6.dev8",
175
175
  ):
176
176
  """mdmd:hidden
177
177
  The Modal client object is not intended to be instantiated directly by users.
modal/exception.py CHANGED
@@ -206,6 +206,10 @@ class VolumeUploadTimeoutError(TimeoutError):
206
206
  """Raised when a Volume upload times out."""
207
207
 
208
208
 
209
+ class LogsFetchError(Error):
210
+ """Raised when trying to fetch too many logs."""
211
+
212
+
209
213
  class InteractiveTimeoutError(TimeoutError):
210
214
  """Raised when interactive frontends time out while trying to connect to a container."""
211
215
 
modal/image.py CHANGED
@@ -388,7 +388,7 @@ async def _image_await_build_result(image_id: str, client: _Client) -> api_pb2.I
388
388
  assert task_log.task_progress.progress_type == api_pb2.IMAGE_SNAPSHOT_UPLOAD
389
389
  output_mgr.update_snapshot_progress(image_id, task_log.task_progress)
390
390
  elif task_log.data:
391
- await output_mgr.put_log_content(task_log)
391
+ await output_mgr.put_streaming_log(task_log)
392
392
  OutputManager.get().flush_lines()
393
393
 
394
394
  # Handle up to n exceptions while fetching logs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.3.6.dev6
3
+ Version: 1.3.6.dev8
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -9,6 +9,7 @@ modal/_grpc_client.py,sha256=G8gWQXChtsuRgyXK9kcPiPNYXOYbl_m9Bz2AVBJb-zs,6924
9
9
  modal/_ipython.py,sha256=Bu9fc3hm3xt2O47qLe_d-Mu2jS81LwMsmXNJ1sawBA0,575
10
10
  modal/_load_context.py,sha256=DvfOWNTevEmHqdlu0-AeO_03JChrmAjoupgppPVW2Pc,4867
11
11
  modal/_location.py,sha256=joiX-0ZeutEUDTrrqLF1GHXCdVLF-rHzstocbMcd_-k,366
12
+ modal/_logs.py,sha256=hQ09VfJiB3ic1QvAm85W7mJ3usoWLiZrlkM10j-B1qA,16966
12
13
  modal/_object.py,sha256=144YrNnTG77vK_PAHRU9GFxHBOlt2PWFgTUXpgX4BQk,14315
13
14
  modal/_partial_function.py,sha256=6HsV3xqwzmaTgytZ-CL5sNKIttxNjVQv_QgUF-sR20E,37489
14
15
  modal/_resolver.py,sha256=u9GdlHo3Ul_40B97LH0vSrjtEJB__WeRf-Oo79vSmPE,5516
@@ -25,7 +26,7 @@ modal/app.pyi,sha256=8lv2eLKP-y3BLT1irRnzdULSD45OgU4_VbCIYEy_Ic0,56387
25
26
  modal/billing.py,sha256=B2zXa7KeMnpqi2AQINycEk-KSXVYaLTTTAQTtuf2Gfs,257
26
27
  modal/call_graph.py,sha256=1g2DGcMIJvRy-xKicuf63IVE98gJSnQsr8R_NVMptNc,2581
27
28
  modal/client.py,sha256=-IxazxLhdkNFAHS2AP2FpyTVpCDVR58RF6ecSVV59r0,14862
28
- modal/client.pyi,sha256=cCVbhdJo__FcT48OAE1vDkMTJsSVELD6x_ztE6wuib4,14930
29
+ modal/client.pyi,sha256=LLr14WrQldwtchJ1O4Uxs-xsxuoMtZuWI-BB4qPvhls,14930
29
30
  modal/cloud_bucket_mount.py,sha256=KkaJVy5vpO_u2NtDYtv1JM-M3y4J0UtWBapTDKpQRcA,6034
30
31
  modal/cloud_bucket_mount.pyi,sha256=mGDeEd1XvxGpCE6O5jRBSfIvMXvZ5AtIybea5k-0w9w,7560
31
32
  modal/cls.py,sha256=ozx5ZVGyG6w5SQ1ouj9Y6M3yPtnpL9PfblnmqFzf694,42338
@@ -37,14 +38,14 @@ modal/dict.py,sha256=dOzPHAckMo5MgwlQckSTLv-TCvzOwM8AXbsehY48_OI,24102
37
38
  modal/dict.pyi,sha256=Cbyer90PA_H-S-PCbj9ZjvD6KknEsnH7CWDbcfy_d0Y,34085
38
39
  modal/environments.py,sha256=wMiMNpPkvIl_BdxRb81CY9Djzhczp_y4GEYKWp0JyBc,6135
39
40
  modal/environments.pyi,sha256=bMIxvwahckgaolGD3-hP2iovRHDXewvgbVs_nx4ze0o,3808
40
- modal/exception.py,sha256=YijJMGg2_dpr541d0_qYORZNhNsWVe7Djg8Lx5DBDOg,11363
41
+ modal/exception.py,sha256=AMvcFzlJxiFqaYe2An7fNgC4_2VFLUoNUDxr6ODwVkU,11447
41
42
  modal/file_io.py,sha256=AKTaKR4y2JO1vsmJ8tRxMhgJtHmtb-TCe1ZZYDte-w4,22221
42
43
  modal/file_io.pyi,sha256=DPaq0BhySaiCX3g56QHhyIMdU1izbfWsjpSp30JoJUU,15745
43
44
  modal/file_pattern_matcher.py,sha256=A_Kdkej6q7YQyhM_2-BvpFmPqJ0oHb54B6yf9VqvPVE,8116
44
45
  modal/functions.py,sha256=kcNHvqeGBxPI7Cgd57NIBBghkfbeFJzXO44WW0jSmao,325
45
46
  modal/functions.pyi,sha256=jnC5QZvfs_yPPwXmEoHO16xUXzX448aP6eWawVmeGko,36498
46
47
  modal/gpu.py,sha256=JW8C1hIObmXGduXWyJZysvAsLxyBEiaCmtnUAGFqh-8,6852
47
- modal/image.py,sha256=-ssy6tDTwCB_y8Bu-ULsXYArceXdQiEWEaTJZzJ9l9I,109358
48
+ modal/image.py,sha256=zgCZy0OZz010fRdlzOscD-cTbC-cCTT7mRWaANJRW7Y,109360
48
49
  modal/image.pyi,sha256=QAY8F2e2kkfQHOQnT2t4B1I-VbzvbNjksJHivx-ZA4k,78536
49
50
  modal/io_streams.py,sha256=JcYbvvjMZFugv53yHc9Id0N4nIvDMwV63ZtnN1bPmZc,30940
50
51
  modal/io_streams.pyi,sha256=aBxCcbHTprL08FLvPlkzu4bypCTT7xUdelNe5kc7OZY,18506
@@ -88,9 +89,9 @@ modal/token_flow.pyi,sha256=5zdXJ5SLLHM4j6xSPuEvnpSeEJBbzE6NkSX-MSyE5wM,2282
88
89
  modal/volume.py,sha256=W55RPwGQfSeuZjZEyslsCtLnK-WOFTaBJm9jMYWVOYw,55699
89
90
  modal/volume.pyi,sha256=34G4oA5WUL5Bs0kDlniUMuE_uWsxzbr9hLJFrY77yIo,56014
90
91
  modal/_output/__init__.py,sha256=xtGN6SPv2zvByrJ32JPao0yJKpxyCIZ-2JMTScrisuQ,28
91
- modal/_output/manager.py,sha256=sEi1udrEkJ7AIbuDtT2lS578uBimVyLxjzaALhDXoho,17204
92
- modal/_output/pty.py,sha256=hNfj7yHqgPCVV8oRrs9vLET4vdz4uE5Oo2OZc9rWTMw,8429
93
- modal/_output/rich.py,sha256=lnNvJUJZv56obSN-8_pqj5jYA7OIImEE9qpWRAP3Uw8,28100
92
+ modal/_output/manager.py,sha256=8JGUm8S1PfiibvyI1t_pcniktZbe8KAV1TvLtoZ80rk,18161
93
+ modal/_output/pty.py,sha256=AAxJsCm4__HCZL2AoPzZgii5zoNH-ldTSTiCX0Zfwi8,9888
94
+ modal/_output/rich.py,sha256=5pzIxJqlJSc1Fn02KnQq3VodslCtvo8dVpYitd85L0U,31920
94
95
  modal/_output/status.py,sha256=xk3sksl8bOftfnJSGz57IFDWtpM3YPJze-IzYaKT9DY,4520
95
96
  modal/_runtime/__init__.py,sha256=MIEP8jhXUeGq_eCjYFcqN5b1bxBM4fdk0VESpjWR0fc,28
96
97
  modal/_runtime/asgi.py,sha256=kaSjq_3bnWogx0YF24y1PscArt6Lx5INHnU9YIofg7A,22613
@@ -144,7 +145,7 @@ modal/builder/base-images.json,sha256=9zgY2-a2U_iEBBe60VNLQvErJQosRhQuXXRqcG6mHG
144
145
  modal/cli/__init__.py,sha256=6FRleWQxBDT19y7OayO4lBOzuL6Bs9r0rLINYYYbHwQ,769
145
146
  modal/cli/_download.py,sha256=5PQL3tkTJkG-Wb6I0vxfHY4L8kUzd4Fy83jlT6vh9Og,5062
146
147
  modal/cli/_traceback.py,sha256=fkqKcf4a6sBonha32NmBqGu5G_wqUVNBDpCcY94Hro0,6006
147
- modal/cli/app.py,sha256=yt5_49FU1urQWCesdvAcIcYExtCh3s-kqO7mzUZOgzA,8657
148
+ modal/cli/app.py,sha256=3EZ4PgevioGq9ZvKLya2tKyCKiYS0Bv0kedrGlVW0-g,14958
148
149
  modal/cli/billing.py,sha256=9Gj1H_Y8ziRAupJj90XhnPOw6nBMQfGU_Ys_ImheLYk,5690
149
150
  modal/cli/changelog.py,sha256=Ilzy1_3xF3y49w-_nwQkEVjoCupwdht7UuBrfV53nT0,5686
150
151
  modal/cli/cluster.py,sha256=FdCe4a0I_o9dT6Vi3r3k-19D64fOGEWFaPYFvBlnTBE,3337
@@ -164,7 +165,7 @@ modal/cli/secret.py,sha256=yBpjzzkbA2dNb_Q_2qqltMvCfhzUxDH7-xehLiqrvhI,8061
164
165
  modal/cli/selector.py,sha256=2P4fAgvntNLBJaMjZdDHHopCSoefHKpCMfKGzXzk9jc,4178
165
166
  modal/cli/shell.py,sha256=L5Dw8yeuu9-f6vNlBGbUT4oh9AVSK4GuB5J6m6vYLBk,12613
166
167
  modal/cli/token.py,sha256=KIEOgHJ-k1t2h6tolBmQJSSOJlpU3kvmH_IKQzaHhig,4098
167
- modal/cli/utils.py,sha256=sTpL7q0k8GOhg6mFSizyzliuoI01CpEIDLNDvnogvXg,3850
168
+ modal/cli/utils.py,sha256=aLTpttgH2lkOAC6HRtaAMCIKgo-zluvC_d-yQ3gsrz4,6462
168
169
  modal/cli/volume.py,sha256=Tp97ekMTEHT0NXwgyEudO8yIZ75OGOfjIfH44WS5r9Y,11026
169
170
  modal/cli/programs/__init__.py,sha256=svYKtV8HDwDCN86zbdWqyq5T8sMdGDj0PVlzc2tIxDM,28
170
171
  modal/cli/programs/run_jupyter.py,sha256=IJw8nds8Cjl9j4dxBqMGxhz-bIyVX0kle7jbt8HFOhM,2688
@@ -173,7 +174,7 @@ modal/experimental/__init__.py,sha256=4UyAPlExXYM0tJH7s6WSDt2dIfSVXvzARkRLsLMyuq
173
174
  modal/experimental/flash.py,sha256=ATGhVO8AleJ-TOe2PGolI5T7zfCEtj0kl_ivMPp7Rqg,33362
174
175
  modal/experimental/flash.pyi,sha256=R4h2nQl7-oiwqq_twCp0YqID_LnPjPGqxywXvxbiLIk,17011
175
176
  modal/experimental/ipython.py,sha256=TrCfmol9LGsRZMeDoeMPx3Hv3BFqQhYnmD_iH0pqdhk,2904
176
- modal-1.3.6.dev6.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
177
+ modal-1.3.6.dev8.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
177
178
  modal_docs/__init__.py,sha256=svYKtV8HDwDCN86zbdWqyq5T8sMdGDj0PVlzc2tIxDM,28
178
179
  modal_docs/gen_cli_docs.py,sha256=5sVABw4uGUqPXjcWT0Nqjkpe5KLs89C3tFnla58-D0M,3756
179
180
  modal_docs/gen_cli_docs_main.py,sha256=UPGr7O1DYTIRLwfINe2rbdh89J3cPVTU1OWL-SjxnOQ,198
@@ -195,10 +196,10 @@ modal_proto/task_command_router_pb2.py,sha256=uSSR8Y9Yp07cIL60ds7__wUr5rj1uL2CQY
195
196
  modal_proto/task_command_router_pb2.pyi,sha256=utNwipYgMZUEUuWBE7sJ5cNjRctozHKPKDRpLy6cPLM,25667
196
197
  modal_proto/task_command_router_pb2_grpc.py,sha256=YoinPeLc-uwFShig8gE0MluOi6n-qqJZQ3YvnKwKUz8,23194
197
198
  modal_proto/task_command_router_pb2_grpc.pyi,sha256=VUSE5l1O6ATPT8Wbj4M3faMiIy5vFxSoS5ZlKfTEpHc,7449
198
- modal_version/__init__.py,sha256=Tx1QZEugcyev19-iVVlRqrvkmoOpDp0QVPwiFC6pwDo,120
199
+ modal_version/__init__.py,sha256=TQcAaacjQbH68W3yWBnqkf13UFtTR4NEtj4t4iaD0kM,120
199
200
  modal_version/__main__.py,sha256=nLUbubxKx7GXNzJIkTB7M7_u8080LyLM0IS0rzFkC_c,119
200
- modal-1.3.6.dev6.dist-info/METADATA,sha256=oRFOtR0iYrXusm-oTYbjt2pJF92FhlQXHBCYp1EhzF8,2529
201
- modal-1.3.6.dev6.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
202
- modal-1.3.6.dev6.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
203
- modal-1.3.6.dev6.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
204
- modal-1.3.6.dev6.dist-info/RECORD,,
201
+ modal-1.3.6.dev8.dist-info/METADATA,sha256=LwbIIVZv_hDJcsQMQ4rillVUUikSOxTm_NB7O2kgv0w,2529
202
+ modal-1.3.6.dev8.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
203
+ modal-1.3.6.dev8.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
204
+ modal-1.3.6.dev8.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
205
+ modal-1.3.6.dev8.dist-info/RECORD,,
modal_version/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # Copyright Modal Labs 2026
2
2
  """Supplies the current version of the modal client library."""
3
3
 
4
- __version__ = "1.3.6.dev6"
4
+ __version__ = "1.3.6.dev8"