diff --git a/.coveragerc b/.coveragerc index b75828b..3bbf7b8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,6 +4,7 @@ branch = True omit = alive_progress/styles/* alive_progress/tools/* + alive_progress/utils/terminal/* [report] ignore_errors = True diff --git a/CHANGELOG.md b/CHANGELOG.md index c1a649e..797ea97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2.1.0 - Oct 18, 2021 +- Jupyter notebook support (experimental), Jupyter auto-detection, disable feature and configuration +- four internal terminal abstractions, to support TTY, JUPYTER, NON_TTY and VOID + + ## 2.0.0 - Aug 25, 2021 This is a major breakthrough in `alive-progress`! - now there's complete support for Emojis 🀩 and exotic Unicode chars in general, which required MAJOR refactoring deep within the project, giving rise to what I called **Cells Architecture** => now all internal components use and generate streams of cells instead of chars, and correctly interprets grapheme clusters β€” it has enabled to render complex multi-chars symbols as if they were one, thus making them work on any spinners, bars, texts, borders, backgrounds, everything!!! there's even support for wide chars, which are represented with any number of chars, including one, but take two spaces on screen!! pretty advanced stuff πŸ€“ diff --git a/README.md b/README.md index 87bdca2..ac34e56 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,19 @@ I like to think of it as a new kind of progress bar for Python, since it has amo - it is **customizable**, with a growing smorgasbord of different bar and spinner styles, as well as several factories to easily generate yours! Now (πŸ“Œ new in 2.0) we even have super powerful and cool `.check()` tools in both bars and spinners, to help you design your animations! You can see all the frames and cycles exploded on screen, with several verbosity levels, even including an **alive** rendition! 😜 +## πŸ“Œ NEW 2.1 series! + +YES! Now `alive-progres` has support for Jupyter Notebooks, and also includes a Disabled state! Both were highly sought after, and have finally landed! +
And better, I've implemented an auto-detection mechanism for jupyter notebooks, so it just works, out of the box, without any changes in your code!! + +See for yourself: + +![alive-progress demo](img/alive-jupyter.gif) + +It seems to work very well, but at this moment it should be considered **Experimental**. +
There were instances in which some visual glitches did appear to me, but it's something I think I can't possibly workaround. It seems Jupyter sometimes refresh the screen at odd times, which makes the screen loses some updates... + + ## πŸ“Œ NEW 2.0 series! This is a major breakthrough in `alive-progress`! @@ -130,7 +143,8 @@ So, in short: retrieve the items as usual, enter the `alive_bar` context manager While inside an `alive_bar` context, you can effortlessly display messages with: - the usual Python `print()` statement, where `alive_bar` nicely cleans up the line, prints your message alongside the current bar position at the time, and continues the bar right below it; - the standard Python `logging` framework, including file outputs, are also enriched exactly like the previous one; -- the cool `bar.text('message')`, which sets a situational message right within the bar, where you can display something about the current item, or the phase the processing is in! +- the cool `bar.text('message')`, which sets a situational message right within the bar, where you can display something about the current item, or the phase the processing is in; +- and all of this works just the same in an actual terminal or in a Jupyter notebook! ![alive-progress printing messages](img/print-hook.gif) @@ -297,7 +311,8 @@ These are the options - default values in brackets:
↳ accepts a predefined spinner name, or a custom spinner factory (cannot be None) - `theme`: [`'smooth'`] a set of matching spinner, bar and unknown
↳ accepts a predefined theme name -- `force_tty`: [`None`] forces animations to be on, off, or according to the tty (more details [here](#advanced)) +- `force_tty`: [`None`] forces animations to be on, off, or according to the tty (more details [here](#forcing-animations-on-non-interactive-consoles)) +- `disable`: [`False`] if True, completely disables all output, do not install hooks - `manual`: [`False`] set to manually control the bar position - `enrich_print`: [`True`] enriches print() and logging messages with the bar position - `receipt_text`: [`False`] set to repeat the last text message in the final receipt @@ -309,7 +324,7 @@ These are the options - default values in brackets: - `spinner_length`: [`0`] forces the spinner length, or `0` for its natural one And there's also one that can only be set locally in an `alive_bar` context: -- `calibrate`: maximum theoretical throughput to calibrate animation speed (more details [here](#advanced)) +- `calibrate`: maximum theoretical throughput to calibrate animation speed (more details [here](#fps-calibration)) To set them locally, just send them as keyword arguments to `alive_bar`: @@ -432,10 +447,10 @@ If you've appreciated my work and would like me to continue improving it, please ## Advanced -### Static loop-less use +### Loop-less use So, you need to monitor a fixed operation, without any loops? -
It'll work for sure! Here is an example (although a naive approach, we'll do better): +
It'll work for sure! Here is a naive example (we'll do better in a moment): ```python with alive_bar(4) as bar: @@ -449,9 +464,9 @@ with alive_bar(4) as bar: bar() # we're done! four bar calls with `total=4` ``` -It's naive because it considers all steps are equal, but actually each one may take a very different time to complete. Think a `read_file` and a `tokenize` steps being extremely fast, making the percentage skyrocket to 50%, then stopping for a long time in the `process` step. You get the point, it can ruin the user experience and create a very misleading ETA. +It's naive because it assumes all steps take the same amount of time, but actually each one may take very different times to complete. Think `read_file` and `tokenize` may be extremely fast, which makes the percentage skyrocket to 50%, then stopping for a long time in the `process` step... You get the point, it can ruin the user experience and create a very misleading ETA. -What you need to do is distribute the steps accordingly! Since you told `alive_bar` there were four steps, when the first one completed it understood 1/4 or 25% of the whole processing was complete, which as we've seen may not be the case. Thus, you need to measure how long your steps do take, and use the **manual mode** to increase the bar percentage by different amounts at each step! +To improve upon that you need to distribute the steps' percentages accordingly! Since you told `alive_bar` there were four steps, when the first one completed it understood 1/4 or 25% of the whole processing was complete. Thus, you need to measure how long your steps actually take, and use the **manual mode** to increase the bar percentage by different amounts at each step! You can use my other open source project [about-time](https://github.com/rsalmei/about-time) to easily measure these durations! Just try to simulate with some representative inputs, to get better results. Something like: @@ -560,9 +575,11 @@ In [21]: next(gen, None) Those astonishing animations refuse to display? -There are ttys that do not report themselves as "interactive", which are valid for example in shell pipelines "|" or headless consoles. But there are some that do that for no good reason, like Pycharm's python console for instance. And if a console is not interactive, `alive_bar` disables all animations and refreshes, only printing the final receipt. This is made to avoid spamming a log file or messing up a pipe output with hundreds of refreshes. +There are terminals that occasionally do not report themselves as "interactive", for example in shell pipeline commands "|" or background processes. And there are some that never reports themselves as interactive, like Pycharm's python console and Jupyter Notebooks for instance. -So if you are in an interactive environment, like the aforementioned Pycharm console, you can see `alive_bar` in all its glory! Just use the `force_tty` argument! +When a console is not interactive, `alive-progress` disables all kinds of animations, only printing the final receipt. This is made to avoid spamming a log file or messing up a pipe output with thousands of progress bar updates. + +So, when you know it's safe, you can in fact see `alive-progress` in all its glory! Just use the `force_tty` argument! ```python with alive_bar(1000, force_tty=True) as bar: @@ -571,11 +588,16 @@ with alive_bar(1000, force_tty=True) as bar: bar() ``` -You can also set it system-wide using the `config_handler`, then you won't need to pass it manually anymore. +The values accepted are: +- `force_tty=True` -> enables animations, and auto-detects Jupyter Notebooks! +- `force_tty=False` -> disables animations, keeping only the final receipt +- `force_tty=None` (default) -> auto select, according to the terminal's tty state + +You can also set it system-wide using the `config_handler`, then you won't need to pass that manually in all `alive_bar` calls. -Do note that Pycharm's console is heavily instrumented and thus has more overhead, so the outcome may not be as fluid as one would expect. To see `alive_bar` animations perfectly, always prefer a full-fledged terminal. +Do note that Pycharm's console and Jupyter notebooks are heavily instrumented and thus have more overhead, so the outcome may not be as fluid as you would expect; and on top of that Jupyter notebooks do not support ANSI Escape Codes, so I had to develop some workarounds, to emulate functions like "clear the line" and "clear from cursor". -> (πŸ“Œ new) Now `force_tty` also supports `False`, which will disable animations even on interactive displays. +> To see `alive_bar` animations as I intended, always prefer a full-fledged terminal. ## Interesting facts @@ -631,6 +653,7 @@ The `alive_progress` framework starting from version 2.0 does not support Python ## Changelog highlights (complete [here](CHANGELOG.md)): +- 2.1.0: Jupyter notebook support (experimental), Jupyter auto-detection, disable feature and configuration - 2.0.0: new system-wide Cell Architecture with grapheme clusters support; super cool spinner compiler and runner; `.check()` tools in both spinners and bars; bars and spinners engines revamp; new animation modes in alongside and sequential spinners; new builtin spinners, bars and themes; dynamic showtime with themes, scroll protection and filter patterns; improved logging for files; several new configuration options for customizing appearance; new iterator adapter `alive_it`; uses `time.perf_counter()` high resolution clock; requires python 3.6+ (and officially supports python 3.9 and 3.10) - 1.6.2: new `bar.current()` method; newlines get printed on vanilla Python REPL; bar is truncated to 80 chars on Windows. - 1.6.1: fix logging support for python 3.6 and lower; support logging for file; support for wide unicode chars, which use 2 columns but have length 1 diff --git a/alive_progress/__init__.py b/alive_progress/__init__.py index e3d02aa..75d31fe 100644 --- a/alive_progress/__init__.py +++ b/alive_progress/__init__.py @@ -1,7 +1,7 @@ from .core.configuration import config_handler from .core.progress import alive_bar, alive_it -VERSION = (2, 0, 0) +VERSION = (2, 1, 0) __author__ = 'RogΓ©rio Sampaio de Almeida' __email__ = 'rsalmei@gmail.com' diff --git a/alive_progress/animations/bars.py b/alive_progress/animations/bars.py index 6fb8931..b9defd9 100644 --- a/alive_progress/animations/bars.py +++ b/alive_progress/animations/bars.py @@ -7,7 +7,7 @@ from ..utils.cells import VS_15, combine_cells, fix_cells, has_wide, is_wide, join_cells, \ mark_graphemes, split_graphemes, strip_marks, to_cells from ..utils.colors import BLUE, BLUE_BOLD, CYAN, DIM, GREEN, ORANGE, ORANGE_BOLD, RED, YELLOW_BOLD -from ..utils.terminal import clear_end, cursor_up_1, hide_cursor, show_cursor +from ..utils.terminal import FULL def bar_factory(chars=None, *, tip=None, background=None, borders=None, errors=None): @@ -222,16 +222,16 @@ def animate(bar): # pragma: no cover print(f'\n{SECTION("Animation")}') from ..styles.exhibit import exhibit_bar bar_gen = exhibit_bar(bar, 15) - hide_cursor() + FULL.hide_cursor() try: while True: rendition, percent = next(bar_gen) print(f'\r{join_cells(rendition)}', CYAN(percent, "6.1%")) print(DIM('(press CTRL+C to stop)'), end='') - clear_end() + FULL.clear_end() time.sleep(1 / 15) - cursor_up_1() + FULL.cursor_up_1() except KeyboardInterrupt: pass finally: - show_cursor() + FULL.show_cursor() diff --git a/alive_progress/animations/spinner_compiler.py b/alive_progress/animations/spinner_compiler.py index 4ab00f7..889996a 100644 --- a/alive_progress/animations/spinner_compiler.py +++ b/alive_progress/animations/spinner_compiler.py @@ -10,7 +10,7 @@ from .utils import fix_signature from ..utils.cells import fix_cells, is_wide, join_cells, strip_marks, to_cells from ..utils.colors import BLUE, BLUE_BOLD, CYAN, DIM, GREEN, ORANGE, ORANGE_BOLD, RED, YELLOW_BOLD -from ..utils.terminal import clear_end, cursor_up_1, hide_cursor, show_cursor +from ..utils.terminal import FULL def spinner_controller(*, natural, skip_compiler=False): @@ -335,7 +335,7 @@ def animate(spec): # pragma: no cover cf, lf, tf = (f'>{len(str(x))}' for x in (spec.cycles, max(spec.frames), spec.total_frames)) from itertools import cycle cycles, frames = cycle(range(1, spec.cycles + 1)), cycle(range(1, spec.total_frames + 1)) - hide_cursor() + FULL.hide_cursor() try: while True: c = next(cycles) @@ -343,10 +343,10 @@ def animate(spec): # pragma: no cover n = next(frames) print(f'\r{CYAN(c, cf)}:{CYAN(i, lf)} -->{join_cells(f)}<-- {CYAN(n, tf)} ') print(DIM('(press CTRL+C to stop)'), end='') - clear_end() + FULL.clear_end() time.sleep(1 / 15) - cursor_up_1() + FULL.cursor_up_1() except KeyboardInterrupt: pass finally: - show_cursor() + FULL.show_cursor() diff --git a/alive_progress/core/configuration.py b/alive_progress/core/configuration.py index 9ec9081..c2b1f3e 100644 --- a/alive_progress/core/configuration.py +++ b/alive_progress/core/configuration.py @@ -1,7 +1,10 @@ import os +import sys from collections import namedtuple from types import FunctionType +from ..utils.terminal import NON_TTY, FULL + ERROR = object() # represents a config value not accepted. @@ -63,10 +66,15 @@ def _input(x): return _input -def _tristate_input_factory(): +def _force_tty_input_factory(): def _input(x): - return None if x is None else bool(x) + return table.get(x, ERROR) + table = { + None: FULL if sys.stdout.isatty() else NON_TTY, + False: NON_TTY, + True: FULL, + } return _input @@ -77,8 +85,9 @@ def _input(x): return _input -Config = namedtuple('Config', 'title length spinner bar unknown force_tty manual enrich_print ' - ' receipt_text monitor stats elapsed title_length spinner_length') +Config = namedtuple('Config', 'title length spinner bar unknown force_tty disable manual ' + 'enrich_print receipt_text monitor stats elapsed title_length ' + 'spinner_length') def create_config(): @@ -89,6 +98,7 @@ def reset(): length=40, theme='smooth', # includes spinner, bar and unknown. force_tty=None, + disable=False, manual=False, enrich_print=True, receipt_text=False, @@ -113,8 +123,7 @@ def create_context(theme=None, **options): """Create an immutable copy of the current configuration, with optional customization.""" lazy_init() local_config = {**global_config, **_parse(theme, options)} - # noinspection PyArgumentList - return Config(**{k: local_config[k] for k in Config._fields}) + return Config(**local_config) def _parse(theme, options): """Validate and convert some configuration options.""" @@ -149,7 +158,8 @@ def lazy_init(): spinner=_spinner_input_factory(None), # accept empty. bar=_bar_input_factory(), unknown=_spinner_input_factory(ERROR), # do not accept empty. - force_tty=_tristate_input_factory(), + force_tty=_force_tty_input_factory(), + disable=_bool_input_factory(), manual=_bool_input_factory(), enrich_print=_bool_input_factory(), receipt_text=_bool_input_factory(), diff --git a/alive_progress/core/hook_manager.py b/alive_progress/core/hook_manager.py index 904c775..6f5576b 100644 --- a/alive_progress/core/hook_manager.py +++ b/alive_progress/core/hook_manager.py @@ -6,10 +6,8 @@ from logging import StreamHandler from types import SimpleNamespace -from ..utils.terminal import clear_line - -def buffered_hook_manager(header_template, get_pos, cond_refresh): +def buffered_hook_manager(header_template, get_pos, cond_refresh, term): """Create and maintain a buffered hook manager, used for instrumenting print statements and logging. @@ -17,6 +15,7 @@ def buffered_hook_manager(header_template, get_pos, cond_refresh): header_template (): the template for enriching output get_pos (Callable[..., Any]): the container to retrieve the current position cond_refresh: Condition object to force a refresh when printing + term: the current terminal Returns: a closure with several functions @@ -43,11 +42,12 @@ def write(stream, part): header = get_header() with cond_refresh: nested = ''.join(line or ' ' * len(header) for line in buffer) - if stream in base: - # this avoids potential flickering, since now the stream can also be - # files from logging, and thus not needing to clear the screen... - clear_line() - stream.write(f'{header}{nested.strip()}\n') + text = f'{header}{nested.strip()}\n' + if stream in base: # pragma: no cover + # use the current terminal abstraction for preparing the screen. + term.clear_line() + # handle all streams, both screen and logging. + stream.write(text) stream.flush() cond_refresh.notify() buffer[:] = [] @@ -57,7 +57,7 @@ def get_hook_for(handler): handler.stream.flush() return SimpleNamespace(write=partial(write, handler.stream), flush=partial(flush, handler.stream), - isatty=sys.__stdout__.isatty) + isatty=sys.stdout.isatty) def install(): root = logging.root @@ -77,7 +77,7 @@ def uninstall(): # internal data. buffers = defaultdict(list) - get_header = (lambda: header_template.format(get_pos())) if header_template else lambda: '' + get_header = gen_header(header_template, get_pos) if header_template else null_header base = sys.stdout, sys.stderr # needed for tests. before_handlers = {} @@ -91,6 +91,28 @@ def uninstall(): return hook_manager +def passthrough_hook_manager(): # pragma: no cover + passthrough_hook_manager.flush_buffers = __noop + passthrough_hook_manager.install = __noop + passthrough_hook_manager.uninstall = __noop + return passthrough_hook_manager + + +def __noop(): # pragma: no cover + pass + + +def gen_header(header_template, get_pos): # pragma: no cover + def inner(): + return header_template.format(get_pos()) + + return inner + + +def null_header(): # pragma: no cover + return '' + + if sys.version_info >= (3, 7): # pragma: no cover def _set_stream(handler, stream): return handler.setStream(stream) diff --git a/alive_progress/core/progress.py b/alive_progress/core/progress.py index aad1edb..8941688 100644 --- a/alive_progress/core/progress.py +++ b/alive_progress/core/progress.py @@ -1,14 +1,13 @@ import math -import sys import threading import time from contextlib import contextmanager from .calibration import calibrated_fps from .configuration import config_handler -from .hook_manager import buffered_hook_manager +from .hook_manager import buffered_hook_manager, passthrough_hook_manager from ..utils.cells import combine_cells, fix_cells, print_cells, to_cells -from ..utils.terminal import hide_cursor, show_cursor, terminal_cols +from ..utils.terminal import VOID from ..utils.timing import elapsed_text, eta_text, gen_simple_exponential_smoothing_eta @@ -75,7 +74,11 @@ def alive_bar(total=None, *, calibrate=None, **options): accepts a predefined spinner name, or a custom spinner factory (cannot be None) theme (str): a set of matching spinner, bar and unknown accepts a predefined theme name - force_tty (Optional[bool]): forces animations to be on, off, or according to the tty + force_tty (Optional[int|bool]): forces a specific kind of terminal: + False -> disables animations, keeping only the the final receipt + True -> enables animations, and auto-detects Jupyter Notebooks! + None (default) -> auto select, according to the terminal/Jupyter + disable (bool): if True, completely disables all output, do not install hooks manual (bool): set to manually control the bar position enrich_print (bool): enriches print() and logging messages with the bar position receipt_text (bool): set to repeat the last text message in the final receipt @@ -92,9 +95,7 @@ def alive_bar(total=None, *, calibrate=None, **options): @contextmanager -def __alive_bar(config, total=None, *, calibrate=None, - _write=sys.__stdout__.write, _flush=sys.__stdout__.flush, _cond=threading.Condition, - _term_cols=terminal_cols, _hook_manager=buffered_hook_manager, _sampler=None): +def __alive_bar(config, total=None, *, calibrate=None, _cond=threading.Condition): """Actual alive_bar handler, that exposes internal functions for configuration of both normal operation and overhead estimation.""" @@ -119,11 +120,10 @@ def alive_repr(spin=None): elapsed(), stats(), run.text) with cond_refresh: - run.last_len = print_cells(fragments, _term_cols(), run.last_len, _write=_write) - _flush() + run.last_len = print_cells(fragments, term.cols(), run.last_len, _term=term) + term.flush() - if _sampler is not None: # used for sampling estimation. - _sampler._alive_repr = alive_repr + __alive_bar._alive_repr = alive_repr def set_text(message): run.text = to_cells(message) @@ -140,26 +140,45 @@ def bar_handle(count=1): # for counting progress modes. update_hook() def start_monitoring(offset=0.): - hide_cursor() + term.hide_cursor() hook_manager.install() bar._handle, bar.text = bar_handle, set_text run.init = time.perf_counter() - offset event_renderer.set() def stop_monitoring(): - show_cursor() + term.show_cursor() hook_manager.uninstall() bar._handle, bar.text = __noop, __noop return time.perf_counter() - run.init - bar, thread, event_renderer, cond_refresh = __AliveBarHandle(), None, threading.Event(), _cond() - if sys.stdout.isatty() if config.force_tty is None else config.force_tty: + if total or not config.manual: # we can count items. + logic_total, current = total, lambda: run.count + rate_spec, factor, header = 'f', 1.e6, 'on {:d}: ' + else: # there's only a manual percentage. + logic_total, current = 1., lambda: run.percent + rate_spec, factor, header = '%', 1., 'on {:.1%}: ' + + title, fps = _render_title(config), calibrated_fps(calibrate or factor) + bar, bar_repr = __AliveBarHandle(), _create_bars(config) + bar.current, run.text, run.last_len, run.elapsed = current, '', 0, 0. + run.count, run.percent, run.rate, run.init = 0, 0., 0., 0. + thread, event_renderer, cond_refresh = None, threading.Event(), _cond() + + if config.disable: + term, hook_manager = VOID, passthrough_hook_manager() + else: + term = config.force_tty + hook_manager = buffered_hook_manager( + header if config.enrich_print else '', current, cond_refresh, term) + + if term.interactive: @contextmanager def pause_monitoring(): event_renderer.clear() offset = stop_monitoring() alive_repr() - _write('\n') + term.emit('\n') try: yield finally: @@ -170,14 +189,6 @@ def pause_monitoring(): thread.daemon = True thread.start() - if total or not config.manual: # we can count items. - logic_total, current = total, lambda: run.count - rate_spec, factor, header = 'f', 1.e6, 'on {:d}: ' - else: # there's only a manual percentage. - logic_total, current = 1., lambda: run.percent - rate_spec, factor, header = '%', 1., 'on {:.1%}: ' - bar.current, bar_repr = current, _create_bars(config) - if total or config.manual: # we can track progress and therefore eta. gen_eta = gen_simple_exponential_smoothing_eta(.5, logic_total) gen_eta.send(None) @@ -200,9 +211,6 @@ def elapsed(): def elapsed_end(): return f'in {elapsed_text(run.elapsed, True)}' - run.text, run.last_len, run.elapsed = '', 0, 0. - run.count, run.percent, run.rate, run.init = 0, 0., 0., 0. - if total: if config.manual: def update_hook(): @@ -241,9 +249,6 @@ def monitor(): if not config.elapsed: elapsed = elapsed_end = __noop - title = _render_title(config) - fps = calibrated_fps(calibrate or factor) - hook_manager = _hook_manager(header if config.enrich_print else '', current, cond_refresh) start_monitoring() try: yield bar @@ -259,8 +264,7 @@ def monitor(): if not config.receipt_text: run.text = '' alive_repr() - _write('\n') - _flush() + term.emit('\n') class __AliveBarHandle: @@ -273,7 +277,7 @@ def __call__(self, *args, **kwargs): def _create_bars(local_config): bar = local_config.bar if bar is None: - obj = lambda p: None + obj = __noop obj.unknown, obj.end = obj, obj return obj return bar(local_config.length, local_config.unknown) diff --git a/alive_progress/styles/exhibit.py b/alive_progress/styles/exhibit.py index 0a0ad88..869db79 100644 --- a/alive_progress/styles/exhibit.py +++ b/alive_progress/styles/exhibit.py @@ -11,7 +11,7 @@ from ..animations.utils import spinner_player from ..core.configuration import config_handler from ..utils.cells import combine_cells, print_cells -from ..utils.terminal import clear_end, hide_cursor, show_cursor +from ..utils.terminal import FULL Show = Enum('Show', 'SPINNERS BARS THEMES') @@ -160,19 +160,19 @@ def _showtime_gen(fps, gens, info, length): start, sleep, frame, line_num = time.perf_counter(), 1. / fps, 0, 0 start, current = start - sleep, start # simulates the first frame took exactly "sleep" ms. - hide_cursor() + FULL.hide_cursor() try: while True: cols, lines = os.get_terminal_size() title = 'Welcome to alive-progress!', next(logo) print_cells(title, cols) # line 1. - clear_end() + FULL.clear_end() print() info = fps_monitor.format(frame / (current - start)), next(info_player) print_cells(info, cols) # line 2. - clear_end() + FULL.clear_end() content = [next(gen) for gen in gens] # always consume gens, to maintain them in sync. for line_num, fragments in enumerate(content, 3): @@ -180,7 +180,7 @@ def _showtime_gen(fps, gens, info, length): break print() print_cells(fragments, cols) - clear_end() + FULL.clear_end() frame += 1 current = time.perf_counter() @@ -189,7 +189,7 @@ def _showtime_gen(fps, gens, info, length): except KeyboardInterrupt: pass finally: - show_cursor() + FULL.show_cursor() def _spinner_gen(name, spinner_factory, max_natural): diff --git a/alive_progress/styles/internal.py b/alive_progress/styles/internal.py index cb9d1f5..795e926 100644 --- a/alive_progress/styles/internal.py +++ b/alive_progress/styles/internal.py @@ -33,10 +33,10 @@ def __create_spinners(): dots_waves2 = delayed_spinner_factory(dots, 5, 2) _balloon = bouncing_spinner_factory('🎈', 12, background='β β ˆβ β  β’€β‘€β „β ‚', overlay=True) - pennywise = sequential_spinner_factory( # do not use block mode, so that they doesn't grow. + it = sequential_spinner_factory( # do not use block mode, so that they doesn't grow. _balloon, _balloon, # makes the balloon twice as common. - bouncing_spinner_factory('🀑', background='β β ˆβ β  β’€β‘€β „β ‚', overlay=True), + bouncing_spinner_factory('🀑', background='β β ˆβ β  β’€β‘€β „β ‚', overlay=False), intermix=False ).randomize() @@ -100,8 +100,8 @@ def __create_spinners(): return _wrap_ordered( locals(), 'classic stars twirl twirls horizontal vertical waves waves2 waves3 dots dots_waves' - ' dots_waves2 pennywise ball_belt balls_belt triangles brackets bubbles flowers elements' - ' loving notes notes2 arrow arrows arrows2 arrows_in arrows_out radioactive boat fish fish2' + ' dots_waves2 it ball_belt balls_belt triangles brackets bubbles flowers elements loving' + ' notes notes2 arrow arrows arrows2 arrows_in arrows_out radioactive boat fish fish2' ' fishes crab frank wait wait2 wait3 pulse' ) @@ -114,9 +114,9 @@ def __create_bars(): blocks = bar_factory('β–β–Žβ–β–Œβ–‹β–Šβ–‰') bubbles = bar_factory('βˆ™β—‹β¦Ώβ—', borders='<>') solid = bar_factory('βˆ™β–‘β˜β– ', borders='<>') + checks = bar_factory('βœ“') circles = bar_factory('●', background='β—‹', borders='<>') squares = bar_factory('β– ', background='β–‘', borders='<>') - checks = bar_factory('βœ“') halloween = bar_factory('πŸŽƒ', background=' πŸ‘» πŸ’€', errors=('😱', 'πŸ—‘πŸ—‘πŸ—‘πŸ—‘')) filling = bar_factory('β–β–‚β–ƒβ–„β–…β–†β–‡β–ˆ') notes = bar_factory('β™©β™ͺ♫♬', errors='β™­β™―') @@ -127,7 +127,7 @@ def __create_bars(): return _wrap_ordered( locals(), - 'smooth classic classic2 brackets blocks bubbles solid circles squares checks halloween' + 'smooth classic classic2 brackets blocks bubbles solid checks circles squares halloween' ' filling notes ruler ruler2 fish scuba' ) diff --git a/alive_progress/tools/demo.py b/alive_progress/tools/demo.py index 9467f13..4e1aa89 100644 --- a/alive_progress/tools/demo.py +++ b/alive_progress/tools/demo.py @@ -29,13 +29,13 @@ def title(text): Case('Overflow+total', 1200, dict(total=800)), Case('Unknown', 1000, dict(total=0)), - Case(title='Manual mode'), + Case(title='Manual modes'), Case('Normal+total+manual', 1000, dict(total=1000, manual=True)), Case('Underflow+total+manual', 800, dict(total=1200, manual=True)), Case('Overflow+total+manual', 1200, dict(total=800, manual=True)), Case('Unknown+manual', 1000, dict(total=0, manual=True)), - Case(title='Logging hooks'), + Case(title='Print and Logging hooks'), Case('Simultaneous', 1000, dict(total=1000), hooks=True), # title('Quantifying mode') # soon, quantifying mode... @@ -47,7 +47,7 @@ def title(text): ] features = [dict(total=1000, bar=bar, spinner='loving') for bar in BARS] cases += [Case(name.capitalize(), 1000, {**features[i % len(BARS)], **config}, done=True) - for i, (name, config) in enumerate(OVERHEAD_SAMPLING, 2)] + for i, (name, config) in enumerate(OVERHEAD_SAMPLING, 1)] def demo(sleep=None): diff --git a/alive_progress/tools/sampling.py b/alive_progress/tools/sampling.py index 7659d88..84e4f17 100644 --- a/alive_progress/tools/sampling.py +++ b/alive_progress/tools/sampling.py @@ -1,4 +1,4 @@ -from types import SimpleNamespace +import timeit from about_time import duration_human @@ -11,15 +11,12 @@ def overhead(total=None, *, calibrate=None, **options): number = 400 # timeit number of runs inside each repetition. repeat = 300 # timeit how many times to repeat the whole test. - import timeit - config, sampler = config_handler(force_tty=False, **options), SimpleNamespace() - with __alive_bar(config, total, calibrate=calibrate, _write=__noop_p, _flush=__noop, - _cond=__lock, _term_cols=__noop_z, _hook_manager=__hook_manager, - _sampler=sampler) as bar: - sampler.__dict__.update(bar.__dict__) + config = config_handler(disable=True, **options) + with __alive_bar(config, total, calibrate=calibrate, _cond=__lock): # the timing of the print_cells function increases proportionately with the - # number of columns in the terminal, so I want a baseline here with `_term_cols=0`. - res = timeit.repeat('_alive_repr()', repeat=repeat, number=number, globals=sampler.__dict__) + # number of columns in the terminal, so I want a baseline here `VOID.cols == 0`. + res = timeit.repeat('_alive_repr()', repeat=repeat, number=number, + globals=__alive_bar.__dict__) return duration_human(min(res) / number).replace('us', 'Β΅s') @@ -60,18 +57,10 @@ def overhead_sampling(): print('|') -def __noop(): - pass - - def __noop_p(_ignore): return 0 -def __noop_z(): - return 0 - - class __lock: def __enter__(self): pass @@ -80,13 +69,6 @@ def __exit__(self, _type, value, traceback): pass -def __hook_manager(_=None, __=None, ___=None): - __hook_manager.flush_buffers = __noop - __hook_manager.install = __noop - __hook_manager.uninstall = __noop - return __hook_manager - - if __name__ == '__main__': parser, run = toolkit('Estimates the alive_progress overhead per cycle on your system.') diff --git a/alive_progress/utils/cells.py b/alive_progress/utils/cells.py index d7c784c..76f6d98 100644 --- a/alive_progress/utils/cells.py +++ b/alive_progress/utils/cells.py @@ -49,18 +49,17 @@ """ import re -import sys import unicodedata from itertools import chain, islice, repeat -from .terminal import carriage_return, clear_end +from . import terminal PATTERN_SANITIZE = re.compile(r'[\r\n]') SPACES = repeat(' ') VS_15 = '\ufe0e' -def print_cells(fragments, cols, last_line_len=0, _write=sys.__stdout__.write): # noqa +def print_cells(fragments, cols, last_line_len=0, _term=terminal.FULL): """Print a tuple of fragments of tuples of cells on the terminal, until a given number of cols is achieved, slicing over cells when needed. @@ -70,6 +69,7 @@ def print_cells(fragments, cols, last_line_len=0, _write=sys.__stdout__.write): cols (int): maximum columns to use last_line_len (int): if the size of these fragments are smaller than this, the line is cleared before printing anything + _term: the terminal to be used Returns: the number of actually used cols. @@ -77,20 +77,20 @@ def print_cells(fragments, cols, last_line_len=0, _write=sys.__stdout__.write): """ line = islice(chain.from_iterable(zip(SPACES, filter(None, fragments))), 1, None) available = cols - _write(carriage_return) # should be noop in both non-interactive tty and sampling. + _term.write(_term.cr) for fragment in line: length = len(fragment) - if length > available: - available, fragment = 0, fix_cells(fragment[:available]) - else: + if length <= available: available -= length + else: + available, fragment = 0, fix_cells(fragment[:available]) - _write(join_cells(fragment)) - if not available: + _term.write(join_cells(fragment)) + if available == 0: break else: if last_line_len and sum(len(fragment) for fragment in line) < last_line_len: - clear_end() + _term.clear_end(available) return cols - available diff --git a/alive_progress/utils/terminal.py b/alive_progress/utils/terminal.py deleted file mode 100644 index f987293..0000000 --- a/alive_progress/utils/terminal.py +++ /dev/null @@ -1,46 +0,0 @@ -import os -import sys -from functools import partial - -if sys.stdout.isatty(): - def _send(sequence): # pragma: no cover - def inner(): - sys.__stdout__.write(sequence) - - return inner - - - def terminal_cols(): # pragma: no cover - return os.get_terminal_size()[0] - - - carriage_return = '\r' -else: - def _send(_sequence): # pragma: no cover - def __noop(): - return 0 - - return __noop - - - def terminal_cols(): # pragma: no cover - return sys.maxsize # do not truncate if there's no tty. - - - carriage_return = '' - - -def _send_ansi_escape(sequence, param=''): # pragma: no cover - return _send(f'\x1b[{param}{sequence}') - - -clear_line = _send_ansi_escape('2K\r') # clears the entire line: CSI n K -> with n=2. -clear_end = _send_ansi_escape('K') # clears line from cursor: CSI K. -hide_cursor = _send_ansi_escape('?25l') # hides the cursor: CSI ? 25 l. -show_cursor = _send_ansi_escape('?25h') # shows the cursor: CSI ? 25 h. -factory_cursor_up = partial(_send_ansi_escape, 'A') # sends cursor up: CSI {x}A. -cursor_up_1 = factory_cursor_up(1) - -# work around a bug on Windows OS command prompt, where ANSI escape codes are disabled by default. -if sys.platform == 'win32': - os.system('') diff --git a/alive_progress/utils/terminal/__init__.py b/alive_progress/utils/terminal/__init__.py new file mode 100644 index 0000000..7aff552 --- /dev/null +++ b/alive_progress/utils/terminal/__init__.py @@ -0,0 +1,56 @@ +import sys +from types import SimpleNamespace + +from . import jupyter, non_tty, tty, void + +# work around a bug on Windows' command prompt, where ANSI escape codes are disabled by default. +if sys.platform == 'win32': + import os + + os.system('') + + +def _create(mod, interactive): + def emit(text): + mod.write(text) + mod.flush() + + terminal = SimpleNamespace( + emit=emit, + interactive=interactive, + cursor_up_1=mod.factory_cursor_up(1), + + # from mod terminal impl. + write=mod.write, + flush=mod.flush, + cols=mod.cols, + cr=mod.carriage_return, + clear_line=mod.clear_line, + clear_end=mod.clear_end, + hide_cursor=mod.hide_cursor, + show_cursor=mod.show_cursor, + factory_cursor_up=mod.factory_cursor_up, + ) + return terminal + + +def _is_notebook(): + """This detection is tricky, because by design there's no way to tell which kind + of frontend is connected, there may even be more than one with different types! + Also, there may be other types I'm not aware of... + So, I've chosen what I thought it was the safest method, with a negative logic: + if it _isn't_ None or TerminalInteractiveShell, it should be the "jupyter" type. + The jupyter type does not emit any ANSI Escape Codes. + """ + if 'IPython' not in sys.modules: + # if IPython hasn't been imported, there's nothing to check. + return False + + from IPython import get_ipython + class_ = get_ipython().__class__.__name__ + return class_ != 'TerminalInteractiveShell' + + +FULL = _create(jupyter if _is_notebook() else tty, True) +NON_TTY = _create(non_tty, False) +VOID = _create(void, False) diff --git a/alive_progress/utils/terminal/jupyter.py b/alive_progress/utils/terminal/jupyter.py new file mode 100644 index 0000000..d2fbb8a --- /dev/null +++ b/alive_progress/utils/terminal/jupyter.py @@ -0,0 +1,20 @@ +from .tty import carriage_return, cols, flush, write # noqa +from .void import factory_cursor_up, hide_cursor, show_cursor # noqa + +_last_cols, _clear_line = -1, '' + + +def clear_line(): + c = cols() + global _last_cols, _clear_line + if _last_cols != c: + _clear_line = f'\r{" " * cols()}\r' + _last_cols = c + write(_clear_line) + flush() + + +def clear_end(available): + for _ in range(available): + write(' ') + flush() diff --git a/alive_progress/utils/terminal/non_tty.py b/alive_progress/utils/terminal/non_tty.py new file mode 100644 index 0000000..b0717e6 --- /dev/null +++ b/alive_progress/utils/terminal/non_tty.py @@ -0,0 +1,11 @@ +import sys + +from .tty import flush, write # noqa +from .void import clear_end, clear_line, factory_cursor_up, hide_cursor, show_cursor # noqa + + +def cols(): + return sys.maxsize # do not truncate when there's no tty. + + +carriage_return = '' diff --git a/alive_progress/utils/terminal/tty.py b/alive_progress/utils/terminal/tty.py new file mode 100644 index 0000000..dd3d250 --- /dev/null +++ b/alive_progress/utils/terminal/tty.py @@ -0,0 +1,35 @@ +import os +import sys + +_original_stdout = sys.stdout # support for jupyter notebooks. + + +def write(text): + return _original_stdout.write(text) + + +def flush(): + _original_stdout.flush() + + +def _emit_ansi_escape(sequence, param=''): + def inner(_=None): + write(text) + flush() + + text = f'\x1b[{param}{sequence}' + return inner + + +clear_line = _emit_ansi_escape('2K\r') # clears the entire line: CSI n K -> with n=2. +clear_end = _emit_ansi_escape('K') # clears line from cursor: CSI K. +hide_cursor = _emit_ansi_escape('?25l') # hides the cursor: CSI ? 25 l. +show_cursor = _emit_ansi_escape('?25h') # shows the cursor: CSI ? 25 h. +factory_cursor_up = lambda num: _emit_ansi_escape('A', num) # sends cursor up: CSI {x}A. + + +def cols(): + return os.get_terminal_size()[0] + + +carriage_return = '\r' diff --git a/alive_progress/utils/terminal/void.py b/alive_progress/utils/terminal/void.py new file mode 100644 index 0000000..d7c0a94 --- /dev/null +++ b/alive_progress/utils/terminal/void.py @@ -0,0 +1,27 @@ +def write(_text): + return 0 + + +def flush(): + pass + + +def _emit_ansi_escape(_=''): + def inner(_=None): + pass + + return inner + + +clear_line = _emit_ansi_escape() +clear_end = _emit_ansi_escape() +hide_cursor = _emit_ansi_escape() +show_cursor = _emit_ansi_escape() +factory_cursor_up = lambda _: _emit_ansi_escape() + + +def cols(): + return 0 # more details in `alive_progress.tools.sampling#overhead`. + + +carriage_return = '' diff --git a/img/alive-jupyter.gif b/img/alive-jupyter.gif new file mode 100644 index 0000000..3d4e90a Binary files /dev/null and b/img/alive-jupyter.gif differ diff --git a/setup.py b/setup.py index 8e628a0..81a1f70 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,3 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function, unicode_literals - from distutils.core import setup from setuptools import find_packages diff --git a/tests/core/test_configuration.py b/tests/core/test_configuration.py index 18a408d..520fc0b 100644 --- a/tests/core/test_configuration.py +++ b/tests/core/test_configuration.py @@ -6,6 +6,7 @@ from alive_progress.core.configuration import Config, ERROR, __style_input_factory, \ _bool_input_factory, _int_input_factory, create_config from alive_progress.styles.internal import BARS, SPINNERS, THEMES +from alive_progress.utils.terminal import NON_TTY, FULL @pytest.mark.parametrize('lower, upper, num, expected', [ @@ -90,11 +91,11 @@ def test_config_creation(handler): (dict(spinner=SPINNERS['pulse']), {}), (dict(bar='solid'), dict(bar=BARS['solid'])), (dict(bar=BARS['solid']), {}), - (dict(force_tty=True), {}), + (dict(force_tty=False), dict(force_tty=NON_TTY)), (dict(manual=True), {}), (dict(enrich_print=False), {}), (dict(title_length=20), {}), - (dict(force_tty=True, manual=True, enrich_print=False, title_length=10), {}), + (dict(force_tty=True, manual=True, enrich_print=False, title_length=10), dict(force_tty=FULL)), (dict(spinner=None, manual=None), dict(manual=False)), ]) def config_params(request): diff --git a/tests/core/test_hook_manager.py b/tests/core/test_hook_manager.py index a84916e..4eb195b 100644 --- a/tests/core/test_hook_manager.py +++ b/tests/core/test_hook_manager.py @@ -7,6 +7,7 @@ import pytest from alive_progress.core.hook_manager import buffered_hook_manager +from alive_progress.utils.terminal import FULL, VOID @contextmanager @@ -17,7 +18,7 @@ def hook(hook_manager): def test_hook_manager_captures_stdout(capsys): - hook_manager = buffered_hook_manager('nice {}! ', lambda: 35, Condition()) + hook_manager = buffered_hook_manager('nice {}! ', lambda: 35, Condition(), FULL) with hook(hook_manager): print('ok') assert capsys.readouterr().out == 'nice 35! ok\n' @@ -30,28 +31,28 @@ def _hook_manager_captures_logging(capsys): logging.basicConfig(stream=sys.stderr) logger = logging.getLogger('?name?') - hook_manager = buffered_hook_manager('nice {}! ', lambda: 35, Condition()) + hook_manager = buffered_hook_manager('nice {}! ', lambda: 35, Condition(), FULL) with hook(hook_manager): logger.error('oops') assert capsys.readouterr().err == 'nice 35! ERROR:?name?:oops\n' def test_hook_manager_captures_multiple_lines(capsys): - hook_manager = buffered_hook_manager('nice {}! ', lambda: 35, Condition()) + hook_manager = buffered_hook_manager('nice {}! ', lambda: 35, Condition(), FULL) with hook(hook_manager): print('ok1\nok2') assert capsys.readouterr().out == 'nice 35! ok1\n ok2\n' def test_hook_manager_can_be_disabled(capsys): - hook_manager = buffered_hook_manager('', None, Condition()) + hook_manager = buffered_hook_manager('', None, Condition(), FULL) with hook(hook_manager): print('ok') assert capsys.readouterr().out == 'ok\n' def test_hook_manager_flush(capsys): - hook_manager = buffered_hook_manager('', None, Condition()) + hook_manager = buffered_hook_manager('', None, Condition(), FULL) with hook(hook_manager): print('ok', end='') assert capsys.readouterr().out == '' @@ -64,18 +65,20 @@ def test_hook_manager_flush(capsys): def test_hook_manager_do_clear_line_on_stdout(): - hook_manager = buffered_hook_manager('', None, Condition()) - with hook(hook_manager), mock.patch('alive_progress.core.hook_manager.clear_line') as m_clear: + hook_manager = buffered_hook_manager('', None, Condition(), VOID) + m_clear = mock.Mock() + with hook(hook_manager), mock.patch.dict(VOID.__dict__, clear_line=m_clear): print('some') m_clear.assert_called() -def test_hook_manager_do_not_flicker_screen_when_logging(capsys): +def test_hook_manager_do_not_flicker_screen_when_logging(): logging.basicConfig() logger = logging.getLogger() - hook_manager = buffered_hook_manager('', None, Condition()) - with hook(hook_manager), mock.patch('alive_progress.core.hook_manager.clear_line') as m_clear: + hook_manager = buffered_hook_manager('', None, Condition(), VOID) + m_clear = mock.Mock() + with hook(hook_manager), mock.patch.dict(VOID.__dict__, clear_line=m_clear): logger.error('oops') m_clear.assert_not_called() @@ -91,14 +94,14 @@ def handlers(): def test_install(handlers): - hook_manager = buffered_hook_manager('', None, Condition()) + hook_manager = buffered_hook_manager('', None, Condition(), FULL) with mock.patch('alive_progress.core.hook_manager._set_stream') as mock_set_stream: hook_manager.install() mock_set_stream.assert_has_calls(tuple(mock.call(h, mock.ANY) for h in handlers)) def test_uninstall(handlers): - hook_manager = buffered_hook_manager('', None, Condition()) + hook_manager = buffered_hook_manager('', None, Condition(), FULL) with mock.patch('alive_progress.core.hook_manager._set_stream') as mock_set_stream: hook_manager.install() hook_manager.uninstall()