diff --git a/CHANGELOG.md b/CHANGELOG.md index e565bd20c..7f2a07846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added Console.bell method - Added Set to types that Console.print will automatically pretty print +- Added show_locals to Traceback +- Added theme stack mechanism, see Console.push_theme and Console.pop_theme ### Changed diff --git a/pyproject.toml b/pyproject.toml index 62dd26ba0..814d92e07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "rich" homepage = "https://github.com/willmcgugan/rich" documentation = "https://rich.readthedocs.io/en/latest/" -version = "7.1.0" +version = "8.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" authors = ["Will McGugan "] license = "MIT" @@ -37,5 +37,5 @@ jupyter = ["ipywidgets"] [tool.poetry.dev-dependencies] [build-system] -requires = ["poetry-core>=1.0.0a9"] -build-backend = "poetry.core.masonry.api" +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index c98a42695..155048478 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ black==20.8b1 mypy==0.782 poetry==1.0.10 -pytest==5.4.3 +pytest==6.1.0 pytest-cov==2.10.1 diff --git a/rich/__init__.py b/rich/__init__.py index d0f82dd88..80c9b61d5 100644 --- a/rich/__init__.py +++ b/rich/__init__.py @@ -10,7 +10,8 @@ def get_console() -> "Console": - """Get a global Console instance. + """Get a global :class:`~rich.console.Console` instance. This function is used when Rich requires a Console, + and hasn't been explicitly given one. Returns: Console: A console instance. @@ -31,6 +32,16 @@ def print( file: IO[str] = None, flush: bool = False, ): + """Print object(s) supplied via positional arguments. This function has an identical signature + to the built-in print. For more advanced features, see the :class:`~rich.console.Console` class. + + Args: + sep (str, optional): Separator between printed objects. Defaults to " ". + end (str, optional): Character to write at end of output. Defaults to "\n". + file (IO[str], optional): File to write to, or None for stdout. Defaults to None. + flush (bool, optional): Has no effect as Rich always flushes output.. Defaults to False. + + """ from .console import Console write_console = get_console() if file is None else Console(file=file) diff --git a/rich/console.py b/rich/console.py index d21dd6847..f9847d2e4 100644 --- a/rich/console.py +++ b/rich/console.py @@ -41,7 +41,7 @@ from .style import Style from .terminal_theme import DEFAULT_TERMINAL_THEME, TerminalTheme from .text import Text, TextType -from .theme import Theme +from .theme import Theme, ThemeStack if TYPE_CHECKING: from ._windows import WindowsConsoleFeatures @@ -182,6 +182,22 @@ def get(self) -> str: return self._result +class ThemeContext: + """A context manager to use a temporary theme. See :meth:`~rich.console.Console.theme` for usage.""" + + def __init__(self, console: "Console", theme: Theme, inherit: bool = True) -> None: + self.console = console + self.theme = theme + self.inherit = inherit + + def __enter__(self) -> "ThemeContext": + self.console.push_theme(self.theme) + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.console.pop_theme() + + class RenderGroup: """Takes a group of renderables and returns a renderable object that renders the group. @@ -372,7 +388,7 @@ def __init__( if self.is_jupyter: width = width or 93 height = height or 100 - self._styles = themes.DEFAULT.styles if theme is None else theme.styles + self._theme_stack = ThemeStack(themes.DEFAULT if theme is None else theme) self._width = width self._height = height self.tab_size = tab_size @@ -498,6 +514,33 @@ def end_capture(self) -> str: self._exit_buffer() return render_result + def push_theme(self, theme: Theme, inherit: bool = True) -> None: + """Push a new theme on to the top of the stack, overwriting the styles from the previous theme. + Generally speaking, you call :meth:`~rich.console.Console.theme` to get a context manager, rather + than calling this method directly. + + Args: + theme (Theme): A theme instance. + inherit (bool, optional): Inherit existing styles. Defaults to True. + """ + self._theme_stack.push_theme(theme, inherit=inherit) + + def pop_theme(self) -> None: + """Remove theme from top of stack, restoring previous theme.""" + self._theme_stack.pop_theme() + + def theme(self, theme: Theme, inherit: bool = True) -> ThemeContext: + """Use a new temporary theme for the duration of the context manager. + + Args: + theme (Theme): Theme instance to user. + inherit (bool, optional): Inherit existing styles. Defaults to True. + + Returns: + ThemeContext: [description] + """ + return ThemeContext(self, theme, inherit) + @property def color_system(self) -> Optional[str]: """Get color system string. @@ -785,7 +828,7 @@ def get_style( return name try: - style = self._styles.get(name) + style = self._theme_stack.get(name) if style is None: style = Style.parse(name) return style.copy() if style.link else style diff --git a/rich/progress.py b/rich/progress.py index 72ec5e7b8..66fb35aff 100644 --- a/rich/progress.py +++ b/rich/progress.py @@ -75,6 +75,7 @@ def run(self) -> None: if last_completed != completed: advance(task_id, completed - last_completed) last_completed = completed + self.progress.update(self.task_id, completed=self.completed, refresh=True) def __enter__(self) -> "_TrackThread": @@ -780,7 +781,7 @@ def advance(self, task_id: TaskID, advance: float = 1) -> None: popleft = _progress.popleft while _progress and _progress[0].timestamp < old_sample_time: popleft() - while len(_progress) > 10: + while len(_progress) > 1000: popleft() _progress.append(ProgressSample(current_time, update_completed)) diff --git a/rich/theme.py b/rich/theme.py index 14bf011c9..c8dccf269 100644 --- a/rich/theme.py +++ b/rich/theme.py @@ -1,5 +1,5 @@ import configparser -from typing import IO, Mapping +from typing import Dict, List, IO, Mapping, Optional from .default_styles import DEFAULT_STYLES from .style import Style, StyleType @@ -9,10 +9,12 @@ class Theme: """A container for style information, used by :class:`~rich.console.Console`. Args: - styles (Dict[str, Style], optional): A mapping of style names on to styles. Defaults to None for empty styles. + styles (Dict[str, Style], optional): A mapping of style names on to styles. Defaults to None for a theme with no styles. inherit (bool, optional): Inherit default styles. Defaults to True. """ + styles: Dict[str, Style] + def __init__(self, styles: Mapping[str, StyleType] = None, inherit: bool = True): self.styles = DEFAULT_STYLES.copy() if inherit else {} if styles is not None: @@ -66,6 +68,44 @@ def read(cls, path: str, inherit: bool = True) -> "Theme": return cls.from_file(config_file, source=path, inherit=inherit) +class ThemeStackError(Exception): + """Base exception for errors related to the theme stack.""" + + +class ThemeStack: + """A stack of themes. + + Args: + theme (Theme): A theme instance + """ + + def __init__(self, theme: Theme) -> None: + self._entries: List[Dict[str, Style]] = [theme.styles] + self.get = self._entries[-1].get + + def push_theme(self, theme: Theme, inherit: bool = True) -> None: + """Push a theme on the top of the stack. + + Args: + theme (Theme): A Theme instance. + inherit (boolean, optional): Inherit styles from current top of stack. + """ + styles: Dict[str, Style] + if inherit: + styles = {**self._entries[-1], **theme.styles} + else: + styles = theme.styles.copy() + self._entries.append(styles) + self.get = self._entries[-1].get + + def pop_theme(self) -> None: + """Pop (and discard) the top-most theme.""" + if len(self._entries) == 1: + raise ThemeStackError("Unable to pop base theme") + self._entries.pop() + self.get = self._entries[-1].get + + if __name__ == "__main__": # pragma: no cover theme = Theme() print(theme.config) diff --git a/rich/traceback.py b/rich/traceback.py index 93b61e094..215ef5330 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -7,6 +7,7 @@ from types import TracebackType from typing import Any, Callable, Dict, List, Optional, Type +from pygments.lexers import guess_lexer_for_filename from pygments.token import Token, String, Name, Number from ._loop import loop_first, loop_last @@ -335,6 +336,24 @@ def _render_syntax_error( def _render_stack(self, stack: Stack, styles: Dict[str, Style]) -> RenderResult: path_highlighter = PathHighlighter() theme = self.theme + code_cache: Dict[str, str] = {} + + def read_code(filename: str) -> str: + """Read files, and cache results on filename. + + Args: + filename (str): Filename to read + + Returns: + str: Contents of file + """ + code = code_cache.get(filename) + if code is None: + with open(filename, "rt") as code_file: + code = code_file.read() + code_cache[filename] = code + return code + for first, frame in loop_first(stack.frames): text = Text.assemble( path_highlighter(Text(frame.filename, style=styles["string"])), @@ -350,8 +369,12 @@ def _render_stack(self, stack: Stack, styles: Dict[str, Style]) -> RenderResult: if frame.filename.startswith("<"): continue try: - syntax = Syntax.from_path( - frame.filename, + code = read_code(frame.filename) + lexer = guess_lexer_for_filename(frame.filename, code) + lexer_name = lexer.name + syntax = Syntax( + code, + lexer_name, theme=theme, line_numbers=True, line_range=( @@ -366,16 +389,17 @@ def _render_stack(self, stack: Stack, styles: Dict[str, Style]) -> RenderResult: except Exception: pass else: - if frame.locals: - yield Columns( + yield ( + Columns( [ syntax, render_scope(frame.locals, title="locals"), ], padding=1, ) - else: - yield syntax + if frame.locals + else syntax + ) if __name__ == "__main__": # pragma: no cover diff --git a/tests/test_logging.py b/tests/test_logging.py index ee03c3c13..566c6859f 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -25,19 +25,6 @@ ) -def make_log(): - log.debug("foo") - render = handler.console.file.getvalue() - return render - - -def test_log(): - render = make_log() - print(repr(render)) - expected = "\x1b[2;36m[DATE]\x1b[0m\x1b[2;36m \x1b[0m\x1b[32mDEBUG\x1b[0m foo \x1b[2mtest_logging.py\x1b[0m\x1b[2m:29\x1b[0m\n" - assert render == expected - - @skip_win def test_exception(): console = Console(