Merge pull request #368 from willmcgugan/safe-box-encoding

Safe box encoding
This commit is contained in:
Will McGugan 2020-10-09 15:48:43 +01:00 committed by GitHub
commit b0a68d3341
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 119 additions and 71 deletions

View File

@ -8,14 +8,15 @@ jobs:
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
python-version: [3.6, 3.7, 3.8]
python-version: [3.6, 3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
architecture: x64
- name: Install dependencies
run: |
python -m pip install --upgrade pip

View File

@ -5,15 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [8.1.0] - unreleased
## [9.0.0] - unreleased
### Fixed
- Progress download column now displays decimal units
### Added
- Added legacy_windows to ConsoleOptions
- Added ascii_only to ConsoleOptions
- Addded box.SQUARE_DOUBLE_HEAD
- Added highlighting of EUI-48 and EUI-64 (MAC addresses)
### Changed
- Change the render prefix to correspond to the decimal units in progress
- Dropped box.get_safe_box function in favor of Box.substitute
### Fixed

View File

@ -146,7 +146,7 @@ You can also set overflow to "ignore" which allows text to run on to the next li
Soft Wrapping
-------------
Rich word wraps text you print by inserting line breaks. You can disable this behavior by setting ``soft_wrap=True`` when calling :meth:`~rich.console.Console.print`. With *soft wrapping* enabled text any text that doesn't fit will run on to the following line(s), just like the builtin ``print``.
Rich word wraps text you print by inserting line breaks. You can disable this behavior by setting ``soft_wrap=True`` when calling :meth:`~rich.console.Console.print`. With *soft wrapping* enabled any text that doesn't fit will run on to the following line(s), just like the builtin ``print``.
Cropping

View File

@ -17,6 +17,7 @@ classifiers = [
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
]
include = ["rich/py.typed"]

View File

@ -1,7 +1,7 @@
from functools import lru_cache
import math
from functools import lru_cache
from time import monotonic
from typing import Iterable, Optional, List
from typing import Iterable, List, Optional
from .color import Color, blend_rgb
from .color_triplet import ColorTriplet
@ -11,7 +11,6 @@ from .measure import Measurement
from .segment import Segment
from .style import Style, StyleType
# Number of characters before 'pulse' animation repeats
PULSE_SIZE = 20
@ -71,14 +70,14 @@ class Bar(JupyterMixin):
fore_style: Style,
back_style: Style,
color_system: str,
legacy_windows: bool,
ascii: bool = False,
) -> List[Segment]:
"""Get a list of segments to render a pulse animation.
Returns:
List[Segment]: A list of segments, one segment per character.
"""
bar = "" if legacy_windows else ""
bar = "-" if ascii else ""
segments: List[Segment] = []
if color_system != "truecolor":
@ -122,7 +121,9 @@ class Bar(JupyterMixin):
self.completed = completed
self.total = total if total is not None else self.total
def _render_pulse(self, console: Console, width: int) -> Iterable[Segment]:
def _render_pulse(
self, console: Console, width: int, ascii: bool = False
) -> Iterable[Segment]:
"""Renders the pulse animation.
Args:
@ -139,7 +140,7 @@ class Bar(JupyterMixin):
back_style = console.get_style(self.style, default="black")
pulse_segments = self._get_pulse_segments(
fore_style, back_style, console.color_system, console.legacy_windows
fore_style, back_style, console.color_system, ascii=ascii
)
segment_count = len(pulse_segments)
current_time = (
@ -155,15 +156,16 @@ class Bar(JupyterMixin):
) -> RenderResult:
width = min(self.width or options.max_width, options.max_width)
ascii = options.legacy_windows or options.ascii_only
if self.pulse:
yield from self._render_pulse(console, width)
yield from self._render_pulse(console, width, ascii=ascii)
return
completed = min(self.total, max(0, self.completed))
legacy_windows = console.legacy_windows
bar = "" if legacy_windows else ""
half_bar_right = " " if legacy_windows else ""
half_bar_left = " " if legacy_windows else ""
bar = "-" if ascii else ""
half_bar_right = " " if ascii else ""
half_bar_left = " " if ascii else ""
complete_halves = (
int(width * 2 * completed / self.total) if self.total else width * 2
)

View File

@ -1,8 +1,12 @@
from typing import Iterable, List, Optional, overload
from typing import TYPE_CHECKING, Iterable, List
from typing_extensions import Literal
from ._loop import loop_last
if TYPE_CHECKING:
from rich.console import ConsoleOptions
class Box:
"""Defines characters to render boxes.
@ -16,11 +20,14 @@ class Box:
foot
bottom
Args:
box (str): Characters making up box.
ascii (bool, optional): True if this box uses ascii characters only. Default is False.
"""
def __init__(self, box: str) -> None:
def __init__(self, box: str, *, ascii: bool = False) -> None:
self._box = box
self.ascii = ascii
line1, line2, line3, line4, line5, line6, line7, line8 = box.splitlines()
# top
self.top_left, self.top, self.top_divider, self.top_right = iter(line1)
@ -58,6 +65,24 @@ class Box:
def __str__(self) -> str:
return self._box
def substitute(self, options: "ConsoleOptions", safe: bool = True) -> "Box":
"""Substitute this box for another if it won't render due to platform issues.
Args:
options (ConsoleOptions): Console options used in rendering.
safe (bool, optional): Substitute this for another Box if there are known problems
displaying on the platform (currently only relevant on Windows). Default is True.
Returns:
Box: A different Box or the same Box.
"""
box = self
if options.legacy_windows and safe:
box = LEGACY_WINDOWS_SUBSTITUTIONS.get(box, box)
if options.ascii_only:
box = ASCII
return box
def get_top(self, widths: Iterable[int]) -> str:
"""Get the top of a simple box.
@ -158,7 +183,8 @@ ASCII: Box = Box(
|-+|
| ||
+--+
"""
""",
ascii=True,
)
ASCII2: Box = Box(
@ -171,7 +197,8 @@ ASCII2: Box = Box(
+-++
| ||
+-++
"""
""",
ascii=True,
)
ASCII_DOUBLE_HEAD: Box = Box(
@ -184,7 +211,8 @@ ASCII_DOUBLE_HEAD: Box = Box(
+-++
| ||
+-++
"""
""",
ascii=True,
)
SQUARE: Box = Box(
@ -200,6 +228,18 @@ SQUARE: Box = Box(
"""
)
SQUARE_DOUBLE_HEAD: Box = Box(
"""\
"""
)
MINIMAL: Box = Box(
"""\
@ -385,40 +425,15 @@ LEGACY_WINDOWS_SUBSTITUTIONS = {
}
@overload
def get_safe_box(box: None, legacy_windows: bool) -> None:
...
@overload
def get_safe_box(box: Box, legacy_windows: bool) -> Box:
...
def get_safe_box(box: Optional[Box], legacy_windows: bool) -> Optional[Box]:
"""Substitute Box constants that don't render on windows legacy.
Args:
box (Optional[Box]): A Box instance.
legacy_windows (bool): Enable legacy Windows.
Returns:
Optional[Box]: A Box instance (potentially a new instance).
"""
if legacy_windows and box is not None:
return LEGACY_WINDOWS_SUBSTITUTIONS.get(box, box)
else:
return box
if __name__ == "__main__": # pragma: no cover
from rich.columns import Columns
from rich.panel import Panel
from . import box
from .console import Console
from .table import Table
from .text import Text
from . import box
console = Console(record=True)
@ -427,6 +442,7 @@ if __name__ == "__main__": # pragma: no cover
"ASCII2",
"ASCII_DOUBLE_HEAD",
"SQUARE",
"SQUARE_DOUBLE_HEAD",
"MINIMAL",
"MINIMAL_HEAVY_HEAD",
"MINIMAL_DOUBLE_HEAD",

View File

@ -81,6 +81,8 @@ _TERM_COLORS = {"256color": ColorSystem.EIGHT_BIT, "16color": ColorSystem.STANDA
class ConsoleOptions:
"""Options for __rich_console__ method."""
legacy_windows: bool
"""legacy_windows: flag for legacy windows."""
min_width: int
"""Minimum width of renderable."""
max_width: int
@ -96,6 +98,11 @@ class ConsoleOptions:
no_wrap: Optional[bool] = False
""""Disable wrapping for text."""
@property
def ascii_only(self) -> bool:
"""Check if renderables should use ascii only."""
return not self.encoding.startswith("utf")
def update(
self,
width: int = None,
@ -341,7 +348,7 @@ class Console:
force_terminal (Optional[bool], optional): Enable/disable terminal control codes, or None to auto-detect terminal. Defaults to None.
force_jupyter (Optional[bool], optional): Enable/disable Jupyter rendering, or None to auto-detect Jupyter. Defaults to None.
theme (Theme, optional): An optional style theme object, or ``None`` for default theme.
file (IO, optional): A file object where the console should write to. Defaults to stdoutput.
file (IO, optional): A file object where the console should write to. Defaults to stdout.
width (int, optional): The width of the terminal. Leave as default to auto-detect width.
height (int, optional): The height of the terminal. Leave as default to auto-detect height.
record (bool, optional): Boolean to enable recording of terminal output,
@ -568,7 +575,7 @@ class Console:
Returns:
str: A standard encoding string.
"""
return getattr(self.file, "encoding", "utf-8")
return (getattr(self.file, "encoding", "utf-8") or "utf-8").lower()
@property
def is_terminal(self) -> bool:
@ -599,6 +606,7 @@ class Console:
def options(self) -> ConsoleOptions:
"""Get default console options."""
return ConsoleOptions(
legacy_windows=self.legacy_windows,
min_width=1,
max_width=self.width,
encoding=self.encoding,

View File

@ -1,6 +1,6 @@
from typing import Optional, TYPE_CHECKING
from .box import get_safe_box, Box, SQUARE, ROUNDED
from .box import Box, ROUNDED
from .align import AlignValues
from .jupyter import JupyterMixin
@ -113,7 +113,7 @@ class Panel(JupyterMixin):
width = options.max_width if self.width is None else self.width
safe_box: bool = console.safe_box if self.safe_box is None else self.safe_box # type: ignore
box = get_safe_box(self.box, console.legacy_windows) if safe_box else self.box
box = self.box.substitute(options, safe=safe_box)
title_text = self._title
if title_text is not None:

View File

@ -299,9 +299,7 @@ class DownloadColumn(ProgressColumn):
completed = int(task.completed)
total = int(task.total)
unit, suffix = filesize.pick_unit_and_suffix(
total,
["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"],
1000,
total, ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"], 1000
)
completed_ratio = completed / unit
total_ratio = total / unit

View File

@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Optional, Tuple, U
from . import box, errors
from ._loop import loop_first_last, loop_last
from ._pick import pick_bool
from ._ratio import ratio_distribute, ratio_reduce
from .jupyter import JupyterMixin
from .measure import Measurement
@ -609,9 +610,12 @@ class Table(JupyterMixin):
)
)
)
safe_box: bool = console.safe_box if self.safe_box is None else self.safe_box # type: ignore
_box = (
box.get_safe_box(self.box, console.legacy_windows) if safe_box else self.box
self.box.substitute(
options, safe=pick_bool(self.safe_box, console.safe_box)
)
if self.box
else None
)
# _box = self.box

View File

@ -1,6 +1,7 @@
import pytest
from rich.box import get_safe_box, ASCII, DOUBLE, ROUNDED, HEAVY, SQUARE
from rich.console import ConsoleOptions
from rich.box import ASCII, DOUBLE, ROUNDED, HEAVY, SQUARE
def test_str():
@ -35,8 +36,18 @@ def test_get_bottom():
assert bottom == "┗━┻━━┻━━━┛"
def test_get_safe_box():
assert get_safe_box(HEAVY, True) == SQUARE
assert get_safe_box(HEAVY, False) == HEAVY
assert get_safe_box(None, True) is None
assert get_safe_box(None, False) is None
def test_box_substitute():
options = ConsoleOptions(
legacy_windows=True,
min_width=1,
max_width=100,
is_terminal=True,
encoding="utf-8",
)
assert HEAVY.substitute(options) == SQUARE
options.legacy_windows = False
assert HEAVY.substitute(options) == HEAVY
options.encoding = "ascii"
assert HEAVY.substitute(options) == ASCII

View File

@ -43,7 +43,11 @@ def test_truecolor_terminal():
def test_console_options_update():
options = ConsoleOptions(
min_width=10, max_width=20, is_terminal=False, encoding="utf-8"
legacy_windows=False,
min_width=10,
max_width=20,
is_terminal=False,
encoding="utf-8",
)
options1 = options.update(width=15)
assert options1.min_width == 15 and options1.max_width == 15

View File

@ -103,12 +103,8 @@ def test_inspect_empty_dict():
"│ in the keyword argument list. For │\n"
"│ example: dict(one=1, two=2) │\n"
"│ │\n"
"│ 30 attribute(s) not shown. Use │\n"
"│ inspect(<OBJECT>, all=True) to see all │\n"
"│ attributes. │\n"
"╰────────────────────────────────────────────────╯\n"
)
assert expected == render({})
assert render({}).startswith(expected)
def test_inspect_builtin_function():