modal-progress 0.1.0__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_progress/__init__.py +7 -0
- modal_progress/__main__.py +63 -0
- modal_progress/agent.py +80 -0
- modal_progress/container.py +236 -0
- modal_progress/render.py +175 -0
- modal_progress/sim.py +49 -0
- modal_progress/state.py +102 -0
- modal_progress/watch.py +169 -0
- modal_progress-0.1.0.dist-info/METADATA +128 -0
- modal_progress-0.1.0.dist-info/RECORD +13 -0
- modal_progress-0.1.0.dist-info/WHEEL +4 -0
- modal_progress-0.1.0.dist-info/entry_points.txt +2 -0
- modal_progress-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""python -m modal_progress run app.py modal run, plus plain progress lines in logs
|
|
2
|
+
python -m modal_progress demo a simulated fan-out, no Modal account needed
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def demo(args: argparse.Namespace) -> None:
|
|
12
|
+
from .sim import simulate
|
|
13
|
+
from .watch import watch
|
|
14
|
+
|
|
15
|
+
fail = tuple(int(x) for x in args.fail.split(",") if x)
|
|
16
|
+
with watch(name="work", mode=args.mode, interval=args.interval) as w:
|
|
17
|
+
for _ in w.track(
|
|
18
|
+
simulate(inputs=args.inputs, containers=args.containers, steps=args.steps, step_time=args.step_time, fail=fail),
|
|
19
|
+
total=args.inputs,
|
|
20
|
+
):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run(rest: list[str], interval: float) -> None:
|
|
25
|
+
import modal.__main__
|
|
26
|
+
|
|
27
|
+
from .agent import install
|
|
28
|
+
|
|
29
|
+
install(interval=interval)
|
|
30
|
+
sys.argv = ["modal", "run", *rest]
|
|
31
|
+
modal.__main__.main()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv: list[str] | None = None) -> None:
|
|
35
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
36
|
+
# Everything after `run` goes to modal untouched, flags included.
|
|
37
|
+
if argv[:1] == ["run"]:
|
|
38
|
+
rest = argv[1:]
|
|
39
|
+
interval = 5.0
|
|
40
|
+
if rest[:1] == ["--interval"] and len(rest) > 1:
|
|
41
|
+
interval, rest = float(rest[1]), rest[2:]
|
|
42
|
+
return run(rest, interval)
|
|
43
|
+
|
|
44
|
+
parser = argparse.ArgumentParser(prog="modal-progress")
|
|
45
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
46
|
+
sub.add_parser("run", help="modal run with plain progress lines (args go to modal run)")
|
|
47
|
+
|
|
48
|
+
d = sub.add_parser("demo", help="simulate a fan-out locally")
|
|
49
|
+
d.add_argument("--inputs", type=int, default=60)
|
|
50
|
+
d.add_argument("--containers", type=int, default=6)
|
|
51
|
+
d.add_argument("--steps", type=int, default=40)
|
|
52
|
+
d.add_argument("--step-time", type=float, default=0.1)
|
|
53
|
+
d.add_argument("--fail", default="37", help="comma-separated inputs that raise")
|
|
54
|
+
d.add_argument("--mode", choices=["rich", "plain"], default=None)
|
|
55
|
+
d.add_argument("--interval", type=float, default=None, help="seconds between plain lines")
|
|
56
|
+
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
if args.cmd == "demo":
|
|
59
|
+
demo(args)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
sys.exit(main())
|
modal_progress/agent.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Plain map progress for `modal run` when nobody is at a terminal.
|
|
2
|
+
|
|
3
|
+
Modal tracks map progress with its own counter and draws it as a rich live
|
|
4
|
+
bar. When stdout is a file or a pipe, which is how a background agent runs
|
|
5
|
+
it, rich draws nothing until the end, so the log has no progress at all.
|
|
6
|
+
install() wraps that counter so it also prints a throttled plain line.
|
|
7
|
+
No change to the user's code is needed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from typing import Callable, TextIO
|
|
15
|
+
|
|
16
|
+
from .render import PREFIX
|
|
17
|
+
from .state import clock
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LineThrottle:
|
|
21
|
+
"""Turns (completed, total) updates into at most one line per interval."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, tag: str, interval: float = 5.0, out: TextIO | None = None, now=time.monotonic):
|
|
24
|
+
self.tag = tag
|
|
25
|
+
self.interval = interval
|
|
26
|
+
self.out = out
|
|
27
|
+
self.now = now
|
|
28
|
+
self.started = now()
|
|
29
|
+
self.last = self.started
|
|
30
|
+
self.last_line: tuple[int, int] | None = None
|
|
31
|
+
|
|
32
|
+
def __call__(self, completed: int, total: int) -> None:
|
|
33
|
+
t = self.now()
|
|
34
|
+
finished = total > 0 and completed >= total
|
|
35
|
+
if (completed, total) == self.last_line:
|
|
36
|
+
return
|
|
37
|
+
if not finished and t - self.last < self.interval:
|
|
38
|
+
return
|
|
39
|
+
self.last = t
|
|
40
|
+
self.last_line = (completed, total)
|
|
41
|
+
elapsed = t - self.started
|
|
42
|
+
if finished:
|
|
43
|
+
line = f"{PREFIX} {self.tag} {completed}/{total} done, {clock(elapsed)}"
|
|
44
|
+
else:
|
|
45
|
+
pct = f" ({100 * completed / total:.0f}%)" if total else ""
|
|
46
|
+
rate = completed / elapsed if elapsed > 0 else 0
|
|
47
|
+
# One finished input says little about the rest, so wait for 5%.
|
|
48
|
+
trusted = total and completed >= max(2, total * 0.05)
|
|
49
|
+
eta = f", eta {clock((total - completed) / rate)}" if rate > 0 and trusted else ""
|
|
50
|
+
line = f"{PREFIX} {self.tag} {completed}/{total} done{pct}, {clock(elapsed)} elapsed{eta}"
|
|
51
|
+
out = self.out or sys.stdout
|
|
52
|
+
out.write(line + "\n")
|
|
53
|
+
out.flush()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def install(interval: float = 5.0, manager_cls=None) -> Callable[[], None]:
|
|
57
|
+
"""Patches Modal's rich output manager. Returns a function that undoes it."""
|
|
58
|
+
if manager_cls is None:
|
|
59
|
+
from modal._output.rich import RichOutputManager as manager_cls
|
|
60
|
+
|
|
61
|
+
original = manager_cls.function_progress_callback
|
|
62
|
+
|
|
63
|
+
def function_progress_callback(self, tag: str, total: int | None):
|
|
64
|
+
update = original(self, tag, total)
|
|
65
|
+
if self.is_terminal:
|
|
66
|
+
return update
|
|
67
|
+
line = LineThrottle(tag, interval)
|
|
68
|
+
|
|
69
|
+
def both(completed: int, total: int) -> None:
|
|
70
|
+
update(completed, total)
|
|
71
|
+
line(completed, total)
|
|
72
|
+
|
|
73
|
+
return both
|
|
74
|
+
|
|
75
|
+
manager_cls.function_progress_callback = function_progress_callback
|
|
76
|
+
|
|
77
|
+
def undo() -> None:
|
|
78
|
+
manager_cls.function_progress_callback = original
|
|
79
|
+
|
|
80
|
+
return undo
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Container side: ship progress events to whoever is watching the map.
|
|
2
|
+
|
|
3
|
+
Events go to one named Modal Queue, in a partition keyed by the app ID, so
|
|
4
|
+
the caller can find them without any change to the function's signature.
|
|
5
|
+
A background thread batches them, keeping only the latest event per bar,
|
|
6
|
+
so a tight loop never waits on the network.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
QUEUE_NAME = "modal-progress"
|
|
18
|
+
PARTITION_TTL = 3600
|
|
19
|
+
FLUSH_EVERY = 0.25
|
|
20
|
+
|
|
21
|
+
# Set by watch() for in-process runs (the simulator and tests).
|
|
22
|
+
_local_sink: Callable[[dict], None] | None = None
|
|
23
|
+
_task_override = threading.local()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def set_local_sink(sink: Callable[[dict], None] | None) -> None:
|
|
27
|
+
global _local_sink
|
|
28
|
+
_local_sink = sink
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def disabled() -> bool:
|
|
32
|
+
return os.environ.get("MODAL_PROGRESS", "").lower() == "off"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def task_id() -> str:
|
|
36
|
+
override = getattr(_task_override, "task", None)
|
|
37
|
+
if override:
|
|
38
|
+
return override
|
|
39
|
+
return os.environ.get("MODAL_TASK_ID") or "local"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def input_id() -> str | None:
|
|
43
|
+
# Inside a container modal is already loaded. Elsewhere, importing it
|
|
44
|
+
# just to learn there is no input would cost a second.
|
|
45
|
+
modal = sys.modules.get("modal")
|
|
46
|
+
if modal is None:
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return modal.current_input_id()
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _remote_target():
|
|
55
|
+
"""The queue and partition key to send to, or (None, None) outside Modal."""
|
|
56
|
+
if os.environ.get("MODAL_IS_REMOTE") != "1":
|
|
57
|
+
return None, None
|
|
58
|
+
import modal
|
|
59
|
+
|
|
60
|
+
key = os.environ.get("MODAL_PROGRESS_KEY")
|
|
61
|
+
if not key:
|
|
62
|
+
app = modal.App._get_container_app()
|
|
63
|
+
key = app.app_id if app is not None else None
|
|
64
|
+
if not key:
|
|
65
|
+
return None, None
|
|
66
|
+
return modal.Queue.from_name(QUEUE_NAME, create_if_missing=True), key
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Sender:
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self._pending: dict[str, dict] = {}
|
|
72
|
+
self._lock = threading.Lock()
|
|
73
|
+
self._wake = threading.Event()
|
|
74
|
+
self._sent = threading.Condition(self._lock)
|
|
75
|
+
self._queued = 0 # events handed to send()
|
|
76
|
+
self._done = 0 # events that have left the process
|
|
77
|
+
self._thread: threading.Thread | None = None
|
|
78
|
+
self._target = None
|
|
79
|
+
|
|
80
|
+
def send(self, ev: dict) -> None:
|
|
81
|
+
with self._lock:
|
|
82
|
+
self._pending[ev["id"]] = ev
|
|
83
|
+
self._queued += 1
|
|
84
|
+
if self._thread is None:
|
|
85
|
+
self._thread = threading.Thread(target=self._run, name="modal-progress", daemon=True)
|
|
86
|
+
self._thread.start()
|
|
87
|
+
|
|
88
|
+
def flush(self, timeout: float = 2.0) -> None:
|
|
89
|
+
"""Waits until the events sent before this call have left the process.
|
|
90
|
+
|
|
91
|
+
Only those: other threads may keep sending, and waiting for an empty
|
|
92
|
+
buffer would stall every input while any container is busy.
|
|
93
|
+
"""
|
|
94
|
+
with self._lock:
|
|
95
|
+
target = self._queued
|
|
96
|
+
self._wake.set()
|
|
97
|
+
with self._sent:
|
|
98
|
+
self._sent.wait_for(lambda: self._done >= target, timeout=timeout)
|
|
99
|
+
|
|
100
|
+
def _run(self) -> None:
|
|
101
|
+
while True:
|
|
102
|
+
self._wake.wait(FLUSH_EVERY)
|
|
103
|
+
self._wake.clear()
|
|
104
|
+
with self._lock:
|
|
105
|
+
batch = list(self._pending.values())
|
|
106
|
+
self._pending.clear()
|
|
107
|
+
upto = self._queued
|
|
108
|
+
if batch:
|
|
109
|
+
self._put(batch)
|
|
110
|
+
with self._sent:
|
|
111
|
+
self._done = upto
|
|
112
|
+
self._sent.notify_all()
|
|
113
|
+
|
|
114
|
+
def _put(self, batch: list[dict]) -> None:
|
|
115
|
+
# Progress must never break the job, so every failure here is swallowed.
|
|
116
|
+
try:
|
|
117
|
+
if _local_sink is not None:
|
|
118
|
+
for ev in batch:
|
|
119
|
+
_local_sink(ev)
|
|
120
|
+
return
|
|
121
|
+
if self._target is None:
|
|
122
|
+
self._target = _remote_target()
|
|
123
|
+
queue, key = self._target
|
|
124
|
+
if queue is not None:
|
|
125
|
+
queue.put_many(batch, partition=key, partition_ttl=PARTITION_TTL)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_sender = _Sender()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class bar:
|
|
134
|
+
"""A tqdm-style bar that reports to the caller instead of a terminal.
|
|
135
|
+
|
|
136
|
+
for step in bar(range(100), desc=f"input {i}"):
|
|
137
|
+
...
|
|
138
|
+
|
|
139
|
+
Updates are sent at most once every `every` seconds, plus the first and
|
|
140
|
+
the last, so wrapping a fast loop costs almost nothing.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, iterable=None, total: float | None = None, desc: str = "", every: float = 0.5):
|
|
144
|
+
if total is None and iterable is not None:
|
|
145
|
+
try:
|
|
146
|
+
total = len(iterable)
|
|
147
|
+
except TypeError:
|
|
148
|
+
total = None
|
|
149
|
+
self.iterable = iterable
|
|
150
|
+
self.total = total
|
|
151
|
+
self.desc = desc
|
|
152
|
+
self.every = every
|
|
153
|
+
self.n: float = 0
|
|
154
|
+
self.closed = False
|
|
155
|
+
self.id = os.urandom(4).hex()
|
|
156
|
+
self._last = 0.0
|
|
157
|
+
self._off = disabled() # read once: os.environ costs a microsecond per lookup
|
|
158
|
+
self._emit(force=True)
|
|
159
|
+
|
|
160
|
+
def __iter__(self):
|
|
161
|
+
# The hot path, so the throttle check is inlined.
|
|
162
|
+
clock = time.monotonic
|
|
163
|
+
try:
|
|
164
|
+
for item in self.iterable:
|
|
165
|
+
yield item
|
|
166
|
+
self.n += 1
|
|
167
|
+
if not self._off and clock() - self._last >= self.every:
|
|
168
|
+
self._emit(force=True)
|
|
169
|
+
finally:
|
|
170
|
+
self.close()
|
|
171
|
+
|
|
172
|
+
def __enter__(self):
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def __exit__(self, *exc):
|
|
176
|
+
self.close()
|
|
177
|
+
|
|
178
|
+
def update(self, k: float = 1) -> None:
|
|
179
|
+
self.n += k
|
|
180
|
+
self._emit()
|
|
181
|
+
|
|
182
|
+
def set_description(self, desc: str) -> None:
|
|
183
|
+
self.desc = desc
|
|
184
|
+
self._emit()
|
|
185
|
+
|
|
186
|
+
def close(self) -> None:
|
|
187
|
+
if self.closed:
|
|
188
|
+
return
|
|
189
|
+
self.closed = True
|
|
190
|
+
self._emit(force=True)
|
|
191
|
+
if not self._off:
|
|
192
|
+
_sender.flush()
|
|
193
|
+
|
|
194
|
+
def _emit(self, force: bool = False) -> None:
|
|
195
|
+
if self._off:
|
|
196
|
+
return
|
|
197
|
+
now = time.monotonic()
|
|
198
|
+
if not force and now - self._last < self.every:
|
|
199
|
+
return
|
|
200
|
+
self._last = now
|
|
201
|
+
_sender.send(
|
|
202
|
+
{
|
|
203
|
+
"id": self.id,
|
|
204
|
+
"task": task_id(),
|
|
205
|
+
"input": input_id(),
|
|
206
|
+
"desc": self.desc,
|
|
207
|
+
"n": self.n,
|
|
208
|
+
"total": self.total,
|
|
209
|
+
"done": self.closed,
|
|
210
|
+
"t": time.time(),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_manual: dict[str | None, bar] = {}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def report(n: float, total: float | None = None, desc: str = "") -> None:
|
|
219
|
+
"""Sets progress for the current input directly, for loops bar() can't wrap.
|
|
220
|
+
|
|
221
|
+
The bar closes itself once n reaches total.
|
|
222
|
+
"""
|
|
223
|
+
key = input_id()
|
|
224
|
+
b = _manual.get(key)
|
|
225
|
+
if b is None or b.closed:
|
|
226
|
+
b = _manual[key] = bar(total=total, desc=desc)
|
|
227
|
+
if total is not None:
|
|
228
|
+
b.total = total
|
|
229
|
+
if desc:
|
|
230
|
+
b.desc = desc
|
|
231
|
+
b.n = n
|
|
232
|
+
if b.total and n >= b.total:
|
|
233
|
+
b.close()
|
|
234
|
+
_manual.pop(key, None)
|
|
235
|
+
else:
|
|
236
|
+
b._emit()
|
modal_progress/render.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Two ways to draw a State: plain lines for logs and agents, rich bars for people."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
from .state import State, clock
|
|
10
|
+
|
|
11
|
+
PREFIX = "[progress]"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def plain_line(state: State, now: float | None = None) -> str:
|
|
15
|
+
name = state.name
|
|
16
|
+
if state.finished is not None:
|
|
17
|
+
counts = f"{state.done}/{state.total}" if state.total else f"{state.done}"
|
|
18
|
+
return f"{PREFIX} {name} finished: {counts} done, {state.failed} failed, {clock(state.elapsed())}"
|
|
19
|
+
parts = []
|
|
20
|
+
if state.total:
|
|
21
|
+
pct = 100 * state.done / state.total
|
|
22
|
+
parts.append(f"{name} {state.done}/{state.total} done ({pct:.0f}%)")
|
|
23
|
+
else:
|
|
24
|
+
parts.append(f"{name} {state.done} done")
|
|
25
|
+
parts.append(f"{state.failed} failed")
|
|
26
|
+
if state.bars:
|
|
27
|
+
parts.append(f"{state.busy(now)} running")
|
|
28
|
+
parts.append(f"{clock(state.elapsed(now))} elapsed")
|
|
29
|
+
eta = state.eta(now)
|
|
30
|
+
if eta is not None:
|
|
31
|
+
parts.append(f"eta {clock(eta)}")
|
|
32
|
+
return f"{PREFIX} " + ", ".join(parts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PlainRenderer:
|
|
36
|
+
"""Prints one line every `interval` seconds, plus failures as they happen.
|
|
37
|
+
|
|
38
|
+
Lines have a fixed shape so an agent reading the log can grep them.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, state: State, lock: threading.Lock, out: TextIO | None = None, interval: float = 5.0):
|
|
42
|
+
self.state = state
|
|
43
|
+
self.lock = lock
|
|
44
|
+
self.out = out or sys.stdout
|
|
45
|
+
self.interval = interval
|
|
46
|
+
self._stop = threading.Event()
|
|
47
|
+
self._thread: threading.Thread | None = None
|
|
48
|
+
|
|
49
|
+
def start(self) -> None:
|
|
50
|
+
self._thread = threading.Thread(target=self._run, name="modal-progress-plain", daemon=True)
|
|
51
|
+
self._thread.start()
|
|
52
|
+
|
|
53
|
+
def _run(self) -> None:
|
|
54
|
+
while not self._stop.wait(self.interval):
|
|
55
|
+
self.tick()
|
|
56
|
+
|
|
57
|
+
def _write(self, line: str) -> None:
|
|
58
|
+
self.out.write(line + "\n")
|
|
59
|
+
self.out.flush()
|
|
60
|
+
|
|
61
|
+
def tick(self, now: float | None = None) -> None:
|
|
62
|
+
with self.lock:
|
|
63
|
+
line = plain_line(self.state, now)
|
|
64
|
+
self._write(line)
|
|
65
|
+
|
|
66
|
+
def failure(self, error: str) -> None:
|
|
67
|
+
self._write(f"{PREFIX} {self.state.name}: an input failed: {error}")
|
|
68
|
+
|
|
69
|
+
def close(self) -> None:
|
|
70
|
+
self._stop.set()
|
|
71
|
+
if self._thread is not None:
|
|
72
|
+
self._thread.join(timeout=1)
|
|
73
|
+
self.tick()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def rich_view(state: State, now: float | None = None):
|
|
77
|
+
"""One overall bar, then one row per container that is still reporting."""
|
|
78
|
+
from rich.progress_bar import ProgressBar
|
|
79
|
+
from rich.table import Table
|
|
80
|
+
from rich.text import Text
|
|
81
|
+
|
|
82
|
+
grid = Table.grid(padding=(0, 1))
|
|
83
|
+
grid.add_column(no_wrap=True)
|
|
84
|
+
grid.add_column(no_wrap=True, min_width=14)
|
|
85
|
+
grid.add_column(width=32)
|
|
86
|
+
grid.add_column(justify="right", no_wrap=True)
|
|
87
|
+
grid.add_column(no_wrap=True)
|
|
88
|
+
|
|
89
|
+
total = state.total or max(state.done, 1)
|
|
90
|
+
eta = state.eta(now)
|
|
91
|
+
tail = Text(clock(state.elapsed(now)), style="cyan")
|
|
92
|
+
if eta is not None:
|
|
93
|
+
tail.append(f" eta {clock(eta)}", style="dim")
|
|
94
|
+
if state.failed:
|
|
95
|
+
tail.append(f" {state.failed} failed", style="red")
|
|
96
|
+
grid.add_row(
|
|
97
|
+
Text(state.name, style="bold"),
|
|
98
|
+
Text(f"{state.busy(now)} running", style="dim"),
|
|
99
|
+
ProgressBar(total=total, completed=state.effective(now), width=32, complete_style="green"),
|
|
100
|
+
Text(f"{state.done}/{state.total if state.total else '?'}"),
|
|
101
|
+
tail,
|
|
102
|
+
)
|
|
103
|
+
for b in state.active(now):
|
|
104
|
+
short = b.task[-6:]
|
|
105
|
+
bar = ProgressBar(
|
|
106
|
+
total=b.total or 1,
|
|
107
|
+
completed=b.n if b.total else 0,
|
|
108
|
+
pulse=not b.total and not b.done,
|
|
109
|
+
width=32,
|
|
110
|
+
complete_style="grey50" if b.done else "magenta",
|
|
111
|
+
)
|
|
112
|
+
count = f"{b.n:g}/{b.total:g}" if b.total else f"{b.n:g}"
|
|
113
|
+
grid.add_row(
|
|
114
|
+
Text(f" {short}", style="dim"),
|
|
115
|
+
Text(b.desc or "", style="grey50" if b.done else ""),
|
|
116
|
+
bar,
|
|
117
|
+
Text(count, style="dim"),
|
|
118
|
+
Text("idle" if b.done else "", style="dim"),
|
|
119
|
+
)
|
|
120
|
+
return grid
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class RichRenderer:
|
|
124
|
+
"""Live bars in a terminal.
|
|
125
|
+
|
|
126
|
+
Under `modal run`, Modal already has a live display on screen, and two
|
|
127
|
+
live displays fight over the cursor. So the view joins Modal's own
|
|
128
|
+
display when there is one, and opens its own otherwise.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, state: State, lock: threading.Lock, console=None):
|
|
132
|
+
self.state = state
|
|
133
|
+
self.lock = lock
|
|
134
|
+
self.console = console
|
|
135
|
+
self._live = None
|
|
136
|
+
self._group = None
|
|
137
|
+
|
|
138
|
+
def __rich__(self):
|
|
139
|
+
with self.lock:
|
|
140
|
+
return rich_view(self.state)
|
|
141
|
+
|
|
142
|
+
def start(self) -> None:
|
|
143
|
+
self._group = _modal_render_group()
|
|
144
|
+
if self._group is not None:
|
|
145
|
+
self._group.renderables.append(self)
|
|
146
|
+
return
|
|
147
|
+
from rich.live import Live
|
|
148
|
+
|
|
149
|
+
self._live = Live(self, console=self.console, refresh_per_second=4, transient=False)
|
|
150
|
+
self._live.start()
|
|
151
|
+
|
|
152
|
+
def failure(self, error: str) -> None:
|
|
153
|
+
pass # the failed count is on the overall row
|
|
154
|
+
|
|
155
|
+
def close(self) -> None:
|
|
156
|
+
if self._live is not None:
|
|
157
|
+
self._live.stop()
|
|
158
|
+
return
|
|
159
|
+
if self._group is not None and self in self._group.renderables:
|
|
160
|
+
self._group.renderables.remove(self)
|
|
161
|
+
from rich.console import Console
|
|
162
|
+
|
|
163
|
+
with self.lock:
|
|
164
|
+
line = plain_line(self.state)
|
|
165
|
+
(self.console or Console()).print(line, highlight=False)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _modal_render_group():
|
|
169
|
+
"""Modal's live render group, if a `modal run` display is up. Private API, so guarded."""
|
|
170
|
+
try:
|
|
171
|
+
from modal._output.manager import OutputManager
|
|
172
|
+
|
|
173
|
+
return getattr(OutputManager.get(), "_current_render_group", None)
|
|
174
|
+
except Exception:
|
|
175
|
+
return None
|
modal_progress/sim.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""A fake fan-out that runs on threads, so the display can be tried without Modal.
|
|
2
|
+
|
|
3
|
+
Each thread plays one container: it pulls inputs, walks a bar() for each,
|
|
4
|
+
and raises on the inputs listed in `fail`. Results come back in completion
|
|
5
|
+
order, like `.map(..., order_outputs=False, return_exceptions=True)`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
14
|
+
from typing import Iterator
|
|
15
|
+
|
|
16
|
+
from . import container
|
|
17
|
+
from .container import bar
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def simulate(
|
|
21
|
+
inputs: int = 60,
|
|
22
|
+
containers: int = 6,
|
|
23
|
+
steps: int = 40,
|
|
24
|
+
step_time: float = 0.1,
|
|
25
|
+
fail: tuple[int, ...] = (37,),
|
|
26
|
+
seed: int = 0,
|
|
27
|
+
) -> Iterator:
|
|
28
|
+
rng = random.Random(seed)
|
|
29
|
+
# Some inputs are slower than others, as in a real map.
|
|
30
|
+
speed = [rng.uniform(0.6, 1.6) for _ in range(inputs)]
|
|
31
|
+
names = iter(f"sim-{k:02d}-{rng.getrandbits(24):06x}" for k in range(containers))
|
|
32
|
+
names_lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def boot():
|
|
35
|
+
with names_lock:
|
|
36
|
+
container._task_override.task = next(names)
|
|
37
|
+
|
|
38
|
+
def work(i: int) -> int:
|
|
39
|
+
for step in bar(range(steps), desc=f"input {i}", every=0.2):
|
|
40
|
+
time.sleep(step_time * speed[i])
|
|
41
|
+
if i in fail and step == steps * 2 // 3:
|
|
42
|
+
raise ValueError(f"bad input {i}")
|
|
43
|
+
return i * i
|
|
44
|
+
|
|
45
|
+
with ThreadPoolExecutor(max_workers=containers, initializer=boot) as pool:
|
|
46
|
+
futures = [pool.submit(work, i) for i in range(inputs)]
|
|
47
|
+
for f in as_completed(futures):
|
|
48
|
+
e = f.exception()
|
|
49
|
+
yield e if e is not None else f.result()
|
modal_progress/state.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Folds progress events into one snapshot that the renderers draw."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
# A container that has sent nothing for this long drops off the display.
|
|
9
|
+
STALE_AFTER = 15.0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Bar:
|
|
14
|
+
id: str
|
|
15
|
+
task: str
|
|
16
|
+
desc: str = ""
|
|
17
|
+
n: float = 0
|
|
18
|
+
total: float | None = None
|
|
19
|
+
done: bool = False
|
|
20
|
+
seen: float = 0.0
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def fraction(self) -> float | None:
|
|
24
|
+
if not self.total:
|
|
25
|
+
return None
|
|
26
|
+
return max(0.0, min(self.n / self.total, 1.0))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class State:
|
|
31
|
+
name: str = "map"
|
|
32
|
+
total: int | None = None
|
|
33
|
+
done: int = 0
|
|
34
|
+
failed: int = 0
|
|
35
|
+
started: float = field(default_factory=time.monotonic)
|
|
36
|
+
finished: float | None = None
|
|
37
|
+
bars: dict[str, Bar] = field(default_factory=dict) # latest bar per container
|
|
38
|
+
first_seen: dict[str, float] = field(default_factory=dict)
|
|
39
|
+
last_error: str | None = None
|
|
40
|
+
|
|
41
|
+
def apply(self, ev: dict, now: float | None = None) -> None:
|
|
42
|
+
"""Takes one event sent by bar() inside a container."""
|
|
43
|
+
now = time.monotonic() if now is None else now
|
|
44
|
+
task = ev.get("task") or "local"
|
|
45
|
+
self.first_seen.setdefault(task, now)
|
|
46
|
+
bar = self.bars.get(task)
|
|
47
|
+
if bar is None or bar.id != ev["id"]:
|
|
48
|
+
bar = self.bars[task] = Bar(id=ev["id"], task=task)
|
|
49
|
+
bar.desc = ev.get("desc") or ""
|
|
50
|
+
bar.n = ev.get("n", 0)
|
|
51
|
+
bar.total = ev.get("total")
|
|
52
|
+
bar.done = bool(ev.get("done"))
|
|
53
|
+
bar.seen = now
|
|
54
|
+
|
|
55
|
+
def result(self, ok: bool, error: str | None = None) -> None:
|
|
56
|
+
"""Counts one finished input from the map's result stream."""
|
|
57
|
+
self.done += 1
|
|
58
|
+
if not ok:
|
|
59
|
+
self.failed += 1
|
|
60
|
+
self.last_error = error
|
|
61
|
+
|
|
62
|
+
def active(self, now: float | None = None) -> list[Bar]:
|
|
63
|
+
"""Bars from containers that are still sending, oldest container first."""
|
|
64
|
+
now = time.monotonic() if now is None else now
|
|
65
|
+
live = [b for b in self.bars.values() if now - b.seen < STALE_AFTER]
|
|
66
|
+
return sorted(live, key=lambda b: self.first_seen[b.task])
|
|
67
|
+
|
|
68
|
+
def busy(self, now: float | None = None) -> int:
|
|
69
|
+
return sum(1 for b in self.active(now) if not b.done)
|
|
70
|
+
|
|
71
|
+
def elapsed(self, now: float | None = None) -> float:
|
|
72
|
+
end = self.finished if self.finished is not None else (time.monotonic() if now is None else now)
|
|
73
|
+
return max(0.0, end - self.started)
|
|
74
|
+
|
|
75
|
+
def effective(self, now: float | None = None) -> float:
|
|
76
|
+
"""Finished inputs plus the finished share of the ones still running."""
|
|
77
|
+
partial = sum(b.fraction or 0.0 for b in self.active(now) if not b.done)
|
|
78
|
+
value = self.done + partial
|
|
79
|
+
return min(value, self.total) if self.total else value
|
|
80
|
+
|
|
81
|
+
def eta(self, now: float | None = None) -> float | None:
|
|
82
|
+
# Too early to say until 5% of inputs are back.
|
|
83
|
+
if not self.total or self.finished is not None or self.done < max(1, self.total * 0.05):
|
|
84
|
+
return None
|
|
85
|
+
now = time.monotonic() if now is None else now
|
|
86
|
+
progress = self.effective(now)
|
|
87
|
+
# Time from the first container event, so the cold start doesn't count as work.
|
|
88
|
+
began = min(self.first_seen.values(), default=self.started)
|
|
89
|
+
working = now - began
|
|
90
|
+
if progress <= 0 or working <= 0:
|
|
91
|
+
return None
|
|
92
|
+
return max(0.0, (self.total - progress) * working / progress)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def clock(seconds: float | None) -> str:
|
|
96
|
+
"""0:07, 3:41, 1:02:09."""
|
|
97
|
+
if seconds is None:
|
|
98
|
+
return "?"
|
|
99
|
+
s = int(round(seconds))
|
|
100
|
+
h, s = divmod(s, 3600)
|
|
101
|
+
m, s = divmod(s, 60)
|
|
102
|
+
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
modal_progress/watch.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Caller side: collect progress from the containers and draw it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Iterable, Iterator, TextIO
|
|
11
|
+
|
|
12
|
+
from . import container
|
|
13
|
+
from .render import PlainRenderer, RichRenderer
|
|
14
|
+
from .state import State
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pick_mode(mode: str | None = None, out: TextIO | None = None) -> str:
|
|
18
|
+
"""rich in a terminal, plain lines anywhere else. MODAL_PROGRESS overrides."""
|
|
19
|
+
mode = mode or os.environ.get("MODAL_PROGRESS") or ""
|
|
20
|
+
if mode in ("rich", "plain", "off"):
|
|
21
|
+
return mode
|
|
22
|
+
stream = out or sys.stdout
|
|
23
|
+
return "rich" if getattr(stream, "isatty", lambda: False)() else "plain"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Watcher:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
app=None,
|
|
30
|
+
name: str = "map",
|
|
31
|
+
mode: str | None = None,
|
|
32
|
+
interval: float | None = None,
|
|
33
|
+
out: TextIO | None = None,
|
|
34
|
+
record: str | None = None,
|
|
35
|
+
):
|
|
36
|
+
self.app = app
|
|
37
|
+
self.state = State(name=name)
|
|
38
|
+
self.lock = threading.Lock()
|
|
39
|
+
self.mode = pick_mode(mode, out)
|
|
40
|
+
interval = interval or float(os.environ.get("MODAL_PROGRESS_INTERVAL", 5))
|
|
41
|
+
if self.mode == "rich":
|
|
42
|
+
self.renderer = RichRenderer(self.state, self.lock)
|
|
43
|
+
elif self.mode == "plain":
|
|
44
|
+
self.renderer = PlainRenderer(self.state, self.lock, out=out, interval=interval)
|
|
45
|
+
else:
|
|
46
|
+
self.renderer = None
|
|
47
|
+
self._stop = threading.Event()
|
|
48
|
+
self._drain: threading.Thread | None = None
|
|
49
|
+
self._queue = None
|
|
50
|
+
self._key: str | None = None
|
|
51
|
+
self._record = open(record, "w", encoding="utf8") if record else None
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> "Watcher":
|
|
54
|
+
self.state.started = time.monotonic()
|
|
55
|
+
container.set_local_sink(self.feed)
|
|
56
|
+
key = getattr(self.app, "app_id", None) if self.app is not None else None
|
|
57
|
+
if key:
|
|
58
|
+
import modal
|
|
59
|
+
|
|
60
|
+
self._key = key
|
|
61
|
+
self._queue = modal.Queue.from_name(container.QUEUE_NAME, create_if_missing=True)
|
|
62
|
+
self._drain = threading.Thread(target=self._drain_loop, name="modal-progress-drain", daemon=True)
|
|
63
|
+
self._drain.start()
|
|
64
|
+
if self.renderer:
|
|
65
|
+
self.renderer.start()
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __exit__(self, *exc) -> None:
|
|
69
|
+
container.set_local_sink(None)
|
|
70
|
+
if self._drain is not None:
|
|
71
|
+
self._stop.set()
|
|
72
|
+
self._drain.join(timeout=3)
|
|
73
|
+
try:
|
|
74
|
+
# Containers flush before returning, so the last events can still be queued.
|
|
75
|
+
for ev in self._queue.get_many(1024, block=False, partition=self._key):
|
|
76
|
+
self.feed(ev)
|
|
77
|
+
self._queue.clear(partition=self._key)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
with self.lock:
|
|
81
|
+
self.state.finished = time.monotonic()
|
|
82
|
+
if self.renderer:
|
|
83
|
+
self.renderer.close()
|
|
84
|
+
if self._record:
|
|
85
|
+
self._write({"end": True, "done": self.state.done, "failed": self.state.failed})
|
|
86
|
+
self._record.close()
|
|
87
|
+
|
|
88
|
+
def _write(self, row: dict) -> None:
|
|
89
|
+
"""One JSON line per event, timed from the start. Call with the lock held."""
|
|
90
|
+
if self._record:
|
|
91
|
+
row["at"] = round(time.monotonic() - self.state.started, 3)
|
|
92
|
+
row["wall"] = round(time.time(), 3) # container clocks can differ from ours by seconds
|
|
93
|
+
self._record.write(json.dumps(row) + "\n")
|
|
94
|
+
|
|
95
|
+
def feed(self, ev: dict) -> None:
|
|
96
|
+
with self.lock:
|
|
97
|
+
self.state.apply(ev)
|
|
98
|
+
self._write({"ev": ev})
|
|
99
|
+
|
|
100
|
+
def _drain_loop(self) -> None:
|
|
101
|
+
while not self._stop.is_set():
|
|
102
|
+
try:
|
|
103
|
+
batch = self._queue.get_many(256, timeout=1.0, partition=self._key)
|
|
104
|
+
except Exception:
|
|
105
|
+
time.sleep(1.0)
|
|
106
|
+
continue
|
|
107
|
+
for ev in batch:
|
|
108
|
+
self.feed(ev)
|
|
109
|
+
|
|
110
|
+
def track(self, results: Iterable, total: int | None = None, name: str | None = None) -> Iterator:
|
|
111
|
+
"""Passes results through and counts them. Exceptions count as failures.
|
|
112
|
+
|
|
113
|
+
for r in w.track(work.map(xs, return_exceptions=True), total=len(xs)):
|
|
114
|
+
...
|
|
115
|
+
"""
|
|
116
|
+
if total is None:
|
|
117
|
+
try:
|
|
118
|
+
total = len(results) # type: ignore[arg-type]
|
|
119
|
+
except TypeError:
|
|
120
|
+
pass
|
|
121
|
+
with self.lock:
|
|
122
|
+
self.state.total = total
|
|
123
|
+
if name:
|
|
124
|
+
self.state.name = name
|
|
125
|
+
self._write({"start": True, "name": self.state.name, "total": total})
|
|
126
|
+
it = iter(results)
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
r = next(it)
|
|
130
|
+
except StopIteration:
|
|
131
|
+
return
|
|
132
|
+
except Exception as e:
|
|
133
|
+
self._result(False, _describe(e))
|
|
134
|
+
raise
|
|
135
|
+
if isinstance(r, BaseException):
|
|
136
|
+
self._result(False, _describe(r))
|
|
137
|
+
else:
|
|
138
|
+
self._result(True)
|
|
139
|
+
yield r
|
|
140
|
+
|
|
141
|
+
def _result(self, ok: bool, error: str | None = None) -> None:
|
|
142
|
+
with self.lock:
|
|
143
|
+
self.state.result(ok, error)
|
|
144
|
+
self._write({"result": ok, "error": error})
|
|
145
|
+
if not ok and self.renderer:
|
|
146
|
+
self.renderer.failure(error or "")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _describe(e: BaseException) -> str:
|
|
150
|
+
text = str(e).strip().splitlines()
|
|
151
|
+
return f"{type(e).__name__}: {text[-1]}" if text else type(e).__name__
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def watch(
|
|
155
|
+
app=None,
|
|
156
|
+
name: str = "map",
|
|
157
|
+
mode: str | None = None,
|
|
158
|
+
interval: float | None = None,
|
|
159
|
+
out=None,
|
|
160
|
+
record: str | None = None,
|
|
161
|
+
) -> Watcher:
|
|
162
|
+
"""Shows live progress for a map while the block runs.
|
|
163
|
+
|
|
164
|
+
with watch(app, name="work") as w:
|
|
165
|
+
results = list(w.track(work.map(xs, return_exceptions=True)))
|
|
166
|
+
|
|
167
|
+
`record` writes every event to a JSON lines file, for replaying the run later.
|
|
168
|
+
"""
|
|
169
|
+
return Watcher(app=app, name=name, mode=mode, interval=interval, out=out, record=record)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: modal-progress
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Live progress from inside Modal containers, for people at a terminal and agents reading a log
|
|
5
|
+
Project-URL: Homepage, https://github.com/EnesYilmazcode/modal-progress
|
|
6
|
+
Author: Enes Yilmaz
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: modal>=1.0
|
|
11
|
+
Requires-Dist: rich>=13
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
<h1 align="center">modal-progress</h1>
|
|
17
|
+
|
|
18
|
+
<p align="center"><b>Live progress from inside Modal containers.</b><br>
|
|
19
|
+
Progress bars per container in a terminal, plain progress lines in a log file.</p>
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="https://raw.githubusercontent.com/EnesYilmazcode/modal-progress/main/assets/media/with-and-without.gif" width="820" alt="The same 60-input Modal job logged to a file: without modal-progress the log is silent until the end, with it there is a progress line every 5 seconds">
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
`modal run` draws `.map` progress as a live terminal bar. When the output goes to a file or a pipe, which is how coding agents run commands, nothing is printed until the job ends, so there is no way to tell a slow job from a stuck one. modal-progress prints a plain line every few seconds instead, and lets each container report its own progress.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install git+https://github.com/EnesYilmazcode/modal-progress
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Python 3.10+ and modal 1.x.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### No code changes
|
|
38
|
+
|
|
39
|
+
Use `modal-progress run` in place of `modal run`. Arguments are passed through unchanged.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
modal-progress run app.py > log.txt
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
[progress] work 11/60 done (18%), 0:13 elapsed, eta 1:00
|
|
47
|
+
[progress] work 19/60 done (32%), 0:20 elapsed, eta 0:44
|
|
48
|
+
[progress] work 29/60 done (48%), 0:25 elapsed, eta 0:27
|
|
49
|
+
[progress] work 60/60 done, 0:47
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
In a terminal it behaves exactly like `modal run`.
|
|
53
|
+
|
|
54
|
+
### Progress from inside each container
|
|
55
|
+
|
|
56
|
+
Wrap a loop in `bar()` inside the function, and wrap the map in `watch()` on the caller's side.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import modal
|
|
60
|
+
from modal_progress import bar, watch
|
|
61
|
+
|
|
62
|
+
app = modal.App("example")
|
|
63
|
+
image = modal.Image.debian_slim().add_local_python_source("modal_progress")
|
|
64
|
+
|
|
65
|
+
@app.function(image=image)
|
|
66
|
+
def work(i: int) -> int:
|
|
67
|
+
for step in bar(range(40), desc=f"input {i}"):
|
|
68
|
+
... # one step of work
|
|
69
|
+
return i
|
|
70
|
+
|
|
71
|
+
@app.local_entrypoint()
|
|
72
|
+
def main():
|
|
73
|
+
xs = range(60)
|
|
74
|
+
with watch(app, name="work") as w:
|
|
75
|
+
results = list(w.track(work.map(xs, return_exceptions=True), total=len(xs)))
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
In a terminal, this shows an overall bar plus one bar per container. In a log file, it prints:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
[progress] work 20/60 done (33%), 0 failed, 6 running, 0:25 elapsed, eta 0:31
|
|
82
|
+
[progress] work: an input failed: ValueError: bad input 37
|
|
83
|
+
[progress] work 40/60 done (67%), 1 failed, 6 running, 0:40 elapsed, eta 0:14
|
|
84
|
+
[progress] work finished: 60/60 done, 1 failed, 0:57
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Failures are reported the moment they happen. Every line starts with `[progress]`, so it can be filtered with `grep`. A full example is in [`examples/fanout.py`](https://github.com/EnesYilmazcode/modal-progress/blob/main/examples/fanout.py).
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
| Call | Where | What it does |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `bar(iterable=None, total=None, desc="", every=0.5)` | container | tqdm-style wrapper. Also supports `update(n)`, `set_description(s)`, `close()`, and `with`. Sends at most one update every `every` seconds. |
|
|
94
|
+
| `report(n, total=None, desc="")` | container | Sets progress for the current input directly, for loops `bar()` can't wrap. |
|
|
95
|
+
| `watch(app, name="map", mode=None, interval=None, record=None)` | caller | Context manager that collects and draws progress. `record` writes every event to a JSON lines file. |
|
|
96
|
+
| `w.track(results, total=None)` | caller | Passes map results through and counts them. Exceptions count as failures. |
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
| Variable | Values | Default |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `MODAL_PROGRESS` | `rich`, `plain`, `off` | `rich` in a terminal, `plain` otherwise. `off` also stops containers from sending. |
|
|
103
|
+
| `MODAL_PROGRESS_INTERVAL` | seconds between plain lines | `5` |
|
|
104
|
+
|
|
105
|
+
## How it works
|
|
106
|
+
|
|
107
|
+
Each container sends its latest progress to a [Modal Queue](https://modal.com/docs/reference/modal.Queue) named `modal-progress`, in a partition keyed by the app ID, batched every 0.25 seconds from a background thread. `watch()` reads that partition and combines it with the map's results as they come back. A `bar()` update costs about 0.2 µs, so it can wrap tight loops.
|
|
108
|
+
|
|
109
|
+
`modal-progress run` needs no queue. It hooks Modal's own count of finished inputs and prints it as plain lines when stdout isn't a terminal.
|
|
110
|
+
|
|
111
|
+
## Limitations
|
|
112
|
+
|
|
113
|
+
- Uses three private Modal internals: `App._get_container_app()` to find the app ID in a container, Modal's live display to add the bars to it, and its map progress callback for `modal-progress run`. All are guarded, so if one changes you lose progress output, not the job. Tested on modal 1.6.1.
|
|
114
|
+
- Works with `modal run` and `app.run()`. A deployed function called from a different app reports to its own app's partition, so the caller won't see it.
|
|
115
|
+
- Progress shows up within about a second of happening.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pip install -e ".[dev]"
|
|
121
|
+
python -m pytest -q
|
|
122
|
+
python -m modal_progress demo # simulated map, no Modal account needed
|
|
123
|
+
modal run examples/fanout.py # real map on Modal
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
modal_progress/__init__.py,sha256=7aWWPQbZMAy6Y7ODzBVzCj36-gp4Oqvn0QecahkFwIg,191
|
|
2
|
+
modal_progress/__main__.py,sha256=DLFH90R936iFjfUyB0KOA2jWeQZuTsh0_MB3Oh4aZ-Q,2169
|
|
3
|
+
modal_progress/agent.py,sha256=tDxYagqerJ4Fp6G4etubKJBmz2eU4gUUbBfmbrZa20k,2895
|
|
4
|
+
modal_progress/container.py,sha256=4mrXP2iV1AWcfHm7D-zk_BEYDrOb15QOy018dyPAQu8,6996
|
|
5
|
+
modal_progress/render.py,sha256=d68VJPVY8RA6ChOCI30mOB2pAe9mfA34BuNCedr5Ix0,5976
|
|
6
|
+
modal_progress/sim.py,sha256=6ixrSfIiX3jdIncUWU2rr2oIxQn6SJKiLyH_VWa8Imo,1615
|
|
7
|
+
modal_progress/state.py,sha256=KySVHSkCsA3Fg8miOX_tPZW0JzXiqvS_KAG-jqIjHsU,3696
|
|
8
|
+
modal_progress/watch.py,sha256=vhAYNkQRn5c2qSd1Fz18dM40dk1T-07zxa4wuJFTFDs,5955
|
|
9
|
+
modal_progress-0.1.0.dist-info/METADATA,sha256=qHKv8G04tuPrHTd79Gz7IdkKSRa75xy34gjXRx_jdl0,5385
|
|
10
|
+
modal_progress-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
modal_progress-0.1.0.dist-info/entry_points.txt,sha256=r_eaioXawBOcksEHOi6ld5wVd7aQG-CAb2HYToznSBw,64
|
|
12
|
+
modal_progress-0.1.0.dist-info/licenses/LICENSE,sha256=l2BoUGp77thMwWpA0qNXlg1_3PEyxbFLV3Xx8wkxHpw,1068
|
|
13
|
+
modal_progress-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Enes Yilmaz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|