modal-progress 0.1.0__tar.gz

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,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -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.
@@ -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,113 @@
1
+ <h1 align="center">modal-progress</h1>
2
+
3
+ <p align="center"><b>Live progress from inside Modal containers.</b><br>
4
+ Progress bars per container in a terminal, plain progress lines in a log file.</p>
5
+
6
+ <p align="center">
7
+ <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">
8
+ </p>
9
+
10
+ `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.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install git+https://github.com/EnesYilmazcode/modal-progress
16
+ ```
17
+
18
+ Requires Python 3.10+ and modal 1.x.
19
+
20
+ ## Usage
21
+
22
+ ### No code changes
23
+
24
+ Use `modal-progress run` in place of `modal run`. Arguments are passed through unchanged.
25
+
26
+ ```bash
27
+ modal-progress run app.py > log.txt
28
+ ```
29
+
30
+ ```
31
+ [progress] work 11/60 done (18%), 0:13 elapsed, eta 1:00
32
+ [progress] work 19/60 done (32%), 0:20 elapsed, eta 0:44
33
+ [progress] work 29/60 done (48%), 0:25 elapsed, eta 0:27
34
+ [progress] work 60/60 done, 0:47
35
+ ```
36
+
37
+ In a terminal it behaves exactly like `modal run`.
38
+
39
+ ### Progress from inside each container
40
+
41
+ Wrap a loop in `bar()` inside the function, and wrap the map in `watch()` on the caller's side.
42
+
43
+ ```python
44
+ import modal
45
+ from modal_progress import bar, watch
46
+
47
+ app = modal.App("example")
48
+ image = modal.Image.debian_slim().add_local_python_source("modal_progress")
49
+
50
+ @app.function(image=image)
51
+ def work(i: int) -> int:
52
+ for step in bar(range(40), desc=f"input {i}"):
53
+ ... # one step of work
54
+ return i
55
+
56
+ @app.local_entrypoint()
57
+ def main():
58
+ xs = range(60)
59
+ with watch(app, name="work") as w:
60
+ results = list(w.track(work.map(xs, return_exceptions=True), total=len(xs)))
61
+ ```
62
+
63
+ In a terminal, this shows an overall bar plus one bar per container. In a log file, it prints:
64
+
65
+ ```
66
+ [progress] work 20/60 done (33%), 0 failed, 6 running, 0:25 elapsed, eta 0:31
67
+ [progress] work: an input failed: ValueError: bad input 37
68
+ [progress] work 40/60 done (67%), 1 failed, 6 running, 0:40 elapsed, eta 0:14
69
+ [progress] work finished: 60/60 done, 1 failed, 0:57
70
+ ```
71
+
72
+ 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).
73
+
74
+ ## API
75
+
76
+ | Call | Where | What it does |
77
+ |---|---|---|
78
+ | `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. |
79
+ | `report(n, total=None, desc="")` | container | Sets progress for the current input directly, for loops `bar()` can't wrap. |
80
+ | `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. |
81
+ | `w.track(results, total=None)` | caller | Passes map results through and counts them. Exceptions count as failures. |
82
+
83
+ ## Configuration
84
+
85
+ | Variable | Values | Default |
86
+ |---|---|---|
87
+ | `MODAL_PROGRESS` | `rich`, `plain`, `off` | `rich` in a terminal, `plain` otherwise. `off` also stops containers from sending. |
88
+ | `MODAL_PROGRESS_INTERVAL` | seconds between plain lines | `5` |
89
+
90
+ ## How it works
91
+
92
+ 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.
93
+
94
+ `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.
95
+
96
+ ## Limitations
97
+
98
+ - 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.
99
+ - 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.
100
+ - Progress shows up within about a second of happening.
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ pip install -e ".[dev]"
106
+ python -m pytest -q
107
+ python -m modal_progress demo # simulated map, no Modal account needed
108
+ modal run examples/fanout.py # real map on Modal
109
+ ```
110
+
111
+ ## License
112
+
113
+ MIT
@@ -0,0 +1,34 @@
1
+ # 60 inputs over 6 containers. Each input walks 40 steps, input 37 fails
2
+ # two thirds of the way in. Run from the repo root:
3
+ #
4
+ # modal run examples/fanout.py rich bars in a terminal
5
+ # modal run examples/fanout.py > log.txt plain lines, like an agent sees
6
+ # modal run examples/fanout.py --record run.jsonl also save every event
7
+ import random
8
+ import time
9
+
10
+ import modal
11
+
12
+ from modal_progress import bar, watch
13
+
14
+ app = modal.App("modal-progress-example")
15
+ image = modal.Image.debian_slim().add_local_python_source("modal_progress")
16
+
17
+
18
+ @app.function(image=image, max_containers=6)
19
+ def work(i: int) -> int:
20
+ speed = random.Random(i).uniform(0.6, 1.6)
21
+ for step in bar(range(40), desc=f"input {i}"):
22
+ time.sleep(0.1 * speed)
23
+ if i == 37 and step == 26:
24
+ raise ValueError("bad input 37")
25
+ return i * i
26
+
27
+
28
+ @app.local_entrypoint()
29
+ def main(record: str = ""):
30
+ xs = range(60)
31
+ with watch(app, name="work", record=record or None) as w:
32
+ results = list(w.track(work.map(xs, return_exceptions=True, order_outputs=False), total=len(xs)))
33
+ failed = sum(isinstance(r, Exception) for r in results)
34
+ print(f"{len(results)} results, {failed} failed")
@@ -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