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.
- modal_progress-0.1.0/.gitignore +8 -0
- modal_progress-0.1.0/LICENSE +21 -0
- modal_progress-0.1.0/PKG-INFO +128 -0
- modal_progress-0.1.0/README.md +113 -0
- modal_progress-0.1.0/examples/fanout.py +34 -0
- modal_progress-0.1.0/modal_progress/__init__.py +7 -0
- modal_progress-0.1.0/modal_progress/__main__.py +63 -0
- modal_progress-0.1.0/modal_progress/agent.py +80 -0
- modal_progress-0.1.0/modal_progress/container.py +236 -0
- modal_progress-0.1.0/modal_progress/render.py +175 -0
- modal_progress-0.1.0/modal_progress/sim.py +49 -0
- modal_progress-0.1.0/modal_progress/state.py +102 -0
- modal_progress-0.1.0/modal_progress/watch.py +169 -0
- modal_progress-0.1.0/pyproject.toml +31 -0
- modal_progress-0.1.0/tests/test_agent.py +62 -0
- modal_progress-0.1.0/tests/test_container.py +69 -0
- modal_progress-0.1.0/tests/test_render.py +57 -0
- modal_progress-0.1.0/tests/test_sim.py +35 -0
- modal_progress-0.1.0/tests/test_state.py +76 -0
|
@@ -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,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
|