barflow 0.2.3__tar.gz → 0.3.1__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.
- {barflow-0.2.3/src/barflow.egg-info → barflow-0.3.1}/PKG-INFO +1 -1
- {barflow-0.2.3 → barflow-0.3.1}/examples/gallery.py +76 -5
- {barflow-0.2.3 → barflow-0.3.1}/pyproject.toml +5 -4
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow/__init__.py +1 -1
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow/_core.cpp +251 -45
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow/themes.py +23 -0
- {barflow-0.2.3 → barflow-0.3.1/src/barflow.egg-info}/PKG-INFO +1 -1
- {barflow-0.2.3 → barflow-0.3.1}/LICENSE +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/MANIFEST.in +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/README.md +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_first_frame.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_import_to_iter.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_memory.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_metadata_churn.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_multibar.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_raw.md +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_tail_latency.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/benchmarks/results.md +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/docs/DESIGN.md +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/docs/FEATURE_REQUESTS.md +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/docs/PUBLISHING.md +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/examples/async_stream.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/examples/parallel_presets.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/examples/presets_gallery.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/examples/showcase.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/examples/themes_showcase.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/setup.cfg +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/setup.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow/_progress.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow/aio.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow/bar_styles.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow/columns.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow/hooks.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow/spinners.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow/style.py +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow.egg-info/SOURCES.txt +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow.egg-info/dependency_links.txt +0 -0
- {barflow-0.2.3 → barflow-0.3.1}/src/barflow.egg-info/top_level.txt +0 -0
|
@@ -30,8 +30,10 @@ from __future__ import annotations
|
|
|
30
30
|
|
|
31
31
|
import argparse
|
|
32
32
|
import random
|
|
33
|
+
import shutil
|
|
33
34
|
import sys
|
|
34
35
|
import time
|
|
36
|
+
import unicodedata
|
|
35
37
|
from pathlib import Path
|
|
36
38
|
|
|
37
39
|
_REPO_SRC = Path(__file__).resolve().parent.parent / "src"
|
|
@@ -126,6 +128,29 @@ def extract(cols):
|
|
|
126
128
|
|
|
127
129
|
# ----- Per-row renderer ---------------------------------------------------
|
|
128
130
|
|
|
131
|
+
def cell_width(s):
|
|
132
|
+
"""Display columns a glyph string occupies in a terminal.
|
|
133
|
+
|
|
134
|
+
Emoji and CJK render two columns wide; combining marks, ZWJ, and variation
|
|
135
|
+
selectors add none. Over-counting is safe here (the bar just ends a hair
|
|
136
|
+
short); under-counting is not (the row overflows and the percent clips), so
|
|
137
|
+
anything in the emoji planes is treated as width 2.
|
|
138
|
+
"""
|
|
139
|
+
w = 0
|
|
140
|
+
prev = 0 # display width of the most recent base glyph
|
|
141
|
+
for ch in s:
|
|
142
|
+
cp = ord(ch)
|
|
143
|
+
if cp == 0xFE0F: # emoji variation selector forces the base to 2 cols
|
|
144
|
+
w += 2 - prev
|
|
145
|
+
prev = 2
|
|
146
|
+
continue
|
|
147
|
+
if unicodedata.combining(ch) or cp in (0x200D, 0xFE0E): # ZWJ, text VS
|
|
148
|
+
continue
|
|
149
|
+
prev = 2 if unicodedata.east_asian_width(ch) in ("W", "F") or cp >= 0x1F000 else 1
|
|
150
|
+
w += prev
|
|
151
|
+
return w
|
|
152
|
+
|
|
153
|
+
|
|
129
154
|
def build_bar(glyphs, width, fraction):
|
|
130
155
|
"""Build one bar string from a 5-tuple glyphs spec at given fill fraction."""
|
|
131
156
|
fill, empty, partials, left, right = glyphs
|
|
@@ -173,23 +198,65 @@ def run_gallery(presets, *, duration=6.0, fps=24, seed=None):
|
|
|
173
198
|
print("nothing to show.", file=sys.stderr)
|
|
174
199
|
return
|
|
175
200
|
|
|
201
|
+
# Each preset is one terminal row; the redraw moves the cursor up
|
|
202
|
+
# exactly len(presets) rows every frame. If the block is taller than the
|
|
203
|
+
# rows actually free below the header, the cursor-up clamps at the top of
|
|
204
|
+
# the screen and the overflow scrolls into scrollback every frame — a wall
|
|
205
|
+
# of the duplicated top row(s). Reserve the header the caller already
|
|
206
|
+
# printed (title + blank = 2 lines), the trailing footer (1), plus a
|
|
207
|
+
# one-line safety margin, then trim to what's left.
|
|
208
|
+
HEADER_LINES, FOOTER_LINES, SAFETY = 2, 1, 1
|
|
209
|
+
term_lines = shutil.get_terminal_size().lines
|
|
210
|
+
avail = max(1, term_lines - HEADER_LINES - FOOTER_LINES - SAFETY)
|
|
211
|
+
if len(presets) > avail:
|
|
212
|
+
sys.stderr.write(
|
|
213
|
+
f"\x1b[1;93mTerminal is {term_lines} rows; showing first "
|
|
214
|
+
f"{avail} of {len(presets)} presets. Resize taller or use "
|
|
215
|
+
f"--section / --only to pick fewer.\x1b[0m\n\n"
|
|
216
|
+
)
|
|
217
|
+
presets = presets[:avail]
|
|
218
|
+
|
|
176
219
|
rng = random.Random(seed)
|
|
177
220
|
parts = {n: extract(themes.get(n)) for n in presets}
|
|
178
221
|
speeds = {n: rng.uniform(0.6, 1.6) for n in presets} # finish-time multiplier
|
|
179
222
|
fractions = {n: 0.0 for n in presets}
|
|
180
223
|
name_width = max(len(n) for n in presets)
|
|
181
224
|
n_rows = len(presets)
|
|
225
|
+
|
|
226
|
+
# Cap each bar so the whole row fits one terminal line. A cell costs as many
|
|
227
|
+
# columns as its fill glyph is wide, so emoji bars (lightning's ⚡, width 2)
|
|
228
|
+
# need half the cells of an ASCII bar to occupy the same space. Without this
|
|
229
|
+
# the row overflows the right margin and — now that autowrap is off — the
|
|
230
|
+
# trailing percent is clipped clean off ("lightning … ⚡⚡ %").
|
|
231
|
+
term_cols = shutil.get_terminal_size().columns
|
|
232
|
+
for p in parts.values():
|
|
233
|
+
fill, _empty, _partials, left, right = p["bar_glyphs"]
|
|
234
|
+
glyph_w = cell_width(fill) or 1
|
|
235
|
+
spinner_w = 2 if p["spinner_frames"] else 0
|
|
236
|
+
# spinner + name + " " + bar-brackets + " 100%" + 1-col margin
|
|
237
|
+
overhead = (spinner_w + name_width + 1 + cell_width(left)
|
|
238
|
+
+ cell_width(right) + 5 + 1)
|
|
239
|
+
max_cells = max(1, (term_cols - overhead) // glyph_w)
|
|
240
|
+
p["bar_width"] = min(p["bar_width"], max_cells)
|
|
182
241
|
frame_delay = 1.0 / fps
|
|
183
242
|
total_frames = max(1, int(duration * fps))
|
|
184
243
|
|
|
185
244
|
out = sys.stdout
|
|
186
|
-
|
|
245
|
+
# Hide cursor and turn OFF autowrap (DECAWM). Double-width glyphs (emoji
|
|
246
|
+
# bars like lightning's ⚡, fire 🔥) can push a row past the right margin;
|
|
247
|
+
# with autowrap on the terminal folds the overflow onto a second physical
|
|
248
|
+
# line, so the row occupies 2 lines while the redraw only climbs n_rows
|
|
249
|
+
# lines — a 1-line drift per frame that scrolls the top row off as a wall.
|
|
250
|
+
# With autowrap off the overflow is clipped at the margin instead, keeping
|
|
251
|
+
# every row exactly one physical line so the cursor-up math stays exact.
|
|
252
|
+
out.write("\x1b[?25l\x1b[?7l")
|
|
187
253
|
out.flush()
|
|
188
254
|
|
|
189
255
|
drew_once = False
|
|
190
256
|
t_start = time.perf_counter()
|
|
191
257
|
try:
|
|
192
258
|
for frame in range(total_frames + fps): # extra slack so all bars hit 100%
|
|
259
|
+
frame_start = time.perf_counter()
|
|
193
260
|
for n in presets:
|
|
194
261
|
step = speeds[n] / total_frames
|
|
195
262
|
fractions[n] = min(1.0, fractions[n] + step)
|
|
@@ -209,9 +276,13 @@ def run_gallery(presets, *, duration=6.0, fps=24, seed=None):
|
|
|
209
276
|
|
|
210
277
|
if all(f >= 1.0 for f in fractions.values()):
|
|
211
278
|
break
|
|
212
|
-
|
|
279
|
+
# Subtract render+write+flush cost so the real frame rate tracks
|
|
280
|
+
# the target instead of (target_period + work) per frame.
|
|
281
|
+
slack = frame_delay - (time.perf_counter() - frame_start)
|
|
282
|
+
if slack > 0:
|
|
283
|
+
time.sleep(slack)
|
|
213
284
|
finally:
|
|
214
|
-
out.write("\x1b[?25h") # show cursor
|
|
285
|
+
out.write("\x1b[?7h\x1b[?25h") # restore autowrap, show cursor
|
|
215
286
|
out.flush()
|
|
216
287
|
|
|
217
288
|
elapsed = time.perf_counter() - t_start
|
|
@@ -234,8 +305,8 @@ def main():
|
|
|
234
305
|
help="explicit preset list (overrides --section)")
|
|
235
306
|
ap.add_argument("--duration", type=float, default=6.0,
|
|
236
307
|
help="approximate seconds for fastest bar to finish (default 6)")
|
|
237
|
-
ap.add_argument("--fps", type=int, default=
|
|
238
|
-
help="target frames per second (default
|
|
308
|
+
ap.add_argument("--fps", type=int, default=60,
|
|
309
|
+
help="target frames per second (default 60)")
|
|
239
310
|
ap.add_argument("--seed", type=int, default=None,
|
|
240
311
|
help="seed for per-preset speed randomness")
|
|
241
312
|
ap.add_argument("--list", action="store_true",
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "barflow"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Fast Python progress bars with a C++ core. Windows-first."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -61,6 +61,7 @@ skip = ["*-musllinux_i686", "*-win32"]
|
|
|
61
61
|
# The C++17 core uses aligned new/delete, which Clang only emits for a
|
|
62
62
|
# deployment target of macOS 10.13+. CPython 3.12+ already defaults the
|
|
63
63
|
# x86_64 target high enough, but 3.10/3.11 default to 10.9 and fail to
|
|
64
|
-
# compile.
|
|
65
|
-
# 11.0+ regardless, so this only lifts
|
|
66
|
-
|
|
64
|
+
# compile. cibuildwheel now enforces a 10.15 floor, so pin to that for
|
|
65
|
+
# every macOS build (arm64 is always 11.0+ regardless, so this only lifts
|
|
66
|
+
# the x86_64 leg). 10.15 still satisfies the aligned-new requirement.
|
|
67
|
+
environment = { MACOSX_DEPLOYMENT_TARGET = "10.15" }
|
|
@@ -244,6 +244,14 @@ struct ProgressState {
|
|
|
244
244
|
std::thread render_thread;
|
|
245
245
|
std::once_flag close_once;
|
|
246
246
|
|
|
247
|
+
// pause()/resume() let an external writer (e.g. an output redirector)
|
|
248
|
+
// suspend the render thread, emit lines on the cleared bar area, and
|
|
249
|
+
// then have the bar repainted below. `paused` is checked by the render
|
|
250
|
+
// loop on every wakeup; `force_render` makes the first frame after
|
|
251
|
+
// resume repaint even if no counter moved while paused.
|
|
252
|
+
std::atomic<bool> paused{false};
|
|
253
|
+
bool force_render{false}; // guarded by render_mtx
|
|
254
|
+
|
|
247
255
|
double min_interval{0.05};
|
|
248
256
|
bool disable{false};
|
|
249
257
|
bool vt_enabled{false};
|
|
@@ -1273,6 +1281,49 @@ void render_frame(ProgressState* st) {
|
|
|
1273
1281
|
if (pending_skip > 0) {
|
|
1274
1282
|
append_cursor_right(buf, pending_skip);
|
|
1275
1283
|
}
|
|
1284
|
+
// The cursor now sits at the end of this row's actual content
|
|
1285
|
+
// (re-emitted columns end there; trailing matched columns were
|
|
1286
|
+
// CUF-skipped to their end). Erase from here to end of line so a
|
|
1287
|
+
// column that SHRANK since last frame — e.g. a variable-width
|
|
1288
|
+
// callback/postfix going from "subprocess" to "sqlite3" — doesn't
|
|
1289
|
+
// leave stale trailing characters ("sqlite3sses"). EL0 (\x1b[K)
|
|
1290
|
+
// clears only rightward, so already-emitted columns are safe.
|
|
1291
|
+
if (st->vt_enabled) {
|
|
1292
|
+
buf.append("\x1b[K", 3);
|
|
1293
|
+
// EL0 clears only the current physical row's tail. If this
|
|
1294
|
+
// line wrapped across multiple rows last frame and shrank to
|
|
1295
|
+
// fewer rows now, the surplus old wrapped row(s) below are
|
|
1296
|
+
// left on screen — and once last_task_cells shrinks the next
|
|
1297
|
+
// frame's cursor-up walk won't reach them, making the stale
|
|
1298
|
+
// rows permanent. Erase each surplus row, then walk back up
|
|
1299
|
+
// (vertical moves preserve the column) so the cursor stays at
|
|
1300
|
+
// the end of this row's content for the downstream newline.
|
|
1301
|
+
if (term_w > 0) {
|
|
1302
|
+
int cur_total = 0, prev_total = 0;
|
|
1303
|
+
for (size_t ci = 0; ci < ncols; ++ci) {
|
|
1304
|
+
if (!visible_cols[ci]) continue;
|
|
1305
|
+
cur_total += this_cells[ci];
|
|
1306
|
+
prev_total += prev_cells[ci];
|
|
1307
|
+
}
|
|
1308
|
+
int cur_rows = (cur_total + term_w - 1) / term_w;
|
|
1309
|
+
int prev_rows = (prev_total + term_w - 1) / term_w;
|
|
1310
|
+
if (cur_rows < 1) cur_rows = 1;
|
|
1311
|
+
if (prev_rows < 1) prev_rows = 1;
|
|
1312
|
+
if (prev_rows > cur_rows) {
|
|
1313
|
+
int surplus = prev_rows - cur_rows;
|
|
1314
|
+
for (int k = 0; k < surplus; ++k) {
|
|
1315
|
+
buf.append("\x1b[1B", 4); // down one row, no scroll
|
|
1316
|
+
buf.append("\x1b[2K", 4); // erase that stale row
|
|
1317
|
+
}
|
|
1318
|
+
buf.append("\x1b[", 2);
|
|
1319
|
+
char tmp[20];
|
|
1320
|
+
size_t nn = u64_to_chars(
|
|
1321
|
+
tmp, static_cast<uint64_t>(surplus));
|
|
1322
|
+
buf.append(tmp, nn);
|
|
1323
|
+
buf.push_back('A'); // walk back up to content row
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1276
1327
|
} else {
|
|
1277
1328
|
// Full emit: concatenate every visible column's bytes.
|
|
1278
1329
|
// When use_delta was true globally we skipped the up-front
|
|
@@ -1340,11 +1391,17 @@ void render_loop(ProgressState* st) {
|
|
|
1340
1391
|
st->render_cv.wait_for(lock, interval);
|
|
1341
1392
|
if (!st->running.load(std::memory_order_acquire)) break;
|
|
1342
1393
|
|
|
1394
|
+
// Suspended for external writing: do not touch the terminal until
|
|
1395
|
+
// resume() clears the flag. pause() already erased the bar area, so
|
|
1396
|
+
// the external writer owns the screen meanwhile.
|
|
1397
|
+
if (st->paused.load(std::memory_order_acquire)) continue;
|
|
1398
|
+
|
|
1343
1399
|
// Render unconditionally if there is a spinner (it animates even
|
|
1344
|
-
// with no producer progress)
|
|
1345
|
-
// The spinner flag is cached at
|
|
1346
|
-
// the column vector on every
|
|
1347
|
-
|
|
1400
|
+
// with no producer progress) or if a resume forced a repaint;
|
|
1401
|
+
// otherwise only if counters moved. The spinner flag is cached at
|
|
1402
|
+
// column setup so we don't re-scan the column vector on every
|
|
1403
|
+
// wakeup (every `min_interval`).
|
|
1404
|
+
bool any_dirty = st->has_spinner_col || st->force_render;
|
|
1348
1405
|
if (!any_dirty) {
|
|
1349
1406
|
for (auto& t : st->tasks) {
|
|
1350
1407
|
if (t->completed.load(std::memory_order_acquire) != t->last_snapshot) {
|
|
@@ -1353,7 +1410,10 @@ void render_loop(ProgressState* st) {
|
|
|
1353
1410
|
}
|
|
1354
1411
|
}
|
|
1355
1412
|
}
|
|
1356
|
-
if (any_dirty)
|
|
1413
|
+
if (any_dirty) {
|
|
1414
|
+
render_frame(st);
|
|
1415
|
+
st->force_render = false;
|
|
1416
|
+
}
|
|
1357
1417
|
}
|
|
1358
1418
|
// Final frame, still under the lock.
|
|
1359
1419
|
render_frame(st);
|
|
@@ -1735,15 +1795,26 @@ PyObject* Progress_update(PyProgress* self,
|
|
|
1735
1795
|
if (nargs >= 2) {
|
|
1736
1796
|
if (barflow_pylong_to_u64(args[1], &n) < 0) return nullptr;
|
|
1737
1797
|
}
|
|
1798
|
+
// Drop the GIL around render_mtx: render_frame holds the lock while a
|
|
1799
|
+
// CallbackColumn re-enters Python, so a GIL-holding caller blocked here
|
|
1800
|
+
// would deadlock. Defer the out-of-range error until the GIL is back.
|
|
1738
1801
|
Task* target = nullptr;
|
|
1802
|
+
bool oob = false;
|
|
1803
|
+
auto* st = self->state;
|
|
1804
|
+
Py_BEGIN_ALLOW_THREADS
|
|
1739
1805
|
{
|
|
1740
|
-
std::lock_guard<std::mutex> lg(
|
|
1806
|
+
std::lock_guard<std::mutex> lg(st->render_mtx);
|
|
1741
1807
|
if (task_id < 0 ||
|
|
1742
|
-
static_cast<size_t>(task_id) >=
|
|
1743
|
-
|
|
1744
|
-
|
|
1808
|
+
static_cast<size_t>(task_id) >= st->tasks.size()) {
|
|
1809
|
+
oob = true;
|
|
1810
|
+
} else {
|
|
1811
|
+
target = st->tasks[static_cast<size_t>(task_id)].get();
|
|
1745
1812
|
}
|
|
1746
|
-
|
|
1813
|
+
}
|
|
1814
|
+
Py_END_ALLOW_THREADS
|
|
1815
|
+
if (oob) {
|
|
1816
|
+
PyErr_SetString(PyExc_IndexError, "task_id out of range");
|
|
1817
|
+
return nullptr;
|
|
1747
1818
|
}
|
|
1748
1819
|
target->completed.fetch_add(n, std::memory_order_relaxed);
|
|
1749
1820
|
Py_RETURN_NONE;
|
|
@@ -1766,19 +1837,26 @@ PyObject* Progress_add_task(PyProgress* self, PyObject* args, PyObject* kwds) {
|
|
|
1766
1837
|
task->start_time_ns = now_ns();
|
|
1767
1838
|
if (desc) task->description = desc;
|
|
1768
1839
|
Task* raw = task.get();
|
|
1769
|
-
int
|
|
1840
|
+
int id;
|
|
1841
|
+
// Drop the GIL around render_mtx (see set_total): render_frame holds the
|
|
1842
|
+
// lock while a CallbackColumn re-enters Python. The task is fully built
|
|
1843
|
+
// above; only the vector push + task0 publish happen under the lock, none
|
|
1844
|
+
// of which touch Python state, so they are safe with the GIL released.
|
|
1845
|
+
auto* st = self->state;
|
|
1846
|
+
Py_BEGIN_ALLOW_THREADS
|
|
1770
1847
|
{
|
|
1771
|
-
std::lock_guard<std::mutex> lg(
|
|
1772
|
-
id = static_cast<int>(
|
|
1848
|
+
std::lock_guard<std::mutex> lg(st->render_mtx);
|
|
1849
|
+
id = static_cast<int>(st->tasks.size());
|
|
1773
1850
|
task->id = id;
|
|
1774
|
-
|
|
1851
|
+
st->tasks.push_back(std::move(task));
|
|
1775
1852
|
// Publish task0 if this is the first task. CAS so concurrent
|
|
1776
1853
|
// first-add races resolve to whichever wins.
|
|
1777
1854
|
Task* expected = nullptr;
|
|
1778
|
-
|
|
1855
|
+
st->task0.compare_exchange_strong(
|
|
1779
1856
|
expected, raw,
|
|
1780
1857
|
std::memory_order_release, std::memory_order_relaxed);
|
|
1781
1858
|
}
|
|
1859
|
+
Py_END_ALLOW_THREADS
|
|
1782
1860
|
return PyLong_FromLong(id);
|
|
1783
1861
|
}
|
|
1784
1862
|
|
|
@@ -1796,16 +1874,27 @@ PyObject* Progress_set_total(PyProgress* self,
|
|
|
1796
1874
|
if (PyErr_Occurred()) return nullptr;
|
|
1797
1875
|
uint64_t total = 0;
|
|
1798
1876
|
if (barflow_pylong_to_u64(args[1], &total) < 0) return nullptr;
|
|
1877
|
+
// Drop the GIL around render_mtx: render_frame holds the lock while a
|
|
1878
|
+
// CallbackColumn re-enters Python, so a GIL-holding caller blocked here
|
|
1879
|
+
// would deadlock. Defer the out-of-range error until the GIL is back.
|
|
1880
|
+
bool oob = false;
|
|
1881
|
+
auto* st = self->state;
|
|
1882
|
+
Py_BEGIN_ALLOW_THREADS
|
|
1799
1883
|
{
|
|
1800
|
-
std::lock_guard<std::mutex> lg(
|
|
1884
|
+
std::lock_guard<std::mutex> lg(st->render_mtx);
|
|
1801
1885
|
if (task_id < 0 ||
|
|
1802
|
-
static_cast<size_t>(task_id) >=
|
|
1803
|
-
|
|
1804
|
-
|
|
1886
|
+
static_cast<size_t>(task_id) >= st->tasks.size()) {
|
|
1887
|
+
oob = true;
|
|
1888
|
+
} else {
|
|
1889
|
+
st->tasks[static_cast<size_t>(task_id)]->total = total;
|
|
1805
1890
|
}
|
|
1806
|
-
self->state->tasks[static_cast<size_t>(task_id)]->total = total;
|
|
1807
1891
|
}
|
|
1808
|
-
|
|
1892
|
+
Py_END_ALLOW_THREADS
|
|
1893
|
+
if (oob) {
|
|
1894
|
+
PyErr_SetString(PyExc_IndexError, "task_id out of range");
|
|
1895
|
+
return nullptr;
|
|
1896
|
+
}
|
|
1897
|
+
st->render_cv.notify_one();
|
|
1809
1898
|
Py_RETURN_NONE;
|
|
1810
1899
|
}
|
|
1811
1900
|
|
|
@@ -1830,17 +1919,27 @@ PyObject* Progress_set_task_description(PyProgress* self,
|
|
|
1830
1919
|
Py_ssize_t dlen = 0;
|
|
1831
1920
|
const char* ds = PyUnicode_AsUTF8AndSize(args[1], &dlen);
|
|
1832
1921
|
if (!ds) return nullptr;
|
|
1922
|
+
// ds points into args[1]'s cached UTF-8 buffer; args[1] is kept alive by
|
|
1923
|
+
// the caller and not mutated, so reading it with the GIL dropped is safe.
|
|
1924
|
+
bool oob = false;
|
|
1925
|
+
auto* st = self->state;
|
|
1926
|
+
Py_BEGIN_ALLOW_THREADS
|
|
1833
1927
|
{
|
|
1834
|
-
std::lock_guard<std::mutex> lg(
|
|
1928
|
+
std::lock_guard<std::mutex> lg(st->render_mtx);
|
|
1835
1929
|
if (task_id < 0 ||
|
|
1836
|
-
static_cast<size_t>(task_id) >=
|
|
1837
|
-
|
|
1838
|
-
|
|
1930
|
+
static_cast<size_t>(task_id) >= st->tasks.size()) {
|
|
1931
|
+
oob = true;
|
|
1932
|
+
} else {
|
|
1933
|
+
st->tasks[static_cast<size_t>(task_id)]->description.assign(
|
|
1934
|
+
ds, static_cast<size_t>(dlen));
|
|
1839
1935
|
}
|
|
1840
|
-
self->state->tasks[static_cast<size_t>(task_id)]->description.assign(
|
|
1841
|
-
ds, static_cast<size_t>(dlen));
|
|
1842
1936
|
}
|
|
1843
|
-
|
|
1937
|
+
Py_END_ALLOW_THREADS
|
|
1938
|
+
if (oob) {
|
|
1939
|
+
PyErr_SetString(PyExc_IndexError, "task_id out of range");
|
|
1940
|
+
return nullptr;
|
|
1941
|
+
}
|
|
1942
|
+
st->render_cv.notify_one();
|
|
1844
1943
|
Py_RETURN_NONE;
|
|
1845
1944
|
}
|
|
1846
1945
|
|
|
@@ -1854,16 +1953,23 @@ PyObject* Progress_set_description(PyProgress* self, PyObject* desc_obj) {
|
|
|
1854
1953
|
Py_ssize_t dlen = 0;
|
|
1855
1954
|
const char* ds = PyUnicode_AsUTF8AndSize(desc_obj, &dlen);
|
|
1856
1955
|
if (!ds) return nullptr;
|
|
1956
|
+
bool empty = false;
|
|
1957
|
+
auto* st = self->state;
|
|
1958
|
+
Py_BEGIN_ALLOW_THREADS
|
|
1857
1959
|
{
|
|
1858
|
-
std::lock_guard<std::mutex> lg(
|
|
1859
|
-
if (
|
|
1860
|
-
|
|
1861
|
-
|
|
1960
|
+
std::lock_guard<std::mutex> lg(st->render_mtx);
|
|
1961
|
+
if (st->tasks.empty()) {
|
|
1962
|
+
empty = true;
|
|
1963
|
+
} else {
|
|
1964
|
+
st->tasks[0]->description.assign(ds, static_cast<size_t>(dlen));
|
|
1862
1965
|
}
|
|
1863
|
-
self->state->tasks[0]->description.assign(
|
|
1864
|
-
ds, static_cast<size_t>(dlen));
|
|
1865
1966
|
}
|
|
1866
|
-
|
|
1967
|
+
Py_END_ALLOW_THREADS
|
|
1968
|
+
if (empty) {
|
|
1969
|
+
PyErr_SetString(PyExc_IndexError, "no tasks");
|
|
1970
|
+
return nullptr;
|
|
1971
|
+
}
|
|
1972
|
+
st->render_cv.notify_one();
|
|
1867
1973
|
Py_RETURN_NONE;
|
|
1868
1974
|
}
|
|
1869
1975
|
|
|
@@ -1892,6 +1998,11 @@ PyObject* Progress_write_above(PyProgress* self, PyObject* text_obj) {
|
|
|
1892
1998
|
const char* text = PyUnicode_AsUTF8AndSize(text_obj, &len);
|
|
1893
1999
|
if (!text) return nullptr;
|
|
1894
2000
|
|
|
2001
|
+
// Drop the GIL around render_mtx (see set_total): render_frame holds the
|
|
2002
|
+
// lock while a CallbackColumn re-enters Python. `text` points into
|
|
2003
|
+
// text_obj's cached UTF-8 buffer, which stays valid (caller holds a ref,
|
|
2004
|
+
// no mutation) while the GIL is released.
|
|
2005
|
+
Py_BEGIN_ALLOW_THREADS
|
|
1895
2006
|
{
|
|
1896
2007
|
std::lock_guard<std::mutex> lg(self->state->render_mtx);
|
|
1897
2008
|
auto* st = self->state;
|
|
@@ -1944,10 +2055,83 @@ PyObject* Progress_write_above(PyProgress* self, PyObject* text_obj) {
|
|
|
1944
2055
|
// — invalidate so the next render_frame does a full emit.
|
|
1945
2056
|
st->delta_valid = false;
|
|
1946
2057
|
}
|
|
2058
|
+
Py_END_ALLOW_THREADS
|
|
1947
2059
|
self->state->render_cv.notify_one();
|
|
1948
2060
|
Py_RETURN_NONE;
|
|
1949
2061
|
}
|
|
1950
2062
|
|
|
2063
|
+
// pause() — suspend the render thread and erase the bar area, leaving the
|
|
2064
|
+
// cursor at column 0 of the cleared region so an external writer can emit
|
|
2065
|
+
// lines there without the bar repainting on top. Mirrors the walk-back +
|
|
2066
|
+
// erase that write_above performs. Idempotent and safe to call when the bar
|
|
2067
|
+
// is disabled or closed (no-op). Pairs with resume().
|
|
2068
|
+
PyObject* Progress_pause(PyProgress* self, PyObject* /*args*/) {
|
|
2069
|
+
auto* st = self->state;
|
|
2070
|
+
if (st->disable || st->closed) Py_RETURN_NONE;
|
|
2071
|
+
// Release the GIL before taking render_mtx. The render thread holds
|
|
2072
|
+
// render_mtx across render_frame, which re-enters Python (PyGILState)
|
|
2073
|
+
// for any CallbackColumn — so a GIL-holding caller that blocked on
|
|
2074
|
+
// render_mtx here would deadlock against the render thread waiting on
|
|
2075
|
+
// the GIL. Dropping the GIL lets the render thread finish and release
|
|
2076
|
+
// the lock. write_bytes touches no Python state, so it is GIL-free safe.
|
|
2077
|
+
Py_BEGIN_ALLOW_THREADS
|
|
2078
|
+
{
|
|
2079
|
+
std::lock_guard<std::mutex> lg(st->render_mtx);
|
|
2080
|
+
// Acquiring render_mtx guarantees no frame is mid-flight: the render
|
|
2081
|
+
// loop only holds the lock outside its cv wait, so once we hold it
|
|
2082
|
+
// the last frame is fully written.
|
|
2083
|
+
st->paused.store(true, std::memory_order_release);
|
|
2084
|
+
|
|
2085
|
+
if (st->vt_enabled) {
|
|
2086
|
+
std::string& buf = st->scratch;
|
|
2087
|
+
buf.clear();
|
|
2088
|
+
int term_w = query_terminal_width(st);
|
|
2089
|
+
if (!st->last_task_cells.empty()) {
|
|
2090
|
+
int total_rows = 0;
|
|
2091
|
+
for (int c : st->last_task_cells) {
|
|
2092
|
+
int r = (term_w > 0) ? ((c + term_w - 1) / term_w) : 1;
|
|
2093
|
+
if (r < 1) r = 1;
|
|
2094
|
+
total_rows += r;
|
|
2095
|
+
}
|
|
2096
|
+
if (total_rows > 0) {
|
|
2097
|
+
buf.append("\x1b[", 2);
|
|
2098
|
+
char tmp[20];
|
|
2099
|
+
size_t n = u64_to_chars(tmp, static_cast<uint64_t>(total_rows));
|
|
2100
|
+
buf.append(tmp, n);
|
|
2101
|
+
buf.push_back('A');
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
buf.push_back('\r');
|
|
2105
|
+
buf.append("\x1b[J", 3); // erase from cursor to end of screen
|
|
2106
|
+
write_bytes(st, buf.data(), buf.size());
|
|
2107
|
+
st->last_rendered_lines = 0;
|
|
2108
|
+
st->last_task_cells.clear();
|
|
2109
|
+
st->delta_valid = false; // resume repaints from scratch
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
Py_END_ALLOW_THREADS
|
|
2113
|
+
Py_RETURN_NONE;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// resume() — undo pause(): clear the suspend flag and force the next render
|
|
2117
|
+
// loop wakeup to repaint the bar below whatever the external writer emitted.
|
|
2118
|
+
PyObject* Progress_resume(PyProgress* self, PyObject* /*args*/) {
|
|
2119
|
+
auto* st = self->state;
|
|
2120
|
+
if (st->disable || st->closed) Py_RETURN_NONE;
|
|
2121
|
+
// Drop the GIL before render_mtx for the same reason as pause(): the
|
|
2122
|
+
// render thread may hold render_mtx while waiting on the GIL inside a
|
|
2123
|
+
// CallbackColumn.
|
|
2124
|
+
Py_BEGIN_ALLOW_THREADS
|
|
2125
|
+
{
|
|
2126
|
+
std::lock_guard<std::mutex> lg(st->render_mtx);
|
|
2127
|
+
st->paused.store(false, std::memory_order_release);
|
|
2128
|
+
st->force_render = true;
|
|
2129
|
+
}
|
|
2130
|
+
Py_END_ALLOW_THREADS
|
|
2131
|
+
st->render_cv.notify_one();
|
|
2132
|
+
Py_RETURN_NONE;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
1951
2135
|
PyObject* Progress_get_completed(PyProgress* self, void* /*closure*/) {
|
|
1952
2136
|
Task* t = self->state->task0.load(std::memory_order_acquire);
|
|
1953
2137
|
uint64_t v = t ? t->completed.load(std::memory_order_relaxed) : 0;
|
|
@@ -1955,11 +2139,16 @@ PyObject* Progress_get_completed(PyProgress* self, void* /*closure*/) {
|
|
|
1955
2139
|
}
|
|
1956
2140
|
|
|
1957
2141
|
PyObject* Progress_get_n_tasks(PyProgress* self, void* /*closure*/) {
|
|
2142
|
+
// Drop the GIL around render_mtx (see set_total): render_frame may hold
|
|
2143
|
+
// the lock while a CallbackColumn re-enters Python and waits on the GIL.
|
|
1958
2144
|
Py_ssize_t n;
|
|
2145
|
+
auto* st = self->state;
|
|
2146
|
+
Py_BEGIN_ALLOW_THREADS
|
|
1959
2147
|
{
|
|
1960
|
-
std::lock_guard<std::mutex> lg(
|
|
1961
|
-
n = static_cast<Py_ssize_t>(
|
|
2148
|
+
std::lock_guard<std::mutex> lg(st->render_mtx);
|
|
2149
|
+
n = static_cast<Py_ssize_t>(st->tasks.size());
|
|
1962
2150
|
}
|
|
2151
|
+
Py_END_ALLOW_THREADS
|
|
1963
2152
|
return PyLong_FromSsize_t(n);
|
|
1964
2153
|
}
|
|
1965
2154
|
|
|
@@ -1989,6 +2178,10 @@ PyMethodDef Progress_methods[] = {
|
|
|
1989
2178
|
"Force an immediate render frame."},
|
|
1990
2179
|
{"write_above",reinterpret_cast<PyCFunction>(Progress_write_above),
|
|
1991
2180
|
METH_O, "Emit text above the bar area without tearing."},
|
|
2181
|
+
{"pause", reinterpret_cast<PyCFunction>(Progress_pause), METH_NOARGS,
|
|
2182
|
+
"Suspend the render thread and clear the bar so external writes are clean."},
|
|
2183
|
+
{"resume", reinterpret_cast<PyCFunction>(Progress_resume), METH_NOARGS,
|
|
2184
|
+
"Resume rendering after pause(), repainting the bar."},
|
|
1992
2185
|
{nullptr, nullptr, 0, nullptr}
|
|
1993
2186
|
};
|
|
1994
2187
|
|
|
@@ -2045,21 +2238,34 @@ int Tracker_init(PyTracker* self, PyObject* args, PyObject* kwds) {
|
|
|
2045
2238
|
Py_INCREF(progress);
|
|
2046
2239
|
self->progress = reinterpret_cast<PyProgress*>(progress);
|
|
2047
2240
|
|
|
2241
|
+
// Drop the GIL around render_mtx (see set_total): render_frame holds the
|
|
2242
|
+
// lock while a CallbackColumn re-enters Python and waits on the GIL.
|
|
2243
|
+
// Defer error reporting until the GIL is reacquired.
|
|
2244
|
+
enum { LOOKUP_OK, LOOKUP_EMPTY, LOOKUP_OOB } lookup = LOOKUP_OK;
|
|
2245
|
+
auto* st = self->progress->state;
|
|
2246
|
+
Py_BEGIN_ALLOW_THREADS
|
|
2048
2247
|
{
|
|
2049
2248
|
// Lock the vector for the lookup. The Task* itself is heap-stable
|
|
2050
2249
|
// (unique_ptr) so the cached pointer remains valid for the
|
|
2051
2250
|
// lifetime of the Progress instance.
|
|
2052
|
-
auto* st = self->progress->state;
|
|
2053
2251
|
std::lock_guard<std::mutex> lg(st->render_mtx);
|
|
2054
2252
|
if (st->tasks.empty()) {
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2253
|
+
lookup = LOOKUP_EMPTY;
|
|
2254
|
+
} else if (task_id < 0 ||
|
|
2255
|
+
static_cast<size_t>(task_id) >= st->tasks.size()) {
|
|
2256
|
+
lookup = LOOKUP_OOB;
|
|
2257
|
+
} else {
|
|
2258
|
+
self->task = st->tasks[static_cast<size_t>(task_id)].get();
|
|
2061
2259
|
}
|
|
2062
|
-
|
|
2260
|
+
}
|
|
2261
|
+
Py_END_ALLOW_THREADS
|
|
2262
|
+
if (lookup == LOOKUP_EMPTY) {
|
|
2263
|
+
PyErr_SetString(PyExc_RuntimeError, "progress has no tasks");
|
|
2264
|
+
return -1;
|
|
2265
|
+
}
|
|
2266
|
+
if (lookup == LOOKUP_OOB) {
|
|
2267
|
+
PyErr_SetString(PyExc_IndexError, "task_id out of range");
|
|
2268
|
+
return -1;
|
|
2063
2269
|
}
|
|
2064
2270
|
self->closed = 0;
|
|
2065
2271
|
self->owns_progress = owns;
|
|
@@ -21,6 +21,8 @@ matches their app's visual identity.
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
+
import random as _random
|
|
25
|
+
|
|
24
26
|
from . import columns as c
|
|
25
27
|
|
|
26
28
|
|
|
@@ -778,6 +780,22 @@ def training():
|
|
|
778
780
|
]
|
|
779
781
|
|
|
780
782
|
|
|
783
|
+
# ---------------------------------------------------------------------------
|
|
784
|
+
# Meta
|
|
785
|
+
# ---------------------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
def random_theme():
|
|
788
|
+
"""Pick a random theme each call and return its columns."""
|
|
789
|
+
# Collapse aliases, drop self, so every pick is a distinct real theme.
|
|
790
|
+
# dict.fromkeys de-dupes by identity while preserving THEMES' insertion
|
|
791
|
+
# order — a plain set would iterate in a hash-randomized order, defeating
|
|
792
|
+
# reproducibility under a seeded RNG.
|
|
793
|
+
pool = list(dict.fromkeys(
|
|
794
|
+
fn for fn in THEMES.values() if fn is not random_theme
|
|
795
|
+
))
|
|
796
|
+
return _random.choice(pool)()
|
|
797
|
+
|
|
798
|
+
|
|
781
799
|
# ---------------------------------------------------------------------------
|
|
782
800
|
# Registry
|
|
783
801
|
# ---------------------------------------------------------------------------
|
|
@@ -875,6 +893,9 @@ THEMES = {
|
|
|
875
893
|
"downloading": downloading,
|
|
876
894
|
"building": building,
|
|
877
895
|
"training": training,
|
|
896
|
+
|
|
897
|
+
# Meta
|
|
898
|
+
"random": random_theme,
|
|
878
899
|
}
|
|
879
900
|
|
|
880
901
|
|
|
@@ -922,4 +943,6 @@ __all__ = [
|
|
|
922
943
|
"github_dark", "discord", "dracula", "solarized", "nord", "gruvbox",
|
|
923
944
|
# Specialized
|
|
924
945
|
"tiny", "detailed", "downloading", "building", "training",
|
|
946
|
+
# Meta
|
|
947
|
+
"random_theme",
|
|
925
948
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|