From dc184ceafff03331d1b5829b91803a421996ccc2 Mon Sep 17 00:00:00 2001 From: orcarmi Date: Wed, 2 Aug 2017 14:30:18 +0300 Subject: [PATCH 01/29] Created context manager --- pyspin/spin.py | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index 7093a0c..c33b8e8 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -4,6 +4,7 @@ from __future__ import absolute_import, print_function import sys +import threading import time from functools import wraps from concurrent.futures import ThreadPoolExecutor @@ -14,7 +15,6 @@ else: text_type = str - Box1 = u'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' Box2 = u'⠋⠙⠚⠞⠖⠦⠴⠲⠳⠓' Box3 = u'⠄⠆⠇⠋⠙⠸⠰⠠⠰⠸⠙⠋⠇⠆' @@ -35,11 +35,14 @@ class Spinner(object): - - def __init__(self, frames): + def __init__(self, frames=Default, words="", ending="\n"): self.frames = frames self.length = len(frames) self.position = 0 + self.words = words + self.ending = ending + self.stop_running = None + self.spin_thread = None def current(self): return self.frames[self.position] @@ -52,6 +55,35 @@ def next(self): def reset(self): self.position = 0 + def start(self): + if sys.stdout.isatty(): + self.stop_running = threading.Event() + self.spin_thread = threading.Thread(target=self.init_spin) + self.spin_thread.start() + + def stop(self): + if self.spin_thread: + self.stop_running.set() + self.spin_thread.join() + + def init_spin(self): + while not self.stop_running.is_set(): + print(text_type("\r{0} {1}").format(self.next(), + self.words), + end="") + sys.stdout.flush() + time.sleep(0.1) + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + print(self.ending, end="") + sys.stdout.flush() + return False + def make_spin(spin_style=Default, words="", ending="\n"): spinner = Spinner(spin_style) @@ -71,5 +103,7 @@ def wrapper(*args, **kwargs): print(ending, end="") return future.result() + return wrapper + return decorator From 8cc412bc1d5da03a8192239f2491ca75cf4edf3d Mon Sep 17 00:00:00 2001 From: orcarmi Date: Wed, 2 Aug 2017 14:33:03 +0300 Subject: [PATCH 02/29] Added test --- test_pyspin.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test_pyspin.py b/test_pyspin.py index d0510a1..d981cfd 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -28,6 +28,7 @@ def test_make_spin(): @spin.make_spin(spin.Default, 'Downloading...') def fake_download(): time.sleep(2) + fake_download() @@ -36,6 +37,7 @@ def test_make_spin_with_args(): def fake_download(url, retry_times=3): print("Downloading {0}, will retry {1} times".format(url, retry_times)) time.sleep(2) + fake_download("https://www.example.com/text.txt", retry_times=5) @@ -43,6 +45,7 @@ def test_stop_on_exception(): @spin.make_spin(spin.Default, 'Downloading...') def fake_download(): 1 / 0 + try: fake_download() except ZeroDivisionError: @@ -53,7 +56,16 @@ def test_several_calls(): @spin.make_spin(spin.Default, 'Downloading...') def fake_download(): time.sleep(2) + print("Begin the first download.") fake_download() print("Begin the second download.") fake_download() + + +def test_context_manager(): + def fake_download(): + time.sleep(2) + + with spin.Spinner(): + fake_download() From d0d63d02da5d88f2e13b2f20a666d0fce16101ef Mon Sep 17 00:00:00 2001 From: orcarmi Date: Wed, 2 Aug 2017 14:40:36 +0300 Subject: [PATCH 03/29] Fixed pep8 --- pyspin/spin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index c33b8e8..527c842 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -69,8 +69,8 @@ def stop(self): def init_spin(self): while not self.stop_running.is_set(): print(text_type("\r{0} {1}").format(self.next(), - self.words), - end="") + self.words), + end="") sys.stdout.flush() time.sleep(0.1) From 384e6f82760f58716c55071af1d14be408bfd258 Mon Sep 17 00:00:00 2001 From: "Daniel M. Capella" Date: Thu, 22 Nov 2018 00:31:39 +0000 Subject: [PATCH 04/29] Publish test To be able to run the test prior to installation, as part of building a distro package. --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 04f196a..436a04e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include README.md include LICENSE +include test_pyspin.py From f038a8e3315bb00e905ab9c555cfb847fc9c0bdf Mon Sep 17 00:00:00 2001 From: lord63 Date: Tue, 1 Jan 2019 21:55:10 +0800 Subject: [PATCH 05/29] Exclude useless file in dist, #17 --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 436a04e..200963c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include README.md include LICENSE include test_pyspin.py +global-exclude *.py[co] From c6a7d565c372a1c9f30d13ac072766bd20dee56c Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:07:23 +0800 Subject: [PATCH 06/29] Avoid ending output when spinner never starts --- pyspin/spin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index 527c842..b56ccc4 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -80,8 +80,9 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self.stop() - print(self.ending, end="") - sys.stdout.flush() + if self.spin_thread: + print(self.ending, end="") + sys.stdout.flush() return False From 866dc916ce556090675cc0df4017f4f4ced5f692 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:08:19 +0800 Subject: [PATCH 07/29] Unify spinner rendering logic --- pyspin/spin.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index b56ccc4..b6e2a71 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -68,12 +68,13 @@ def stop(self): def init_spin(self): while not self.stop_running.is_set(): - print(text_type("\r{0} {1}").format(self.next(), - self.words), - end="") - sys.stdout.flush() + self._spin_cursor() time.sleep(0.1) + def _spin_cursor(self): + print(text_type("\r{0} {1}").format(self.next(), self.words), end="") + sys.stdout.flush() + def __enter__(self): self.start() return self @@ -87,7 +88,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): def make_spin(spin_style=Default, words="", ending="\n"): - spinner = Spinner(spin_style) + spinner = Spinner(spin_style, words, ending) def decorator(func): @wraps(func) @@ -95,14 +96,13 @@ def wrapper(*args, **kwargs): with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(func, *args, **kwargs) - while future.running(): - print(text_type("\r{0} {1}").format(spinner.next(), - words), - end="") - sys.stdout.flush() - time.sleep(0.1) + if sys.stdout.isatty(): + while future.running(): + spinner._spin_cursor() + time.sleep(0.1) - print(ending, end="") + if sys.stdout.isatty(): + print(spinner.ending, end="") return future.result() return wrapper From 60da802451cf18113f4d86f295544166065c26bb Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:09:12 +0800 Subject: [PATCH 08/29] Add spinner regression tests --- test_pyspin.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test_pyspin.py b/test_pyspin.py index d981cfd..2c9a633 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -1,11 +1,25 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import io +import threading import time +import pytest + from pyspin import spin +class TtyStringIO(io.StringIO): + def isatty(self): + return True + + +class NonTtyStringIO(io.StringIO): + def isatty(self): + return False + + def test_spinner(): spinner = spin.Spinner(spin.Spin9) assert spinner.length == 4 @@ -69,3 +83,48 @@ def fake_download(): with spin.Spinner(): fake_download() + + +def test_make_spin_custom_params(monkeypatch): + stdout = TtyStringIO() + monkeypatch.setattr(spin.sys, 'stdout', stdout) + + @spin.make_spin('+-', 'Downloading...', 'DONE') + def fake_download(): + threading.Event().wait(0.2) + + fake_download() + + output = stdout.getvalue() + assert 'Downloading...' in output + assert 'DONE' in output + assert '\r+ Downloading...' in output or '\r- Downloading...' in output + + +def test_context_manager_stops_on_exception(monkeypatch): + stdout = TtyStringIO() + monkeypatch.setattr(spin.sys, 'stdout', stdout) + spinner = spin.Spinner('+-', 'Downloading...', 'DONE') + + with pytest.raises(RuntimeError): + with spinner: + threading.Event().wait(0.2) + raise RuntimeError('boom') + + assert not spinner.spin_thread.is_alive() + assert stdout.getvalue().endswith('DONE') + + +def test_no_output_when_stdout_is_not_a_tty(monkeypatch): + stdout = NonTtyStringIO() + monkeypatch.setattr(spin.sys, 'stdout', stdout) + + @spin.make_spin('+-', 'Downloading...', 'DONE') + def fake_download(): + threading.Event().wait(0.2) + + fake_download() + with spin.Spinner('+-', 'Downloading...', 'DONE'): + threading.Event().wait(0.2) + + assert stdout.getvalue() == '' From ac4b08798a3921124768ef5de1ab677994fd1496 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:09:35 +0800 Subject: [PATCH 09/29] Make spinner lifecycle helpers private --- pyspin/spin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index b6e2a71..73dfded 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -55,13 +55,13 @@ def next(self): def reset(self): self.position = 0 - def start(self): + def _start(self): if sys.stdout.isatty(): self.stop_running = threading.Event() self.spin_thread = threading.Thread(target=self.init_spin) self.spin_thread.start() - def stop(self): + def _stop(self): if self.spin_thread: self.stop_running.set() self.spin_thread.join() @@ -76,11 +76,11 @@ def _spin_cursor(self): sys.stdout.flush() def __enter__(self): - self.start() + self._start() return self def __exit__(self, exc_type, exc_val, exc_tb): - self.stop() + self._stop() if self.spin_thread: print(self.ending, end="") sys.stdout.flush() From 4ea76f711ec8d22bc2fd0f9c5ab6fb270fbbec75 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:09:59 +0800 Subject: [PATCH 10/29] Guard against re-entering a running spinner --- pyspin/spin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyspin/spin.py b/pyspin/spin.py index 73dfded..9dd54e7 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -76,6 +76,8 @@ def _spin_cursor(self): sys.stdout.flush() def __enter__(self): + if self.spin_thread and self.spin_thread.is_alive(): + raise RuntimeError("Spinner is already running") self._start() return self From eb0454cbedc812b90e0af29c875fe1309d4d6409 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:10:25 +0800 Subject: [PATCH 11/29] Rename stop event for clarity --- pyspin/spin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index 9dd54e7..55b8b1f 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -41,7 +41,7 @@ def __init__(self, frames=Default, words="", ending="\n"): self.position = 0 self.words = words self.ending = ending - self.stop_running = None + self._stop_event = None self.spin_thread = None def current(self): @@ -57,17 +57,17 @@ def reset(self): def _start(self): if sys.stdout.isatty(): - self.stop_running = threading.Event() + self._stop_event = threading.Event() self.spin_thread = threading.Thread(target=self.init_spin) self.spin_thread.start() def _stop(self): if self.spin_thread: - self.stop_running.set() + self._stop_event.set() self.spin_thread.join() def init_spin(self): - while not self.stop_running.is_set(): + while not self._stop_event.is_set(): self._spin_cursor() time.sleep(0.1) From 4c1e96dcfdb9d17dffe11d995af2d8903ed1f8b6 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:12:55 +0800 Subject: [PATCH 12/29] Reset spinner state after stop --- pyspin/spin.py | 5 ++++- test_pyspin.py | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index 55b8b1f..5232a0d 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -65,6 +65,8 @@ def _stop(self): if self.spin_thread: self._stop_event.set() self.spin_thread.join() + self.spin_thread = None + self._stop_event = None def init_spin(self): while not self._stop_event.is_set(): @@ -82,8 +84,9 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): + should_print_ending = self.spin_thread is not None self._stop() - if self.spin_thread: + if should_print_ending: print(self.ending, end="") sys.stdout.flush() return False diff --git a/test_pyspin.py b/test_pyspin.py index 2c9a633..fb82aab 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -111,7 +111,8 @@ def test_context_manager_stops_on_exception(monkeypatch): threading.Event().wait(0.2) raise RuntimeError('boom') - assert not spinner.spin_thread.is_alive() + assert spinner.spin_thread is None + assert spinner._stop_event is None assert stdout.getvalue().endswith('DONE') @@ -128,3 +129,22 @@ def fake_download(): threading.Event().wait(0.2) assert stdout.getvalue() == '' + + +def test_context_manager_reuse_after_tty_then_non_tty(monkeypatch): + tty_stdout = TtyStringIO() + monkeypatch.setattr(spin.sys, 'stdout', tty_stdout) + spinner = spin.Spinner('+-', 'Downloading...', 'DONE') + + with spinner: + threading.Event().wait(0.2) + + non_tty_stdout = NonTtyStringIO() + monkeypatch.setattr(spin.sys, 'stdout', non_tty_stdout) + + with spinner: + threading.Event().wait(0.2) + + assert spinner.spin_thread is None + assert spinner._stop_event is None + assert non_tty_stdout.getvalue() == '' From a2e3ac29795af0f1c7a52779f0e01dbeb5a80d8a Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:13:11 +0800 Subject: [PATCH 13/29] Rename init_spin to _init_spin --- pyspin/spin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index 5232a0d..90a3eed 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -58,7 +58,7 @@ def reset(self): def _start(self): if sys.stdout.isatty(): self._stop_event = threading.Event() - self.spin_thread = threading.Thread(target=self.init_spin) + self.spin_thread = threading.Thread(target=self._init_spin) self.spin_thread.start() def _stop(self): @@ -68,7 +68,7 @@ def _stop(self): self.spin_thread = None self._stop_event = None - def init_spin(self): + def _init_spin(self): while not self._stop_event.is_set(): self._spin_cursor() time.sleep(0.1) From d2105c9affe21bb5053cb6a5c65b875121a41352 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:13:26 +0800 Subject: [PATCH 14/29] Deduplicate TTY check in make_spin --- pyspin/spin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index 90a3eed..e186d06 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -100,13 +100,14 @@ def decorator(func): def wrapper(*args, **kwargs): with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(func, *args, **kwargs) + is_tty = sys.stdout.isatty() - if sys.stdout.isatty(): + if is_tty: while future.running(): spinner._spin_cursor() time.sleep(0.1) - if sys.stdout.isatty(): + if is_tty: print(spinner.ending, end="") return future.result() From c73e6c32bd6e61a903cb041891d6c559956c9d77 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:34:30 +0800 Subject: [PATCH 15/29] Shorten context manager test wait --- test_pyspin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_pyspin.py b/test_pyspin.py index fb82aab..4c200f1 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -79,7 +79,7 @@ def fake_download(): def test_context_manager(): def fake_download(): - time.sleep(2) + threading.Event().wait(0.2) with spin.Spinner(): fake_download() From 592b003cd55e0f40176abf72ba975c6a7fa59abd Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:35:01 +0800 Subject: [PATCH 16/29] Harden tty detection --- pyspin/spin.py | 8 ++++++-- test_pyspin.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index e186d06..ca8935d 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -34,6 +34,10 @@ Default = Box1 +def _is_tty(output): + return getattr(output, 'isatty', lambda: False)() + + class Spinner(object): def __init__(self, frames=Default, words="", ending="\n"): self.frames = frames @@ -56,7 +60,7 @@ def reset(self): self.position = 0 def _start(self): - if sys.stdout.isatty(): + if _is_tty(sys.stdout): self._stop_event = threading.Event() self.spin_thread = threading.Thread(target=self._init_spin) self.spin_thread.start() @@ -100,7 +104,7 @@ def decorator(func): def wrapper(*args, **kwargs): with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(func, *args, **kwargs) - is_tty = sys.stdout.isatty() + is_tty = _is_tty(sys.stdout) if is_tty: while future.running(): diff --git a/test_pyspin.py b/test_pyspin.py index 4c200f1..5c4c1ab 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -20,6 +20,10 @@ def isatty(self): return False +class NoIsattyStringIO(io.StringIO): + pass + + def test_spinner(): spinner = spin.Spinner(spin.Spin9) assert spinner.length == 4 @@ -131,6 +135,21 @@ def fake_download(): assert stdout.getvalue() == '' +def test_no_output_when_stdout_has_no_isatty(monkeypatch): + stdout = NoIsattyStringIO() + monkeypatch.setattr(spin.sys, 'stdout', stdout) + + @spin.make_spin('+-', 'Downloading...', 'DONE') + def fake_download(): + threading.Event().wait(0.2) + + fake_download() + with spin.Spinner('+-', 'Downloading...', 'DONE'): + threading.Event().wait(0.2) + + assert stdout.getvalue() == '' + + def test_context_manager_reuse_after_tty_then_non_tty(monkeypatch): tty_stdout = TtyStringIO() monkeypatch.setattr(spin.sys, 'stdout', tty_stdout) From 8ff29a32b03fb0803c1dc47dba45e8f407f4d49b Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:35:30 +0800 Subject: [PATCH 17/29] Flush ending output in make_spin --- pyspin/spin.py | 1 + test_pyspin.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/pyspin/spin.py b/pyspin/spin.py index ca8935d..1f1e994 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -113,6 +113,7 @@ def wrapper(*args, **kwargs): if is_tty: print(spinner.ending, end="") + sys.stdout.flush() return future.result() return wrapper diff --git a/test_pyspin.py b/test_pyspin.py index 5c4c1ab..d902185 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -24,6 +24,16 @@ class NoIsattyStringIO(io.StringIO): pass +class FlushTrackingTtyStringIO(TtyStringIO): + def __init__(self): + super(FlushTrackingTtyStringIO, self).__init__() + self.flush_count = 0 + + def flush(self): + self.flush_count += 1 + super(FlushTrackingTtyStringIO, self).flush() + + def test_spinner(): spinner = spin.Spinner(spin.Spin9) assert spinner.length == 4 @@ -167,3 +177,16 @@ def test_context_manager_reuse_after_tty_then_non_tty(monkeypatch): assert spinner.spin_thread is None assert spinner._stop_event is None assert non_tty_stdout.getvalue() == '' + + +def test_make_spin_flushes_ending(monkeypatch): + stdout = FlushTrackingTtyStringIO() + monkeypatch.setattr(spin.sys, 'stdout', stdout) + + @spin.make_spin('+-', 'Downloading...', 'DONE') + def fake_download(): + return 'ok' + + assert fake_download() == 'ok' + assert stdout.getvalue() == 'DONE' + assert stdout.flush_count == 1 From 8b03e16586301bfc9f54b2df32b82573db440a4c Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:35:38 +0800 Subject: [PATCH 18/29] Document Spinner context manager usage --- README.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.rst b/README.rst index fe83b14..819e0bc 100644 --- a/README.rst +++ b/README.rst @@ -61,6 +61,24 @@ or you can use the decorator pyspin provide: download_video() print("Done!") +You can also use ``Spinner`` as a context manager: + +.. code:: python + + from __future__ import print_function + + import time + + from pyspin.spin import Default, Spinner + + def download_video(): + time.sleep(10) + + if __name__ == '__main__': + print("I'm going to download a video, and it'll cost much time.") + with Spinner(Default, "Downloading...", "Done!\n"): + download_video() + You can have a look at the example code in the example folder. Run it via: From a59c2f83ff7bc4459ee894fc835e275655e2b368 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:37:48 +0800 Subject: [PATCH 19/29] Fix no-isatty stdout test double --- test_pyspin.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test_pyspin.py b/test_pyspin.py index d902185..42b3f79 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -20,8 +20,15 @@ def isatty(self): return False -class NoIsattyStringIO(io.StringIO): - pass +class BareStream(object): + def __init__(self): + self._buf = [] + + def write(self, s): + self._buf.append(s) + + def getvalue(self): + return ''.join(self._buf) class FlushTrackingTtyStringIO(TtyStringIO): @@ -146,7 +153,7 @@ def fake_download(): def test_no_output_when_stdout_has_no_isatty(monkeypatch): - stdout = NoIsattyStringIO() + stdout = BareStream() monkeypatch.setattr(spin.sys, 'stdout', stdout) @spin.make_spin('+-', 'Downloading...', 'DONE') From 57b28da5a7891d4d9eb4e4a7e687069c4f7944f1 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:58:45 +0800 Subject: [PATCH 20/29] Handle non-callable isatty safely --- pyspin/spin.py | 7 ++++++- test_pyspin.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index 1f1e994..a2a6e0e 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -35,7 +35,12 @@ def _is_tty(output): - return getattr(output, 'isatty', lambda: False)() + isatty_attr = getattr(output, 'isatty', None) + if callable(isatty_attr): + return isatty_attr() + if isinstance(isatty_attr, bool): + return isatty_attr + return False class Spinner(object): diff --git a/test_pyspin.py b/test_pyspin.py index 42b3f79..784c07f 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -31,6 +31,10 @@ def getvalue(self): return ''.join(self._buf) +class BoolIsattyStream(BareStream): + isatty = False + + class FlushTrackingTtyStringIO(TtyStringIO): def __init__(self): super(FlushTrackingTtyStringIO, self).__init__() @@ -167,6 +171,21 @@ def fake_download(): assert stdout.getvalue() == '' +def test_no_output_when_stdout_has_bool_isatty(monkeypatch): + stdout = BoolIsattyStream() + monkeypatch.setattr(spin.sys, 'stdout', stdout) + + @spin.make_spin('+-', 'Downloading...', 'DONE') + def fake_download(): + threading.Event().wait(0.2) + + fake_download() + with spin.Spinner('+-', 'Downloading...', 'DONE'): + threading.Event().wait(0.2) + + assert stdout.getvalue() == '' + + def test_context_manager_reuse_after_tty_then_non_tty(monkeypatch): tty_stdout = TtyStringIO() monkeypatch.setattr(spin.sys, 'stdout', tty_stdout) From 753eea8aa969ae90e86f2fbdeefe70aae7bbd613 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:58:55 +0800 Subject: [PATCH 21/29] Stabilize context manager test stdout handling --- test_pyspin.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test_pyspin.py b/test_pyspin.py index 784c07f..4d8f25d 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -102,13 +102,21 @@ def fake_download(): fake_download() -def test_context_manager(): +def test_context_manager(monkeypatch): + stdout = TtyStringIO() + monkeypatch.setattr(spin.sys, 'stdout', stdout) + def fake_download(): threading.Event().wait(0.2) - with spin.Spinner(): + spinner = spin.Spinner() + with spinner: fake_download() + assert spinner.spin_thread is None + assert spinner._stop_event is None + assert stdout.getvalue() + def test_make_spin_custom_params(monkeypatch): stdout = TtyStringIO() From de146ecef5223cd520b1a647f74bbd8d9642c9ed Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 16:59:05 +0800 Subject: [PATCH 22/29] Relax flush test timing-sensitive assertions --- test_pyspin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test_pyspin.py b/test_pyspin.py index 4d8f25d..6d7afc0 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -222,5 +222,6 @@ def fake_download(): return 'ok' assert fake_download() == 'ok' - assert stdout.getvalue() == 'DONE' - assert stdout.flush_count == 1 + output = stdout.getvalue() + assert output.endswith('DONE') + assert stdout.flush_count >= 1 From 1b67847a6850390c0041eb0136219757b982abd9 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 17:11:52 +0800 Subject: [PATCH 23/29] Add more tests - Add spinner reentry context manager test - Parametrize non-TTY stdout tests --- test_pyspin.py | 47 +++++++++++++++++------------------------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/test_pyspin.py b/test_pyspin.py index 6d7afc0..8c8e673 100644 --- a/test_pyspin.py +++ b/test_pyspin.py @@ -149,38 +149,25 @@ def test_context_manager_stops_on_exception(monkeypatch): assert stdout.getvalue().endswith('DONE') -def test_no_output_when_stdout_is_not_a_tty(monkeypatch): - stdout = NonTtyStringIO() - monkeypatch.setattr(spin.sys, 'stdout', stdout) - - @spin.make_spin('+-', 'Downloading...', 'DONE') - def fake_download(): - threading.Event().wait(0.2) - - fake_download() - with spin.Spinner('+-', 'Downloading...', 'DONE'): - threading.Event().wait(0.2) - - assert stdout.getvalue() == '' - - -def test_no_output_when_stdout_has_no_isatty(monkeypatch): - stdout = BareStream() +def test_context_manager_raises_when_reentered_while_running(monkeypatch): + stdout = TtyStringIO() monkeypatch.setattr(spin.sys, 'stdout', stdout) + spinner = spin.Spinner('+-', 'Downloading...', 'DONE') - @spin.make_spin('+-', 'Downloading...', 'DONE') - def fake_download(): - threading.Event().wait(0.2) - - fake_download() - with spin.Spinner('+-', 'Downloading...', 'DONE'): - threading.Event().wait(0.2) - - assert stdout.getvalue() == '' - - -def test_no_output_when_stdout_has_bool_isatty(monkeypatch): - stdout = BoolIsattyStream() + spinner.__enter__() + try: + with pytest.raises(RuntimeError, match="already running"): + spinner.__enter__() + finally: + spinner.__exit__(None, None, None) + + +@pytest.mark.parametrize( + 'stdout_factory', + [NonTtyStringIO, BareStream, BoolIsattyStream], +) +def test_no_output_when_stdout_is_not_a_tty(monkeypatch, stdout_factory): + stdout = stdout_factory() monkeypatch.setattr(spin.sys, 'stdout', stdout) @spin.make_spin('+-', 'Downloading...', 'DONE') From 57a17046faf6306e32dabb2d49e4a2dc0f5aac3a Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 17:33:53 +0800 Subject: [PATCH 24/29] Migrate to uv and github action, drop 2.x support --- .github/workflows/ci.yml | 27 +++++++ .travis.yml | 19 ----- dev-requirements.txt | 3 - pyproject.toml | 34 +++++++++ pyspin/spin.py | 13 +--- setup.cfg | 2 - setup.py | 46 ------------ tox.ini | 15 ---- uv.lock | 156 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 219 insertions(+), 96 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml delete mode 100644 dev-requirements.txt create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py delete mode 100644 tox.ini create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ebc06f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [master, dev] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --dev + + - name: Run tests + run: uv run pytest -v test_pyspin.py diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 155db79..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: python - -python: - - "2.7" - - "3.3" - - "3.4" - - "3.5" - - "3.6" - -install: - - pip install -U pip setuptools wheel - - pip install -r dev-requirements.txt - - pip install -e . - -script: - - make test - -notifications: - email: false diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index abc6245..0000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pytest -pytest-pep8 -tox diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4b99b91 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "pyspin" +version = "1.1.1" +description = "Little terminal spinner lib." +readme = "README.rst" +license = { text = "MIT" } +authors = [{ name = "lord63", email = "lord63.j@gmail.com" }] +requires-python = ">=3.10" +keywords = ["terminal", "spin", "spinner"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +[project.urls] +Homepage = "http://github.com/lord63/py-spin" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = ["pytest>=8.0"] + +[tool.hatch.build.targets.wheel] +packages = ["pyspin"] diff --git a/pyspin/spin.py b/pyspin/spin.py index a2a6e0e..cefbb41 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -1,7 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- - -from __future__ import absolute_import, print_function import sys import threading @@ -9,12 +6,6 @@ from functools import wraps from concurrent.futures import ThreadPoolExecutor -# For python 2/3 compatible. -if sys.version_info.major == 2: - text_type = unicode -else: - text_type = str - Box1 = u'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' Box2 = u'⠋⠙⠚⠞⠖⠦⠴⠲⠳⠓' Box3 = u'⠄⠆⠇⠋⠙⠸⠰⠠⠰⠸⠙⠋⠇⠆' @@ -59,7 +50,7 @@ def current(self): def next(self): current_frame = self.current() self.position = (self.position + 1) % self.length - return text_type(current_frame) + return str(current_frame) def reset(self): self.position = 0 @@ -83,7 +74,7 @@ def _init_spin(self): time.sleep(0.1) def _spin_cursor(self): - print(text_type("\r{0} {1}").format(self.next(), self.words), end="") + print(str("\r{0} {1}").format(self.next(), self.words), end="") sys.stdout.flush() def __enter__(self): diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2a9acf1..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 4cc4e8c..0000000 --- a/setup.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import sys - -from setuptools import setup - -import pyspin - -with open('README.rst') as f: - long_description = f.read() - - -setup( - name='pyspin', - version=pyspin.__version__, - description='Little terminal spinner lib.', - long_description=long_description, - url='http://github.com/lord63/py-spin', - author='lord63', - author_email='lord63.j@gmail.com', - license='MIT', - classifiers=[ - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'Operating System :: POSIX', - 'Operating System :: POSIX :: Linux', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - ], - keywords='terminal spin spinner', - packages=['pyspin'], - include_package_data=True, - setup_requires=[ - 'setuptools >= 20.8.1', - ], - install_requires=[ - 'futures; python_version < "3.2"', - ], -) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 8d41e77..0000000 --- a/tox.ini +++ /dev/null @@ -1,15 +0,0 @@ -# Tox (http://tox.testrun.org/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. - -[tox] -envlist = py27, py33, py34, py35, py36 - -[testenv] -commands = - py.test --pep8 -v test_pyspin.py pyspin/ -deps = - pytest - pytest-pep8 - diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ce2b304 --- /dev/null +++ b/uv.lock @@ -0,0 +1,156 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyspin" +version = "1.1.1" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] From 3d54390ba4ce1054c6dd79da45b639619edba0ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 09:43:42 +0000 Subject: [PATCH 25/29] Apply PR review: frozen sync, ruff lint, https URL, remove redundant str() Co-authored-by: lord63 <5268051+lord63@users.noreply.github.com> Agent-Logs-Url: https://github.com/lord63/py-spin/sessions/d2ede27f-2a5b-4c50-8efc-1cdfb90920d3 --- .github/workflows/ci.yml | 5 ++++- pyproject.toml | 4 ++-- pyspin/spin.py | 2 +- uv.lock | 31 ++++++++++++++++++++++++++++++- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ebc06f..8c95210 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,10 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: uv sync --dev + run: uv sync --dev --frozen + + - name: Lint + run: uv run ruff check . - name: Run tests run: uv run pytest -v test_pyspin.py diff --git a/pyproject.toml b/pyproject.toml index 4b99b91..077dcbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,14 +21,14 @@ classifiers = [ ] [project.urls] -Homepage = "http://github.com/lord63/py-spin" +Homepage = "https://github.com/lord63/py-spin" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [dependency-groups] -dev = ["pytest>=8.0"] +dev = ["pytest>=8.0", "ruff"] [tool.hatch.build.targets.wheel] packages = ["pyspin"] diff --git a/pyspin/spin.py b/pyspin/spin.py index cefbb41..2b4e170 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -74,7 +74,7 @@ def _init_spin(self): time.sleep(0.1) def _spin_cursor(self): - print(str("\r{0} {1}").format(self.next(), self.words), end="") + print("\r{0} {1}".format(self.next(), self.words), end="") sys.stdout.flush() def __enter__(self): diff --git a/uv.lock b/uv.lock index ce2b304..537765f 100644 --- a/uv.lock +++ b/uv.lock @@ -67,12 +67,16 @@ source = { editable = "." } [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0" }] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff" }, +] [[package]] name = "pytest" @@ -92,6 +96,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + [[package]] name = "tomli" version = "2.4.0" From e21ea5f9dd6886c06ff8c951e5879f838a041511 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 18:00:32 +0800 Subject: [PATCH 26/29] Set spinner thread as daemon to prevent hanging process on unexpected exit Co-Authored-By: Claude Sonnet 4.6 --- pyspin/spin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index 2b4e170..0ba4cb4 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -58,7 +58,11 @@ def reset(self): def _start(self): if _is_tty(sys.stdout): self._stop_event = threading.Event() - self.spin_thread = threading.Thread(target=self._init_spin) + self.spin_thread = threading.Thread( + target=self._init_spin, + daemon=True, + name="SpinnerThread", + ) self.spin_thread.start() def _stop(self): From c223308c9801b0d03890a4a5e82272ddedcd4f36 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 18:06:46 +0800 Subject: [PATCH 27/29] Modernize examples: drop Python 2 leftovers, add context manager usage Co-Authored-By: Claude Sonnet 4.6 --- example/example_spin.py | 49 ++++++++++++++++++++-------------------- example/usage_example.py | 27 +++++++++++++--------- 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/example/example_spin.py b/example/example_spin.py index 80f8075..08118ee 100644 --- a/example/example_spin.py +++ b/example/example_spin.py @@ -1,42 +1,43 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +#!/usr/bin/env python3 +# Style showcase — runs each built-in spinner style so you can see what it looks like. -from __future__ import absolute_import, print_function - -import sys import time from pyspin import spin +STYLES = [ + ("Default", spin.Default), + ("Box1", spin.Box1), + ("Box2", spin.Box2), + ("Box3", spin.Box3), + ("Box4", spin.Box4), + ("Box5", spin.Box5), + ("Box6", spin.Box6), + ("Box7", spin.Box7), + ("Spin1", spin.Spin1), + ("Spin2", spin.Spin2), + ("Spin3", spin.Spin3), + ("Spin4", spin.Spin4), + ("Spin5", spin.Spin5), + ("Spin6", spin.Spin6), + ("Spin7", spin.Spin7), + ("Spin8", spin.Spin8), + ("Spin9", spin.Spin9), +] + def show(name, frames): s = spin.Spinner(frames) print(name) for i in range(50): - print(u"\r{0}".format(s.next()), end="") - sys.stdout.flush() + print("\r{0}".format(s.next()), end="", flush=True) time.sleep(0.1) print('\n') def main(): - show("Default", spin.Default) - show("Box1", spin.Box1) - show("Box2", spin.Box2) - show("Box3", spin.Box3) - show("Box4", spin.Box4) - show("Box5", spin.Box5) - show("Box6", spin.Box6) - show("Box7", spin.Box7) - show("Spin1", spin.Spin1) - show("Spin2", spin.Spin2) - show("Spin3", spin.Spin3) - show("Spin4", spin.Spin4) - show("Spin5", spin.Spin5) - show("Spin6", spin.Spin6) - show("Spin7", spin.Spin7) - show("Spin8", spin.Spin8) - show("Spin9", spin.Spin9) + for name, frames in STYLES: + show(name, frames) if __name__ == '__main__': diff --git a/example/usage_example.py b/example/usage_example.py index c28427f..0da8308 100644 --- a/example/usage_example.py +++ b/example/usage_example.py @@ -1,20 +1,25 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -from __future__ import absolute_import, print_function +#!/usr/bin/env python3 import time -from pyspin.spin import make_spin, Spin1 +from pyspin.spin import Default, Spinner, make_spin, Spin1 -@make_spin(Spin1, "Downloading...") -def demo(): +# Context manager — recommended usage. +def download_video(): time.sleep(5) if __name__ == '__main__': - print("Assume we're downloading a video") - print("It would cost much time.") - demo() - print("Download success!") + print("Downloading a video with context manager:") + with Spinner(Default, "Downloading...", "Done!\n"): + download_video() + + # Decorator — alternative usage. + @make_spin(Spin1, "Downloading...") + def download_video_with_decorator(): + time.sleep(5) + + print("Downloading a video with decorator:") + download_video_with_decorator() + print("Done!") From 9a9bd694d5ab7432976e20b0114ce133a7cd9cc7 Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 18:35:24 +0800 Subject: [PATCH 28/29] Add comments --- pyspin/spin.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/pyspin/spin.py b/pyspin/spin.py index 0ba4cb4..9658188 100644 --- a/pyspin/spin.py +++ b/pyspin/spin.py @@ -24,7 +24,8 @@ Spin9 = u'←↑→↓' Default = Box1 - +# isatty is not part of any formal interface, so guard against streams that +# define it as a non-callable attribute (e.g. a plain bool) rather than a method. def _is_tty(output): isatty_attr = getattr(output, 'isatty', None) if callable(isatty_attr): @@ -58,6 +59,9 @@ def reset(self): def _start(self): if _is_tty(sys.stdout): self._stop_event = threading.Event() + # daemon=True: if the process exits unexpectedly without calling + # _stop(), this thread is killed automatically instead of keeping + # the process alive. self.spin_thread = threading.Thread( target=self._init_spin, daemon=True, @@ -75,11 +79,11 @@ def _stop(self): def _init_spin(self): while not self._stop_event.is_set(): self._spin_cursor() - time.sleep(0.1) def _spin_cursor(self): print("\r{0} {1}".format(self.next(), self.words), end="") sys.stdout.flush() + time.sleep(0.1) def __enter__(self): if self.spin_thread and self.spin_thread.is_alive(): @@ -92,6 +96,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._stop() if should_print_ending: print(self.ending, end="") + # The ending may not contain a newline, so flush explicitly to + # ensure it appears on screen before the caller continues. sys.stdout.flush() return False @@ -107,9 +113,11 @@ def wrapper(*args, **kwargs): is_tty = _is_tty(sys.stdout) if is_tty: - while future.running(): + # Use done() rather than running(): a future sits in PENDING + # state briefly after submit() where running() is False, which + # would cause the loop to exit before the task even starts. + while not future.done(): spinner._spin_cursor() - time.sleep(0.1) if is_tty: print(spinner.ending, end="") From d2674b6fc7877eec7bfdf4e5b95c875d17fe562c Mon Sep 17 00:00:00 2001 From: lord63 Date: Sat, 21 Mar 2026 18:11:43 +0800 Subject: [PATCH 29/29] Release 1.2.0 --- ChangLog => ChangLog.md | 13 +++++++++++++ MANIFEST.in | 2 +- Makefile | 9 ++++++--- README.rst | 10 ++-------- pyproject.toml | 2 +- pyspin/__init__.py | 2 +- uv.lock | 2 +- 7 files changed, 25 insertions(+), 15 deletions(-) rename ChangLog => ChangLog.md (66%) diff --git a/ChangLog b/ChangLog.md similarity index 66% rename from ChangLog rename to ChangLog.md index 59c029a..ff740e7 100644 --- a/ChangLog +++ b/ChangLog.md @@ -1,5 +1,18 @@ Version numbers are following the Semantic Versioning. +2026.03.21 v1.2.0 +- add + - context manager support for Spinner, thanks to @liiight +- fix + - flush ending output in make_spin + - harden TTY detection (support non-callable isatty and bool isatty) +- change + - drop Python 2.x support + - require Python 3.10+ +- other + - migrate from Travis CI to GitHub Actions + - migrate from pip/setup.py to uv + 2017.04.13 v1.1.1 - fix - fix incorrect futures requirement, thanks to @xoviat diff --git a/MANIFEST.in b/MANIFEST.in index 200963c..099a415 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include README.md +include README.rst include LICENSE include test_pyspin.py global-exclude *.py[co] diff --git a/Makefile b/Makefile index 3f539a2..495e1b6 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,11 @@ test: - @py.test --pep8 -v test_pyspin.py pyspin/ + @uv run pytest -v test_pyspin.py + +lint: + @uv run ruff check . create: - @python setup.py sdist bdist_wheel + @uv build upload: - @python setup.py sdist bdist_wheel upload + @uv publish diff --git a/README.rst b/README.rst index 819e0bc..b53c791 100644 --- a/README.rst +++ b/README.rst @@ -26,8 +26,6 @@ make a spinner by hand: .. code:: python - from __future__ import print_function - import sys import time @@ -45,8 +43,6 @@ or you can use the decorator pyspin provide: .. code:: python - from __future__ import print_function - import time from pyspin.spin import make_spin, Default @@ -65,8 +61,6 @@ You can also use ``Spinner`` as a context manager: .. code:: python - from __future__ import print_function - import time from pyspin.spin import Default, Spinner @@ -104,8 +98,8 @@ MIT. .. |Latest Version| image:: http://img.shields.io/pypi/v/pyspin.svg :target: https://pypi.python.org/pypi/pyspin -.. |Build Status| image:: https://travis-ci.org/lord63/py-spin.svg - :target: https://travis-ci.org/lord63/py-spin +.. |Build Status| image:: https://github.com/lord63/py-spin/actions/workflows/ci.yml/badge.svg + :target: https://github.com/lord63/py-spin/actions/workflows/ci.yml .. |Python Versions| image:: https://img.shields.io/pypi/pyversions/pyspin.svg :target: https://pypi.python.org/pypi/pyspin .. |pyspin_demo| image:: https://cloud.githubusercontent.com/assets/5268051/7448038/ba152a8c-f241-11e4-86e0-50bc3b33bce5.gif diff --git a/pyproject.toml b/pyproject.toml index 077dcbf..2f3d62a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyspin" -version = "1.1.1" +version = "1.2.0" description = "Little terminal spinner lib." readme = "README.rst" license = { text = "MIT" } diff --git a/pyspin/__init__.py b/pyspin/__init__.py index 2ab093a..9e9c689 100644 --- a/pyspin/__init__.py +++ b/pyspin/__init__.py @@ -12,7 +12,7 @@ """ __title__ = "pyspin" -__version__ = '1.1.1' +__version__ = '1.2.0' __author__ = "lord63" __license__ = "MIT" __copyright__ = "Copyright 2015 lord63" diff --git a/uv.lock b/uv.lock index 537765f..756c62b 100644 --- a/uv.lock +++ b/uv.lock @@ -61,7 +61,7 @@ wheels = [ [[package]] name = "pyspin" -version = "1.1.1" +version = "1.2.0" source = { editable = "." } [package.dev-dependencies]