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.
Files changed (39) hide show
  1. {barflow-0.2.3/src/barflow.egg-info → barflow-0.3.1}/PKG-INFO +1 -1
  2. {barflow-0.2.3 → barflow-0.3.1}/examples/gallery.py +76 -5
  3. {barflow-0.2.3 → barflow-0.3.1}/pyproject.toml +5 -4
  4. {barflow-0.2.3 → barflow-0.3.1}/src/barflow/__init__.py +1 -1
  5. {barflow-0.2.3 → barflow-0.3.1}/src/barflow/_core.cpp +251 -45
  6. {barflow-0.2.3 → barflow-0.3.1}/src/barflow/themes.py +23 -0
  7. {barflow-0.2.3 → barflow-0.3.1/src/barflow.egg-info}/PKG-INFO +1 -1
  8. {barflow-0.2.3 → barflow-0.3.1}/LICENSE +0 -0
  9. {barflow-0.2.3 → barflow-0.3.1}/MANIFEST.in +0 -0
  10. {barflow-0.2.3 → barflow-0.3.1}/README.md +0 -0
  11. {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench.py +0 -0
  12. {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_first_frame.py +0 -0
  13. {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_import_to_iter.py +0 -0
  14. {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_memory.py +0 -0
  15. {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_metadata_churn.py +0 -0
  16. {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_multibar.py +0 -0
  17. {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_raw.md +0 -0
  18. {barflow-0.2.3 → barflow-0.3.1}/benchmarks/bench_tail_latency.py +0 -0
  19. {barflow-0.2.3 → barflow-0.3.1}/benchmarks/results.md +0 -0
  20. {barflow-0.2.3 → barflow-0.3.1}/docs/DESIGN.md +0 -0
  21. {barflow-0.2.3 → barflow-0.3.1}/docs/FEATURE_REQUESTS.md +0 -0
  22. {barflow-0.2.3 → barflow-0.3.1}/docs/PUBLISHING.md +0 -0
  23. {barflow-0.2.3 → barflow-0.3.1}/examples/async_stream.py +0 -0
  24. {barflow-0.2.3 → barflow-0.3.1}/examples/parallel_presets.py +0 -0
  25. {barflow-0.2.3 → barflow-0.3.1}/examples/presets_gallery.py +0 -0
  26. {barflow-0.2.3 → barflow-0.3.1}/examples/showcase.py +0 -0
  27. {barflow-0.2.3 → barflow-0.3.1}/examples/themes_showcase.py +0 -0
  28. {barflow-0.2.3 → barflow-0.3.1}/setup.cfg +0 -0
  29. {barflow-0.2.3 → barflow-0.3.1}/setup.py +0 -0
  30. {barflow-0.2.3 → barflow-0.3.1}/src/barflow/_progress.py +0 -0
  31. {barflow-0.2.3 → barflow-0.3.1}/src/barflow/aio.py +0 -0
  32. {barflow-0.2.3 → barflow-0.3.1}/src/barflow/bar_styles.py +0 -0
  33. {barflow-0.2.3 → barflow-0.3.1}/src/barflow/columns.py +0 -0
  34. {barflow-0.2.3 → barflow-0.3.1}/src/barflow/hooks.py +0 -0
  35. {barflow-0.2.3 → barflow-0.3.1}/src/barflow/spinners.py +0 -0
  36. {barflow-0.2.3 → barflow-0.3.1}/src/barflow/style.py +0 -0
  37. {barflow-0.2.3 → barflow-0.3.1}/src/barflow.egg-info/SOURCES.txt +0 -0
  38. {barflow-0.2.3 → barflow-0.3.1}/src/barflow.egg-info/dependency_links.txt +0 -0
  39. {barflow-0.2.3 → barflow-0.3.1}/src/barflow.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: barflow
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: Fast Python progress bars with a C++ core. Windows-first.
5
5
  Author: NevermindNilas
6
6
  License-Expression: MIT
@@ -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
- out.write("\x1b[?25l") # hide cursor
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
- time.sleep(frame_delay)
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=24,
238
- help="target frames per second (default 24)")
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.2.3"
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. Pin the floor to 10.13 for every macOS build (arm64 is always
65
- # 11.0+ regardless, so this only lifts the x86_64 leg).
66
- environment = { MACOSX_DEPLOYMENT_TARGET = "10.13" }
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" }
@@ -8,7 +8,7 @@ asyncio wrapper are lazy-loaded via `__getattr__` on first access.
8
8
  from . import _core
9
9
  from ._core import Tracker
10
10
 
11
- __version__ = "0.2.3"
11
+ __version__ = "0.3.1"
12
12
  __all__ = ["Progress", "Tracker", "track", "__version__"]
13
13
 
14
14
 
@@ -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); otherwise only if counters moved.
1345
- // The spinner flag is cached at column setup so we don't re-scan
1346
- // the column vector on every wakeup (every `min_interval`).
1347
- bool any_dirty = st->has_spinner_col;
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) render_frame(st);
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(self->state->render_mtx);
1806
+ std::lock_guard<std::mutex> lg(st->render_mtx);
1741
1807
  if (task_id < 0 ||
1742
- static_cast<size_t>(task_id) >= self->state->tasks.size()) {
1743
- PyErr_SetString(PyExc_IndexError, "task_id out of range");
1744
- return nullptr;
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
- target = self->state->tasks[static_cast<size_t>(task_id)].get();
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 id;
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(self->state->render_mtx);
1772
- id = static_cast<int>(self->state->tasks.size());
1848
+ std::lock_guard<std::mutex> lg(st->render_mtx);
1849
+ id = static_cast<int>(st->tasks.size());
1773
1850
  task->id = id;
1774
- self->state->tasks.push_back(std::move(task));
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
- self->state->task0.compare_exchange_strong(
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(self->state->render_mtx);
1884
+ std::lock_guard<std::mutex> lg(st->render_mtx);
1801
1885
  if (task_id < 0 ||
1802
- static_cast<size_t>(task_id) >= self->state->tasks.size()) {
1803
- PyErr_SetString(PyExc_IndexError, "task_id out of range");
1804
- return nullptr;
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
- self->state->render_cv.notify_one();
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(self->state->render_mtx);
1928
+ std::lock_guard<std::mutex> lg(st->render_mtx);
1835
1929
  if (task_id < 0 ||
1836
- static_cast<size_t>(task_id) >= self->state->tasks.size()) {
1837
- PyErr_SetString(PyExc_IndexError, "task_id out of range");
1838
- return nullptr;
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
- self->state->render_cv.notify_one();
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(self->state->render_mtx);
1859
- if (self->state->tasks.empty()) {
1860
- PyErr_SetString(PyExc_IndexError, "no tasks");
1861
- return nullptr;
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
- self->state->render_cv.notify_one();
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(self->state->render_mtx);
1961
- n = static_cast<Py_ssize_t>(self->state->tasks.size());
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
- PyErr_SetString(PyExc_RuntimeError, "progress has no tasks");
2056
- return -1;
2057
- }
2058
- if (task_id < 0 || static_cast<size_t>(task_id) >= st->tasks.size()) {
2059
- PyErr_SetString(PyExc_IndexError, "task_id out of range");
2060
- return -1;
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
- self->task = st->tasks[static_cast<size_t>(task_id)].get();
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
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: barflow
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: Fast Python progress bars with a C++ core. Windows-first.
5
5
  Author: NevermindNilas
6
6
  License-Expression: MIT
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