Skip to content

Commit fcade0a

Browse files
authored
Add stdlib-only tqdm to pylabrobot.utils (#998)
* add stdlib tqdm utility from tinygrad fully attribute tinygrad's fantastic tqdm implementation * `make format`
1 parent c4fe7e6 commit fcade0a

File tree

2 files changed

+151
-0
lines changed

2 files changed

+151
-0
lines changed

pylabrobot/utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .list import assert_shape, chunk_list, reshape_2d
22
from .object_parsing import find_subclass
33
from .positions import expand_string_range
4+
from .tqdm import tqdm

pylabrobot/utils/tqdm.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Minimal progress bar, stdlib-only.
2+
3+
Adapted verbatim from tinygrad/helpers.py
4+
(https://github.com/tinygrad/tinygrad/blob/master/tinygrad/helpers.py).
5+
6+
Kept as a pylabrobot utility to avoid pulling in the ``tqdm`` dependency for
7+
long-running protocols (multi-plate transfers, incubator waits, firmware
8+
replay, etc.). API is a subset of the ``tqdm`` package: iterator + context
9+
manager + ``update()`` + ``tqdm.write()``. The ``trange`` helper is omitted
10+
— use ``tqdm(range(n))`` directly.
11+
12+
----------------------------------------------------------------------------
13+
The ``tqdm`` class and its helpers in this module are redistributed under the
14+
following MIT license:
15+
16+
Copyright (c) 2024, the tiny corp
17+
18+
Permission is hereby granted, free of charge, to any person obtaining a
19+
copy of this software and associated documentation files (the
20+
"Software"), to deal in the Software without restriction, including
21+
without limitation the rights to use, copy, modify, merge, publish,
22+
distribute, sublicense, and/or sell copies of the Software, and to
23+
permit persons to whom the Software is furnished to do so, subject to
24+
the following conditions:
25+
26+
The above copyright notice and this permission notice shall be included
27+
in all copies or substantial portions of the Software.
28+
29+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
30+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
31+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
32+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
33+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
34+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
35+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
36+
----------------------------------------------------------------------------
37+
"""
38+
39+
import math
40+
import shutil
41+
import sys
42+
import time
43+
from typing import Generic, Iterable, Iterator, Optional, TypeVar
44+
45+
T = TypeVar("T")
46+
47+
48+
class tqdm(Generic[T]):
49+
"""Progress bar. Wrap an iterable, or use as a context manager with manual ``update()``."""
50+
51+
def __init__(
52+
self,
53+
iterable: Optional[Iterable[T]] = None,
54+
desc: str = "",
55+
disable: bool = False,
56+
unit: str = "it",
57+
unit_scale: bool = False,
58+
total: Optional[int] = None,
59+
rate: int = 100,
60+
):
61+
self.iterable, self.disable, self.unit, self.unit_scale, self.rate = (
62+
iterable,
63+
disable,
64+
unit,
65+
unit_scale,
66+
rate,
67+
)
68+
self.st, self.i, self.n, self.skip, self.t = (
69+
time.perf_counter(),
70+
-1,
71+
0,
72+
1,
73+
getattr(iterable, "__len__", lambda: 0)() if total is None else total,
74+
)
75+
self.set_description(desc)
76+
self.update(0)
77+
78+
def __iter__(self) -> Iterator[T]:
79+
assert self.iterable is not None, "need an iterable to iterate"
80+
for item in self.iterable:
81+
yield item
82+
self.update(1)
83+
self.update(close=True)
84+
85+
def __enter__(self):
86+
return self
87+
88+
def __exit__(self, *_):
89+
self.update(close=True)
90+
91+
def set_description(self, desc: str):
92+
self.desc = f"{desc}: " if desc else ""
93+
94+
def update(self, n: int = 0, close: bool = False):
95+
self.n, self.i = self.n + n, self.i + 1
96+
if self.disable or (not close and self.i % self.skip != 0):
97+
return
98+
prog, elapsed, ncols = (
99+
self.n / self.t if self.t else 0,
100+
time.perf_counter() - self.st,
101+
shutil.get_terminal_size().columns,
102+
)
103+
if elapsed and self.i / elapsed > self.rate and self.i:
104+
self.skip = max(int(self.i / elapsed) // self.rate, 1)
105+
106+
def HMS(t):
107+
return ":".join(
108+
f"{x:02d}" if i else str(x)
109+
for i, x in enumerate([int(t) // 3600, int(t) % 3600 // 60, int(t) % 60])
110+
if i or x
111+
)
112+
113+
def SI(x):
114+
if not x:
115+
return "0.00"
116+
v = f"{x / 1000 ** int(g := round(math.log(x, 1000), 6)):.{int(3 - 3 * math.fmod(g, 1))}f}"[
117+
:4
118+
].rstrip(".")
119+
return (
120+
(f"{x / 1000 ** (int(g) + 1):.3f}"[:4].rstrip(".") + " kMGTPEZY"[int(g) + 1])
121+
if v == "1000"
122+
else v + " kMGTPEZY"[int(g)].strip()
123+
)
124+
125+
prog_text = (
126+
f"{SI(self.n)}{f'/{SI(self.t)}' if self.t else self.unit}"
127+
if self.unit_scale
128+
else f"{self.n}{f'/{self.t}' if self.t else self.unit}"
129+
)
130+
est_text = f"<{HMS(elapsed / prog - elapsed) if self.n else '?'}" if self.t else ""
131+
it_text = (
132+
(SI(self.n / elapsed) if self.unit_scale else f"{self.n / elapsed:5.2f}") if self.n else "?"
133+
)
134+
suf = f"{prog_text} [{HMS(elapsed)}{est_text}, {it_text}{self.unit}/s]"
135+
sz = max(ncols - len(self.desc) - 3 - 2 - 2 - len(suf), 1)
136+
bar = (
137+
"\r"
138+
+ self.desc
139+
+ (
140+
f"{100 * prog:3.0f}%|{('█' * int(num := sz * prog) + ' ▏▎▍▌▋▊▉'[int(8 * num) % 8].strip()).ljust(sz, ' ')}| "
141+
if self.t
142+
else ""
143+
)
144+
+ suf
145+
)
146+
print(bar[: ncols + 1], flush=True, end="\n" * close, file=sys.stderr)
147+
148+
@classmethod
149+
def write(cls, s: str):
150+
print(f"\r\033[K{s}", flush=True, file=sys.stderr)

0 commit comments

Comments
 (0)