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.
@@ -0,0 +1,7 @@
1
+ """Live progress from inside Modal containers."""
2
+
3
+ from .container import bar, report
4
+ from .watch import Watcher, watch
5
+
6
+ __all__ = ["bar", "report", "watch", "Watcher"]
7
+ __version__ = "0.1.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())
@@ -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()
@@ -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()
@@ -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}"
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ modal-progress = modal_progress.__main__:main
@@ -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.