muutils.spinner
decorator spinner_decorator
and context manager SpinnerContext
to display a spinner
using the base Spinner
class while some code is running.
1"""decorator `spinner_decorator` and context manager `SpinnerContext` to display a spinner 2 3using the base `Spinner` class while some code is running. 4""" 5 6import os 7import time 8import threading 9import sys 10from functools import wraps 11from typing import ( 12 Callable, 13 Any, 14 Optional, 15 TextIO, 16 TypeVar, 17 Sequence, 18 Dict, 19 Union, 20 ContextManager, 21) 22 23DecoratedFunction = TypeVar("DecoratedFunction", bound=Callable[..., Any]) 24"Define a generic type for the decorated function" 25 26 27SPINNER_CHARS: Dict[str, Sequence[str]] = dict( 28 default=["|", "/", "-", "\\"], 29 dots=[". ", ".. ", "..."], 30 bars=["| ", "|| ", "|||"], 31 arrows=["<", "^", ">", "v"], 32 arrows_2=["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"], 33 bouncing_bar=["[ ]", "[= ]", "[== ]", "[=== ]", "[ ===]", "[ ==]", "[ =]"], 34 bouncing_ball=[ 35 "( ● )", 36 "( ● )", 37 "( ● )", 38 "( ● )", 39 "( ●)", 40 "( ● )", 41 "( ● )", 42 "( ● )", 43 "( ● )", 44 "(● )", 45 ], 46 ooo=[".", "o", "O", "o"], 47 braille=["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], 48 clock=["🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚"], 49 hourglass=["⏳", "⌛"], 50 square_corners=["◰", "◳", "◲", "◱"], 51 triangle=["◢", "◣", "◤", "◥"], 52 square_dot=[ 53 "⣷", 54 "⣯", 55 "⣟", 56 "⡿", 57 "⢿", 58 "⣻", 59 "⣽", 60 "⣾", 61 ], 62 box_bounce=["▌", "▀", "▐", "▄"], 63 hamburger=["☱", "☲", "☴"], 64 earth=["🌍", "🌎", "🌏"], 65 growing_dots=["⣀", "⣄", "⣤", "⣦", "⣶", "⣷", "⣿"], 66 dice=["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"], 67 wifi=["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"], 68 bounce=["⠁", "⠂", "⠄", "⠂"], 69 arc=["◜", "◠", "◝", "◞", "◡", "◟"], 70 toggle=["⊶", "⊷"], 71 toggle2=["▫", "▪"], 72 toggle3=["□", "■"], 73 toggle4=["■", "□", "▪", "▫"], 74 toggle5=["▮", "▯"], 75 toggle7=["⦾", "⦿"], 76 toggle8=["◍", "◌"], 77 toggle9=["◉", "◎"], 78 arrow2=["⬆️ ", "↗️ ", "➡️ ", "↘️ ", "⬇️ ", "↙️ ", "⬅️ ", "↖️ "], 79 point=["∙∙∙", "●∙∙", "∙●∙", "∙∙●", "∙∙∙"], 80 layer=["-", "=", "≡"], 81 speaker=["🔈 ", "🔉 ", "🔊 ", "🔉 "], 82 orangePulse=["🔸 ", "🔶 ", "🟠 ", "🟠 ", "🔷 "], 83 bluePulse=["🔹 ", "🔷 ", "🔵 ", "🔵 ", "🔷 "], 84 satellite_signal=["📡 ", "📡· ", "📡·· ", "📡···", "📡 ··", "📡 ·"], 85 rocket_orbit=["🌍🚀 ", "🌏 🚀 ", "🌎 🚀"], 86 ogham=["ᚁ ", "ᚂ ", "ᚃ ", "ᚄ", "ᚅ"], 87 eth=["᛫", "፡", "፥", "፤", "፧", "።", "፨"], 88) 89"""dict of spinner sequences to show. some from Claude 3.5 Sonnet, 90some from [cli-spinners](https://github.com/sindresorhus/cli-spinners) 91""" 92 93SPINNER_COMPLETE: Dict[str, str] = dict( 94 default="#", 95 dots="***", 96 bars="|||", 97 bouncing_bar="[====]", 98 bouncing_ball="(●●●●●●)", 99 braille="⣿", 100 clock="✔️", 101 hourglass="✔️", 102 square_corners="◼", 103 triangle="◆", 104 square_dot="⣿", 105 box_bounce="■", 106 hamburger="☰", 107 earth="✔️", 108 growing_dots="⣿", 109 dice="🎲", 110 wifi="✔️", 111 arc="○", 112 toggle="-", 113 toggle2="▪", 114 toggle3="■", 115 toggle4="■", 116 toggle5="▮", 117 toggle6="၀", 118 toggle7="⦿", 119 toggle8="◍", 120 toggle9="◉", 121 arrow2="➡️", 122 point="●●●", 123 layer="≡", 124 speaker="🔊", 125 orangePulse="🟠", 126 bluePulse="🔵", 127 satellite_signal="📡 ✔️ ", 128 rocket_orbit="🌍 ✨", 129 ogham="᚛᚜", 130 eth="፠", 131) 132"string to display when the spinner is complete" 133 134 135class Spinner: 136 """displays a spinner, and optionally elapsed time and a mutable value while a function is running. 137 138 # Parameters: 139 - `spinner_chars : Union[str, Sequence[str]]` 140 sequence of strings, or key to look up in `SPINNER_CHARS`, to use as the spinner characters 141 (defaults to `"default"`) 142 - `update_interval : float` 143 how often to update the spinner display in seconds 144 (defaults to `0.1`) 145 - `spinner_complete : str` 146 string to display when the spinner is complete 147 (defaults to looking up `spinner_chars` in `SPINNER_COMPLETE` or `"#"`) 148 - `initial_value : str` 149 initial value to display with the spinner 150 (defaults to `""`) 151 - `message : str` 152 message to display with the spinner 153 (defaults to `""`) 154 - `format_string : str` 155 string to format the spinner with. must have `"\\r"` prepended to clear the line. 156 allowed keys are `spinner`, `elapsed_time`, `message`, and `value` 157 (defaults to `"\\r{spinner} ({elapsed_time:.2f}s) {message}{value}"`) 158 - `output_stream : TextIO` 159 stream to write the spinner to 160 (defaults to `sys.stdout`) 161 - `format_string_when_updated : Union[bool,str]` 162 whether to use a different format string when the value is updated. 163 if `True`, use the default format string with a newline appended. if a string, use that string. 164 this is useful if you want update_value to print to console and be preserved. 165 (defaults to `False`) 166 167 # Methods: 168 - `update_value(value: Any) -> None` 169 update the current value displayed by the spinner 170 171 # Usage: 172 173 ## As a context manager: 174 ```python 175 with SpinnerContext() as sp: 176 for i in range(1): 177 time.sleep(0.1) 178 spinner.update_value(f"Step {i+1}") 179 ``` 180 181 ## As a decorator: 182 ```python 183 @spinner_decorator 184 def long_running_function(): 185 for i in range(1): 186 time.sleep(0.1) 187 spinner.update_value(f"Step {i+1}") 188 return "Function completed" 189 ``` 190 """ 191 192 def __init__( 193 self, 194 *args, 195 spinner_chars: Union[str, Sequence[str]] = "default", 196 update_interval: float = 0.1, 197 spinner_complete: Optional[str] = None, 198 initial_value: str = "", 199 message: str = "", 200 format_string: str = "\r{spinner} ({elapsed_time:.2f}s) {message}{value}", 201 output_stream: TextIO = sys.stdout, 202 format_string_when_updated: Union[str, bool] = False, 203 **kwargs: Any, 204 ): 205 if args: 206 raise ValueError(f"Spinner does not accept positional arguments: {args}") 207 if kwargs: 208 raise ValueError( 209 f"Spinner did not recognize these keyword arguments: {kwargs}" 210 ) 211 212 # spinner display 213 self.spinner_complete: str = ( 214 ( 215 # if None, use `spinner_chars` key as default 216 SPINNER_COMPLETE.get(spinner_chars, "#") 217 if isinstance(spinner_chars, str) 218 else "#" 219 ) 220 if spinner_complete is None 221 # if not None, use the value provided 222 else spinner_complete 223 ) 224 "string to display when the spinner is complete" 225 226 self.spinner_chars: Sequence[str] = ( 227 SPINNER_CHARS[spinner_chars] 228 if isinstance(spinner_chars, str) 229 else spinner_chars 230 ) 231 "sequence of strings to use as the spinner characters" 232 233 # special format string for when the value is updated 234 self.format_string_when_updated: Optional[str] = None 235 "format string to use when the value is updated" 236 if format_string_when_updated is not False: 237 if format_string_when_updated is True: 238 # modify the default format string 239 self.format_string_when_updated = format_string + "\n" 240 elif isinstance(format_string_when_updated, str): 241 # use the provided format string 242 self.format_string_when_updated = format_string_when_updated 243 else: 244 raise TypeError( 245 "format_string_when_updated must be a string or True, got" 246 + f" {type(format_string_when_updated) = }{format_string_when_updated}" 247 ) 248 249 # copy other kwargs 250 self.update_interval: float = update_interval 251 self.message: str = message 252 self.current_value: Any = initial_value 253 self.format_string: str = format_string 254 self.output_stream: TextIO = output_stream 255 256 # test out format string 257 try: 258 self.format_string.format( 259 spinner=self.spinner_chars[0], 260 elapsed_time=0.0, 261 message=self.message, 262 value=self.current_value, 263 ) 264 except Exception as e: 265 raise ValueError( 266 f"Invalid format string: {format_string}. Must take keys " 267 + "'spinner: str', 'elapsed_time: float', 'message: str', and 'value: Any'." 268 ) from e 269 270 # init 271 self.start_time: float = 0 272 "for measuring elapsed time" 273 self.stop_spinner: threading.Event = threading.Event() 274 "to stop the spinner" 275 self.spinner_thread: Optional[threading.Thread] = None 276 "the thread running the spinner" 277 self.value_changed: bool = False 278 "whether the value has been updated since the last display" 279 self.term_width: int 280 "width of the terminal, for padding with spaces" 281 try: 282 self.term_width = os.get_terminal_size().columns 283 except OSError: 284 self.term_width = 80 285 286 def spin(self) -> None: 287 "Function to run in a separate thread, displaying the spinner and optional information" 288 i: int = 0 289 while not self.stop_spinner.is_set(): 290 # get current spinner str 291 spinner: str = self.spinner_chars[i % len(self.spinner_chars)] 292 293 # args for display string 294 display_parts: dict[str, Any] = dict( 295 spinner=spinner, # str 296 elapsed_time=time.time() - self.start_time, # float 297 message=self.message, # str 298 value=self.current_value, # Any, but will be formatted as str 299 ) 300 301 # use the special one if needed 302 format_str: str = self.format_string 303 if self.value_changed and (self.format_string_when_updated is not None): 304 self.value_changed = False 305 format_str = self.format_string_when_updated 306 307 # write and flush the display string 308 output: str = format_str.format(**display_parts).ljust(self.term_width) 309 self.output_stream.write(output) 310 self.output_stream.flush() 311 312 # wait for the next update 313 time.sleep(self.update_interval) 314 i += 1 315 316 def update_value(self, value: Any) -> None: 317 "Update the current value displayed by the spinner" 318 self.current_value = value 319 self.value_changed = True 320 321 def start(self) -> None: 322 "Start the spinner" 323 self.start_time = time.time() 324 self.spinner_thread = threading.Thread(target=self.spin) 325 self.spinner_thread.start() 326 327 def stop(self) -> None: 328 "Stop the spinner" 329 self.output_stream.write( 330 self.format_string.format( 331 spinner=self.spinner_complete, 332 elapsed_time=time.time() - self.start_time, # float 333 message=self.message, # str 334 value=self.current_value, # Any, but will be formatted as str 335 ).ljust(self.term_width) 336 ) 337 self.stop_spinner.set() 338 if self.spinner_thread: 339 self.spinner_thread.join() 340 self.output_stream.write("\n") 341 self.output_stream.flush() 342 343 344class NoOpContextManager(ContextManager): 345 """A context manager that does nothing.""" 346 347 def __init__(self, *args, **kwargs): 348 pass 349 350 def __enter__(self): 351 return self 352 353 def __exit__(self, exc_type, exc_value, traceback): 354 pass 355 356 357class SpinnerContext(Spinner, ContextManager): 358 "see `Spinner` for parameters" 359 360 def __enter__(self) -> "SpinnerContext": 361 self.start() 362 return self 363 364 def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: 365 self.stop() 366 367 368SpinnerContext.__doc__ = Spinner.__doc__ 369 370 371def spinner_decorator( 372 *args, 373 # passed to `Spinner.__init__` 374 spinner_chars: Union[str, Sequence[str]] = "default", 375 update_interval: float = 0.1, 376 spinner_complete: Optional[str] = None, 377 initial_value: str = "", 378 message: str = "", 379 format_string: str = "{spinner} ({elapsed_time:.2f}s) {message}{value}", 380 output_stream: TextIO = sys.stdout, 381 # new kwarg 382 mutable_kwarg_key: Optional[str] = None, 383 **kwargs, 384) -> Callable[[DecoratedFunction], DecoratedFunction]: 385 """see `Spinner` for parameters. Also takes `mutable_kwarg_key` 386 387 `mutable_kwarg_key` is the key with which `Spinner().update_value` 388 will be passed to the decorated function. if `None`, won't pass it. 389 390 """ 391 392 if len(args) > 1: 393 raise ValueError( 394 f"spinner_decorator does not accept positional arguments: {args}" 395 ) 396 if kwargs: 397 raise ValueError( 398 f"spinner_decorator did not recognize these keyword arguments: {kwargs}" 399 ) 400 401 def decorator(func: DecoratedFunction) -> DecoratedFunction: 402 @wraps(func) 403 def wrapper(*args: Any, **kwargs: Any) -> Any: 404 spinner: Spinner = Spinner( 405 spinner_chars=spinner_chars, 406 update_interval=update_interval, 407 spinner_complete=spinner_complete, 408 initial_value=initial_value, 409 message=message, 410 format_string=format_string, 411 output_stream=output_stream, 412 ) 413 414 if mutable_kwarg_key: 415 kwargs[mutable_kwarg_key] = spinner.update_value 416 417 spinner.start() 418 try: 419 result: Any = func(*args, **kwargs) 420 finally: 421 spinner.stop() 422 423 return result 424 425 # TODO: fix this type ignore 426 return wrapper # type: ignore[return-value] 427 428 if not args: 429 # called as `@spinner_decorator(stuff)` 430 return decorator 431 else: 432 # called as `@spinner_decorator` without parens 433 return decorator(args[0]) 434 435 436spinner_decorator.__doc__ = Spinner.__doc__
Define a generic type for the decorated function
dict of spinner sequences to show. some from Claude 3.5 Sonnet, some from cli-spinners
string to display when the spinner is complete
136class Spinner: 137 """displays a spinner, and optionally elapsed time and a mutable value while a function is running. 138 139 # Parameters: 140 - `spinner_chars : Union[str, Sequence[str]]` 141 sequence of strings, or key to look up in `SPINNER_CHARS`, to use as the spinner characters 142 (defaults to `"default"`) 143 - `update_interval : float` 144 how often to update the spinner display in seconds 145 (defaults to `0.1`) 146 - `spinner_complete : str` 147 string to display when the spinner is complete 148 (defaults to looking up `spinner_chars` in `SPINNER_COMPLETE` or `"#"`) 149 - `initial_value : str` 150 initial value to display with the spinner 151 (defaults to `""`) 152 - `message : str` 153 message to display with the spinner 154 (defaults to `""`) 155 - `format_string : str` 156 string to format the spinner with. must have `"\\r"` prepended to clear the line. 157 allowed keys are `spinner`, `elapsed_time`, `message`, and `value` 158 (defaults to `"\\r{spinner} ({elapsed_time:.2f}s) {message}{value}"`) 159 - `output_stream : TextIO` 160 stream to write the spinner to 161 (defaults to `sys.stdout`) 162 - `format_string_when_updated : Union[bool,str]` 163 whether to use a different format string when the value is updated. 164 if `True`, use the default format string with a newline appended. if a string, use that string. 165 this is useful if you want update_value to print to console and be preserved. 166 (defaults to `False`) 167 168 # Methods: 169 - `update_value(value: Any) -> None` 170 update the current value displayed by the spinner 171 172 # Usage: 173 174 ## As a context manager: 175 ```python 176 with SpinnerContext() as sp: 177 for i in range(1): 178 time.sleep(0.1) 179 spinner.update_value(f"Step {i+1}") 180 ``` 181 182 ## As a decorator: 183 ```python 184 @spinner_decorator 185 def long_running_function(): 186 for i in range(1): 187 time.sleep(0.1) 188 spinner.update_value(f"Step {i+1}") 189 return "Function completed" 190 ``` 191 """ 192 193 def __init__( 194 self, 195 *args, 196 spinner_chars: Union[str, Sequence[str]] = "default", 197 update_interval: float = 0.1, 198 spinner_complete: Optional[str] = None, 199 initial_value: str = "", 200 message: str = "", 201 format_string: str = "\r{spinner} ({elapsed_time:.2f}s) {message}{value}", 202 output_stream: TextIO = sys.stdout, 203 format_string_when_updated: Union[str, bool] = False, 204 **kwargs: Any, 205 ): 206 if args: 207 raise ValueError(f"Spinner does not accept positional arguments: {args}") 208 if kwargs: 209 raise ValueError( 210 f"Spinner did not recognize these keyword arguments: {kwargs}" 211 ) 212 213 # spinner display 214 self.spinner_complete: str = ( 215 ( 216 # if None, use `spinner_chars` key as default 217 SPINNER_COMPLETE.get(spinner_chars, "#") 218 if isinstance(spinner_chars, str) 219 else "#" 220 ) 221 if spinner_complete is None 222 # if not None, use the value provided 223 else spinner_complete 224 ) 225 "string to display when the spinner is complete" 226 227 self.spinner_chars: Sequence[str] = ( 228 SPINNER_CHARS[spinner_chars] 229 if isinstance(spinner_chars, str) 230 else spinner_chars 231 ) 232 "sequence of strings to use as the spinner characters" 233 234 # special format string for when the value is updated 235 self.format_string_when_updated: Optional[str] = None 236 "format string to use when the value is updated" 237 if format_string_when_updated is not False: 238 if format_string_when_updated is True: 239 # modify the default format string 240 self.format_string_when_updated = format_string + "\n" 241 elif isinstance(format_string_when_updated, str): 242 # use the provided format string 243 self.format_string_when_updated = format_string_when_updated 244 else: 245 raise TypeError( 246 "format_string_when_updated must be a string or True, got" 247 + f" {type(format_string_when_updated) = }{format_string_when_updated}" 248 ) 249 250 # copy other kwargs 251 self.update_interval: float = update_interval 252 self.message: str = message 253 self.current_value: Any = initial_value 254 self.format_string: str = format_string 255 self.output_stream: TextIO = output_stream 256 257 # test out format string 258 try: 259 self.format_string.format( 260 spinner=self.spinner_chars[0], 261 elapsed_time=0.0, 262 message=self.message, 263 value=self.current_value, 264 ) 265 except Exception as e: 266 raise ValueError( 267 f"Invalid format string: {format_string}. Must take keys " 268 + "'spinner: str', 'elapsed_time: float', 'message: str', and 'value: Any'." 269 ) from e 270 271 # init 272 self.start_time: float = 0 273 "for measuring elapsed time" 274 self.stop_spinner: threading.Event = threading.Event() 275 "to stop the spinner" 276 self.spinner_thread: Optional[threading.Thread] = None 277 "the thread running the spinner" 278 self.value_changed: bool = False 279 "whether the value has been updated since the last display" 280 self.term_width: int 281 "width of the terminal, for padding with spaces" 282 try: 283 self.term_width = os.get_terminal_size().columns 284 except OSError: 285 self.term_width = 80 286 287 def spin(self) -> None: 288 "Function to run in a separate thread, displaying the spinner and optional information" 289 i: int = 0 290 while not self.stop_spinner.is_set(): 291 # get current spinner str 292 spinner: str = self.spinner_chars[i % len(self.spinner_chars)] 293 294 # args for display string 295 display_parts: dict[str, Any] = dict( 296 spinner=spinner, # str 297 elapsed_time=time.time() - self.start_time, # float 298 message=self.message, # str 299 value=self.current_value, # Any, but will be formatted as str 300 ) 301 302 # use the special one if needed 303 format_str: str = self.format_string 304 if self.value_changed and (self.format_string_when_updated is not None): 305 self.value_changed = False 306 format_str = self.format_string_when_updated 307 308 # write and flush the display string 309 output: str = format_str.format(**display_parts).ljust(self.term_width) 310 self.output_stream.write(output) 311 self.output_stream.flush() 312 313 # wait for the next update 314 time.sleep(self.update_interval) 315 i += 1 316 317 def update_value(self, value: Any) -> None: 318 "Update the current value displayed by the spinner" 319 self.current_value = value 320 self.value_changed = True 321 322 def start(self) -> None: 323 "Start the spinner" 324 self.start_time = time.time() 325 self.spinner_thread = threading.Thread(target=self.spin) 326 self.spinner_thread.start() 327 328 def stop(self) -> None: 329 "Stop the spinner" 330 self.output_stream.write( 331 self.format_string.format( 332 spinner=self.spinner_complete, 333 elapsed_time=time.time() - self.start_time, # float 334 message=self.message, # str 335 value=self.current_value, # Any, but will be formatted as str 336 ).ljust(self.term_width) 337 ) 338 self.stop_spinner.set() 339 if self.spinner_thread: 340 self.spinner_thread.join() 341 self.output_stream.write("\n") 342 self.output_stream.flush()
displays a spinner, and optionally elapsed time and a mutable value while a function is running.
Parameters:
spinner_chars : Union[str, Sequence[str]]
sequence of strings, or key to look up inSPINNER_CHARS
, to use as the spinner characters (defaults to"default"
)update_interval : float
how often to update the spinner display in seconds (defaults to0.1
)spinner_complete : str
string to display when the spinner is complete (defaults to looking upspinner_chars
inSPINNER_COMPLETE
or"#"
)initial_value : str
initial value to display with the spinner (defaults to""
)message : str
message to display with the spinner (defaults to""
)format_string : str
string to format the spinner with. must have"\r"
prepended to clear the line. allowed keys arespinner
,elapsed_time
,message
, andvalue
(defaults to"\r{spinner} ({elapsed_time:.2f}s) {message}{value}"
)output_stream : TextIO
stream to write the spinner to (defaults tosys.stdout
)format_string_when_updated : Union[bool,str]
whether to use a different format string when the value is updated. ifTrue
, use the default format string with a newline appended. if a string, use that string. this is useful if you want update_value to print to console and be preserved. (defaults toFalse
)
Methods:
update_value(value: Any) -> None
update the current value displayed by the spinner
Usage:
As a context manager:
with SpinnerContext() as sp:
for i in range(1):
time.sleep(0.1)
spinner.update_value(f"Step {i+1}")
As a decorator:
@spinner_decorator
def long_running_function():
for i in range(1):
time.sleep(0.1)
spinner.update_value(f"Step {i+1}")
return "Function completed"
193 def __init__( 194 self, 195 *args, 196 spinner_chars: Union[str, Sequence[str]] = "default", 197 update_interval: float = 0.1, 198 spinner_complete: Optional[str] = None, 199 initial_value: str = "", 200 message: str = "", 201 format_string: str = "\r{spinner} ({elapsed_time:.2f}s) {message}{value}", 202 output_stream: TextIO = sys.stdout, 203 format_string_when_updated: Union[str, bool] = False, 204 **kwargs: Any, 205 ): 206 if args: 207 raise ValueError(f"Spinner does not accept positional arguments: {args}") 208 if kwargs: 209 raise ValueError( 210 f"Spinner did not recognize these keyword arguments: {kwargs}" 211 ) 212 213 # spinner display 214 self.spinner_complete: str = ( 215 ( 216 # if None, use `spinner_chars` key as default 217 SPINNER_COMPLETE.get(spinner_chars, "#") 218 if isinstance(spinner_chars, str) 219 else "#" 220 ) 221 if spinner_complete is None 222 # if not None, use the value provided 223 else spinner_complete 224 ) 225 "string to display when the spinner is complete" 226 227 self.spinner_chars: Sequence[str] = ( 228 SPINNER_CHARS[spinner_chars] 229 if isinstance(spinner_chars, str) 230 else spinner_chars 231 ) 232 "sequence of strings to use as the spinner characters" 233 234 # special format string for when the value is updated 235 self.format_string_when_updated: Optional[str] = None 236 "format string to use when the value is updated" 237 if format_string_when_updated is not False: 238 if format_string_when_updated is True: 239 # modify the default format string 240 self.format_string_when_updated = format_string + "\n" 241 elif isinstance(format_string_when_updated, str): 242 # use the provided format string 243 self.format_string_when_updated = format_string_when_updated 244 else: 245 raise TypeError( 246 "format_string_when_updated must be a string or True, got" 247 + f" {type(format_string_when_updated) = }{format_string_when_updated}" 248 ) 249 250 # copy other kwargs 251 self.update_interval: float = update_interval 252 self.message: str = message 253 self.current_value: Any = initial_value 254 self.format_string: str = format_string 255 self.output_stream: TextIO = output_stream 256 257 # test out format string 258 try: 259 self.format_string.format( 260 spinner=self.spinner_chars[0], 261 elapsed_time=0.0, 262 message=self.message, 263 value=self.current_value, 264 ) 265 except Exception as e: 266 raise ValueError( 267 f"Invalid format string: {format_string}. Must take keys " 268 + "'spinner: str', 'elapsed_time: float', 'message: str', and 'value: Any'." 269 ) from e 270 271 # init 272 self.start_time: float = 0 273 "for measuring elapsed time" 274 self.stop_spinner: threading.Event = threading.Event() 275 "to stop the spinner" 276 self.spinner_thread: Optional[threading.Thread] = None 277 "the thread running the spinner" 278 self.value_changed: bool = False 279 "whether the value has been updated since the last display" 280 self.term_width: int 281 "width of the terminal, for padding with spaces" 282 try: 283 self.term_width = os.get_terminal_size().columns 284 except OSError: 285 self.term_width = 80
287 def spin(self) -> None: 288 "Function to run in a separate thread, displaying the spinner and optional information" 289 i: int = 0 290 while not self.stop_spinner.is_set(): 291 # get current spinner str 292 spinner: str = self.spinner_chars[i % len(self.spinner_chars)] 293 294 # args for display string 295 display_parts: dict[str, Any] = dict( 296 spinner=spinner, # str 297 elapsed_time=time.time() - self.start_time, # float 298 message=self.message, # str 299 value=self.current_value, # Any, but will be formatted as str 300 ) 301 302 # use the special one if needed 303 format_str: str = self.format_string 304 if self.value_changed and (self.format_string_when_updated is not None): 305 self.value_changed = False 306 format_str = self.format_string_when_updated 307 308 # write and flush the display string 309 output: str = format_str.format(**display_parts).ljust(self.term_width) 310 self.output_stream.write(output) 311 self.output_stream.flush() 312 313 # wait for the next update 314 time.sleep(self.update_interval) 315 i += 1
Function to run in a separate thread, displaying the spinner and optional information
317 def update_value(self, value: Any) -> None: 318 "Update the current value displayed by the spinner" 319 self.current_value = value 320 self.value_changed = True
Update the current value displayed by the spinner
322 def start(self) -> None: 323 "Start the spinner" 324 self.start_time = time.time() 325 self.spinner_thread = threading.Thread(target=self.spin) 326 self.spinner_thread.start()
Start the spinner
328 def stop(self) -> None: 329 "Stop the spinner" 330 self.output_stream.write( 331 self.format_string.format( 332 spinner=self.spinner_complete, 333 elapsed_time=time.time() - self.start_time, # float 334 message=self.message, # str 335 value=self.current_value, # Any, but will be formatted as str 336 ).ljust(self.term_width) 337 ) 338 self.stop_spinner.set() 339 if self.spinner_thread: 340 self.spinner_thread.join() 341 self.output_stream.write("\n") 342 self.output_stream.flush()
Stop the spinner
345class NoOpContextManager(ContextManager): 346 """A context manager that does nothing.""" 347 348 def __init__(self, *args, **kwargs): 349 pass 350 351 def __enter__(self): 352 return self 353 354 def __exit__(self, exc_type, exc_value, traceback): 355 pass
A context manager that does nothing.
358class SpinnerContext(Spinner, ContextManager): 359 "see `Spinner` for parameters" 360 361 def __enter__(self) -> "SpinnerContext": 362 self.start() 363 return self 364 365 def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: 366 self.stop()
displays a spinner, and optionally elapsed time and a mutable value while a function is running.
Parameters:
spinner_chars : Union[str, Sequence[str]]
sequence of strings, or key to look up inSPINNER_CHARS
, to use as the spinner characters (defaults to"default"
)update_interval : float
how often to update the spinner display in seconds (defaults to0.1
)spinner_complete : str
string to display when the spinner is complete (defaults to looking upspinner_chars
inSPINNER_COMPLETE
or"#"
)initial_value : str
initial value to display with the spinner (defaults to""
)message : str
message to display with the spinner (defaults to""
)format_string : str
string to format the spinner with. must have"\r"
prepended to clear the line. allowed keys arespinner
,elapsed_time
,message
, andvalue
(defaults to"\r{spinner} ({elapsed_time:.2f}s) {message}{value}"
)output_stream : TextIO
stream to write the spinner to (defaults tosys.stdout
)format_string_when_updated : Union[bool,str]
whether to use a different format string when the value is updated. ifTrue
, use the default format string with a newline appended. if a string, use that string. this is useful if you want update_value to print to console and be preserved. (defaults toFalse
)
Methods:
update_value(value: Any) -> None
update the current value displayed by the spinner
Usage:
As a context manager:
with SpinnerContext() as sp:
for i in range(1):
time.sleep(0.1)
spinner.update_value(f"Step {i+1}")
As a decorator:
@spinner_decorator
def long_running_function():
for i in range(1):
time.sleep(0.1)
spinner.update_value(f"Step {i+1}")
return "Function completed"
372def spinner_decorator( 373 *args, 374 # passed to `Spinner.__init__` 375 spinner_chars: Union[str, Sequence[str]] = "default", 376 update_interval: float = 0.1, 377 spinner_complete: Optional[str] = None, 378 initial_value: str = "", 379 message: str = "", 380 format_string: str = "{spinner} ({elapsed_time:.2f}s) {message}{value}", 381 output_stream: TextIO = sys.stdout, 382 # new kwarg 383 mutable_kwarg_key: Optional[str] = None, 384 **kwargs, 385) -> Callable[[DecoratedFunction], DecoratedFunction]: 386 """see `Spinner` for parameters. Also takes `mutable_kwarg_key` 387 388 `mutable_kwarg_key` is the key with which `Spinner().update_value` 389 will be passed to the decorated function. if `None`, won't pass it. 390 391 """ 392 393 if len(args) > 1: 394 raise ValueError( 395 f"spinner_decorator does not accept positional arguments: {args}" 396 ) 397 if kwargs: 398 raise ValueError( 399 f"spinner_decorator did not recognize these keyword arguments: {kwargs}" 400 ) 401 402 def decorator(func: DecoratedFunction) -> DecoratedFunction: 403 @wraps(func) 404 def wrapper(*args: Any, **kwargs: Any) -> Any: 405 spinner: Spinner = Spinner( 406 spinner_chars=spinner_chars, 407 update_interval=update_interval, 408 spinner_complete=spinner_complete, 409 initial_value=initial_value, 410 message=message, 411 format_string=format_string, 412 output_stream=output_stream, 413 ) 414 415 if mutable_kwarg_key: 416 kwargs[mutable_kwarg_key] = spinner.update_value 417 418 spinner.start() 419 try: 420 result: Any = func(*args, **kwargs) 421 finally: 422 spinner.stop() 423 424 return result 425 426 # TODO: fix this type ignore 427 return wrapper # type: ignore[return-value] 428 429 if not args: 430 # called as `@spinner_decorator(stuff)` 431 return decorator 432 else: 433 # called as `@spinner_decorator` without parens 434 return decorator(args[0])
displays a spinner, and optionally elapsed time and a mutable value while a function is running.
Parameters:
spinner_chars : Union[str, Sequence[str]]
sequence of strings, or key to look up inSPINNER_CHARS
, to use as the spinner characters (defaults to"default"
)update_interval : float
how often to update the spinner display in seconds (defaults to0.1
)spinner_complete : str
string to display when the spinner is complete (defaults to looking upspinner_chars
inSPINNER_COMPLETE
or"#"
)initial_value : str
initial value to display with the spinner (defaults to""
)message : str
message to display with the spinner (defaults to""
)format_string : str
string to format the spinner with. must have"\r"
prepended to clear the line. allowed keys arespinner
,elapsed_time
,message
, andvalue
(defaults to"\r{spinner} ({elapsed_time:.2f}s) {message}{value}"
)output_stream : TextIO
stream to write the spinner to (defaults tosys.stdout
)format_string_when_updated : Union[bool,str]
whether to use a different format string when the value is updated. ifTrue
, use the default format string with a newline appended. if a string, use that string. this is useful if you want update_value to print to console and be preserved. (defaults toFalse
)
Methods:
update_value(value: Any) -> None
update the current value displayed by the spinner
Usage:
As a context manager:
with SpinnerContext() as sp:
for i in range(1):
time.sleep(0.1)
spinner.update_value(f"Step {i+1}")
As a decorator:
@spinner_decorator
def long_running_function():
for i in range(1):
time.sleep(0.1)
spinner.update_value(f"Step {i+1}")
return "Function completed"