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 +498 -0
- modal/_output/manager.py +25 -4
- modal/_output/pty.py +41 -2
- modal/_output/rich.py +111 -25
- modal/cli/app.py +183 -8
- modal/cli/utils.py +69 -1
- modal/client.pyi +2 -2
- modal/exception.py +4 -0
- modal/image.py +1 -1
- {modal-1.3.6.dev6.dist-info → modal-1.3.6.dev8.dist-info}/METADATA +1 -1
- {modal-1.3.6.dev6.dist-info → modal-1.3.6.dev8.dist-info}/RECORD +16 -15
- modal_version/__init__.py +1 -1
- {modal-1.3.6.dev6.dist-info → modal-1.3.6.dev8.dist-info}/WHEEL +0 -0
- {modal-1.3.6.dev6.dist-info → modal-1.3.6.dev8.dist-info}/entry_points.txt +0 -0
- {modal-1.3.6.dev6.dist-info → modal-1.3.6.dev8.dist-info}/licenses/LICENSE +0 -0
- {modal-1.3.6.dev6.dist-info → modal-1.3.6.dev8.dist-info}/top_level.txt +0 -0
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
127
|
+
if line_prefix:
|
|
109
128
|
for i in range(0, len(chunks) - 1, 2):
|
|
110
|
-
chunks[i] = f"{
|
|
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
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
546
|
-
"""
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 ..
|
|
20
|
-
from .
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ..
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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=
|
|
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=
|
|
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
|
|
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=
|
|
92
|
-
modal/_output/pty.py,sha256=
|
|
93
|
-
modal/_output/rich.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
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=
|
|
199
|
+
modal_version/__init__.py,sha256=TQcAaacjQbH68W3yWBnqkf13UFtTR4NEtj4t4iaD0kM,120
|
|
199
200
|
modal_version/__main__.py,sha256=nLUbubxKx7GXNzJIkTB7M7_u8080LyLM0IS0rzFkC_c,119
|
|
200
|
-
modal-1.3.6.
|
|
201
|
-
modal-1.3.6.
|
|
202
|
-
modal-1.3.6.
|
|
203
|
-
modal-1.3.6.
|
|
204
|
-
modal-1.3.6.
|
|
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
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|