diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-14 20:18:28 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-14 20:18:28 +0000 |
commit | f8363b456f1ab31ee56abad579b215af195093d5 (patch) | |
tree | b1500c675c2e0a55fb75721a854e1510acf7c862 | |
parent | Initial commit. (diff) | |
download | rich-upstream.tar.xz rich-upstream.zip |
Adding upstream version 9.11.0.upstream/9.11.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
274 files changed, 38170 insertions, 0 deletions
diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..ff1a046 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,11 @@ +[run] +omit = rich/jupyter.py + rich/_windows.py + rich/_timer.py + +[report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: + if __name__ == "__main__": + @overload diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2d92677 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: willmcgugan +custom: https://www.willmcgugan.com/sponsorship/ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6bbe40a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: Needs triage +assignees: "" +--- + +**Read the docs** +You might find a solution to your problem in the [docs](https://rich.readthedocs.io/en/latest/introduction.html) -- consider using the search function there. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +A minimal code example that reproduces the problem would be a big help if you can provide it. If the issue is visual in nature, consider posting a screenshot. + +**Platform** +What platform (Win/Linux/Mac) are you running on? What terminal software are you using? + +**Diagnose** +I may ask you to cut and paste the output of the following commands. It may save some time if you do it now. + +``` +python -m rich.diagnose +python -m rich._windows +pip freeze | grep rich +``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ab4de71 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[REQUEST]" +labels: Needs triage +assignees: '' + +--- + +Consider posting in https://github.com/willmcgugan/rich/discussions for feedback before raising a feature request. + +Have you checked the issues for a similar suggestions? + +**How would you improve Rich?** + +Give as much detail as you can. Example code of how you would like it to work would help. + +**What problem does it solved for you?** + +What problem do you have that this feature would solve? I may be able to suggest an existing way of solving it. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cf7a39f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml new file mode 100644 index 0000000..9685a6f --- /dev/null +++ b/.github/workflows/pythonpackage.yml @@ -0,0 +1,45 @@ +name: Test Rich module + +on: [pull_request] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + python-version: [3.6, 3.7, 3.8, 3.9] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install and configure Poetry + uses: snok/install-poetry@v1.1.1 + with: + version: 1.1.4 + virtualenvs-create: false + - name: Install dependencies + run: poetry install + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + - name: Format check with black + run: make format-check + - name: Typecheck with mypy + run: make typecheck + - name: Test with pytest + run: | + pip install . + python -m pytest tests -v --cov=./rich --cov-report=xml:./coverage.xml --cov-report term-missing + - name: Upload code coverage + uses: codecov/codecov-action@v1.0.10 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.xml + name: rich + flags: unittests + env_vars: OS,PYTHON diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc0dfdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,114 @@ +*.ipynb +.pytype +.DS_Store +.vscode +mypy_report +docs/build +docs/source/_build +tools/*.txt +playground/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..05c863a --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,19 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/source/conf.py + +# Optionally build your docs in additional formats such as PDF and ePub +formats: all + +python: + version: 3.7 + install: + - requirements: docs/requirements.txt + - method: pip + path: . diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a8079a9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1187 @@ +# Changelog + +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). + +## [9.11.0] - Unreleased + +### Fixed + +- Fixed error message for tracebacks with broken `__str__` https://github.com/willmcgugan/rich/issues/980 +- Fixed markup edge case https://github.com/willmcgugan/rich/issues/987 + +### Added + +- Added cheeky sponsorship request to test card +- Added `quiet` argument to Console constructor +- Added support for a callback function to format timestamps (allows presentation of milliseconds) +- Added Console.set_alt_screen and Console.screen +- Added height to ConsoleOptions +- Added `vertical` parameter to Algin +- Added Layout class + +### Changed + +- Pretty.overflow now defaults to None +- Panel now respects options.height +- Traceback lexer defaults to Python if no extension on source +- Added ConsoleDimensions size attribute to ConsoleOptions so that size can't change mid-render + +## [9.10.0] - 2021-01-27 + +### Changed + +- Some optimizations for Text +- Further optimized Tracebacks by not tokenizing code more that necessary +- Table Column.header_style and Column.footer_style are now added to Table header/footer style + +## [9.9.0] - 2021-01-23 + +### Changed + +- Extended Windows palette to 16 colors +- Modified windows palette to Windows 10 colors +- Change regex for attrib_name to be more performant +- Optimized traceback generation + +### Fixed + +- Fix double line tree guides on Windows +- Fixed Tracebacks ignoring initial blank lines +- Partial fix for tracebacks not finding source after chdir +- Fixed error message when code in tracebacks doesn't have an extension https://github.com/willmcgugan/rich/issues/996 + +### Added + +- Added post_style argument to Segment.apply_style + +## [9.8.2] - 2021-01-15 + +### Fixed + +- Fixed deadlock in live https://github.com/willmcgugan/rich/issues/927 + +## [9.8.1] - 2021-01-13 + +### Fixed + +- Fixed rich.inspect failing with attributes that claim to be callable but aren't https://github.com/willmcgugan/rich/issues/916 + +## [9.8.0] - 2021-01-11 + +### Added + +- Added **rich_measure** for tree +- Added rich.align.VerticalCenter + +### Changed + +- The `style` argument on Align now applies to background only +- Changed display of progress bars in no_color mode for clarity +- Console property `size` will fall back to getting the terminal size of stdout it stdin fails, this allows size to be correctly determined when piping + +### Fixed + +- Fixed panel cropping when shrunk too bar +- Allow passing markdown over STDIN when using `python -m rich.markdown` +- Fix printing MagicMock.mock_calls https://github.com/willmcgugan/rich/issues/903 + +## [9.7.0] - 2021-01-09 + +### Added + +- Added rich.tree +- Added no_color argument to Console + +## [9.6.2] - 2021-01-07 + +### Fixed + +- Fixed markup escaping edge case https://github.com/willmcgugan/rich/issues/878 +- Double tag escape, i.e. `"\\[foo]"` results in a backslash plus `[foo]` tag +- Fixed header_style not applying to headers in positional args https://github.com/willmcgugan/rich/issues/953 + +## [9.6.1] - 2020-12-31 + +### Fixed + +- Fixed encoding error on Windows when loading code for Tracebacks + +## [9.6.0] - 2020-12-30 + +### Changed + +- MarkupError exception raise from None to omit internal exception +- Factored out RichHandler.render and RichHandler.render_message for easier extending +- Display pretty printed value in rich.inspect + +### Added + +- Added Progress.TimeElapsedColumn +- Added IPython support to pretty.install + +### Fixed + +- Fixed display of locals in Traceback for stdin + +## [9.5.1] - 2020-12-19 + +### Fixed + +- Fixed terminal size detection on Windows https://github.com/willmcgugan/rich/issues/836 +- Fixed hex number highlighting + +## [9.5.0] - 2020-12-18 + +### Changed + +- If file is not specified on Console then the Console.file will return the current sys.stdout. Prior to 9.5.0 sys.stdout was cached on the Console, which could break code that wrapped sys.stdout after the Console was constructed. +- Changed `Color.__str__` to not include ansi codes +- Changed Console.size to get the terminal dimensions via sys.stdin. This means that if you set file to be an io.StringIO file then the width will be set to the current terminal dimensions and not a default of 80. + +### Added + +- Added stderr parameter to Console +- Added rich.reconfigure +- Added `Color.__rich__` +- Added Console.soft_wrap +- Added Console.style parameter +- Added Table.highlight parameter to enable highlighting of cells +- Added Panel.highlight parameter to enable highlighting of panel title +- Added highlight to ConsoleOptions + +### Fixed + +- Fixed double output in rich.live https://github.com/willmcgugan/rich/issues/485 +- Fixed Console.out highlighting not reflecting defaults https://github.com/willmcgugan/rich/issues/827 +- FileProxy now raises TypeError for empty non-str arguments https://github.com/willmcgugan/rich/issues/828 + +## [9.4.0] - 2020-12-12 + +### Added + +- Added rich.live https://github.com/willmcgugan/rich/pull/382 +- Added algin parameter to Rule and Console.rule +- Added rich.Status class and Console.status +- Added getitem to Text +- Added style parameter to Console.log +- Added rich.diagnose command + +### Changed + +- Table.add_row style argument now applies to entire line and not just cells +- Added end_section parameter to Table.add_row to force a line underneath row + +## Fixed + +- Fixed suppressed traceback context https://github.com/willmcgugan/rich/issues/468 + +## [9.3.0] - 2020-12-1 + +### Added + +- Added get_datetime parameter to Console, to allow for repeatable tests +- Added get_time parameter to Console +- Added rich.abc.RichRenderable +- Added expand_all to rich.pretty.install() +- Added locals_max_length, and locals_max_string to Traceback and logging.RichHandler +- Set defaults of max_length and max_string for Traceback to 10 and 80 +- Added disable argument to Progress + +### Changed + +- Reformatted test card (python -m rich) + +### Fixed + +- Fixed redirecting of stderr in Progress +- Fixed broken expanded tuple of one https://github.com/willmcgugan/rich/issues/445 +- Fixed traceback message with `from` exceptions +- Fixed justify argument not working in console.log https://github.com/willmcgugan/rich/issues/460 + +## [9.2.0] - 2020-11-08 + +### Added + +- Added tracebacks_show_locals parameter to RichHandler +- Added max_string to Pretty +- Added rich.ansi.AnsiDecoder +- Added decoding of ansi codes to captured stdout in Progress +- Added expand_all to rich.pretty.pprint + +### Changed + +- Applied dim=True to indent guide styles +- Factored out RichHandler.get_style_and_level to allow for overriding in subclasses +- Hid progress bars from html export +- rich.pretty.pprint now soft wraps + +## [9.1.0] - 2020-10-23 + +### Added + +- Added Text.with_indentation_guide +- Added Text.detect_indentation +- Added Pretty.indent_guides +- Added Syntax.indent_guides +- Added indent_guides parameter on pretty.install +- Added rich.pretty.pprint +- Added max_length to Pretty + +### Changed + +- Enabled indent guides on Tracebacks + +### Fixed + +- Fixed negative time remaining in Progress bars https://github.com/willmcgugan/rich/issues/378 + +## [9.0.1] - 2020-10-19 + +### Fixed + +- Fixed broken ANSI codes in input on windows legacy https://github.com/willmcgugan/rich/issues/393 + +## [9.0.0] - 2020-10-18 + +### Fixed + +- Progress download column now displays decimal units + +### Added + +- Support for Python 3.9 +- Added legacy_windows to ConsoleOptions +- Added ascii_only to ConsoleOptions +- Added box.SQUARE_DOUBLE_HEAD +- Added highlighting of EUI-48 and EUI-64 (MAC addresses) +- Added Console.pager +- Added Console.out +- Added binary_units in progress download column +- Added Progress.reset +- Added Style.background_style property +- Added Bar renderable https://github.com/willmcgugan/rich/pull/361 +- Added Table.min_width +- Added table.Column.min_width and table.Column.max_width, and same to Table.add_column + +### Changed + +- Dropped box.get_safe_box function in favor of Box.substitute +- Changed default padding in Panel from 0 to (0, 1) https://github.com/willmcgugan/rich/issues/385 +- Table with row_styles will extend background color between cells if the box has no vertical dividerhttps://github.com/willmcgugan/rich/issues/383 +- Changed default of fit kwarg in render_group() from False to True +- Renamed rich.bar to rich.progress_bar, and Bar class to ProgressBar, rich.bar is now the new solid bar class + +### Fixed + +- Fixed typo in `Style.transparent_background` method name. + +## [8.0.0] - 2020-10-03 + +### Added + +- 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 + +- Changed Style.empty to Style.null to better reflect what it does +- Optimized combining styles involving a null style +- Change error messages in Style.parse to read better + +### Fixed + +- Fixed Table.\_\_rich_measure\_\_ +- Fixed incorrect calculation of fixed width columns + +## [7.1.0] - 2020-09-26 + +### Added + +- Added Console.begin_capture, Console.end_capture and Console.capture +- Added Table.title_justify and Table.caption_justify https://github.com/willmcgugan/rich/issues/301 + +### Changed + +- Improved formatting of exceptions +- Enabled Rich exceptions in logging https://github.com/taliraj +- UTF-8 encoding is now mentioned in HTML head section + +### Removed + +- Removed line_numbers argument from traceback.install, which was undocumented and did nothing + +## [7.0.0] - 2020-09-18 + +### Added + +- New ansi_dark and ansi_light themes +- Added Text.append_tokens for fast appending of string + Style pairs +- Added Text.remove_suffix +- Added Text.append_tokens + +### Changed + +- Text.tabs_to_spaces was renamed to Text.expand_tabs, which works in place rather than returning a new instance +- Renamed Column.index to Column.\_index +- Optimized Style.combine and Style.chain +- Optimized text rendering by fixing internal cache mechanism +- Optimized hash generation for Styles + +## [6.2.0] - 2020-09-13 + +### Added + +- Added inline code highlighting to Markdown + +## [6.1.2] - 2020-09-11 + +### Added + +- Added ipv4 and ipv6 to ReprHighlighter + +### Changed + +- The `#` sign is included in url highlighting + +### Fixed + +- Fixed force-color switch in rich.syntax and rich.markdown commands + +## [6.1.1] - 2020-09-07 + +### Changed + +- Restored "def" in inspect signature + +## [6.1.0] - 2020-09-07 + +### Added + +- New inspect module +- Added os.\_Environ to pretty print + +### Fixed + +- Prevented recursive renderables from getting stuck + +## Changed + +- force_terminal and force_jupyter can now be used to force the disabled state, or left as None to auto-detect. +- Panel now expands to fit title if supplied + +## [6.0.0] - 2020-08-25 + +### Fixed + +- Fixed use of `__rich__` cast + +### Changed + +- New algorithm to pretty print which fits more on a line if possible +- Deprecated `character` parameter in Rule and Console.rule, in favor of `characters` +- Optimized Syntax.from_path to avoid searching all lexers, which also speeds up tracebacks + +### Added + +- Added soft_wrap flag to Console.print + +## [5.2.1] - 2020-08-19 + +### Fixed + +- Fixed underscore with display hook https://github.com/willmcgugan/rich/issues/235 + +## [5.2.0] - 2020-08-14 + +### Changed + +- Added crop argument to Console.print +- Added "ignore" overflow method +- Added multiple characters per rule @hedythedev https://github.com/willmcgugan/rich/pull/207 + +## [5.1.2] - 2020-08-10 + +### Fixed + +- Further optimized pretty printing ~5X. + +## [5.1.1] - 2020-08-09 + +### Fixed + +- Optimized pretty printing ~3X faster + +## [5.1.0] - 2020-08-08 + +### Added + +- Added Text.cell_len +- Added helpful message regarding unicode decoding errors https://github.com/willmcgugan/rich/issues/212 +- Added display hook with pretty.install() + +### Fixed + +- Fixed deprecation warnings re backslash https://github.com/willmcgugan/rich/issues/210 +- Fixed repr highlighting of scientific notation, e.g. 1e100 + +### Changed + +- Implemented pretty printing, and removed pprintpp from dependencies +- Optimized Text.join + +## [5.0.0] - 2020-08-02 + +### Changed + +- Change to console markup syntax to not parse Python structures as markup, i.e. `[1,2,3]` is treated as a literal, not a tag. +- Standard color numbers syntax has changed to `"color(<number>)"` so that `[5]` (for example) is considered a literal. +- Markup escape method has changed from double brackets to preceding with a backslash, so `foo[[]]` would be `foo\[bar]` + +## [4.2.2] - 2020-07-30 + +### Changed + +- Added thread to automatically call update() in progress.track(). Replacing previous adaptive algorithm. +- Second attempt at working around https://bugs.python.org/issue37871 + +## [4.2.1] - 2020-07-29 + +### Added + +- Added show_time and show_level parameters to RichHandler https://github.com/willmcgugan/rich/pull/182 + +### Fixed + +- Fixed progress.track iterator exiting early https://github.com/willmcgugan/rich/issues/189 +- Added workaround for Python bug https://bugs.python.org/issue37871, fixing https://github.com/willmcgugan/rich/issues/186 + +### Changed + +- Set overflow=fold for log messages https://github.com/willmcgugan/rich/issues/190 + +## [4.2.0] - 2020-07-27 + +### Fixed + +- Fixed missing new lines https://github.com/willmcgugan/rich/issues/178 +- Fixed Progress.track https://github.com/willmcgugan/rich/issues/184 +- Remove control codes from exported text https://github.com/willmcgugan/rich/issues/181 +- Implemented auto-detection and color rendition of 16-color mode + +## [4.1.0] - 2020-07-26 + +### Changed + +- Optimized progress.track for very quick iterations +- Force default size of 80x25 if get_terminal_size reports size of 0,0 + +## [4.0.0] - 2020-07-23 + +Major version bump for a breaking change to `Text.stylize signature`, which corrects a minor but irritating API wart. The style now comes first and the `start` and `end` offsets default to the entire text. This allows for `text.stylize_all(style)` to be replaced with `text.stylize(style)`. The `start` and `end` offsets now support negative indexing, so `text.stylize("bold", -1)` makes the last character bold. + +### Added + +- Added markup switch to RichHandler https://github.com/willmcgugan/rich/issues/171 + +### Changed + +- Change signature of Text.stylize to accept style first +- Remove Text.stylize_all which is no longer necessary + +### Fixed + +- Fixed rendering of Confirm prompt https://github.com/willmcgugan/rich/issues/170 + +## [3.4.1] - 2020-07-22 + +### Fixed + +- Fixed incorrect default of expand in Table.grid + +## [3.4.0] - 2020-07-22 + +### Added + +- Added stream parameter to Console.input +- Added password parameter to Console.input +- Added description parameter to Progress.update +- Added rich.prompt +- Added detecting 'dumb' terminals +- Added Text.styled alternative constructor + +### Fixes + +- Fixed progress bars so that they are readable when color is disabled + +## [3.3.2] - 2020-07-14 + +### Changed + +- Optimized Text.pad + +### Added + +- Added rich.scope +- Change log_locals to use scope.render_scope +- Added title parameter to Columns + +## [3.3.1] - 2020-07-13 + +### Added + +- box.ASCII_DOUBLE_HEAD + +### Changed + +- Removed replace of -- --- ... from Markdown, as it made it impossible to include CLI info + +## [3.3.0] - 2020-07-12 + +### Added + +- Added title and title_align options to Panel +- Added pad and width parameters to Align +- Added end parameter to Rule +- Added Text.pad and Text.align methods +- Added leading parameter to Table + +## [3.2.0] - 2020-07-10 + +### Added + +- Added Align.left Align.center Align.right shortcuts +- Added Panel.fit shortcut +- Added align parameter to Columns + +### Fixed + +- Align class now pads to the right, like Text +- ipywidgets added as an optional dependency +- Issue with Panel and background color +- Fixed missing `__bool__` on Segment + +### Changed + +- Added `border_style` argument to Panel (note, `style` now applies to interior of the panel) + +## [3.1.0] - 2020-07-09 + +### Changed + +- Progress bars now work in Jupyter + +## Added + +- Added refresh_per_second to progress.track +- Added styles to BarColumn and progress.track + +## [3.0.5] - 2020-07-07 + +### Fixed + +- Fixed Windows version number require for truecolor + +## [3.0.4] - 2020-07-07 + +### Changed + +- More precise detection of Windows console https://github.com/willmcgugan/rich/issues/140 + +## [3.0.3] - 2020-07-03 + +### Fixed + +- Fixed edge case with wrapped and overflowed text + +### Changed + +- New algorithm for compressing table that priorities smaller columns + +### Added + +- Added safe_box parameter to Console constructor + +## [3.0.2] - 2020-07-02 + +### Added + +- Added rich.styled.Styled class to apply styles to renderable +- Table.add_row now has an optional style parameter +- Added table_movie.py to examples + +### Changed + +- Modified box options to use half line characters at edges +- Non no_wrap columns will now shrink below minimum width if table is compressed + +## [3.0.1] - 2020-06-30 + +### Added + +- Added box.ASCII2 +- Added markup argument to logging extra + +### Changed + +- Setting a non-None width now implies expand=True + +## [3.0.0] - 2020-06-28 + +### Changed + +- Enabled supported box chars for legacy Windows, and introduce `safe_box` flag +- Disable hyperlinks on legacy Windows +- Constructors for Rule and Panel now have keyword only arguments (reason for major version bump) +- Table.add_colum added keyword only arguments + +### Fixed + +- Fixed Table measure + +## [2.3.1] - 2020-06-26 + +### Fixed + +- Disabled legacy_windows if jupyter is detected https://github.com/willmcgugan/rich/issues/125 + +## [2.3.0] - 2020-06-26 + +### Fixed + +- Fixed highlighting of paths / filenames +- Corrected docs for RichHandler which erroneously said default console writes to stderr + +### Changed + +- Allowed `style` parameter for `highlight_regex` to be a callable that returns a style + +### Added + +- Added optional highlighter parameter to RichHandler + +## [2.2.6] - 2020-06-24 + +### Changed + +- Store a "link id" on Style instance, so links containing different styles are highlighted together. (https://github.com/willmcgugan/rich/pull/123) + +## [2.2.5] - 2020-06-23 + +### Fixed + +- Fixed justify of tables (https://github.com/willmcgugan/rich/issues/117) + +## [2.2.4] - 2020-06-21 + +### Added + +- Added enable_link_path to RichHandler +- Added legacy_windows switch to Console constructor + +## [2.2.3] - 2020-06-15 + +### Fixed + +- Fixed console.log hyperlink not containing full path + +### Changed + +- Used random number for hyperlink id + +## [2.2.2] - 2020-06-14 + +### Changed + +- Exposed RichHandler highlighter as a class var + +## [2.2.1] - 2020-06-14 + +### Changed + +- Linked path in log render to file + +## [2.2.0] - 2020-06-14 + +### Added + +- Added redirect_stdout and redirect_stderr to Progress + +### Changed + +- printing to console with an active Progress doesn't break visuals + +## [2.1.0] - 2020-06-11 + +### Added + +- Added 'transient' option to Progress + +### Changed + +- Truncated overly long text in Rule with ellipsis overflow + +## [2.0.1] - 2020-06-10 + +### Added + +- Added expand option to Padding + +### Changed + +- Some minor optimizations in Text + +### Fixed + +- Fixed broken rule with CJK text + +## [2.0.0] - 2020-06-06 + +### Added + +- Added overflow methods +- Added no_wrap option to print() +- Added width option to print +- Improved handling of compressed tables + +### Fixed + +- Fixed erroneous space at end of log +- Fixed erroneous space at end of progress bar + +### Changed + +- Renamed \_ratio.ratio_divide to \_ratio.ratio_distribute +- Renamed JustifyValues to JustifyMethod (backwards incompatible) +- Optimized \_trim_spans +- Enforced keyword args in Console / Text interfaces (backwards incompatible) +- Return self from text.append + +## [1.3.1] - 2020-06-01 + +### Changed + +- Changed defaults of Table.grid +- Polished listdir.py example + +### Added + +- Added width argument to Columns + +### Fixed + +- Fixed for `columns_first` argument in Columns +- Fixed incorrect padding in columns with fixed width + +## [1.3.0] - 2020-05-31 + +### Added + +- Added rich.get_console() function to get global console instance. +- Added Columns class + +### Changed + +- Updated `markdown.Heading.create()` to work with subclassing. +- Console now transparently works with Jupyter + +### Fixed + +- Fixed issue with broken table with show_edge=False and a non-None box arg + +## [1.2.3] - 2020-05-24 + +### Added + +- Added `padding` parameter to Panel +- Added 'indeterminate' state when progress bars aren't started + +### Fixed + +- Fixed Progress deadlock https://github.com/willmcgugan/rich/issues/90 + +### Changed + +- Auto-detect "truecolor" color system when in Windows Terminal + +## [1.2.2] - 2020-05-22 + +### Fixed + +- Issue with right aligned wrapped text adding extra spaces + +## [1.2.1] - 2020-05-22 + +### Fixed + +- Issue with sum and Style + +## [1.2.0] - 2020-05-22 + +### Added + +- Support for double underline, framed, encircled, and overlined attributes + +### Changed + +- Optimized Style +- Changed methods `__console__` to `__rich_console__`, and `__measure__` to `__rich_measure__` + +## [1.1.9] - 2020-05-20 + +### Fixed + +- Exception when BarColumn.bar_width == None + +## [1.1.8] - 2020-05-20 + +### Changed + +- Optimizations for Segment, Console and Table + +### Added + +- Added Console.clear method +- Added exporting of links to HTML + +## [1.1.7] - 2020-05-19 + +### Added + +- Added collapse_padding option to Table. + +### Changed + +- Some style attributes may be abbreviated (b for bold, i for italic etc). Previously abbreviations worked in console markup but only one at a time, i.e. "[b]Hello[/]" but not "[b i]Hello[/]" -- now they work everywhere. +- Renamed 'text' property on Text to 'plain'. i.e. text.plain returns a string version of the Text instance. + +### Fixed + +- Fixed zero division if total is 0 in progress bar + +## [1.1.6] - 2020-05-17 + +### Added + +- Added rich.align.Align class +- Added justify argument to Console.print and console.log + +## [1.1.5] - 2020-05-15 + +### Changed + +- Changed progress bars to write to stdout on terminal and hide on non-terminal + +## [1.1.4] - 2020-05-15 + +### Fixed + +- Fixed incorrect file and link in progress.log +- Fixes for legacy windows: Bar, Panel, and Rule now use ASCII characters +- show_cursor is now a no-op on legacy windows + +### Added + +- Added Console.input + +### Changed + +- Disable progress bars when not writing to a terminal + +## [1.1.3] - 2020-05-14 + +### Fixed + +- Issue with progress of one line` + +## [1.1.2] - 2020-05-14 + +### Added + +- Added -p switch to python -m rich.markdown to page output +- Added Console.control to output control codes + +### Changed + +- Changed Console log_time_format to no longer require a space at the end +- Added print and log to Progress to render terminal output when progress is active + +## [1.1.1] - 2020-05-12 + +### Changed + +- Stripped cursor moving control codes from text + +## [1.1.0] - 2020-05-10 + +### Added + +- Added hyperlinks to Style and markup +- Added justify and code theme switches to markdown command + +## [1.0.3] - 2020-05-08 + +### Added + +- Added `python -m rich.syntax` command + +## [1.0.2] - 2020-05-08 + +### Fixed + +- Issue with Windows legacy support https://github.com/willmcgugan/rich/issues/59 + +## [1.0.1] - 2020-05-08 + +### Changed + +- Applied console markup after highlighting +- Documented highlighting +- Changed Markup parser to handle overlapping styles +- Relaxed dependency on colorama +- Allowed Theme to accept values as style definitions (str) as well as Style instances +- Added a panel to emphasize code in Markdown + +### Added + +- Added markup.escape +- Added `python -m rich.theme` command +- Added `python -m rich.markdown` command +- Added rendering of images in Readme (links only) + +### Fixed + +- Fixed Text.assemble not working with strings https://github.com/willmcgugan/rich/issues/57 +- Fixed table when column widths must be compressed to fit + +## [1.0.0] - 2020-05-03 + +### Changed + +- Improvements to repr highlighter to highlight URLs + +## [0.8.13] - 2020-04-28 + +### Fixed + +- Fixed incorrect markdown rendering for quotes and changed style + +## [0.8.12] - 2020-04-21 + +### Fixed + +- Removed debug print from rich.progress + +## [0.8.11] - 2020-04-14 + +### Added + +- Added Table.show_lines to render lines between rows + +### Changed + +- Added markup escape with double square brackets + +## [0.8.10] - 2020-04-12 + +### Fixed + +- Fix row_styles applying to header + +## [0.8.9] - 2020-04-12 + +### Changed + +- Added force_terminal option to `Console.__init__` + +### Added + +- Added Table.row_styles to enable zebra striping. + +## [0.8.8] - 2020-03-31 + +### Fixed + +- Fixed background in Syntax + +## [0.8.7] - 2020-03-31 + +### Fixed + +- Broken wrapping of long lines +- Fixed wrapping in Syntax + +### Changed + +- Added word_wrap option to Syntax, which defaults to False. +- Added word_wrap option to Traceback. + +## [0.8.6] - 2020-03-29 + +### Added + +- Experimental Jupyter notebook support: from rich.jupyter import print + +## [0.8.5] - 2020-03-29 + +### Changed + +- Smarter number parsing regex for repr highlighter + +### Added + +- uuid highlighter for repr + +## [0.8.4] - 2020-03-28 + +### Added + +- Added 'test card', run python -m rich + +### Changed + +- Detected windows terminal, defaulting to colorama support + +### Fixed + +- Fixed table scaling issue + +## [0.8.3] - 2020-03-27 + +### Fixed + +- CJK right align + +## [0.8.2] - 2020-03-27 + +### Changed + +- Fixed issue with 0 speed resulting in zero division error +- Changed signature of Progress.update +- Made calling start() a second time a no-op + +## [0.8.1] - 2020-03-22 + +### Added + +- Added progress.DownloadColumn + +## [0.8.0] - 2020-03-17 + +### Added + +- CJK support +- Console level highlight flag +- Added encoding argument to Syntax.from_path + +### Changed + +- Dropped support for Windows command prompt (try https://www.microsoft.com/en-gb/p/windows-terminal-preview/) +- Added task_id to Progress.track + +## [0.7.2] - 2020-03-15 + +### Fixed + +- KeyError for missing pygments style + +## [0.7.1] - 2020-03-13 + +### Fixed + +- Issue with control codes being used in length calculation + +### Changed + +- Remove current_style concept, which wasn't really used and was problematic for concurrency + +## [0.7.0] - 2020-03-12 + +### Changed + +- Added width option to Panel +- Change special method `__render_width__` to `__measure__` +- Dropped the "markdown style" syntax in console markup +- Optimized style rendering + +### Added + +- Added Console.show_cursor method +- Added Progress bars + +### Fixed + +- Fixed wrapping when a single word was too large to fit in a line + +## [0.6.0] - 2020-03-03 + +### Added + +- Added tab_size to Console and Text +- Added protocol.is_renderable for runtime check +- Added emoji switch to Console +- Added inherit boolean to Theme +- Made Console thread safe, with a thread local buffer + +### Changed + +- Console.markup attribute now effects Table +- SeparatedConsoleRenderable and RichCast types + +### Fixed + +- Fixed tabs breaking rendering by converting to spaces + +## [0.5.0] - 2020-02-23 + +### Changed + +- Replaced `__console_str__` with `__rich__` + +## [0.4.1] - 2020-02-22 + +### Fixed + +- Readme links in Pypi + +## [0.4.0] - 2020-02-22 + +### Added + +- Added Traceback rendering and handler +- Added rich.constrain +- Added rich.rule + +### Fixed + +- Fixed unnecessary padding + +## [0.3.3] - 2020-02-04 + +### Fixed + +- Fixed Windows color support +- Fixed line width on windows issue (https://github.com/willmcgugan/rich/issues/7) +- Fixed Pretty print on Windows + +## [0.3.2] - 2020-01-26 + +### Added + +- Added rich.logging + +## [0.3.1] - 2020-01-22 + +### Added + +- Added colorama for Windows support + +## [0.3.0] - 2020-01-19 + +### Added + +- First official release, API still to be stabilized diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c68a49b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at willmcgugan@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6e27d56 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing to Rich + +This project welcomes contributions in the form of Pull Requests. +For clear bug-fixes / typos etc. just submit a PR. +For new features or if there is any doubt in how to fix a bug, you might want +to open an issue prior to starting work, or email willmcgugan+rich@gmail.com +to discuss it first. + +## Development Environment + +Rich uses [poetry](https://python-poetry.org/docs/) for packaging and +dependency management. To start developing with Rich, install Poetry +using the [recommended method](https://python-poetry.org/docs/#installation) or run: + +``` +pip install poetry +``` + +Once Poetry is installed, install the dependencies with the following command: + +``` +poetry install +``` + +### Tests + +Run tests with the following command: + +``` +make test +``` + +Or if you don't have `make`, run the following: + +``` +pytest --cov-report term-missing --cov=rich tests/ -vv +``` + +New code should ideally have tests and not break existing tests. + +### Type Checking + +Rich uses type annotations throughout, and `mypy` to do the checking. +Run the following to type check Rich: + +``` +make typecheck +``` + +Or if you don't have `make`: + +``` +mypy -p rich --ignore-missing-imports --warn-unreachable +``` + +Please add type annotations for all new code. + +### Code Formatting + +Rich uses [`black`](https://github.com/psf/black) for code formatting. +I recommend setting up black in your editor to format on save. + +To run black from the command line, use `make format-check` to check your formatting, +and use `make format` to format and write to the files. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..c6db7bf --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,12 @@ +# Contributors + +The following people have contributed to the development of Rich: + +<!-- Add your name below, sort alphabetically by surname. Link to Github profile / your home page. --> + +- [Oleksis Fraga](https://github.com/oleksis) +- [Finn Hughes](https://github.com/finnhughes) +- [Hedy Li](https://github.com/hedythedev) +- [Alexander Mancevice](https://github.com/amancevice) +- [Will McGugan](https://github.com/willmcgugan) +- [Nathan Page](https://github.com/nathanrpage97) @@ -0,0 +1,8 @@ +Copyright 2020 Will McGugan + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6bed4f7 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +test: + pytest --cov-report term-missing --cov=rich tests/ -vv +format-check: + black --check . +format: + black . +typecheck: + mypy -p rich --ignore-missing-imports --warn-unreachable +typecheck-report: + mypy -p rich --ignore-missing-imports --warn-unreachable --html-report mypy_report +.PHONY: docs +docs: + cd docs; make html diff --git a/README.cn.md b/README.cn.md new file mode 100644 index 0000000..7597b7c --- /dev/null +++ b/README.cn.md @@ -0,0 +1,307 @@ +# Rich + +[![PyPI version](https://badge.fury.io/py/rich.svg)](https://badge.fury.io/py/rich) +[![codecov](https://codecov.io/gh/willmcgugan/rich/branch/master/graph/badge.svg)](https://codecov.io/gh/willmcgugan/rich) +[![Rich blog](https://img.shields.io/badge/blog-rich%20news-yellowgreen)](https://www.willmcgugan.com/tag/rich/) +[![Twitter Follow](https://img.shields.io/twitter/follow/willmcgugan.svg?style=social)](https://twitter.com/willmcgugan) + +Rich 是一个 Python 库,可以为您在终端中提供富文本和精美格式。 + +[Rich API](https://rich.readthedocs.io/en/latest/) 可以很容易的在终端输出添加各种颜色和不同风格。Rich 还可以绘制漂亮的表格,进度条,markdown,突出显示语法的源代码及回溯等等,不胜枚举。 + +![功能纵览](https://github.com/willmcgugan/rich/raw/master/imgs/features.png) + +有关 Rich 的视频介绍,请参见 +[@fishnets88](https://twitter.com/fishnets88)录制的 +[calmcode.io](https://calmcode.io/rich/introduction.html)。 + +## 兼容性 + +Rich 适用于 Linux,OSX 和 Windows。真彩色/表情符号可与新的 Windows 终端一起使用,Windows 的经典终端仅限 8 种颜色。 + +Rich 还可以与[Jupyter 笔记本](https://jupyter.org/)一起使用,而无需其他配置。 + +## 安装说明 + +使用`pip`或其他 PyPi 软件包管理器进行安装。 + +``` +pip install rich +``` + +## Rich 的打印功能 + +想毫不费力地将 Rich 的输出功能添加到您的应用程序中,您只需导入[rich 打印](https://rich.readthedocs.io/en/latest/introduction.html#quick-start)方法,该方法和其他 Python 的自带功能的参数类似。 +您可以试试: + +```python +from rich import print + +print("Hello, [bold magenta]World[/bold magenta]!", ":vampire:", locals()) +``` + +![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/print.png) + +## 使用控制台 + +想要对 Rich 终端内容进行更多控制,请您导入并构造一个[控制台](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console)对象。 + +```python +from rich.console import Console + +console = Console() +``` + +Console 对象含有一个`print` 方法,它的界面与 python 内置的`print`功能界面相似。 + +您可以试试: + +```python +console.print("Hello", "World!") +``` + +您可能已经料到,这时终端上会显示“ Hello World!”。请注意,与内置的“打印”功能不同,Rich 会将文字自动换行以适合终端宽度。 + +有几种方法可以为输出添加颜色和样式。您可以通过添加`style`关键字参数来为整个输出设置样式。例子如下: + +```python +console.print("Hello", "World!", style="bold red") +``` + +输出如下图: + +![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/hello_world.png) + +这个范例一次只设置了一行文字的样式。如果想获得更细腻更复杂的样式,Rich 可以渲染一个特殊的标记,其语法类似于[bbcode](https://en.wikipedia.org/wiki/BBCode)。示例如下: + +```python +console.print("Where there is a [bold cyan]Will[/bold cyan] there [u]is[/u] a [i]way[/i].") +``` + +![控制台标记](https://github.com/willmcgugan/rich/raw/master/imgs/where_there_is_a_will.png) + +### 控制台记录 + +Console 对象具有一个`log()`方法,该方法具有与`print()`类似的界面,除此之外,还能成列显示当前时间以及被调用的文件和行。默认情况下,Rich 将针对 Python 结构和 repr 字符串进行语法突出显示。如果您记录一个集合(如字典或列表),Rich 会把它漂亮地打印出来,使其切合可用空间。下面是其中一些功能的示例: + +```python +from rich.console import Console +console = Console() + +test_data = [ + {"jsonrpc": "2.0", "method": "sum", "params": [None, 1, 2, 4, False, True], "id": "1",}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, + {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}, +] + +def test_log(): + enabled = False + context = { + "foo": "bar", + } + movies = ["Deadpool", "Rise of the Skywalker"] + console.log("Hello from", console, "!") + console.log(test_data, log_locals=True) + + +test_log() +``` + +以上范例的输出如下: + +![日志](https://github.com/willmcgugan/rich/raw/master/imgs/log.png) + +注意其中的`log_locals`参数会输出一个表格,该表格包含调用 log 方法的局部变量。 + +log 方法既可用于将长时间运行应用程序(例如服务器)的日志记录到终端,也可用于辅助调试。 + +### 记录处理程序 + +您还可以使用内置的[处理类](https://rich.readthedocs.io/en/latest/logging.html)来对 Python 日志记录模块的输出进行格式化和着色。下面是输出示例: + +![记录](https://github.com/willmcgugan/rich/raw/master/imgs/logging.png) + +## 表情符号 + +将名称放在两个冒号之间即可在控制台输出中插入表情符号。示例如下: + +```python +>>> console.print(":smiley: :vampire: :pile_of_poo: :thumbs_up: :raccoon:") +😃 🧛 💩 👍 🦝 +``` + +请谨慎地使用此功能。 + +## 表格 + +Rich 可以使用 Unicode 框字符来呈现多变的[表格](https://rich.readthedocs.io/en/latest/tables.html)。Rich 包含多种边框,样式,单元格对齐等格式设置的选项。下面是一个简单的示例: + +```python +from rich.console import Console +from rich.table import Column, Table + +console = Console() + +table = Table(show_header=True, header_style="bold magenta") +table.add_column("Date", style="dim", width=12) +table.add_column("Title") +table.add_column("Production Budget", justify="right") +table.add_column("Box Office", justify="right") +table.add_row( + "Dev 20, 2019", "Star Wars: The Rise of Skywalker", "$275,000,000", "$375,126,118" +) +table.add_row( + "May 25, 2018", + "[red]Solo[/red]: A Star Wars Story", + "$275,000,000", + "$393,151,347", +) +table.add_row( + "Dec 15, 2017", + "Star Wars Ep. VIII: The Last Jedi", + "$262,000,000", + "[bold]$1,332,539,889[/bold]", +) + +console.print(table) +``` + +该示例的输出如下: + +![表格](https://github.com/willmcgugan/rich/raw/master/imgs/table.png) + +请注意,控制台标记的呈现方式与`print()`和`log()`相同。实际上,由 Rich 渲染的任何内容都可以添加到标题/行(甚至其他表格)中。 + +`Table`类很聪明,可以调整列的大小以适合终端的可用宽度,并能根据需要环绕文本。下面是相同的示例,输出与比上表小的终端上: + +![表格 2](https://github.com/willmcgugan/rich/raw/master/imgs/table2.png) + +## 进度条 + +Rich 可以渲染多个不闪烁的[进度](https://rich.readthedocs.io/en/latest/progress.html)条形图,以跟踪长时间运行的任务。 + +基本用法:用`track`函数调用任何程序并迭代结果。下面是一个例子: + +```python +from rich.progress import track + +for step in track(range(100)): + do_step(step) +``` + +添加多个进度条并不难。以下是从文档中获取的示例: + +![进度](https://github.com/willmcgugan/rich/raw/master/imgs/progress.gif) + +这些列可以配置为显示您所需的任何详细信息。内置列包括完成百分比,文件大小,文件速度和剩余时间。下面是显示正在进行的下载的示例: + +![进度](https://github.com/willmcgugan/rich/raw/master/imgs/downloader.gif) + +要自己尝试一下,请参阅[examples/downloader.py](https://github.com/willmcgugan/rich/blob/master/examples/downloader.py),它可以在显示进度的同时下载多个 URL。 + +## 列 + +Rich 可以将内容通过排列整齐的,具有相等或最佳的宽度的[列](https://rich.readthedocs.io/en/latest/columns.html)来呈现。下面是(macOS / Linux)`ls`命令的一个非常基本的克隆,用于用列来显示目录列表: + +```python +import os +import sys + +from rich import print +from rich.columns import Columns + +directory = os.listdir(sys.argv[1]) +print(Columns(directory)) +``` + +以下屏幕截图是[列示例](https://github.com/willmcgugan/rich/blob/master/examples/columns.py)的输出,该列显示了从 API 提取的数据: + +![列](https://github.com/willmcgugan/rich/raw/master/imgs/columns.png) + +## Markdown + +Rich 可以呈现[markdown](https://rich.readthedocs.io/en/latest/markdown.html),并可相当不错的将其格式转移到终端。 + +为了渲染 markdown,请导入`Markdown` 类,并使用包含 markdown 代码的字符串来构造它,然后将其打印到控制台。例子如下: + +```python +from rich.console import Console +from rich.markdown import Markdown + +console = Console() +with open("README.md") as readme: + markdown = Markdown(readme.read()) +console.print(markdown) +``` + +该例子的输出如下图: + +![markdown](https://github.com/willmcgugan/rich/raw/master/imgs/markdown.png) + +## 语法突出显示 + +Rich 使用[pygments](https://pygments.org/)库来实现[语法高亮显示](https://rich.readthedocs.io/en/latest/syntax.html)。用法类似于渲染 markdown。构造一个`Syntax`对象并将其打印到控制台。下面是一个例子: + +```python +from rich.console import Console +from rich.syntax import Syntax + +my_code = ''' +def iter_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: + """Iterate and generate a tuple with a flag for first and last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + first = True + for value in iter_values: + yield first, False, previous_value + first = False + previous_value = value + yield first, True, previous_value +''' +syntax = Syntax(my_code, "python", theme="monokai", line_numbers=True) +console = Console() +console.print(syntax) +``` + +输出如下: + +![语法](https://github.com/willmcgugan/rich/raw/master/imgs/syntax.png) + +## 回溯 + +Rich 可以渲染漂亮的回溯,比标准 Python 回溯更容易阅读,并能显示更多代码。您可以将 Rich 设置为默认的回溯处理程序,这样所有难以捕获的异常都将由 Rich 为您呈现。 + +下面是在 OSX(与 Linux 类似)上的外观: + +![回溯](https://github.com/willmcgugan/rich/raw/master/imgs/traceback.png) + +## 使用Rich的项目 + +这里是一些使用Rich的项目: + +- [BrancoLab/BrainRender](https://github.com/BrancoLab/BrainRender) + 一个用于三维神经解剖数据可视化的python包 +- [Ciphey/Ciphey](https://github.com/Ciphey/Ciphey) + 自动解密工具 +- [emeryberger/scalene](https://github.com/emeryberger/scalene) + 一个高性能、高精度的Python CPU和内存剖析器 +- [hedythedev/StarCli](https://github.com/hedythedev/starcli) + 通过命令行浏览GitHub热门项目 +- [intel/cve-bin-tool](https://github.com/intel/cve-bin-tool) + 这个工具可以扫描一些常见的、有漏洞的组件(openssl、libpng、libxml2、expat和其他一些组件),让你知道你的系统是否包含有已知漏洞的常用库。 +- [nf-core/tools](https://github.com/nf) + 包含nf-core社区帮助工具的Python包 +- [cansarigol/pdbr](https://github.com/cansarigol/pdbr) + pdb + rich 的库,增强调试功能 +- [plant99/felicette](https://github.com/plant99/felicette) + 傻瓜式卫星图像 +- [seleniumbase/SeleniumBase](https://github.com/seleniumbase/SeleniumBase) + 使用Selenium和pytest使自动化和测试速度提高10倍,包括电池 +- [smacke/ffsubsync](https://github.com/smacke/ffsubsync) + 自动将字幕与视频同步 +- [tryolabs/norfair](https://github.com/tryolabs/norfair) + 轻量级Python库,用于向任何检测器添加实时2D对象跟踪 +- +[还有很多](https://github.com/willmcgugan/rich/network/dependents)! diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..53faae5 --- /dev/null +++ b/README.es.md @@ -0,0 +1,355 @@ +# Rich + +[![PyPI version](https://badge.fury.io/py/rich.svg)](https://badge.fury.io/py/rich) +[![codecov](https://codecov.io/gh/willmcgugan/rich/branch/master/graph/badge.svg)](https://codecov.io/gh/willmcgugan/rich) +[![Rich blog](https://img.shields.io/badge/blog-rich%20news-yellowgreen)](https://www.willmcgugan.com/tag/rich/) +[![Twitter Follow](https://img.shields.io/twitter/follow/willmcgugan.svg?style=social)](https://twitter.com/willmcgugan) + +Rich es un paquete de Python para texto _enriquecido_ y un hermoso formato en la terminal. + +La [API Rich](https://rich.readthedocs.io/en/latest/) facilita la adición de color y estilo a la salida del terminal. Rich también puede representar tablas bonitas, barras de progreso, markdown, código fuente resaltado por sintaxis, trazas y más — listo para usar. + +![Funciones](https://github.com/willmcgugan/rich/raw/master/imgs/features.png) + +Para ver un vídeo de introducción a Rich, consulte [calmcode.io](https://calmcode.io/rich/introduction.html) de [@fishnets88](https://twitter.com/fishnets88). + +Vea lo que [la gente dice sobre Rich](https://www.willmcgugan.com/blog/pages/post/rich-tweets/). + +## Compatibilidad + +Rich funciona con Linux, OSX y Windows. True color / emoji funciona con la nueva Terminal de Windows, la terminal clásica está limitada a 8 colores. Rich requiere Python 3.6.1 o posterior. + +Rich funciona con [Jupyter notebooks](https://jupyter.org/) sin necesidad de configuración adicional. + +## Instalación + +Instale con `pip` o su administrador de paquetes PyPi favorito. + +``` +pip install rich +``` + +## Función print de Rich + +Para agregar sin esfuerzo resultados enriquecidos a su aplicación, puede importar el método [rich print](https://rich.readthedocs.io/en/latest/introduction.html#quick-start), que tiene la misma firma que el método incorporado de Python. Prueba esto: + +```python +from rich import print + +print("Hello, [bold magenta]World[/bold magenta]!", ":vampire:", locals()) +``` + +![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/print.png) + +## Rich REPL + +Rich se puede instalar en Python REPL, por lo que cualquier estructura de datos se imprimirá y resaltará bastante. + +```python +>>> from rich import pretty +>>> pretty.install() +``` + +![REPL](https://github.com/willmcgugan/rich/raw/master/imgs/repl.png) + +## Usando la consola + +Para tener más control sobre el contenido enriquecido del terminal, importe y cree un objeto [Console](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console). + +```python +from rich.console import Console + +console = Console() +``` + +El objeto Console tiene un método `print` que tiene una interfaz intencionalmente similar a la función incorporada `print`. Aquí tienes un ejemplo de uso: + +```python +console.print("Hello", "World!") +``` + +Como era de esperar, esto imprimirá `"Hello World!"` en la terminal. Tenga en cuenta que, a diferencia de la función `print` incorporada, Rich ajustará su texto para ajustarlo al ancho de la terminal. + +Hay algunas formas de agregar color y estilo a su salida. Puede establecer un estilo para toda la salida agregando un argumento de palabra clave `style`. He aquí un ejemplo: + +```python +console.print("Hello", "World!", style="bold red") +``` + +La salida será similar a la siguiente: + +![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/hello_world.png) + +Eso está bien para diseñar una línea de texto a la vez. Para un estilo más fino, Rich presenta un marcado especial que es similar en sintaxis a [bbcode](https://en.wikipedia.org/wiki/BBCode). He aquí un ejemplo: + +```python +console.print("Where there is a [bold cyan]Will[/bold cyan] there [u]is[/u] a [i]way[/i].") +``` + +![Console Markup](https://github.com/willmcgugan/rich/raw/master/imgs/where_there_is_a_will.png) + +### Registro de consola + +El objeto Console tiene un método `log()` que tiene una interfaz similar a `print()`, pero también muestra una columna para la hora actual y el archivo y la línea que realizó la llamada. De forma predeterminada, Rich resaltará la sintaxis de las estructuras de Python y de las cadenas de reproducción. Si registra una colección (es decir, un diccionario o una lista), Rich la imprimirá de forma bonita para que quepa en el espacio disponible. A continuación, se muestra un ejemplo de algunas de estas funciones. + +```python +from rich.console import Console +console = Console() + +test_data = [ + {"jsonrpc": "2.0", "method": "sum", "params": [None, 1, 2, 4, False, True], "id": "1",}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, + {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}, +] + +def test_log(): + enabled = False + context = { + "foo": "bar", + } + movies = ["Deadpool", "Rise of the Skywalker"] + console.log("Hello from", console, "!") + console.log(test_data, log_locals=True) + + +test_log() +``` + +Lo anterior produce el siguiente resultado: + +![Registro](https://github.com/willmcgugan/rich/raw/master/imgs/log.png) + +Tenga en cuenta el argumento `log_locals`, que genera una tabla que contiene las variables locales donde se llamó al método log. + +El método de registro podría usarse para iniciar sesión en el terminal para aplicaciones de larga ejecución, como servidores, pero también es una ayuda de depuración muy buena. + +### Controlador de registro + +También puede usar la [Handler class](https://rich.readthedocs.io/en/latest/logging.html) incorporada para formatear y colorear la salida del módulo de registro de Python. Aquí hay un ejemplo de la salida: + +![Registro](https://github.com/willmcgugan/rich/raw/master/imgs/logging.png) + +## Emoji + +Para insertar un emoji en la salida de la consola, coloque el nombre entre dos puntos. He aquí un ejemplo: + +```python +>>> console.print(":smiley: :vampire: :pile_of_poo: :thumbs_up: :raccoon:") +😃 🧛 💩 👍 🦝 +``` + +Utilice esta función con prudencia. + +## Tablas + +Rich puede renderizar [tablas](https://rich.readthedocs.io/en/latest/tables.html) flexibles con caracteres de cuadro Unicode. Existe una gran variedad de opciones de formato para bordes, estilos, alineación de celdas, etc. + +![table movie](https://github.com/willmcgugan/rich/raw/master/imgs/table_movie.gif) + +La animación anterior se generó con [table_movie.py](https://github.com/willmcgugan/rich/blob/master/examples/table_movie.py) en el directorio de ejemplos. + +Aquí hay un ejemplo de tabla más simple: + +```python +from rich.console import Console +from rich.table import Table + +console = Console() + +table = Table(show_header=True, header_style="bold magenta") +table.add_column("Date", style="dim", width=12) +table.add_column("Title") +table.add_column("Production Budget", justify="right") +table.add_column("Box Office", justify="right") +table.add_row( + "Dev 20, 2019", "Star Wars: The Rise of Skywalker", "$275,000,000", "$375,126,118" +) +table.add_row( + "May 25, 2018", + "[red]Solo[/red]: A Star Wars Story", + "$275,000,000", + "$393,151,347", +) +table.add_row( + "Dec 15, 2017", + "Star Wars Ep. VIII: The Last Jedi", + "$262,000,000", + "[bold]$1,332,539,889[/bold]", +) + +console.print(table) +``` + +Esto produce la siguiente salida: + +![table](https://github.com/willmcgugan/rich/raw/master/imgs/table.png) + +Tenga en cuenta que el marcado de la consola se representa de la misma manera que `print()` y `log()`. De hecho, cualquier cosa que Rich pueda representar se puede incluir en los encabezados / filas (incluso en otras tablas). + +La clase `Table` es lo suficientemente inteligente como para cambiar el tamaño de las columnas para que se ajusten al ancho disponible de la terminal, ajustando el texto según sea necesario. Este es el mismo ejemplo, con la terminal más pequeña que la tabla anterior: + +![table2](https://github.com/willmcgugan/rich/raw/master/imgs/table2.png) + +## Barras de progreso + +Rich puede representar varias barras de [progreso](https://rich.readthedocs.io/en/latest/progress.html) sin parpadeos para realizar un seguimiento de las tareas de larga duración. + +Para un uso básico, envuelva cualquier secuencia en la función `track` e itere sobre el resultado. He aquí un ejemplo: + +```python +from rich.progress import track + +for step in track(range(100)): + do_step(step) +``` + +No es mucho más difícil agregar varias barras de progreso. Aquí hay un ejemplo tomado de la documentación: + +![progress](https://github.com/willmcgugan/rich/raw/master/imgs/progress.gif) + +Las columnas pueden configurarse para mostrar los detalles que desee. Las columnas integradas incluyen porcentaje completado, tamaño de archivo, velocidad de archivo y tiempo restante. Aquí hay otro ejemplo que muestra una descarga en progreso: + +![progress](https://github.com/willmcgugan/rich/raw/master/imgs/downloader.gif) + +Para probar esto usted mismo, consulte [examples/downloader.py](https://github.com/willmcgugan/rich/blob/master/examples/downloader.py) que puede descargar varias URL simultáneamente mientras muestra el progreso. + +## Estado + +Para situaciones en las que es difícil calcular el progreso, puede utilizar el método [status](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console.status) que mostrará una animación y un mensaje de "spinner". La animación no le impedirá usar la consola con normalidad. He aquí un ejemplo: + +```python +from time import sleep +from rich.console import Console + +console = Console() +tasks = [f"task {n}" for n in range(1, 11)] + +with console.status("[bold green]Working on tasks...") as status: + while tasks: + task = tasks.pop(0) + sleep(1) + console.log(f"{task} complete") +``` + +Esto genera la siguiente salida en el terminal. + +![status](https://github.com/willmcgugan/rich/raw/master/imgs/status.gif) + +Las animaciones de spinner fueron tomadas de [cli-spinners](https://www.npmjs.com/package/cli-spinners). Puede seleccionar un spinner especificando el `spinner` parameter. Ejecute el siguiente comando para ver los valores disponibles: + +``` +python -m rich.spinner +``` + +El comando anterior genera la siguiente salida en la terminal: + +![spinners](https://github.com/willmcgugan/rich/raw/master/imgs/spinners.gif) + +## Columnas + +Rich puede representar contenido en [columnas](https://rich.readthedocs.io/en/latest/columns.html) ordenadas con un ancho igual u óptimo. Aquí hay un clon muy básico del comando (MacOS / Linux) `ls` que muestra una lista de directorios en columnas: + +```python +import os +import sys + +from rich import print +from rich.columns import Columns + +directory = os.listdir(sys.argv[1]) +print(Columns(directory)) +``` + +La siguiente captura de pantalla es el resultado del [ejemplo de columnas](https://github.com/willmcgugan/rich/blob/master/examples/columns.py) que muestra los datos extraídos de una API en columnas: + +![columns](https://github.com/willmcgugan/rich/raw/master/imgs/columns.png) + +## Markdown + +Rich puede renderizar [markdown](https://rich.readthedocs.io/en/latest/markdown.html) y hace un trabajo razonable al traducir el formato al terminal. + +Para renderizar markdown, importe la clase `Markdown` y constrúyala con una cadena que contenga el código de markdown. Luego imprímalo en la consola. He aquí un ejemplo: + +```python +from rich.console import Console +from rich.markdown import Markdown + +console = Console() +with open("README.md") as readme: + markdown = Markdown(readme.read()) +console.print(markdown) +``` + +Esto producirá una salida similar a la siguiente: + +![markdown](https://github.com/willmcgugan/rich/raw/master/imgs/markdown.png) + +## Resaltado de sintaxis + +Rich usa el paquete [pygments](https://pygments.org/) para implementar [resaltado de sintaxis](https://rich.readthedocs.io/en/latest/syntax.html). El uso es similar a renderizar markdown; construya un objeto `Syntax` e imprímalo en la consola. He aquí un ejemplo: + +```python +from rich.console import Console +from rich.syntax import Syntax + +my_code = ''' +def iter_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: + """Iterate and generate a tuple with a flag for first and last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + first = True + for value in iter_values: + yield first, False, previous_value + first = False + previous_value = value + yield first, True, previous_value +''' +syntax = Syntax(my_code, "python", theme="monokai", line_numbers=True) +console = Console() +console.print(syntax) +``` + +Esto producirá el siguiente resultado: + +![syntax](https://github.com/willmcgugan/rich/raw/master/imgs/syntax.png) + +## Tracebacks + +Rich puede representar [tracebacks hermosos](https://rich.readthedocs.io/en/latest/traceback.html) que son más fáciles de leer y muestran más código que los tracebacks estándar de Python. Puede configurar Rich como el controlador tracebacks predeterminado para que todas las excepciones sin capturar sean procesadas por Rich. + +Así es como se ve en OSX (similar en Linux): + +![traceback](https://github.com/willmcgugan/rich/raw/master/imgs/traceback.png) + +## Proyecto usando Rich + +Aquí hay algunos proyectos que usan Rich: + +- [BrancoLab/BrainRender](https://github.com/BrancoLab/BrainRender) + un paquete de Python para la visualización de datos neuroanatómicos tridimensionales +- [Ciphey/Ciphey](https://github.com/Ciphey/Ciphey) + Herramienta de descifrado automatizado +- [emeryberger/scalene](https://github.com/emeryberger/scalene) + un perfilador de memoria y CPU de alta precisión y alto rendimiento para Python +- [hedythedev/StarCli](https://github.com/hedythedev/starcli) + Explore los proyectos de tendencias de GitHub desde su línea de comando +- [intel/cve-bin-tool](https://github.com/intel/cve-bin-tool) + Esta herramienta busca una serie de componentes vulnerables comunes (openssl, libpng, libxml2, expat y algunos otros) para informarle si su sistema incluye bibliotecas comunes con vulnerabilidades conocidas. +- [nf-core/tools](https://github.com/nf) + Paquete de Python con herramientas auxiliares para la comunidad nf-core. +- [cansarigol/pdbr](https://github.com/cansarigol/pdbr) + pdb + biblioteca Rich para una depuración mejorada +- [plant99/felicette](https://github.com/plant99/felicette) + Imágenes de satélite para tontos. +- [seleniumbase/SeleniumBase](https://github.com/seleniumbase/SeleniumBase) + Automatice y pruebe 10 veces más rápido con Selenium y pytest. Baterias incluidas. +- [smacke/ffsubsync](https://github.com/smacke/ffsubsync) + Sincronice automáticamente los subtítulos con el video. +- [tryolabs/norfair](https://github.com/tryolabs/norfair) + Libreria de Python para agregar tracking a cualquier detector. +- [ansible/ansible-lint](https://github.com/ansible/ansible-lint) Ansible-lint comprueba los playbooks en busca de prácticas y comportamientos que podrían mejorarse +- [ansible-community/molecule](https://github.com/ansible-community/molecule) Marco de prueba de Ansible Molecule +- +¡[Muchos más](https://github.com/willmcgugan/rich/network/dependents)! diff --git a/README.md b/README.md new file mode 100644 index 0000000..698001f --- /dev/null +++ b/README.md @@ -0,0 +1,388 @@ +# Rich + +[![PyPI version](https://badge.fury.io/py/rich.svg)](https://badge.fury.io/py/rich) +[![codecov](https://codecov.io/gh/willmcgugan/rich/branch/master/graph/badge.svg)](https://codecov.io/gh/willmcgugan/rich) +[![Rich blog](https://img.shields.io/badge/blog-rich%20news-yellowgreen)](https://www.willmcgugan.com/tag/rich/) +[![Twitter Follow](https://img.shields.io/twitter/follow/willmcgugan.svg?style=social)](https://twitter.com/willmcgugan) + +[中文 readme](https://github.com/willmcgugan/rich/blob/master/README.cn.md) • [lengua española readme](https://github.com/willmcgugan/rich/blob/master/README.es.md) • [Läs på svenska](https://github.com/willmcgugan/rich/blob/master/README.sv.md) + +Rich is a Python library for _rich_ text and beautiful formatting in the terminal. + +The [Rich API](https://rich.readthedocs.io/en/latest/) makes it easy to add color and style to terminal output. Rich can also render pretty tables, progress bars, markdown, syntax highlighted source code, tracebacks, and more — out of the box. + +![Features](https://github.com/willmcgugan/rich/raw/master/imgs/features.png) + +For a video introduction to Rich see [calmcode.io](https://calmcode.io/rich/introduction.html) by [@fishnets88](https://twitter.com/fishnets88). + +See what [people are saying about Rich](https://www.willmcgugan.com/blog/pages/post/rich-tweets/). + +## Compatibility + +Rich works with Linux, OSX, and Windows. True color / emoji works with new Windows Terminal, classic terminal is limited to 8 colors. Rich requires Python 3.6.1 or later. + +Rich works with [Jupyter notebooks](https://jupyter.org/) with no additional configuration required. + +## Installing + +Install with `pip` or your favorite PyPi package manager. + +``` +pip install rich +``` + +Run the following to test Rich output on your terminal: + +``` +python -m rich +``` + +## Rich print function + +To effortlessly add rich output to your application, you can import the [rich print](https://rich.readthedocs.io/en/latest/introduction.html#quick-start) method, which has the same signature as the builtin Python function. Try this: + +```python +from rich import print + +print("Hello, [bold magenta]World[/bold magenta]!", ":vampire:", locals()) +``` + +![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/print.png) + +## Rich REPL + +Rich can be installed in the Python REPL, so that any data structures will be pretty printed and highlighted. + +```python +>>> from rich import pretty +>>> pretty.install() +``` + +![REPL](https://github.com/willmcgugan/rich/raw/master/imgs/repl.png) + +## Rich Inspect + +Rich has an [inspect](https://rich.readthedocs.io/en/latest/reference/init.html?highlight=inspect#rich.inspect) function which can produce a report on any Python object, such as class, instance, or builtin. + +```python +>>> from rich import inspect +>>> inspect(str, methods=True) +``` + +## Using the Console + +For more control over rich terminal content, import and construct a [Console](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console) object. + +```python +from rich.console import Console + +console = Console() +``` + +The Console object has a `print` method which has an intentionally similar interface to the builtin `print` function. Here's an example of use: + +```python +console.print("Hello", "World!") +``` + +As you might expect, this will print `"Hello World!"` to the terminal. Note that unlike the builtin `print` function, Rich will word-wrap your text to fit within the terminal width. + +There are a few ways of adding color and style to your output. You can set a style for the entire output by adding a `style` keyword argument. Here's an example: + +```python +console.print("Hello", "World!", style="bold red") +``` + +The output will be something like the following: + +![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/hello_world.png) + +That's fine for styling a line of text at a time. For more finely grained styling, Rich renders a special markup which is similar in syntax to [bbcode](https://en.wikipedia.org/wiki/BBCode). Here's an example: + +```python +console.print("Where there is a [bold cyan]Will[/bold cyan] there [u]is[/u] a [i]way[/i].") +``` + +![Console Markup](https://github.com/willmcgugan/rich/raw/master/imgs/where_there_is_a_will.png) + +### Console logging + +The Console object has a `log()` method which has a similar interface to `print()`, but also renders a column for the current time and the file and line which made the call. By default Rich will do syntax highlighting for Python structures and for repr strings. If you log a collection (i.e. a dict or a list) Rich will pretty print it so that it fits in the available space. Here's an example of some of these features. + +```python +from rich.console import Console +console = Console() + +test_data = [ + {"jsonrpc": "2.0", "method": "sum", "params": [None, 1, 2, 4, False, True], "id": "1",}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, + {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}, +] + +def test_log(): + enabled = False + context = { + "foo": "bar", + } + movies = ["Deadpool", "Rise of the Skywalker"] + console.log("Hello from", console, "!") + console.log(test_data, log_locals=True) + + +test_log() +``` + +The above produces the following output: + +![Log](https://github.com/willmcgugan/rich/raw/master/imgs/log.png) + +Note the `log_locals` argument, which outputs a table containing the local variables where the log method was called. + +The log method could be used for logging to the terminal for long running applications such as servers, but is also a very nice debugging aid. + +### Logging Handler + +You can also use the builtin [Handler class](https://rich.readthedocs.io/en/latest/logging.html) to format and colorize output from Python's logging module. Here's an example of the output: + +![Logging](https://github.com/willmcgugan/rich/raw/master/imgs/logging.png) + +## Emoji + +To insert an emoji in to console output place the name between two colons. Here's an example: + +```python +>>> console.print(":smiley: :vampire: :pile_of_poo: :thumbs_up: :raccoon:") +😃 🧛 💩 👍 🦝 +``` + +Please use this feature wisely. + +## Tables + +Rich can render flexible [tables](https://rich.readthedocs.io/en/latest/tables.html) with unicode box characters. There is a large variety of formatting options for borders, styles, cell alignment etc. + +![table movie](https://github.com/willmcgugan/rich/raw/master/imgs/table_movie.gif) + +The animation above was generated with [table_movie.py](https://github.com/willmcgugan/rich/blob/master/examples/table_movie.py) in the examples directory. + +Here's a simpler table example: + +```python +from rich.console import Console +from rich.table import Table + +console = Console() + +table = Table(show_header=True, header_style="bold magenta") +table.add_column("Date", style="dim", width=12) +table.add_column("Title") +table.add_column("Production Budget", justify="right") +table.add_column("Box Office", justify="right") +table.add_row( + "Dev 20, 2019", "Star Wars: The Rise of Skywalker", "$275,000,000", "$375,126,118" +) +table.add_row( + "May 25, 2018", + "[red]Solo[/red]: A Star Wars Story", + "$275,000,000", + "$393,151,347", +) +table.add_row( + "Dec 15, 2017", + "Star Wars Ep. VIII: The Last Jedi", + "$262,000,000", + "[bold]$1,332,539,889[/bold]", +) + +console.print(table) +``` + +This produces the following output: + +![table](https://github.com/willmcgugan/rich/raw/master/imgs/table.png) + +Note that console markup is rendered in the same way as `print()` and `log()`. In fact, anything that is renderable by Rich may be included in the headers / rows (even other tables). + +The `Table` class is smart enough to resize columns to fit the available width of the terminal, wrapping text as required. Here's the same example, with the terminal made smaller than the table above: + +![table2](https://github.com/willmcgugan/rich/raw/master/imgs/table2.png) + +## Progress Bars + +Rich can render multiple flicker-free [progress](https://rich.readthedocs.io/en/latest/progress.html) bars to track long-running tasks. + +For basic usage, wrap any sequence in the `track` function and iterate over the result. Here's an example: + +```python +from rich.progress import track + +for step in track(range(100)): + do_step(step) +``` + +It's not much harder to add multiple progress bars. Here's an example taken from the docs: + +![progress](https://github.com/willmcgugan/rich/raw/master/imgs/progress.gif) + +The columns may be configured to show any details you want. Built-in columns include percentage complete, file size, file speed, and time remaining. Here's another example showing a download in progress: + +![progress](https://github.com/willmcgugan/rich/raw/master/imgs/downloader.gif) + +To try this out yourself, see [examples/downloader.py](https://github.com/willmcgugan/rich/blob/master/examples/downloader.py) which can download multiple URLs simultaneously while displaying progress. + +## Status + +For situations where it is hard to calculate progress, you can use the [status](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console.status) method which will display a 'spinner' animation and message. The animation won't prevent you from using the console as normal. Here's an example: + +```python +from time import sleep +from rich.console import Console + +console = Console() +tasks = [f"task {n}" for n in range(1, 11)] + +with console.status("[bold green]Working on tasks...") as status: + while tasks: + task = tasks.pop(0) + sleep(1) + console.log(f"{task} complete") +``` + +This generates the following output in the terminal. + +![status](https://github.com/willmcgugan/rich/raw/master/imgs/status.gif) + +The spinner animations were borrowed from [cli-spinners](https://www.npmjs.com/package/cli-spinners). You can select a spinner by specifying the `spinner` parameter. Run the following command to see the available values: + +``` +python -m rich.spinner +``` + +The above command generate the following output in the terminal: + +![spinners](https://github.com/willmcgugan/rich/raw/master/imgs/spinners.gif) + +## Tree + +Rich can render a [tree](https://rich.readthedocs.io/en/latest/tree.html) with guide lines. A tree is ideal for displaying a file structure, or any other hierarchical data. + +The labels of the tree can be simple text or anything else Rich can render. Run the following for a demonstration: + +``` +python -m rich.tree +``` + +This generates the following output: + +![markdown](https://github.com/willmcgugan/rich/raw/master/imgs/tree.png) + +See the [tree.py](https://github.com/willmcgugan/rich/blob/master/examples/tree.py) example for a script that displays a tree view of any directory, similar to the linux `tree` command. + +## Columns + +Rich can render content in neat [columns](https://rich.readthedocs.io/en/latest/columns.html) with equal or optimal width. Here's a very basic clone of the (MacOS / Linux) `ls` command which displays a directory listing in columns: + +```python +import os +import sys + +from rich import print +from rich.columns import Columns + +directory = os.listdir(sys.argv[1]) +print(Columns(directory)) +``` + +The following screenshot is the output from the [columns example](https://github.com/willmcgugan/rich/blob/master/examples/columns.py) which displays data pulled from an API in columns: + +![columns](https://github.com/willmcgugan/rich/raw/master/imgs/columns.png) + +## Markdown + +Rich can render [markdown](https://rich.readthedocs.io/en/latest/markdown.html) and does a reasonable job of translating the formatting to the terminal. + +To render markdown import the `Markdown` class and construct it with a string containing markdown code. Then print it to the console. Here's an example: + +```python +from rich.console import Console +from rich.markdown import Markdown + +console = Console() +with open("README.md") as readme: + markdown = Markdown(readme.read()) +console.print(markdown) +``` + +This will produce output something like the following: + +![markdown](https://github.com/willmcgugan/rich/raw/master/imgs/markdown.png) + +## Syntax Highlighting + +Rich uses the [pygments](https://pygments.org/) library to implement [syntax highlighting](https://rich.readthedocs.io/en/latest/syntax.html). Usage is similar to rendering markdown; construct a `Syntax` object and print it to the console. Here's an example: + +```python +from rich.console import Console +from rich.syntax import Syntax + +my_code = ''' +def iter_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: + """Iterate and generate a tuple with a flag for first and last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + first = True + for value in iter_values: + yield first, False, previous_value + first = False + previous_value = value + yield first, True, previous_value +''' +syntax = Syntax(my_code, "python", theme="monokai", line_numbers=True) +console = Console() +console.print(syntax) +``` + +This will produce the following output: + +![syntax](https://github.com/willmcgugan/rich/raw/master/imgs/syntax.png) + +## Tracebacks + +Rich can render [beautiful tracebacks](https://rich.readthedocs.io/en/latest/traceback.html) which are easier to read and show more code than standard Python tracebacks. You can set Rich as the default traceback handler so all uncaught exceptions will be rendered by Rich. + +Here's what it looks like on OSX (similar on Linux): + +![traceback](https://github.com/willmcgugan/rich/raw/master/imgs/traceback.png) + +## Project using Rich + +Here are a few projects using Rich: + +- [BrancoLab/BrainRender](https://github.com/BrancoLab/BrainRender) + a python package for the visualization of three dimensional neuro-anatomical data +- [Ciphey/Ciphey](https://github.com/Ciphey/Ciphey) + Automated decryption tool +- [emeryberger/scalene](https://github.com/emeryberger/scalene) + a high-performance, high-precision CPU and memory profiler for Python +- [hedythedev/StarCli](https://github.com/hedythedev/starcli) + Browse GitHub trending projects from your command line +- [intel/cve-bin-tool](https://github.com/intel/cve-bin-tool) + This tool scans for a number of common, vulnerable components (openssl, libpng, libxml2, expat and a few others) to let you know if your system includes common libraries with known vulnerabilities. +- [nf-core/tools](https://github.com/nf-core/tools) + Python package with helper tools for the nf-core community. +- [cansarigol/pdbr](https://github.com/cansarigol/pdbr) + pdb + Rich library for enhanced debugging +- [plant99/felicette](https://github.com/plant99/felicette) + Satellite imagery for dummies. +- [seleniumbase/SeleniumBase](https://github.com/seleniumbase/SeleniumBase) + Automate & test 10x faster with Selenium & pytest. Batteries included. +- [smacke/ffsubsync](https://github.com/smacke/ffsubsync) + Automagically synchronize subtitles with video. +- [tryolabs/norfair](https://github.com/tryolabs/norfair) + Lightweight Python library for adding real-time 2D object tracking to any detector. +- [ansible/ansible-lint](https://github.com/ansible/ansible-lint) Ansible-lint checks playbooks for practices and behaviour that could potentially be improved +- [ansible-community/molecule](https://github.com/ansible-community/molecule) Ansible Molecule testing framework +- +[Many more](https://github.com/willmcgugan/rich/network/dependents)! diff --git a/README.sv.md b/README.sv.md new file mode 100644 index 0000000..71d5851 --- /dev/null +++ b/README.sv.md @@ -0,0 +1,388 @@ +# Rich + +[![PyPI version](https://badge.fury.io/py/rich.svg)](https://badge.fury.io/py/rich) +[![codecov](https://codecov.io/gh/willmcgugan/rich/branch/master/graph/badge.svg)](https://codecov.io/gh/willmcgugan/rich) +[![Rich blog](https://img.shields.io/badge/blog-rich%20news-yellowgreen)](https://www.willmcgugan.com/tag/rich/) +[![Twitter Follow](https://img.shields.io/twitter/follow/willmcgugan.svg?style=social)](https://twitter.com/willmcgugan) + +[中文 readme](https://github.com/willmcgugan/rich/blob/master/README.cn.md) • [lengua española readme](https://github.com/willmcgugan/rich/blob/master/README.es.md) + +Rich är ett Python bibliotek för _rich_ text och vacker formattering i terminalen. + +[Rich API](https://rich.readthedocs.io/en/latest/) gör det enkelt att lägga till färg och stil till terminal utmatning. Rich kan också framställa fina tabeller, framstegsfält, märkspråk, syntaxmarkerad källkod, tillbaka-spårning, och mera - redo att använda. + +![Funktioner](https://github.com/willmcgugan/rich/raw/master/imgs/features.png) + +För en video demonstration av Rich kolla [calmcode.io](https://calmcode.io/rich/introduction.html) av [@fishnets88](https://twitter.com/fishnets88). + +Se vad [folk pratar om Rich](https://www.willmcgugan.com/blog/pages/post/rich-tweets/). + +## Kompatibilitet + +Rich funkar med Linux, OSX, och Windows. Sann färg / emoji funkar med nya Windows Terminalen, klassiska terminal är begränsad till 8 färger. Rich kräver Python 3.6.1 eller senare. + +Rich funkar med [Jupyter notebooks](https://jupyter.org/) utan någon ytterligare konfiguration behövd. + +## Installering + +Installera med `pip` eller din favorita PyPi packet hanterare. + +``` +pip install rich +``` + +Kör följade följande för att testa Rich utmatning i din terminal: + +``` +python -m rich +``` + +## Rich utskrivningsfunktion + +För att enkelt lägga till rich utmatning i din applikation, kan du importera [rich print](https://rich.readthedocs.io/en/latest/introduction.html#quick-start) metoden, vilket har den samma signatur som den inbyggda Python funktionen. Testa detta: + +```python +from rich import print + +print("Hello, [bold magenta]World[/bold magenta]!", ":vampire:", locals()) +``` + +![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/print.png) + +## Rich REPL + +Rich kan installeras i Python REPL, så att varje datastruktur kommer att skrivas ut fint och markeras. + +```python +>>> from rich import pretty +>>> pretty.install() +``` + +![REPL](https://github.com/willmcgugan/rich/raw/master/imgs/repl.png) + +## Rich Inspektera + +Rich har en [inspektionsfunktion](https://rich.readthedocs.io/en/latest/reference/init.html?highlight=inspect#rich.inspect) som kan producera en rapport om vilket Python objekt som helst, till exempel klass, instans, eller inbyggt. + +```python +>>> from rich import inspect +>>> inspect(str, methods=True) +``` + +## Användning av konsolen + +För mer kontroll över rich terminal innehållsutmatning, importera och konstruera ett [Console](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console) objekt. + +```python +from rich.console import Console + +console = Console() +``` + +`Console` objektet har en `print` metod vilket har ett avsiktligt liknande gränssnitt till den inbyggda `print` funktionen. Här är ett exempel av användningen: + +```python +console.print("Hello", "World!") +``` + +Som du möjligtvis anar, detta kommer skriva ut `"Hello World!"` till terminalen. Notera att till skillnad från den inbyggda `print` funktionen, Rich kommer att radbryta din text så att den passar inom terminalbredden. + +Det finns ett par sätt att lägga till färg och stil till din utmatning. Du kan sätta en stil för hela utmatningen genom att addera ett `style` nyckelord argument. Här är ett exempel: + +```python +console.print("Hello", "World!", style="bold red") +``` + +Utmatningen kommer bli något liknande: + +![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/hello_world.png) + +Det är bra för att ge stil till en textrad åt gången. För mer finkornad stilisering, Rich framställer en speciell märkspråk vilket liknar [bbcode](https://en.wikipedia.org/wiki/BBCode) i syntax. Här är ett exempel: + +```python +console.print("Where there is a [bold cyan]Will[/bold cyan] there [u]is[/u] a [i]way[/i].") +``` + +![Konsol märkspråk](https://github.com/willmcgugan/rich/raw/master/imgs/where_there_is_a_will.png) + +### Konsollogging + +`Console` objektet har en `log()` metod vilket har liknande gränssnitt som `print()`, men framställer även en kolumn för den nuvarande tid och fil samt rad vilket gjorde anroppet. Som standard kommer Rich att markera syntax för Python strukturer och för repr strängar. Ifall du loggar en samling (det vill säga en ordbok eller en lista) kommer Rich att finskriva ut det så att det passar i det tillgängliga utrymme. Här är ett exempel av dessa funktioner. + +```python +from rich.console import Console +console = Console() + +test_data = [ + {"jsonrpc": "2.0", "method": "sum", "params": [None, 1, 2, 4, False, True], "id": "1",}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, + {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}, +] + +def test_log(): + enabled = False + context = { + "foo": "bar", + } + movies = ["Deadpool", "Rise of the Skywalker"] + console.log("Hello from", console, "!") + console.log(test_data, log_locals=True) + + +test_log() +``` + +Det ovanstående har följande utmatning: + +![Log](https://github.com/willmcgugan/rich/raw/master/imgs/log.png) + +Notera `log_locals` argumentet, vilket utmatar en tabell innehållandes de lokala variablerna varifrån log metoden kallades från. + +Log metoden kan användas för att logga till terminal för långkörande applikationer så som servrar, men är också en väldigt bra felsökningsverktyg. + +### Loggningshanterare + +Du kan också använda den inbyggda [Handler klassen](https://rich.readthedocs.io/en/latest/logging.html) för att formatera och färglägga utmatningen från Pythons loggningsmodul. Här är ett exempel av utmatningen: + +![Loggning](https://github.com/willmcgugan/rich/raw/master/imgs/logging.png) + +## Emoji + +För att infoga en emoji till konsolutmatningen placera namnet mellan två kolon. Här är ett exempel: + +```python +>>> console.print(":smiley: :vampire: :pile_of_poo: :thumbs_up: :raccoon:") +😃 🧛 💩 👍 🦝 +``` + +Vänligen använd denna funktion klokt. + +## Tabell + +Rich kan framställa flexibla [tabeller](https://rich.readthedocs.io/en/latest/tables.html) med unicode boxkaraktärer. Det finns en stor mängd av formateringsalternativ för gränser, stilar, och celljustering etc. + +![Tabell film](https://github.com/willmcgugan/rich/raw/master/imgs/table_movie.gif) + +Animationen ovan genererades utav [table_movie.py](https://github.com/willmcgugan/rich/blob/master/examples/table_movie.py) i exempelkatalogen. + +Här är ett exempel av en enklare tabell: + +```python +from rich.console import Console +from rich.table import Table + +console = Console() + +table = Table(show_header=True, header_style="bold magenta") +table.add_column("Date", style="dim", width=12) +table.add_column("Title") +table.add_column("Production Budget", justify="right") +table.add_column("Box Office", justify="right") +table.add_row( + "Dev 20, 2019", "Star Wars: The Rise of Skywalker", "$275,000,000", "$375,126,118" +) +table.add_row( + "May 25, 2018", + "[red]Solo[/red]: A Star Wars Story", + "$275,000,000", + "$393,151,347", +) +table.add_row( + "Dec 15, 2017", + "Star Wars Ep. VIII: The Last Jedi", + "$262,000,000", + "[bold]$1,332,539,889[/bold]", +) + +console.print(table) +``` + +Detta producerar följande utmatning: + +![tabell](https://github.com/willmcgugan/rich/raw/master/imgs/table.png) + +Notera att konsol märkspråk är framställt på samma sätt som `print()` och `log()`. I själva verket, vad som helst som är framställt av Rich kan inkluderas i rubriker / rader (även andra tabeller). + +`Table` klassen är smart nog att storleksändra kolumner att passa den tillgängliga bredden av terminalen, och slår in text ifall det behövs. Här är samma exempel, med terminalen gjord mindre än tabell ovan: + +![tabell2](https://github.com/willmcgugan/rich/raw/master/imgs/table2.png) + +## Framstegsfält + +Rich kan framställa flera flimmerfria [framstegsfält](https://rich.readthedocs.io/en/latest/progress.html) för att följa långvariga uppgifter. + +För grundläggande användning, slå in valfri sekvens i `track` funktion och iterera över resultatet. Här är ett exempel: + +```python +from rich.progress import track + +for step in track(range(100)): + do_step(step) +``` + +Det är inte mycket svårare att lägga till flera framstegsfält. Här är ett exempel tagen från dokumentationen: + +![framsteg](https://github.com/willmcgugan/rich/raw/master/imgs/progress.gif) + +Dessa kolumner kan konfigureras att visa vilka detaljer du vill. Inbyggda kolumner inkluderar procentuell färdig, filstorlek, filhastighet, och återstående tid. Här är ännu ett exempel som visar en pågående nedladdning: + +![framsteg](https://github.com/willmcgugan/rich/raw/master/imgs/downloader.gif) + +För att själv testa detta, kolla [examples/downloader.py](https://github.com/willmcgugan/rich/blob/master/examples/downloader.py) vilket kan ladda ner flera URLs samtidigt medan visar framsteg. + +## Status + +För situationer där det är svårt att beräkna framsteg, kan du använda [status](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console.status) metoden vilket kommer visa en 'snurra' animation och meddelande. Animationen hindrar dig inte från att använda konsolen som normalt. Här är ett exempel: + +```python +from time import sleep +from rich.console import Console + +console = Console() +tasks = [f"task {n}" for n in range(1, 11)] + +with console.status("[bold green]Working on tasks...") as status: + while tasks: + task = tasks.pop(0) + sleep(1) + console.log(f"{task} complete") +``` + +Detta genererar följande utmatning i terminalen. + +![status](https://github.com/willmcgugan/rich/raw/master/imgs/status.gif) + +Snurra animationen är lånad ifrån [cli-spinners](https://www.npmjs.com/package/cli-spinners). Du kan välja en snurra genom att specifiera `spinner` parametern. Kör följande kommando för att se tillgängliga värden: + +``` +python -m rich.spinner +``` + +Kommandot ovan genererar följande utmatning i terminalen: + +![Snurror](https://github.com/willmcgugan/rich/raw/master/imgs/spinners.gif) + +## Träd + +Rich kan framställa ett [träd](https://rich.readthedocs.io/en/latest/tree.html) med riktlinjer. Ett träd är idealt för att visa en filstruktur, eller andra hierarkiska data. + +Etiketter på trädet kan vara enkelt text eller något annat som Rich kan framställa. Kör följande för en demonstration: + +``` +python -m rich.tree +``` + +Detta genererar följande utmatning: + +![märkspråk](https://github.com/willmcgugan/rich/raw/master/imgs/tree.png) + +Se [tree.py](https://github.com/willmcgugan/rich/blob/master/examples/tree.py) exemplet för ett skript som visar en trädvy av vilken katalog som helst, som liknar linux `tree` kommandot. + +## Kolumner + +Rich kan framställa innehåll i prydliga [kolumner](https://rich.readthedocs.io/en/latest/columns.html) med lika eller optimal bredd. Här är en grundläggande klon av (MacOS / Linux) `ls` kommandot vilket visar en kataloglista i kolumner: + +```python +import os +import sys + +from rich import print +from rich.columns import Columns + +directory = os.listdir(sys.argv[1]) +print(Columns(directory)) +``` + +Följande skärmdump är resultatet från [kolumner exempelet](https://github.com/willmcgugan/rich/blob/master/examples/columns.py) vilket visar data tagen från ett API i kolumner: + +![kolumner](https://github.com/willmcgugan/rich/raw/master/imgs/columns.png) + +## Märkspråk + +Rich kan framställa [märkspråk](https://rich.readthedocs.io/en/latest/markdown.html) och gör ett rimligt jobb med att översätta formateringen till terminalen. + +För att framställa märkspråk importera `Markdown` klassen och konstruera den med en sträng innehållandes märkspråkskod. Mata sedan ut det till konsolen. Här är ett exempel: + +```python +from rich.console import Console +from rich.markdown import Markdown + +console = Console() +with open("README.md") as readme: + markdown = Markdown(readme.read()) +console.print(markdown) +``` + +Detta kommer att producera utmatning som liknar följande: + +![märkspråk](https://github.com/willmcgugan/rich/raw/master/imgs/markdown.png) + +## Syntaxmarkering + +Rich använder [pygments](https://pygments.org/) biblioteket för att implementera [syntax markering](https://rich.readthedocs.io/en/latest/syntax.html). Användningen är liknande till framställa märkspråk; konstruera ett `Syntax` objekt och skriv ut den till konsolen. Här är ett exempel: + +```python +from rich.console import Console +from rich.syntax import Syntax + +my_code = ''' +def iter_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: + """Iterate and generate a tuple with a flag for first and last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + first = True + for value in iter_values: + yield first, False, previous_value + first = False + previous_value = value + yield first, True, previous_value +''' +syntax = Syntax(my_code, "python", theme="monokai", line_numbers=True) +console = Console() +console.print(syntax) +``` + +Detta kommer producera följande utmatning: + +![syntax](https://github.com/willmcgugan/rich/raw/master/imgs/syntax.png) + +## Tillbaka-spårning + +Rich kan framställa [vackra tillbaka-spårningar](https://rich.readthedocs.io/en/latest/traceback.html) vilket är enklare att läsa och visar mer kod än vanliga Python tillbaka-spårningar. Du kan sätta Rich som standard tillbaka-spårningshanterare så att alla ofångade undantag kommer att framställas av Rich. + +Så här ser det ut på OSX (liknande på Linux): + +![traceback](https://github.com/willmcgugan/rich/raw/master/imgs/traceback.png) + +## Projekt som använder sig av Rich + +Här är ett par projekt som använder Rich: + +- [BrancoLab/BrainRender](https://github.com/BrancoLab/BrainRender) + ett python packet för visualisering av tredimensionell neuro-anatomiska data +- [Ciphey/Ciphey](https://github.com/Ciphey/Ciphey) + Automatiserat dekrypteringsverktyg +- [emeryberger/scalene](https://github.com/emeryberger/scalene) + en högpresterande processor med hög precision och minnesprofilerare för Python +- [hedythedev/StarCli](https://github.com/hedythedev/starcli) + Bläddra bland trendande projekt i Github från din kommandotolk +- [intel/cve-bin-tool](https://github.com/intel/cve-bin-tool) + Detta verktyg skannar efter vanliga, sårbara komponenter (openssl, libpng, libxml2, expat och en del andra) för att låta dig veta ifall ditt system inkluderar vanliga bibliotek med kända sårbarheter. +- [nf-core/tools](https://github.com/nf-core/tools) + Python packet med hjälpverktyg för nf-core gemenskapen. +- [cansarigol/pdbr](https://github.com/cansarigol/pdbr) + pdb + Rich bibliotek för förbättrad felsökning. +- [plant99/felicette](https://github.com/plant99/felicette) + Satellitbilder för nybörjare. +- [seleniumbase/SeleniumBase](https://github.com/seleniumbase/SeleniumBase) + Automatisera & testa 10x snabbare med Selenium & pytest. Batterier inkluderat. +- [smacke/ffsubsync](https://github.com/smacke/ffsubsync) + Automagiskt synkronisera undertexter med video. +- [tryolabs/norfair](https://github.com/tryolabs/norfair) + Lättvikt Python bibliotek för att addera 2d-objektspårning i realtid till vilken detektor som helst. +- [ansible/ansible-lint](https://github.com/ansible/ansible-lint) Ansible-lint kontroller playbooks för dess metoder och beteenden som potentiellt kan förbättras +- [ansible-community/molecule](https://github.com/ansible-community/molecule) Ansible Molecule ramverk för testning +- +[Many more](https://github.com/willmcgugan/rich/network/dependents)! diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..6247f7e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..49cc557 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +alabaster==0.7.12 +Sphinx==3.5.0 +sphinx-rtd-theme==0.5.1
\ No newline at end of file diff --git a/docs/source/appendix.rst b/docs/source/appendix.rst new file mode 100644 index 0000000..3ae7fa6 --- /dev/null +++ b/docs/source/appendix.rst @@ -0,0 +1,9 @@ +Appendix +========= + +.. toctree:: + :maxdepth: 3 + + appendix/box.rst + appendix/colors.rst +
\ No newline at end of file diff --git a/docs/source/appendix/box.rst b/docs/source/appendix/box.rst new file mode 100644 index 0000000..9178953 --- /dev/null +++ b/docs/source/appendix/box.rst @@ -0,0 +1,79 @@ +.. _appendix_box: + +Box +=== + +Rich has a number of constants that set the box characters used to draw tables and panels. To select a box style import one of the constants below from ``rich.box``. For example:: + + from rich import box + table = Table(box=box.SQUARE) + + +.. note:: + Some of the box drawing characters will not display correctly on Windows legacy terminal (cmd.exe) with *raster* fonts, and are disabled by default. If you want the full range of box options on Windows legacy terminal, use a *truetype* font and set the ``safe_box`` parameter on the Table class to ``False``. + + +The following table is generated with this command:: + + python -m rich.box + +.. raw:: html + + <pre style="font-size:90%;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"><span style="color: #008000">╭──────────────────────────────────────────────────────────────────────────────╮</span> + <span style="color: #008000">│</span> <span style="color: #008000; font-weight: bold">Box Constants</span> <span style="color: #008000">│ + ╰──────────────────────────────────────────────────────────────────────────────╯</span> + + <span style="color: #800080"> box.ASCII </span> <span style="color: #800080"> box.SQUARE </span> <span style="color: #800080"> box.MINIMAL </span> + +------------------------+ ┌────────────┬───────────┐ + |<span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>|<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span>| │<span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span>│ <span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span> + |------------+-----------| ├────────────┼───────────┤ ───────────┼────────── + |<span style="color: #7f7f7f"> Cell </span>|<span style="color: #7f7f7f"> Cell </span>| │<span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span>│ <span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span> + |<span style="color: #7f7f7f"> Cell </span>|<span style="color: #7f7f7f"> Cell </span>| │<span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span>│ <span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span> + |------------+-----------| ├────────────┼───────────┤ ───────────┼────────── + |<span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>|<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span>| │<span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span>│ <span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span> + +------------------------+ └────────────┴───────────┘ + + + <span style="color: #800080"> box.MINIMAL_HEAVY_HEAD </span> <span style="color: #800080"> box.MINIMAL_DOUBLE_HEAD </span> <span style="color: #800080"> box.SIMPLE </span> + + <span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span> <span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span> <span style="color: #7f7f7f; font-weight: bold"> Header 1 </span> <span style="color: #7f7f7f; font-weight: bold"> Header 2 </span> + ━━━━━━━━━━━━┿━━━━━━━━━━━ ════════════╪═══════════ ──────────────────────── + <span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span> + <span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span> + ────────────┼─────────── ────────────┼─────────── ──────────────────────── + <span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span> <span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span> <span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span> <span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span> + + + + <span style="color: #800080"> box.SIMPLE_HEAVY </span> <span style="color: #800080"> box.HORIZONTALS </span> <span style="color: #800080"> box.ROUNDED </span> + ────────────────────────── ╭───────────┬──────────╮ + <span style="color: #7f7f7f; font-weight: bold"> Header 1 </span> <span style="color: #7f7f7f; font-weight: bold"> Header 2 </span> <span style="color: #7f7f7f; font-weight: bold"> Header 1 </span> <span style="color: #7f7f7f; font-weight: bold"> Header 2 </span> │<span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span>│ + ╺━━━━━━━━━━━━━━━━━━━━━━━━╸ ────────────────────────── ├───────────┼──────────┤ + <span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span> │<span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span>│ + <span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span> <span style="color: #7f7f7f"> Cell </span> │<span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span>│ + ╺━━━━━━━━━━━━━━━━━━━━━━━━╸ ────────────────────────── ├───────────┼──────────┤ + <span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span> <span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span> <span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span> <span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span> │<span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span>│ + ────────────────────────── ╰───────────┴──────────╯ + + + <span style="color: #800080"> box.HEAVY </span> <span style="color: #800080"> box.HEAVY_EDGE </span> <span style="color: #800080"> box.HEAVY_HEAD </span> + ┏━━━━━━━━━━━━┳━━━━━━━━━━━┓ ┏━━━━━━━━━━━━┯━━━━━━━━━━━┓ ┏━━━━━━━━━━━┳━━━━━━━━━━┓ + ┃<span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>┃<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span>┃ ┃<span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span>┃ ┃<span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>┃<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span>┃ + ┣━━━━━━━━━━━━╋━━━━━━━━━━━┫ ┠────────────┼───────────┨ ┡━━━━━━━━━━━╇━━━━━━━━━━┩ + ┃<span style="color: #7f7f7f"> Cell </span>┃<span style="color: #7f7f7f"> Cell </span>┃ ┃<span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span>┃ │<span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span>│ + ┃<span style="color: #7f7f7f"> Cell </span>┃<span style="color: #7f7f7f"> Cell </span>┃ ┃<span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span>┃ │<span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span>│ + ┣━━━━━━━━━━━━╋━━━━━━━━━━━┫ ┠────────────┼───────────┨ ├───────────┼──────────┤ + ┃<span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>┃<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span>┃ ┃<span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span>┃ │<span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span>│ + ┗━━━━━━━━━━━━┻━━━━━━━━━━━┛ ┗━━━━━━━━━━━━┷━━━━━━━━━━━┛ └───────────┴──────────┘ + + + <span style="color: #800080"> box.DOUBLE </span> <span style="color: #800080"> box.DOUBLE_EDGE </span> + ╔════════════╦═══════════╗ ╔════════════╤═══════════╗ + ║<span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>║<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span>║ ║<span style="color: #7f7f7f; font-weight: bold"> Header 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Header 2 </span>║ + ╠════════════╬═══════════╣ ╟────────────┼───────────╢ + ║<span style="color: #7f7f7f"> Cell </span>║<span style="color: #7f7f7f"> Cell </span>║ ║<span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span>║ + ║<span style="color: #7f7f7f"> Cell </span>║<span style="color: #7f7f7f"> Cell </span>║ ║<span style="color: #7f7f7f"> Cell </span>│<span style="color: #7f7f7f"> Cell </span>║ + ╠════════════╬═══════════╣ ╟────────────┼───────────╢ + ║<span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>║<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span>║ ║<span style="color: #7f7f7f; font-weight: bold"> Footer 1 </span>│<span style="color: #7f7f7f; font-weight: bold"> Footer 2 </span>║ + ╚════════════╩═══════════╝ ╚════════════╧═══════════╝ + </pre> diff --git a/docs/source/appendix/colors.rst b/docs/source/appendix/colors.rst new file mode 100644 index 0000000..fa65f4f --- /dev/null +++ b/docs/source/appendix/colors.rst @@ -0,0 +1,220 @@ +.. _appendix-colors: + +Standard Colors +=============== + +The following is a list of the standard 8-bit colors supported in terminals. + +Note that the first 16 colors are generally defined by the system or your terminal software, and may not display exactly as rendered here. + +.. raw:: html + + + <pre style="font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace">╔════════════╤════════╤═══════════════════════╤═════════╤══════════════════╗ + ║<span style="font-weight: bold"> Color </span>│<span style="font-weight: bold"> Number </span>│<span style="font-weight: bold"> Name </span>│<span style="font-weight: bold"> Hex </span>│<span style="font-weight: bold"> RGB </span>║ + ╟────────────┼────────┼───────────────────────┼─────────┼──────────────────╢ + ║ <span style="background-color: #000000"> </span> │<span style="color: #808000"> 0 </span>│<span style="color: #008000"> "black" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #800000"> </span> │<span style="color: #808000"> 1 </span>│<span style="color: #008000"> "red" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #008000"> </span> │<span style="color: #808000"> 2 </span>│<span style="color: #008000"> "green" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #808000"> </span> │<span style="color: #808000"> 3 </span>│<span style="color: #008000"> "yellow" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #000080"> </span> │<span style="color: #808000"> 4 </span>│<span style="color: #008000"> "blue" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #800080"> </span> │<span style="color: #808000"> 5 </span>│<span style="color: #008000"> "magenta" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #008080"> </span> │<span style="color: #808000"> 6 </span>│<span style="color: #008000"> "cyan" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #c0c0c0"> </span> │<span style="color: #808000"> 7 </span>│<span style="color: #008000"> "white" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #808080"> </span> │<span style="color: #808000"> 8 </span>│<span style="color: #008000"> "bright_black" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #ff0000"> </span> │<span style="color: #808000"> 9 </span>│<span style="color: #008000"> "bright_red" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #00ff00"> </span> │<span style="color: #808000"> 10 </span>│<span style="color: #008000"> "bright_green" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #ffff00"> </span> │<span style="color: #808000"> 11 </span>│<span style="color: #008000"> "bright_yellow" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #0000ff"> </span> │<span style="color: #808000"> 12 </span>│<span style="color: #008000"> "bright_blue" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #ff00ff"> </span> │<span style="color: #808000"> 13 </span>│<span style="color: #008000"> "bright_magenta" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #00ffff"> </span> │<span style="color: #808000"> 14 </span>│<span style="color: #008000"> "bright_cyan" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #ffffff"> </span> │<span style="color: #808000"> 15 </span>│<span style="color: #008000"> "bright_white" </span>│<span style="color: #000080"> </span>│<span style="color: #800080"> </span>║ + ║ <span style="background-color: #000000"> </span> │<span style="color: #808000"> 16 </span>│<span style="color: #008000"> "grey0" </span>│<span style="color: #000080"> #000000 </span>│<span style="color: #800080"> rgb(0,0,0) </span>║ + ║ <span style="background-color: #00005f"> </span> │<span style="color: #808000"> 17 </span>│<span style="color: #008000"> "navy_blue" </span>│<span style="color: #000080"> #00005f </span>│<span style="color: #800080"> rgb(0,0,95) </span>║ + ║ <span style="background-color: #000087"> </span> │<span style="color: #808000"> 18 </span>│<span style="color: #008000"> "dark_blue" </span>│<span style="color: #000080"> #000087 </span>│<span style="color: #800080"> rgb(0,0,135) </span>║ + ║ <span style="background-color: #0000d7"> </span> │<span style="color: #808000"> 20 </span>│<span style="color: #008000"> "blue3" </span>│<span style="color: #000080"> #0000d7 </span>│<span style="color: #800080"> rgb(0,0,215) </span>║ + ║ <span style="background-color: #0000ff"> </span> │<span style="color: #808000"> 21 </span>│<span style="color: #008000"> "blue1" </span>│<span style="color: #000080"> #0000ff </span>│<span style="color: #800080"> rgb(0,0,255) </span>║ + ║ <span style="background-color: #005f00"> </span> │<span style="color: #808000"> 22 </span>│<span style="color: #008000"> "dark_green" </span>│<span style="color: #000080"> #005f00 </span>│<span style="color: #800080"> rgb(0,95,0) </span>║ + ║ <span style="background-color: #005faf"> </span> │<span style="color: #808000"> 25 </span>│<span style="color: #008000"> "deep_sky_blue4" </span>│<span style="color: #000080"> #005faf </span>│<span style="color: #800080"> rgb(0,95,175) </span>║ + ║ <span style="background-color: #005fd7"> </span> │<span style="color: #808000"> 26 </span>│<span style="color: #008000"> "dodger_blue3" </span>│<span style="color: #000080"> #005fd7 </span>│<span style="color: #800080"> rgb(0,95,215) </span>║ + ║ <span style="background-color: #005fff"> </span> │<span style="color: #808000"> 27 </span>│<span style="color: #008000"> "dodger_blue2" </span>│<span style="color: #000080"> #005fff </span>│<span style="color: #800080"> rgb(0,95,255) </span>║ + ║ <span style="background-color: #008700"> </span> │<span style="color: #808000"> 28 </span>│<span style="color: #008000"> "green4" </span>│<span style="color: #000080"> #008700 </span>│<span style="color: #800080"> rgb(0,135,0) </span>║ + ║ <span style="background-color: #00875f"> </span> │<span style="color: #808000"> 29 </span>│<span style="color: #008000"> "spring_green4" </span>│<span style="color: #000080"> #00875f </span>│<span style="color: #800080"> rgb(0,135,95) </span>║ + ║ <span style="background-color: #008787"> </span> │<span style="color: #808000"> 30 </span>│<span style="color: #008000"> "turquoise4" </span>│<span style="color: #000080"> #008787 </span>│<span style="color: #800080"> rgb(0,135,135) </span>║ + ║ <span style="background-color: #0087d7"> </span> │<span style="color: #808000"> 32 </span>│<span style="color: #008000"> "deep_sky_blue3" </span>│<span style="color: #000080"> #0087d7 </span>│<span style="color: #800080"> rgb(0,135,215) </span>║ + ║ <span style="background-color: #0087ff"> </span> │<span style="color: #808000"> 33 </span>│<span style="color: #008000"> "dodger_blue1" </span>│<span style="color: #000080"> #0087ff </span>│<span style="color: #800080"> rgb(0,135,255) </span>║ + ║ <span style="background-color: #00af87"> </span> │<span style="color: #808000"> 36 </span>│<span style="color: #008000"> "dark_cyan" </span>│<span style="color: #000080"> #00af87 </span>│<span style="color: #800080"> rgb(0,175,135) </span>║ + ║ <span style="background-color: #00afaf"> </span> │<span style="color: #808000"> 37 </span>│<span style="color: #008000"> "light_sea_green" </span>│<span style="color: #000080"> #00afaf </span>│<span style="color: #800080"> rgb(0,175,175) </span>║ + ║ <span style="background-color: #00afd7"> </span> │<span style="color: #808000"> 38 </span>│<span style="color: #008000"> "deep_sky_blue2" </span>│<span style="color: #000080"> #00afd7 </span>│<span style="color: #800080"> rgb(0,175,215) </span>║ + ║ <span style="background-color: #00afff"> </span> │<span style="color: #808000"> 39 </span>│<span style="color: #008000"> "deep_sky_blue1" </span>│<span style="color: #000080"> #00afff </span>│<span style="color: #800080"> rgb(0,175,255) </span>║ + ║ <span style="background-color: #00d700"> </span> │<span style="color: #808000"> 40 </span>│<span style="color: #008000"> "green3" </span>│<span style="color: #000080"> #00d700 </span>│<span style="color: #800080"> rgb(0,215,0) </span>║ + ║ <span style="background-color: #00d75f"> </span> │<span style="color: #808000"> 41 </span>│<span style="color: #008000"> "spring_green3" </span>│<span style="color: #000080"> #00d75f </span>│<span style="color: #800080"> rgb(0,215,95) </span>║ + ║ <span style="background-color: #00d7af"> </span> │<span style="color: #808000"> 43 </span>│<span style="color: #008000"> "cyan3" </span>│<span style="color: #000080"> #00d7af </span>│<span style="color: #800080"> rgb(0,215,175) </span>║ + ║ <span style="background-color: #00d7d7"> </span> │<span style="color: #808000"> 44 </span>│<span style="color: #008000"> "dark_turquoise" </span>│<span style="color: #000080"> #00d7d7 </span>│<span style="color: #800080"> rgb(0,215,215) </span>║ + ║ <span style="background-color: #00d7ff"> </span> │<span style="color: #808000"> 45 </span>│<span style="color: #008000"> "turquoise2" </span>│<span style="color: #000080"> #00d7ff </span>│<span style="color: #800080"> rgb(0,215,255) </span>║ + ║ <span style="background-color: #00ff00"> </span> │<span style="color: #808000"> 46 </span>│<span style="color: #008000"> "green1" </span>│<span style="color: #000080"> #00ff00 </span>│<span style="color: #800080"> rgb(0,255,0) </span>║ + ║ <span style="background-color: #00ff5f"> </span> │<span style="color: #808000"> 47 </span>│<span style="color: #008000"> "spring_green2" </span>│<span style="color: #000080"> #00ff5f </span>│<span style="color: #800080"> rgb(0,255,95) </span>║ + ║ <span style="background-color: #00ff87"> </span> │<span style="color: #808000"> 48 </span>│<span style="color: #008000"> "spring_green1" </span>│<span style="color: #000080"> #00ff87 </span>│<span style="color: #800080"> rgb(0,255,135) </span>║ + ║ <span style="background-color: #00ffaf"> </span> │<span style="color: #808000"> 49 </span>│<span style="color: #008000"> "medium_spring_green" </span>│<span style="color: #000080"> #00ffaf </span>│<span style="color: #800080"> rgb(0,255,175) </span>║ + ║ <span style="background-color: #00ffd7"> </span> │<span style="color: #808000"> 50 </span>│<span style="color: #008000"> "cyan2" </span>│<span style="color: #000080"> #00ffd7 </span>│<span style="color: #800080"> rgb(0,255,215) </span>║ + ║ <span style="background-color: #00ffff"> </span> │<span style="color: #808000"> 51 </span>│<span style="color: #008000"> "cyan1" </span>│<span style="color: #000080"> #00ffff </span>│<span style="color: #800080"> rgb(0,255,255) </span>║ + ║ <span style="background-color: #5f00af"> </span> │<span style="color: #808000"> 55 </span>│<span style="color: #008000"> "purple4" </span>│<span style="color: #000080"> #5f00af </span>│<span style="color: #800080"> rgb(95,0,175) </span>║ + ║ <span style="background-color: #5f00d7"> </span> │<span style="color: #808000"> 56 </span>│<span style="color: #008000"> "purple3" </span>│<span style="color: #000080"> #5f00d7 </span>│<span style="color: #800080"> rgb(95,0,215) </span>║ + ║ <span style="background-color: #5f00ff"> </span> │<span style="color: #808000"> 57 </span>│<span style="color: #008000"> "blue_violet" </span>│<span style="color: #000080"> #5f00ff </span>│<span style="color: #800080"> rgb(95,0,255) </span>║ + ║ <span style="background-color: #5f5f5f"> </span> │<span style="color: #808000"> 59 </span>│<span style="color: #008000"> "grey37" </span>│<span style="color: #000080"> #5f5f5f </span>│<span style="color: #800080"> rgb(95,95,95) </span>║ + ║ <span style="background-color: #5f5f87"> </span> │<span style="color: #808000"> 60 </span>│<span style="color: #008000"> "medium_purple4" </span>│<span style="color: #000080"> #5f5f87 </span>│<span style="color: #800080"> rgb(95,95,135) </span>║ + ║ <span style="background-color: #5f5fd7"> </span> │<span style="color: #808000"> 62 </span>│<span style="color: #008000"> "slate_blue3" </span>│<span style="color: #000080"> #5f5fd7 </span>│<span style="color: #800080"> rgb(95,95,215) </span>║ + ║ <span style="background-color: #5f5fff"> </span> │<span style="color: #808000"> 63 </span>│<span style="color: #008000"> "royal_blue1" </span>│<span style="color: #000080"> #5f5fff </span>│<span style="color: #800080"> rgb(95,95,255) </span>║ + ║ <span style="background-color: #5f8700"> </span> │<span style="color: #808000"> 64 </span>│<span style="color: #008000"> "chartreuse4" </span>│<span style="color: #000080"> #5f8700 </span>│<span style="color: #800080"> rgb(95,135,0) </span>║ + ║ <span style="background-color: #5f8787"> </span> │<span style="color: #808000"> 66 </span>│<span style="color: #008000"> "pale_turquoise4" </span>│<span style="color: #000080"> #5f8787 </span>│<span style="color: #800080"> rgb(95,135,135) </span>║ + ║ <span style="background-color: #5f87af"> </span> │<span style="color: #808000"> 67 </span>│<span style="color: #008000"> "steel_blue" </span>│<span style="color: #000080"> #5f87af </span>│<span style="color: #800080"> rgb(95,135,175) </span>║ + ║ <span style="background-color: #5f87d7"> </span> │<span style="color: #808000"> 68 </span>│<span style="color: #008000"> "steel_blue3" </span>│<span style="color: #000080"> #5f87d7 </span>│<span style="color: #800080"> rgb(95,135,215) </span>║ + ║ <span style="background-color: #5f87ff"> </span> │<span style="color: #808000"> 69 </span>│<span style="color: #008000"> "cornflower_blue" </span>│<span style="color: #000080"> #5f87ff </span>│<span style="color: #800080"> rgb(95,135,255) </span>║ + ║ <span style="background-color: #5faf5f"> </span> │<span style="color: #808000"> 71 </span>│<span style="color: #008000"> "dark_sea_green4" </span>│<span style="color: #000080"> #5faf5f </span>│<span style="color: #800080"> rgb(95,175,95) </span>║ + ║ <span style="background-color: #5fafaf"> </span> │<span style="color: #808000"> 73 </span>│<span style="color: #008000"> "cadet_blue" </span>│<span style="color: #000080"> #5fafaf </span>│<span style="color: #800080"> rgb(95,175,175) </span>║ + ║ <span style="background-color: #5fafd7"> </span> │<span style="color: #808000"> 74 </span>│<span style="color: #008000"> "sky_blue3" </span>│<span style="color: #000080"> #5fafd7 </span>│<span style="color: #800080"> rgb(95,175,215) </span>║ + ║ <span style="background-color: #5fd700"> </span> │<span style="color: #808000"> 76 </span>│<span style="color: #008000"> "chartreuse3" </span>│<span style="color: #000080"> #5fd700 </span>│<span style="color: #800080"> rgb(95,215,0) </span>║ + ║ <span style="background-color: #5fd787"> </span> │<span style="color: #808000"> 78 </span>│<span style="color: #008000"> "sea_green3" </span>│<span style="color: #000080"> #5fd787 </span>│<span style="color: #800080"> rgb(95,215,135) </span>║ + ║ <span style="background-color: #5fd7af"> </span> │<span style="color: #808000"> 79 </span>│<span style="color: #008000"> "aquamarine3" </span>│<span style="color: #000080"> #5fd7af </span>│<span style="color: #800080"> rgb(95,215,175) </span>║ + ║ <span style="background-color: #5fd7d7"> </span> │<span style="color: #808000"> 80 </span>│<span style="color: #008000"> "medium_turquoise" </span>│<span style="color: #000080"> #5fd7d7 </span>│<span style="color: #800080"> rgb(95,215,215) </span>║ + ║ <span style="background-color: #5fd7ff"> </span> │<span style="color: #808000"> 81 </span>│<span style="color: #008000"> "steel_blue1" </span>│<span style="color: #000080"> #5fd7ff </span>│<span style="color: #800080"> rgb(95,215,255) </span>║ + ║ <span style="background-color: #5fff5f"> </span> │<span style="color: #808000"> 83 </span>│<span style="color: #008000"> "sea_green2" </span>│<span style="color: #000080"> #5fff5f </span>│<span style="color: #800080"> rgb(95,255,95) </span>║ + ║ <span style="background-color: #5fffaf"> </span> │<span style="color: #808000"> 85 </span>│<span style="color: #008000"> "sea_green1" </span>│<span style="color: #000080"> #5fffaf </span>│<span style="color: #800080"> rgb(95,255,175) </span>║ + ║ <span style="background-color: #5fffff"> </span> │<span style="color: #808000"> 87 </span>│<span style="color: #008000"> "dark_slate_gray2" </span>│<span style="color: #000080"> #5fffff </span>│<span style="color: #800080"> rgb(95,255,255) </span>║ + ║ <span style="background-color: #870000"> </span> │<span style="color: #808000"> 88 </span>│<span style="color: #008000"> "dark_red" </span>│<span style="color: #000080"> #870000 </span>│<span style="color: #800080"> rgb(135,0,0) </span>║ + ║ <span style="background-color: #8700af"> </span> │<span style="color: #808000"> 91 </span>│<span style="color: #008000"> "dark_magenta" </span>│<span style="color: #000080"> #8700af </span>│<span style="color: #800080"> rgb(135,0,175) </span>║ + ║ <span style="background-color: #875f00"> </span> │<span style="color: #808000"> 94 </span>│<span style="color: #008000"> "orange4" </span>│<span style="color: #000080"> #875f00 </span>│<span style="color: #800080"> rgb(135,95,0) </span>║ + ║ <span style="background-color: #875f5f"> </span> │<span style="color: #808000"> 95 </span>│<span style="color: #008000"> "light_pink4" </span>│<span style="color: #000080"> #875f5f </span>│<span style="color: #800080"> rgb(135,95,95) </span>║ + ║ <span style="background-color: #875f87"> </span> │<span style="color: #808000"> 96 </span>│<span style="color: #008000"> "plum4" </span>│<span style="color: #000080"> #875f87 </span>│<span style="color: #800080"> rgb(135,95,135) </span>║ + ║ <span style="background-color: #875fd7"> </span> │<span style="color: #808000"> 98 </span>│<span style="color: #008000"> "medium_purple3" </span>│<span style="color: #000080"> #875fd7 </span>│<span style="color: #800080"> rgb(135,95,215) </span>║ + ║ <span style="background-color: #875fff"> </span> │<span style="color: #808000"> 99 </span>│<span style="color: #008000"> "slate_blue1" </span>│<span style="color: #000080"> #875fff </span>│<span style="color: #800080"> rgb(135,95,255) </span>║ + ║ <span style="background-color: #87875f"> </span> │<span style="color: #808000"> 101 </span>│<span style="color: #008000"> "wheat4" </span>│<span style="color: #000080"> #87875f </span>│<span style="color: #800080"> rgb(135,135,95) </span>║ + ║ <span style="background-color: #878787"> </span> │<span style="color: #808000"> 102 </span>│<span style="color: #008000"> "grey53" </span>│<span style="color: #000080"> #878787 </span>│<span style="color: #800080"> rgb(135,135,135) </span>║ + ║ <span style="background-color: #8787af"> </span> │<span style="color: #808000"> 103 </span>│<span style="color: #008000"> "light_slate_grey" </span>│<span style="color: #000080"> #8787af </span>│<span style="color: #800080"> rgb(135,135,175) </span>║ + ║ <span style="background-color: #8787d7"> </span> │<span style="color: #808000"> 104 </span>│<span style="color: #008000"> "medium_purple" </span>│<span style="color: #000080"> #8787d7 </span>│<span style="color: #800080"> rgb(135,135,215) </span>║ + ║ <span style="background-color: #8787ff"> </span> │<span style="color: #808000"> 105 </span>│<span style="color: #008000"> "light_slate_blue" </span>│<span style="color: #000080"> #8787ff </span>│<span style="color: #800080"> rgb(135,135,255) </span>║ + ║ <span style="background-color: #87af00"> </span> │<span style="color: #808000"> 106 </span>│<span style="color: #008000"> "yellow4" </span>│<span style="color: #000080"> #87af00 </span>│<span style="color: #800080"> rgb(135,175,0) </span>║ + ║ <span style="background-color: #87af87"> </span> │<span style="color: #808000"> 108 </span>│<span style="color: #008000"> "dark_sea_green" </span>│<span style="color: #000080"> #87af87 </span>│<span style="color: #800080"> rgb(135,175,135) </span>║ + ║ <span style="background-color: #87afd7"> </span> │<span style="color: #808000"> 110 </span>│<span style="color: #008000"> "light_sky_blue3" </span>│<span style="color: #000080"> #87afd7 </span>│<span style="color: #800080"> rgb(135,175,215) </span>║ + ║ <span style="background-color: #87afff"> </span> │<span style="color: #808000"> 111 </span>│<span style="color: #008000"> "sky_blue2" </span>│<span style="color: #000080"> #87afff </span>│<span style="color: #800080"> rgb(135,175,255) </span>║ + ║ <span style="background-color: #87d700"> </span> │<span style="color: #808000"> 112 </span>│<span style="color: #008000"> "chartreuse2" </span>│<span style="color: #000080"> #87d700 </span>│<span style="color: #800080"> rgb(135,215,0) </span>║ + ║ <span style="background-color: #87d787"> </span> │<span style="color: #808000"> 114 </span>│<span style="color: #008000"> "pale_green3" </span>│<span style="color: #000080"> #87d787 </span>│<span style="color: #800080"> rgb(135,215,135) </span>║ + ║ <span style="background-color: #87d7d7"> </span> │<span style="color: #808000"> 116 </span>│<span style="color: #008000"> "dark_slate_gray3" </span>│<span style="color: #000080"> #87d7d7 </span>│<span style="color: #800080"> rgb(135,215,215) </span>║ + ║ <span style="background-color: #87d7ff"> </span> │<span style="color: #808000"> 117 </span>│<span style="color: #008000"> "sky_blue1" </span>│<span style="color: #000080"> #87d7ff </span>│<span style="color: #800080"> rgb(135,215,255) </span>║ + ║ <span style="background-color: #87ff00"> </span> │<span style="color: #808000"> 118 </span>│<span style="color: #008000"> "chartreuse1" </span>│<span style="color: #000080"> #87ff00 </span>│<span style="color: #800080"> rgb(135,255,0) </span>║ + ║ <span style="background-color: #87ff87"> </span> │<span style="color: #808000"> 120 </span>│<span style="color: #008000"> "light_green" </span>│<span style="color: #000080"> #87ff87 </span>│<span style="color: #800080"> rgb(135,255,135) </span>║ + ║ <span style="background-color: #87ffd7"> </span> │<span style="color: #808000"> 122 </span>│<span style="color: #008000"> "aquamarine1" </span>│<span style="color: #000080"> #87ffd7 </span>│<span style="color: #800080"> rgb(135,255,215) </span>║ + ║ <span style="background-color: #87ffff"> </span> │<span style="color: #808000"> 123 </span>│<span style="color: #008000"> "dark_slate_gray1" </span>│<span style="color: #000080"> #87ffff </span>│<span style="color: #800080"> rgb(135,255,255) </span>║ + ║ <span style="background-color: #af005f"> </span> │<span style="color: #808000"> 125 </span>│<span style="color: #008000"> "deep_pink4" </span>│<span style="color: #000080"> #af005f </span>│<span style="color: #800080"> rgb(175,0,95) </span>║ + ║ <span style="background-color: #af0087"> </span> │<span style="color: #808000"> 126 </span>│<span style="color: #008000"> "medium_violet_red" </span>│<span style="color: #000080"> #af0087 </span>│<span style="color: #800080"> rgb(175,0,135) </span>║ + ║ <span style="background-color: #af00d7"> </span> │<span style="color: #808000"> 128 </span>│<span style="color: #008000"> "dark_violet" </span>│<span style="color: #000080"> #af00d7 </span>│<span style="color: #800080"> rgb(175,0,215) </span>║ + ║ <span style="background-color: #af00ff"> </span> │<span style="color: #808000"> 129 </span>│<span style="color: #008000"> "purple" </span>│<span style="color: #000080"> #af00ff </span>│<span style="color: #800080"> rgb(175,0,255) </span>║ + ║ <span style="background-color: #af5faf"> </span> │<span style="color: #808000"> 133 </span>│<span style="color: #008000"> "medium_orchid3" </span>│<span style="color: #000080"> #af5faf </span>│<span style="color: #800080"> rgb(175,95,175) </span>║ + ║ <span style="background-color: #af5fd7"> </span> │<span style="color: #808000"> 134 </span>│<span style="color: #008000"> "medium_orchid" </span>│<span style="color: #000080"> #af5fd7 </span>│<span style="color: #800080"> rgb(175,95,215) </span>║ + ║ <span style="background-color: #af8700"> </span> │<span style="color: #808000"> 136 </span>│<span style="color: #008000"> "dark_goldenrod" </span>│<span style="color: #000080"> #af8700 </span>│<span style="color: #800080"> rgb(175,135,0) </span>║ + ║ <span style="background-color: #af8787"> </span> │<span style="color: #808000"> 138 </span>│<span style="color: #008000"> "rosy_brown" </span>│<span style="color: #000080"> #af8787 </span>│<span style="color: #800080"> rgb(175,135,135) </span>║ + ║ <span style="background-color: #af87af"> </span> │<span style="color: #808000"> 139 </span>│<span style="color: #008000"> "grey63" </span>│<span style="color: #000080"> #af87af </span>│<span style="color: #800080"> rgb(175,135,175) </span>║ + ║ <span style="background-color: #af87d7"> </span> │<span style="color: #808000"> 140 </span>│<span style="color: #008000"> "medium_purple2" </span>│<span style="color: #000080"> #af87d7 </span>│<span style="color: #800080"> rgb(175,135,215) </span>║ + ║ <span style="background-color: #af87ff"> </span> │<span style="color: #808000"> 141 </span>│<span style="color: #008000"> "medium_purple1" </span>│<span style="color: #000080"> #af87ff </span>│<span style="color: #800080"> rgb(175,135,255) </span>║ + ║ <span style="background-color: #afaf5f"> </span> │<span style="color: #808000"> 143 </span>│<span style="color: #008000"> "dark_khaki" </span>│<span style="color: #000080"> #afaf5f </span>│<span style="color: #800080"> rgb(175,175,95) </span>║ + ║ <span style="background-color: #afaf87"> </span> │<span style="color: #808000"> 144 </span>│<span style="color: #008000"> "navajo_white3" </span>│<span style="color: #000080"> #afaf87 </span>│<span style="color: #800080"> rgb(175,175,135) </span>║ + ║ <span style="background-color: #afafaf"> </span> │<span style="color: #808000"> 145 </span>│<span style="color: #008000"> "grey69" </span>│<span style="color: #000080"> #afafaf </span>│<span style="color: #800080"> rgb(175,175,175) </span>║ + ║ <span style="background-color: #afafd7"> </span> │<span style="color: #808000"> 146 </span>│<span style="color: #008000"> "light_steel_blue3" </span>│<span style="color: #000080"> #afafd7 </span>│<span style="color: #800080"> rgb(175,175,215) </span>║ + ║ <span style="background-color: #afafff"> </span> │<span style="color: #808000"> 147 </span>│<span style="color: #008000"> "light_steel_blue" </span>│<span style="color: #000080"> #afafff </span>│<span style="color: #800080"> rgb(175,175,255) </span>║ + ║ <span style="background-color: #afd75f"> </span> │<span style="color: #808000"> 149 </span>│<span style="color: #008000"> "dark_olive_green3" </span>│<span style="color: #000080"> #afd75f </span>│<span style="color: #800080"> rgb(175,215,95) </span>║ + ║ <span style="background-color: #afd787"> </span> │<span style="color: #808000"> 150 </span>│<span style="color: #008000"> "dark_sea_green3" </span>│<span style="color: #000080"> #afd787 </span>│<span style="color: #800080"> rgb(175,215,135) </span>║ + ║ <span style="background-color: #afd7d7"> </span> │<span style="color: #808000"> 152 </span>│<span style="color: #008000"> "light_cyan3" </span>│<span style="color: #000080"> #afd7d7 </span>│<span style="color: #800080"> rgb(175,215,215) </span>║ + ║ <span style="background-color: #afd7ff"> </span> │<span style="color: #808000"> 153 </span>│<span style="color: #008000"> "light_sky_blue1" </span>│<span style="color: #000080"> #afd7ff </span>│<span style="color: #800080"> rgb(175,215,255) </span>║ + ║ <span style="background-color: #afff00"> </span> │<span style="color: #808000"> 154 </span>│<span style="color: #008000"> "green_yellow" </span>│<span style="color: #000080"> #afff00 </span>│<span style="color: #800080"> rgb(175,255,0) </span>║ + ║ <span style="background-color: #afff5f"> </span> │<span style="color: #808000"> 155 </span>│<span style="color: #008000"> "dark_olive_green2" </span>│<span style="color: #000080"> #afff5f </span>│<span style="color: #800080"> rgb(175,255,95) </span>║ + ║ <span style="background-color: #afff87"> </span> │<span style="color: #808000"> 156 </span>│<span style="color: #008000"> "pale_green1" </span>│<span style="color: #000080"> #afff87 </span>│<span style="color: #800080"> rgb(175,255,135) </span>║ + ║ <span style="background-color: #afffaf"> </span> │<span style="color: #808000"> 157 </span>│<span style="color: #008000"> "dark_sea_green2" </span>│<span style="color: #000080"> #afffaf </span>│<span style="color: #800080"> rgb(175,255,175) </span>║ + ║ <span style="background-color: #afffff"> </span> │<span style="color: #808000"> 159 </span>│<span style="color: #008000"> "pale_turquoise1" </span>│<span style="color: #000080"> #afffff </span>│<span style="color: #800080"> rgb(175,255,255) </span>║ + ║ <span style="background-color: #d70000"> </span> │<span style="color: #808000"> 160 </span>│<span style="color: #008000"> "red3" </span>│<span style="color: #000080"> #d70000 </span>│<span style="color: #800080"> rgb(215,0,0) </span>║ + ║ <span style="background-color: #d70087"> </span> │<span style="color: #808000"> 162 </span>│<span style="color: #008000"> "deep_pink3" </span>│<span style="color: #000080"> #d70087 </span>│<span style="color: #800080"> rgb(215,0,135) </span>║ + ║ <span style="background-color: #d700d7"> </span> │<span style="color: #808000"> 164 </span>│<span style="color: #008000"> "magenta3" </span>│<span style="color: #000080"> #d700d7 </span>│<span style="color: #800080"> rgb(215,0,215) </span>║ + ║ <span style="background-color: #d75f00"> </span> │<span style="color: #808000"> 166 </span>│<span style="color: #008000"> "dark_orange3" </span>│<span style="color: #000080"> #d75f00 </span>│<span style="color: #800080"> rgb(215,95,0) </span>║ + ║ <span style="background-color: #d75f5f"> </span> │<span style="color: #808000"> 167 </span>│<span style="color: #008000"> "indian_red" </span>│<span style="color: #000080"> #d75f5f </span>│<span style="color: #800080"> rgb(215,95,95) </span>║ + ║ <span style="background-color: #d75f87"> </span> │<span style="color: #808000"> 168 </span>│<span style="color: #008000"> "hot_pink3" </span>│<span style="color: #000080"> #d75f87 </span>│<span style="color: #800080"> rgb(215,95,135) </span>║ + ║ <span style="background-color: #d75faf"> </span> │<span style="color: #808000"> 169 </span>│<span style="color: #008000"> "hot_pink2" </span>│<span style="color: #000080"> #d75faf </span>│<span style="color: #800080"> rgb(215,95,175) </span>║ + ║ <span style="background-color: #d75fd7"> </span> │<span style="color: #808000"> 170 </span>│<span style="color: #008000"> "orchid" </span>│<span style="color: #000080"> #d75fd7 </span>│<span style="color: #800080"> rgb(215,95,215) </span>║ + ║ <span style="background-color: #d78700"> </span> │<span style="color: #808000"> 172 </span>│<span style="color: #008000"> "orange3" </span>│<span style="color: #000080"> #d78700 </span>│<span style="color: #800080"> rgb(215,135,0) </span>║ + ║ <span style="background-color: #d7875f"> </span> │<span style="color: #808000"> 173 </span>│<span style="color: #008000"> "light_salmon3" </span>│<span style="color: #000080"> #d7875f </span>│<span style="color: #800080"> rgb(215,135,95) </span>║ + ║ <span style="background-color: #d78787"> </span> │<span style="color: #808000"> 174 </span>│<span style="color: #008000"> "light_pink3" </span>│<span style="color: #000080"> #d78787 </span>│<span style="color: #800080"> rgb(215,135,135) </span>║ + ║ <span style="background-color: #d787af"> </span> │<span style="color: #808000"> 175 </span>│<span style="color: #008000"> "pink3" </span>│<span style="color: #000080"> #d787af </span>│<span style="color: #800080"> rgb(215,135,175) </span>║ + ║ <span style="background-color: #d787d7"> </span> │<span style="color: #808000"> 176 </span>│<span style="color: #008000"> "plum3" </span>│<span style="color: #000080"> #d787d7 </span>│<span style="color: #800080"> rgb(215,135,215) </span>║ + ║ <span style="background-color: #d787ff"> </span> │<span style="color: #808000"> 177 </span>│<span style="color: #008000"> "violet" </span>│<span style="color: #000080"> #d787ff </span>│<span style="color: #800080"> rgb(215,135,255) </span>║ + ║ <span style="background-color: #d7af00"> </span> │<span style="color: #808000"> 178 </span>│<span style="color: #008000"> "gold3" </span>│<span style="color: #000080"> #d7af00 </span>│<span style="color: #800080"> rgb(215,175,0) </span>║ + ║ <span style="background-color: #d7af5f"> </span> │<span style="color: #808000"> 179 </span>│<span style="color: #008000"> "light_goldenrod3" </span>│<span style="color: #000080"> #d7af5f </span>│<span style="color: #800080"> rgb(215,175,95) </span>║ + ║ <span style="background-color: #d7af87"> </span> │<span style="color: #808000"> 180 </span>│<span style="color: #008000"> "tan" </span>│<span style="color: #000080"> #d7af87 </span>│<span style="color: #800080"> rgb(215,175,135) </span>║ + ║ <span style="background-color: #d7afaf"> </span> │<span style="color: #808000"> 181 </span>│<span style="color: #008000"> "misty_rose3" </span>│<span style="color: #000080"> #d7afaf </span>│<span style="color: #800080"> rgb(215,175,175) </span>║ + ║ <span style="background-color: #d7afd7"> </span> │<span style="color: #808000"> 182 </span>│<span style="color: #008000"> "thistle3" </span>│<span style="color: #000080"> #d7afd7 </span>│<span style="color: #800080"> rgb(215,175,215) </span>║ + ║ <span style="background-color: #d7afff"> </span> │<span style="color: #808000"> 183 </span>│<span style="color: #008000"> "plum2" </span>│<span style="color: #000080"> #d7afff </span>│<span style="color: #800080"> rgb(215,175,255) </span>║ + ║ <span style="background-color: #d7d700"> </span> │<span style="color: #808000"> 184 </span>│<span style="color: #008000"> "yellow3" </span>│<span style="color: #000080"> #d7d700 </span>│<span style="color: #800080"> rgb(215,215,0) </span>║ + ║ <span style="background-color: #d7d75f"> </span> │<span style="color: #808000"> 185 </span>│<span style="color: #008000"> "khaki3" </span>│<span style="color: #000080"> #d7d75f </span>│<span style="color: #800080"> rgb(215,215,95) </span>║ + ║ <span style="background-color: #d7d7af"> </span> │<span style="color: #808000"> 187 </span>│<span style="color: #008000"> "light_yellow3" </span>│<span style="color: #000080"> #d7d7af </span>│<span style="color: #800080"> rgb(215,215,175) </span>║ + ║ <span style="background-color: #d7d7d7"> </span> │<span style="color: #808000"> 188 </span>│<span style="color: #008000"> "grey84" </span>│<span style="color: #000080"> #d7d7d7 </span>│<span style="color: #800080"> rgb(215,215,215) </span>║ + ║ <span style="background-color: #d7d7ff"> </span> │<span style="color: #808000"> 189 </span>│<span style="color: #008000"> "light_steel_blue1" </span>│<span style="color: #000080"> #d7d7ff </span>│<span style="color: #800080"> rgb(215,215,255) </span>║ + ║ <span style="background-color: #d7ff00"> </span> │<span style="color: #808000"> 190 </span>│<span style="color: #008000"> "yellow2" </span>│<span style="color: #000080"> #d7ff00 </span>│<span style="color: #800080"> rgb(215,255,0) </span>║ + ║ <span style="background-color: #d7ff87"> </span> │<span style="color: #808000"> 192 </span>│<span style="color: #008000"> "dark_olive_green1" </span>│<span style="color: #000080"> #d7ff87 </span>│<span style="color: #800080"> rgb(215,255,135) </span>║ + ║ <span style="background-color: #d7ffaf"> </span> │<span style="color: #808000"> 193 </span>│<span style="color: #008000"> "dark_sea_green1" </span>│<span style="color: #000080"> #d7ffaf </span>│<span style="color: #800080"> rgb(215,255,175) </span>║ + ║ <span style="background-color: #d7ffd7"> </span> │<span style="color: #808000"> 194 </span>│<span style="color: #008000"> "honeydew2" </span>│<span style="color: #000080"> #d7ffd7 </span>│<span style="color: #800080"> rgb(215,255,215) </span>║ + ║ <span style="background-color: #d7ffff"> </span> │<span style="color: #808000"> 195 </span>│<span style="color: #008000"> "light_cyan1" </span>│<span style="color: #000080"> #d7ffff </span>│<span style="color: #800080"> rgb(215,255,255) </span>║ + ║ <span style="background-color: #ff0000"> </span> │<span style="color: #808000"> 196 </span>│<span style="color: #008000"> "red1" </span>│<span style="color: #000080"> #ff0000 </span>│<span style="color: #800080"> rgb(255,0,0) </span>║ + ║ <span style="background-color: #ff005f"> </span> │<span style="color: #808000"> 197 </span>│<span style="color: #008000"> "deep_pink2" </span>│<span style="color: #000080"> #ff005f </span>│<span style="color: #800080"> rgb(255,0,95) </span>║ + ║ <span style="background-color: #ff00af"> </span> │<span style="color: #808000"> 199 </span>│<span style="color: #008000"> "deep_pink1" </span>│<span style="color: #000080"> #ff00af </span>│<span style="color: #800080"> rgb(255,0,175) </span>║ + ║ <span style="background-color: #ff00d7"> </span> │<span style="color: #808000"> 200 </span>│<span style="color: #008000"> "magenta2" </span>│<span style="color: #000080"> #ff00d7 </span>│<span style="color: #800080"> rgb(255,0,215) </span>║ + ║ <span style="background-color: #ff00ff"> </span> │<span style="color: #808000"> 201 </span>│<span style="color: #008000"> "magenta1" </span>│<span style="color: #000080"> #ff00ff </span>│<span style="color: #800080"> rgb(255,0,255) </span>║ + ║ <span style="background-color: #ff5f00"> </span> │<span style="color: #808000"> 202 </span>│<span style="color: #008000"> "orange_red1" </span>│<span style="color: #000080"> #ff5f00 </span>│<span style="color: #800080"> rgb(255,95,0) </span>║ + ║ <span style="background-color: #ff5f87"> </span> │<span style="color: #808000"> 204 </span>│<span style="color: #008000"> "indian_red1" </span>│<span style="color: #000080"> #ff5f87 </span>│<span style="color: #800080"> rgb(255,95,135) </span>║ + ║ <span style="background-color: #ff5fd7"> </span> │<span style="color: #808000"> 206 </span>│<span style="color: #008000"> "hot_pink" </span>│<span style="color: #000080"> #ff5fd7 </span>│<span style="color: #800080"> rgb(255,95,215) </span>║ + ║ <span style="background-color: #ff5fff"> </span> │<span style="color: #808000"> 207 </span>│<span style="color: #008000"> "medium_orchid1" </span>│<span style="color: #000080"> #ff5fff </span>│<span style="color: #800080"> rgb(255,95,255) </span>║ + ║ <span style="background-color: #ff8700"> </span> │<span style="color: #808000"> 208 </span>│<span style="color: #008000"> "dark_orange" </span>│<span style="color: #000080"> #ff8700 </span>│<span style="color: #800080"> rgb(255,135,0) </span>║ + ║ <span style="background-color: #ff875f"> </span> │<span style="color: #808000"> 209 </span>│<span style="color: #008000"> "salmon1" </span>│<span style="color: #000080"> #ff875f </span>│<span style="color: #800080"> rgb(255,135,95) </span>║ + ║ <span style="background-color: #ff8787"> </span> │<span style="color: #808000"> 210 </span>│<span style="color: #008000"> "light_coral" </span>│<span style="color: #000080"> #ff8787 </span>│<span style="color: #800080"> rgb(255,135,135) </span>║ + ║ <span style="background-color: #ff87af"> </span> │<span style="color: #808000"> 211 </span>│<span style="color: #008000"> "pale_violet_red1" </span>│<span style="color: #000080"> #ff87af </span>│<span style="color: #800080"> rgb(255,135,175) </span>║ + ║ <span style="background-color: #ff87d7"> </span> │<span style="color: #808000"> 212 </span>│<span style="color: #008000"> "orchid2" </span>│<span style="color: #000080"> #ff87d7 </span>│<span style="color: #800080"> rgb(255,135,215) </span>║ + ║ <span style="background-color: #ff87ff"> </span> │<span style="color: #808000"> 213 </span>│<span style="color: #008000"> "orchid1" </span>│<span style="color: #000080"> #ff87ff </span>│<span style="color: #800080"> rgb(255,135,255) </span>║ + ║ <span style="background-color: #ffaf00"> </span> │<span style="color: #808000"> 214 </span>│<span style="color: #008000"> "orange1" </span>│<span style="color: #000080"> #ffaf00 </span>│<span style="color: #800080"> rgb(255,175,0) </span>║ + ║ <span style="background-color: #ffaf5f"> </span> │<span style="color: #808000"> 215 </span>│<span style="color: #008000"> "sandy_brown" </span>│<span style="color: #000080"> #ffaf5f </span>│<span style="color: #800080"> rgb(255,175,95) </span>║ + ║ <span style="background-color: #ffaf87"> </span> │<span style="color: #808000"> 216 </span>│<span style="color: #008000"> "light_salmon1" </span>│<span style="color: #000080"> #ffaf87 </span>│<span style="color: #800080"> rgb(255,175,135) </span>║ + ║ <span style="background-color: #ffafaf"> </span> │<span style="color: #808000"> 217 </span>│<span style="color: #008000"> "light_pink1" </span>│<span style="color: #000080"> #ffafaf </span>│<span style="color: #800080"> rgb(255,175,175) </span>║ + ║ <span style="background-color: #ffafd7"> </span> │<span style="color: #808000"> 218 </span>│<span style="color: #008000"> "pink1" </span>│<span style="color: #000080"> #ffafd7 </span>│<span style="color: #800080"> rgb(255,175,215) </span>║ + ║ <span style="background-color: #ffafff"> </span> │<span style="color: #808000"> 219 </span>│<span style="color: #008000"> "plum1" </span>│<span style="color: #000080"> #ffafff </span>│<span style="color: #800080"> rgb(255,175,255) </span>║ + ║ <span style="background-color: #ffd700"> </span> │<span style="color: #808000"> 220 </span>│<span style="color: #008000"> "gold1" </span>│<span style="color: #000080"> #ffd700 </span>│<span style="color: #800080"> rgb(255,215,0) </span>║ + ║ <span style="background-color: #ffd787"> </span> │<span style="color: #808000"> 222 </span>│<span style="color: #008000"> "light_goldenrod2" </span>│<span style="color: #000080"> #ffd787 </span>│<span style="color: #800080"> rgb(255,215,135) </span>║ + ║ <span style="background-color: #ffd7af"> </span> │<span style="color: #808000"> 223 </span>│<span style="color: #008000"> "navajo_white1" </span>│<span style="color: #000080"> #ffd7af </span>│<span style="color: #800080"> rgb(255,215,175) </span>║ + ║ <span style="background-color: #ffd7d7"> </span> │<span style="color: #808000"> 224 </span>│<span style="color: #008000"> "misty_rose1" </span>│<span style="color: #000080"> #ffd7d7 </span>│<span style="color: #800080"> rgb(255,215,215) </span>║ + ║ <span style="background-color: #ffd7ff"> </span> │<span style="color: #808000"> 225 </span>│<span style="color: #008000"> "thistle1" </span>│<span style="color: #000080"> #ffd7ff </span>│<span style="color: #800080"> rgb(255,215,255) </span>║ + ║ <span style="background-color: #ffff00"> </span> │<span style="color: #808000"> 226 </span>│<span style="color: #008000"> "yellow1" </span>│<span style="color: #000080"> #ffff00 </span>│<span style="color: #800080"> rgb(255,255,0) </span>║ + ║ <span style="background-color: #ffff5f"> </span> │<span style="color: #808000"> 227 </span>│<span style="color: #008000"> "light_goldenrod1" </span>│<span style="color: #000080"> #ffff5f </span>│<span style="color: #800080"> rgb(255,255,95) </span>║ + ║ <span style="background-color: #ffff87"> </span> │<span style="color: #808000"> 228 </span>│<span style="color: #008000"> "khaki1" </span>│<span style="color: #000080"> #ffff87 </span>│<span style="color: #800080"> rgb(255,255,135) </span>║ + ║ <span style="background-color: #ffffaf"> </span> │<span style="color: #808000"> 229 </span>│<span style="color: #008000"> "wheat1" </span>│<span style="color: #000080"> #ffffaf </span>│<span style="color: #800080"> rgb(255,255,175) </span>║ + ║ <span style="background-color: #ffffd7"> </span> │<span style="color: #808000"> 230 </span>│<span style="color: #008000"> "cornsilk1" </span>│<span style="color: #000080"> #ffffd7 </span>│<span style="color: #800080"> rgb(255,255,215) </span>║ + ║ <span style="background-color: #ffffff"> </span> │<span style="color: #808000"> 231 </span>│<span style="color: #008000"> "grey100" </span>│<span style="color: #000080"> #ffffff </span>│<span style="color: #800080"> rgb(255,255,255) </span>║ + ║ <span style="background-color: #080808"> </span> │<span style="color: #808000"> 232 </span>│<span style="color: #008000"> "grey3" </span>│<span style="color: #000080"> #080808 </span>│<span style="color: #800080"> rgb(8,8,8) </span>║ + ║ <span style="background-color: #121212"> </span> │<span style="color: #808000"> 233 </span>│<span style="color: #008000"> "grey7" </span>│<span style="color: #000080"> #121212 </span>│<span style="color: #800080"> rgb(18,18,18) </span>║ + ║ <span style="background-color: #1c1c1c"> </span> │<span style="color: #808000"> 234 </span>│<span style="color: #008000"> "grey11" </span>│<span style="color: #000080"> #1c1c1c </span>│<span style="color: #800080"> rgb(28,28,28) </span>║ + ║ <span style="background-color: #262626"> </span> │<span style="color: #808000"> 235 </span>│<span style="color: #008000"> "grey15" </span>│<span style="color: #000080"> #262626 </span>│<span style="color: #800080"> rgb(38,38,38) </span>║ + ║ <span style="background-color: #303030"> </span> │<span style="color: #808000"> 236 </span>│<span style="color: #008000"> "grey19" </span>│<span style="color: #000080"> #303030 </span>│<span style="color: #800080"> rgb(48,48,48) </span>║ + ║ <span style="background-color: #3a3a3a"> </span> │<span style="color: #808000"> 237 </span>│<span style="color: #008000"> "grey23" </span>│<span style="color: #000080"> #3a3a3a </span>│<span style="color: #800080"> rgb(58,58,58) </span>║ + ║ <span style="background-color: #444444"> </span> │<span style="color: #808000"> 238 </span>│<span style="color: #008000"> "grey27" </span>│<span style="color: #000080"> #444444 </span>│<span style="color: #800080"> rgb(68,68,68) </span>║ + ║ <span style="background-color: #4e4e4e"> </span> │<span style="color: #808000"> 239 </span>│<span style="color: #008000"> "grey30" </span>│<span style="color: #000080"> #4e4e4e </span>│<span style="color: #800080"> rgb(78,78,78) </span>║ + ║ <span style="background-color: #585858"> </span> │<span style="color: #808000"> 240 </span>│<span style="color: #008000"> "grey35" </span>│<span style="color: #000080"> #585858 </span>│<span style="color: #800080"> rgb(88,88,88) </span>║ + ║ <span style="background-color: #626262"> </span> │<span style="color: #808000"> 241 </span>│<span style="color: #008000"> "grey39" </span>│<span style="color: #000080"> #626262 </span>│<span style="color: #800080"> rgb(98,98,98) </span>║ + ║ <span style="background-color: #6c6c6c"> </span> │<span style="color: #808000"> 242 </span>│<span style="color: #008000"> "grey42" </span>│<span style="color: #000080"> #6c6c6c </span>│<span style="color: #800080"> rgb(108,108,108) </span>║ + ║ <span style="background-color: #767676"> </span> │<span style="color: #808000"> 243 </span>│<span style="color: #008000"> "grey46" </span>│<span style="color: #000080"> #767676 </span>│<span style="color: #800080"> rgb(118,118,118) </span>║ + ║ <span style="background-color: #808080"> </span> │<span style="color: #808000"> 244 </span>│<span style="color: #008000"> "grey50" </span>│<span style="color: #000080"> #808080 </span>│<span style="color: #800080"> rgb(128,128,128) </span>║ + ║ <span style="background-color: #8a8a8a"> </span> │<span style="color: #808000"> 245 </span>│<span style="color: #008000"> "grey54" </span>│<span style="color: #000080"> #8a8a8a </span>│<span style="color: #800080"> rgb(138,138,138) </span>║ + ║ <span style="background-color: #949494"> </span> │<span style="color: #808000"> 246 </span>│<span style="color: #008000"> "grey58" </span>│<span style="color: #000080"> #949494 </span>│<span style="color: #800080"> rgb(148,148,148) </span>║ + ║ <span style="background-color: #9e9e9e"> </span> │<span style="color: #808000"> 247 </span>│<span style="color: #008000"> "grey62" </span>│<span style="color: #000080"> #9e9e9e </span>│<span style="color: #800080"> rgb(158,158,158) </span>║ + ║ <span style="background-color: #a8a8a8"> </span> │<span style="color: #808000"> 248 </span>│<span style="color: #008000"> "grey66" </span>│<span style="color: #000080"> #a8a8a8 </span>│<span style="color: #800080"> rgb(168,168,168) </span>║ + ║ <span style="background-color: #b2b2b2"> </span> │<span style="color: #808000"> 249 </span>│<span style="color: #008000"> "grey70" </span>│<span style="color: #000080"> #b2b2b2 </span>│<span style="color: #800080"> rgb(178,178,178) </span>║ + ║ <span style="background-color: #bcbcbc"> </span> │<span style="color: #808000"> 250 </span>│<span style="color: #008000"> "grey74" </span>│<span style="color: #000080"> #bcbcbc </span>│<span style="color: #800080"> rgb(188,188,188) </span>║ + ║ <span style="background-color: #c6c6c6"> </span> │<span style="color: #808000"> 251 </span>│<span style="color: #008000"> "grey78" </span>│<span style="color: #000080"> #c6c6c6 </span>│<span style="color: #800080"> rgb(198,198,198) </span>║ + ║ <span style="background-color: #d0d0d0"> </span> │<span style="color: #808000"> 252 </span>│<span style="color: #008000"> "grey82" </span>│<span style="color: #000080"> #d0d0d0 </span>│<span style="color: #800080"> rgb(208,208,208) </span>║ + ║ <span style="background-color: #dadada"> </span> │<span style="color: #808000"> 253 </span>│<span style="color: #008000"> "grey85" </span>│<span style="color: #000080"> #dadada </span>│<span style="color: #800080"> rgb(218,218,218) </span>║ + ║ <span style="background-color: #e4e4e4"> </span> │<span style="color: #808000"> 254 </span>│<span style="color: #008000"> "grey89" </span>│<span style="color: #000080"> #e4e4e4 </span>│<span style="color: #800080"> rgb(228,228,228) </span>║ + ║ <span style="background-color: #eeeeee"> </span> │<span style="color: #808000"> 255 </span>│<span style="color: #008000"> "grey93" </span>│<span style="color: #000080"> #eeeeee </span>│<span style="color: #800080"> rgb(238,238,238) </span>║ + ╚════════════╧════════╧═══════════════════════╧═════════╧══════════════════╝ + </pre> diff --git a/docs/source/columns.rst b/docs/source/columns.rst new file mode 100644 index 0000000..41a9bc6 --- /dev/null +++ b/docs/source/columns.rst @@ -0,0 +1,23 @@ +Columns +======= + +Rich can render text or other Rich renderables in neat columns with the :class:`~rich.columns.Columns` class. To use, construct a Columns instance with an iterable of renderables and print it to the Console. + +The following example is a very basic clone of the ``ls`` command in OSX / Linux to list directory contents:: + + import os + import sys + + from rich import print + from rich.columns import Columns + + if len(sys.argv) < 2: + print("Usage: python columns.py DIRECTORY") + else: + directory = os.listdir(sys.argv[1]) + columns = Columns(directory, equal=True, expand=True) + print(columns) + + +See `columns.py <https://github.com/willmcgugan/rich/blob/master/examples/columns.py>`_ for an example which outputs columns containing more than just text. + diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..928b4d9 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,70 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + + +import pkg_resources + +import sphinx_rtd_theme + +html_theme = "sphinx_rtd_theme" + +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +project = "Rich" +copyright = "2020, Will McGugan" +author = "Will McGugan" + +# The full version, including alpha/beta/rc tags +release = pkg_resources.get_distribution("rich").version + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", + "sphinx.ext.autosectionlabel", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +# html_theme = "alabaster" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +intersphinx_mapping = {"python": ("http://docs.python.org/3", None)} diff --git a/docs/source/console.rst b/docs/source/console.rst new file mode 100644 index 0000000..ee4bd38 --- /dev/null +++ b/docs/source/console.rst @@ -0,0 +1,373 @@ +Console API +=========== + +For complete control over terminal formatting, Rich offers a :class:`~rich.console.Console` class. Most applications will require a single Console instance, so you may want to create one at the module level or as an attribute of your top-level object. For example, you could add a file called "console.py" to your project:: + + from rich.console import Console + console = Console() + +Then you can import the console from anywhere in your project like this:: + + from my_project.console import console + +The console object handles the mechanics of generating ANSI escape sequences for color and style. It will auto-detect the capabilities of the terminal and convert colors if necessary. + + +Attributes +---------- + +The console will auto-detect a number of properties required when rendering. + +* :obj:`~rich.console.Console.size` is the current dimensions of the terminal (which may change if you resize the window). +* :obj:`~rich.console.Console.encoding` is the default encoding (typically "utf-8"). +* :obj:`~rich.console.Console.is_terminal` is a boolean that indicates if the Console instance is writing to a terminal or not. +* :obj:`~rich.console.Console.color_system` is a string containing the Console color system (see below). + + +Color systems +------------- + +There are several "standards" for writing color to the terminal which are not all universally supported. Rich will auto-detect the appropriate color system, or you can set it manually by supplying a value for ``color_system`` to the :class:`~rich.console.Console` constructor. + +You can set ``color_system`` to one of the following values: + +* ``None`` Disables color entirely. +* ``"auto"`` Will auto-detect the color system. +* ``"standard"`` Can display 8 colors, with normal and bright variations, for 16 colors in total. +* ``"256"`` Can display the 16 colors from "standard" plus a fixed palette of 240 colors. +* ``"truecolor"`` Can display 16.7 million colors, which is likely all the colors your monitor can display. +* ``"windows"`` Can display 8 colors in legacy Windows terminal. New Windows terminal can display "truecolor". + +.. warning:: + Be careful when setting a color system, if you set a higher color system than your terminal supports, your text may be unreadable. + + +Printing +-------- + +To write rich content to the terminal use the :meth:`~rich.console.Console.print` method. Rich will convert any object to a string via its (``__str__``) method and perform some simple syntax highlighting. It will also do pretty printing of any containers, such as dicts and lists. If you print a string it will render :ref:`console_markup`. Here are some examples:: + + console.print([1, 2, 3]) + console.print("[blue underline]Looks like a link") + console.print(locals()) + console.print("FOO", style="white on blue") + +You can also use :meth:`~rich.console.Console.print` to render objects that support the :ref:`protocol`, which includes Rich's built in objects such as :class:`~rich.text.Text`, :class:`~rich.table.Table`, and :class:`~rich.syntax.Syntax` -- or other custom objects. + + +Logging +------- + +The :meth:`~rich.console.Console.log` methods offers the same capabilities as print, but adds some features useful for debugging a running application. Logging writes the current time in a column to the left, and the file and line where the method was called to a column on the right. Here's an example:: + + >>> console.log("Hello, World!") + +.. raw:: html + + <pre style="font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"><span style="color: #7fbfbf">[16:32:08] </span>Hello, World! <span style="color: #7f7f7f"><stdin>:1</span> + </pre> + +To help with debugging, the log() method has a ``log_locals`` parameter. If you set this to ``True``, Rich will display a table of local variables where the method was called. + +Low level output +---------------- + +In additional to :meth:`~rich.console.Console.print` and :meth:`~rich.console.Console.log`, Rich has a :meth:`~rich.console.Console.out` method which provides a lower-level way of writing to the terminal. The out() method converts all the positional arguments to strings and won't pretty print, word wrap, or apply markup to the output, but can apply a basic style and will optionally do highlighting. + +Here's an example:: + + >>> console.out("Locals", locals()) + +Rules +----- + +The :meth:`~rich.console.Console.rule` method will draw a horizontal line with an optional title, which is a good way of dividing your terminal output in to sections. + + >>> console.rule("[bold red]Chapter 2") + +.. raw:: html + + <pre style="font-family:Menlo,\'DejaVu Sans Mono\',consolas,\'Courier New\',monospace"><span style="color: #00ff00">─────────────────────────────── </span><span style="color: #800000; font-weight: bold">Chapter 2</span><span style="color: #00ff00"> ───────────────────────────────</span></pre> + +The rule method also accepts a ``style`` parameter to set the style of the line, and an ``align`` parameter to align the title ("left", "center", or "right"). + + +Status +------ + +Rich can display a status message with a 'spinner' animation that won't interfere with regular console output. Run the following command for a demo of this feature:: + + python -m rich.status + +To display a status message, call :meth:`~rich.console.Console.status` with the status message (which may be a string, Text, or other renderable). The result is a context manager which starts and stop the status display around a block of code. Here's an example:: + + with console.status("Working..."): + do_work() + +You can change the spinner animation via the ``spinner`` parameter:: + + with console.status("Monkeying around...", spinner="monkey"): + do_work() + +Run the following command to see the available choices for ``spinner``:: + + python -m rich.spinner + + +Justify / Alignment +------------------- + +Both print and log support a ``justify`` argument which if set must be one of "default", "left", "right", "center", or "full". If "left", any text printed (or logged) will be left aligned, if "right" text will be aligned to the right of the terminal, if "center" the text will be centered, and if "full" the text will be lined up with both the left and right edges of the terminal (like printed text in a book). + +The default for ``justify`` is ``"default"`` which will generally look the same as ``"left"`` but with a subtle difference. Left justify will pad the right of the text with spaces, while a default justify will not. You will only notice the difference if you set a background color with the ``style`` argument. The following example demonstrates the difference:: + + from rich.console import Console + + console = Console(width=20) + + style = "bold white on blue" + console.print("Rich", style=style) + console.print("Rich", style=style, justify="left") + console.print("Rich", style=style, justify="center") + console.print("Rich", style=style, justify="right") + + +This produces the following output: + +.. raw:: html + + <pre style="font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"><span style="color: #c0c0c0; background-color: #000080; font-weight: bold">Rich + Rich + Rich + Rich + </span></pre> + +Overflow +-------- + +Overflow is what happens when text you print is larger than the available space. Overflow may occur if you print long 'words' such as URLs for instance, or if you have text inside a panel or table cell with restricted space. + +You can specify how Rich should handle overflow with the ``overflow`` argument to :meth:`~rich.console.Console.print` which should be one of the following strings: "fold", "crop", "ellipsis", or "ignore". The default is "fold" which will put any excess characters on the following line, creating as many new lines as required to fit the text. + +The "crop" method truncates the text at the end of the line, discarding any characters that would overflow. + +The "ellipsis" method is similar to "crop", but will insert an ellipsis character ("…") at the end of any text that has been truncated. + +The following code demonstrates the basic overflow methods:: + + from typing import List + from rich.console import Console, OverflowMethod + + console = Console(width=14) + supercali = "supercalifragilisticexpialidocious" + + overflow_methods: List[OverflowMethod] = ["fold", "crop", "ellipsis"] + for overflow in overflow_methods: + console.rule(overflow) + console.print(supercali, overflow=overflow, style="bold blue") + console.print() + +This produces the following output: + +.. raw:: html + + <pre style="font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"><span style="color: #00ff00">──── </span>fold<span style="color: #00ff00"> ────</span> + <span style="color: #000080; font-weight: bold">supercalifragi + listicexpialid + ocious + </span> + <span style="color: #00ff00">──── </span>crop<span style="color: #00ff00"> ────</span> + <span style="color: #000080; font-weight: bold">supercalifragi + </span> + <span style="color: #00ff00">── </span>ellipsis<span style="color: #00ff00"> ──</span> + <span style="color: #000080; font-weight: bold">supercalifrag… + </span> + </pre> + +You can also set overflow to "ignore" which allows text to run on to the next line. In practice this will look the same as "crop" unless you also set ``crop=False`` when calling :meth:`~rich.console.Console.print`. + + +Console style +------------- + +The Console has a ``style`` attribute which you can use to apply a style to everything you print. By default ``style`` is None meaning no extra style is applied, but you can set it to any valid style. Here's an example of a Console with a style attribute set:: + + from rich.console import Console + blue_console = Console(style="white on blue") + blue_console.print("I'm blue. Da ba dee da ba di.") + + +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 any text that doesn't fit will run on to the following line(s), just like the builtin ``print``. + + +Cropping +-------- + +The :meth:`~rich.console.Console.print` method has a boolean ``crop`` argument. The default value for crop is True which tells Rich to crop any content that would otherwise run on to the next line. You generally don't need to think about cropping, as Rich will resize content to fit within the available width. + +.. note:: + Cropping is automatically disabled if you print with ``soft_wrap=True``. + + +Input +----- + +The console class has an :meth:`~rich.console.Console.input` which works in the same way as Python's builtin ``input()`` method, but can use anything that Rich can print as a prompt. For example, here's a colorful prompt with an emoji:: + + from rich.console import Console + console = Console() + console.input("What is [i]your[/i] [bold red]name[/]? :smiley: ") + +Exporting +--------- + +The Console class can export anything written to it as either text or html. To enable exporting, first set ``record=True`` on the constructor. This tells Rich to save a copy of any data you ``print()`` or ``log()``. Here's an example:: + + from rich.console import Console + console = Console(record=True) + +After you have written content, you can call :meth:`~rich.console.Console.export_text` or :meth:`~rich.console.Console.export_html` to get the console output as a string. You can also call :meth:`~rich.console.Console.save_text` or :meth:`~rich.console.Console.save_html` to write the contents directly to disk. + +For examples of the html output generated by Rich Console, see :ref:`appendix-colors`. + +Error console +------------- + +The Console object will write to ``sys.stdout`` by default (so that you see output in the terminal). If you construct the Console with ``stderr=True`` Rich will write to ``sys.stderr``. You may want to use this to create an *error console* so you can split error messages from regular output. Here's an example:: + + from rich.console import Console + error_console = Console(stderr=True) + +You might also want to set the ``style`` parameter on the Console to make error messages visually distinct. Here's how you might do that:: + + error_console = Console(stderr=True, style="bold red") + +File output +----------- + +You can also tell the Console object to write to a file by setting the ``file`` argument on the constructor -- which should be a file-like object opened for writing text. You could use this to write to a file without the output ever appearing on the terminal. Here's an example:: + + import sys + from rich.console import Console + from datetime import datetime + + with open("report.txt", "wt") as report_file: + console = Console(file=report_file) + console.rule(f"Report Generated {datetime.now().ctime()}") + +Note that when writing to a file you may want to explicitly the ``width`` argument if you don't want to wrap the output to the current console width. + +Capturing output +---------------- + +There may be situations where you want to *capture* the output from a Console rather than writing it directly to the terminal. You can do this with the :meth:`~rich.console.Console.capture` method which returns a context manager. On exit from this context manager, call :meth:`~rich.console.Capture.get` to return the string that would have been written to the terminal. Here's an example:: + + from rich.console import Console + console = Console() + with console.capture() as capture: + console.print("[bold red]Hello[/] World") + str_output = capture.get() + +An alternative way of capturing output is to set the Console file to a :py:class:`io.StringIO`. This is the recommended method if you are testing console output in unit tests. Here's an example:: + + from io import StringIO + from rich.console import Console + console = Console(file=StringIO()) + console.print("[bold red]Hello[/] World") + str_output = console.file.getvalue() + +Paging +------ + +If you have some long output to present to the user you can use a *pager* to display it. A pager is typically an application on your operating system which will at least support pressing a key to scroll, but will often support scrolling up and down through the text and other features. + +You can page output from a Console by calling :meth:`~rich.console.Console.pager` which returns a context manger. When the pager exits, anything that was printed will be sent to the pager. Here's an example:: + + from rich.__main__ import make_test_card + from rich.console import Console + + console = Console() + with console.pager(): + console.print(make_test_card()) + +Since the default pager on most platforms don't support color, Rich will strip color from the output. If you know that your pager supports color, you can set ``styles=True`` when calling the :meth:`~rich.console.Console.pager` method. + +.. note:: + Rich will use the ``PAGER`` environment variable to get the pager command. On Linux and macOS you can set this to ``less -r`` to enable paging with ANSI styles. + +Alternate screen +---------------- + +.. warning:: + This feature is currently experimental. You might want to wait before using it in production. + +Terminals support an 'alternate screen' mode which is separate from the regular terminal and allows for full-screen applications that leave your stream of input and commands intact. Rich supports this mode via the :meth:`~rich.console.Console.set_alt_screen` method, although it is recommended that you use :meth:`~rich.console.Console.screen` which returns a context manager that disables alternate mode on exit. + +Here's an example of an alternate screen:: + + from time import sleep + from rich.console import Console + + console = Console() + with console.screen(): + console.print(locals()) + sleep(5) + +The above code will display a pretty printed dictionary on the alternate screen before returning to the command prompt after 5 seconds. + +You can also provide a renderable to :meth:`~rich.console.Console.screen` which will be displayed in the alternate screen when you call :meth:`~rich.ScreenContext.update`. + +Here's an example:: + + from time import sleep + + from rich.console import Console + from rich.align import Align + from rich.text import Text + from rich.panel import Panel + + console = Console() + + with console.screen(style="bold white on red") as screen: + for count in range(5, 0, -1): + text = Align.center( + Text.from_markup(f"[blink]Don't Panic![/blink]\n{count}", justify="center"), + vertical="middle", + ) + screen.update(Panel(text)) + sleep(1) + +Updating the screen with a renderable allows Rich to crop the contents to fit the screen without scrolling. + +For a more powerful way of building full screen interfaces with Rich, see :ref:`live`. + + +.. note:: + If you ever find yourself stuck in alternate mode after exiting Python code, type ``reset`` in the terminal + +Terminal detection +------------------ + +If Rich detects that it is not writing to a terminal it will strip control codes from the output. If you want to write control codes to a regular file then set ``force_terminal=True`` on the constructor. + +Letting Rich auto-detect terminals is useful as it will write plain text when you pipe output to a file or other application. + +Interactive mode +~~~~~~~~~~~~~~~~ + +Rich will remove animations such as progress bars and status indicators when not writing to a terminal as you probably don't want to write these out to a text file (for example). You can override this behavior by setting the ``force_interactive`` argument on the constructor. Set it to True to enable animations or False to disable them. + +.. note:: + Some CI systems support ANSI color and style but not anything that moves the cursor or selectively refreshes parts of the terminal. For these you might want to set ``force_terminal`` to ``True`` and ``force_interactve`` to ``False``. + +Environment variables +--------------------- + +Rich respects some standard environment variables. + +Setting the environment variable ``TERM`` to ``"dumb"`` or ``"unknown"`` will disable color/style and some features that require moving the cursor, such as progress bars. + +If the environment variable ``NO_COLOR`` is set, Rich will disable all color in the output. diff --git a/docs/source/group.rst b/docs/source/group.rst new file mode 100644 index 0000000..799c0d1 --- /dev/null +++ b/docs/source/group.rst @@ -0,0 +1,30 @@ +Render Groups +============= + +The :class:`~rich.console.RenderGroup` class allows you to group several renderables together so they may be rendered in a context where only a single renderable may be supplied. For instance, you might want to display several renderables within a :class:`~rich.panel.Panel`. + +To render two panels within a third panel, you would construct a RenderGroup with the *child* renderables as positional arguments then wrap the result in another Panel:: + + from rich import print + from rich.console import RenderGroup + from rich.panel import Panel + + panel_group = RenderGroup( + Panel("Hello", style="on blue"), + Panel("World", style="on red"), + ) + print(Panel(panel_group)) + + +This pattern is nice when you know in advance what renderables will be in a group, put can get awkward if you have a larger number of renderables, especially if they are dynamic. Rich provides a :func:`~rich.console.render_group` decorator to help with these situations. The decorator builds a render group from an iterator of renderables. The following is the equivalent of the previous example using the decorator:: + + from rich import print + from rich.console import render_group + from rich.panel import Panel + + @render_group() + def get_panels(): + yield Panel("Hello", style="on blue") + yield Panel("World", style="on red") + + print(Panel(get_panels()))
\ No newline at end of file diff --git a/docs/source/highlighting.rst b/docs/source/highlighting.rst new file mode 100644 index 0000000..260cfbe --- /dev/null +++ b/docs/source/highlighting.rst @@ -0,0 +1,58 @@ +Highlighting +============ + +Rich can apply styles to patterns in text which you :meth:`~rich.console.Console.print` or :meth:`~rich.console.Console.log`. With the default settings, Rich will highlight things such as numbers, strings, collections, booleans, None, and a few more exotic patterns such as file paths, URLs and UUIDs. + +You can disable highlighting either by setting ``highlight=False`` on :meth:`~rich.console.Console.print` or :meth:`~rich.console.Console.log`, or by setting ``highlight=False`` on the :class:`~rich.console.Console` constructor which disables it everywhere. If you disable highlighting on the constructor, you can still selectively *enable* highlighting with ``highlight=True`` on print/log. + +Custom Highlighters +------------------- + +If the default highlighting doesn't fit your needs, you can define a custom highlighter. The easiest way to do this is to extend the :class:`~rich.highlighter.RegexHighlighter` class which applies a style to any text matching a list of regular expressions. + +Here's an example which highlights text that looks like an email address:: + + from rich.console import Console + from rich.highlighter import RegexHighlighter + from rich.theme import Theme + + class EmailHighlighter(RegexHighlighter): + """Apply style to anything that looks like an email.""" + + base_style = "example." + highlights = [r"(?P<email>[\w-]+@([\w-]+\.)+[\w-]+)"] + + + theme = Theme({"example.email": "bold magenta"}) + console = Console(highlighter=EmailHighlighter(), theme=theme) + console.print("Send funds to money@example.org") + + +The ``highlights`` class variable should contain a list of regular expressions. The group names of any matching expressions are prefixed with the ``base_style`` attribute and used as styles for matching text. In the example above, any email addresses will have the style "example.email" applied, which we've defined in a custom :ref:`Theme <themes>`. + +Setting the highlighter on the Console will apply highlighting to all text you print (if enabled). You can also use a highlighter on a more granular level by using the instance as a callable and printing the result. For example, we could use the email highlighter class like this:: + + + console = Console(theme=theme) + highlight_emails = EmailHighlighter() + console.print(highlight_emails("Send funds to money@example.org")) + + +While :class:`~rich.highlighter.RegexHighlighter` is quite powerful, you can also extend its base class :class:`~rich.highlighter.Highlighter` to implement a custom scheme for highlighting. It contains a single method :class:`~rich.highlighter.Highlighter.highlight` which is passed the :class:`~rich.text.Text` to highlight. + +Here's a silly example that highlights every character with a different color:: + + from random import randint + + from rich import print + from rich.highlighter import Highlighter + + + class RainbowHighlighter(Highlighter): + def highlight(self, text): + for index in range(len(text)): + text.stylize(f"color({randint(16, 255)})", index, index + 1) + + + rainbow = RainbowHighlighter() + print(rainbow("I must not fear. Fear is the mind-killer.")) diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..12198de --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,45 @@ +.. Rich documentation master file, created by + sphinx-quickstart on Thu Dec 26 17:03:20 2019. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Rich's documentation! +================================ + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + introduction.rst + console.rst + style.rst + markup.rst + text.rst + highlighting.rst + logging.rst + traceback.rst + prompt.rst + + columns.rst + group.rst + markdown.rst + padding.rst + panel.rst + progress.rst + syntax.rst + tables.rst + tree.rst + live.rst + layout.rst + + protocol.rst + + reference.rst + appendix.rst + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst new file mode 100644 index 0000000..dc7d49a --- /dev/null +++ b/docs/source/introduction.rst @@ -0,0 +1,97 @@ +Introduction +============ + +Rich is a Python library for writing *rich* text (with color and style) to the terminal, and for displaying advanced content such as tables, markdown, and syntax highlighted code. + +Use Rich to make your command line applications visually appealing and present data in a more readable way. Rich can also be a useful debugging aid by pretty printing and syntax highlighting data structures. + +Requirements +------------ + +Rich works with OSX, Linux and Windows. + +On Windows both the (ancient) cmd.exe terminal is supported and the new `Windows Terminal <https://github.com/microsoft/terminal/releases>`_. The later has much improved support for color and style. + +Rich requires Python 3.6.1 and above. Note that Python 3.6.0 is *not* supported due to lack of support for methods on NamedTuples. + +.. note:: + PyCharm users will need to enable "emulate terminal" in output console option in run/debug configuration to see styled output. + +Installation +------------ + +You can install Rich from PyPi with `pip` or your favorite package manager:: + + pip install rich + +Add the ``-U`` switch to update to the current version, if Rich is already installed. + +If you intend to use Rich with Jupyter then there are some additional dependencies which you can install with the following command:: + + pip install rich[jupyter] + + +Quick Start +----------- + +The quickest way to get up and running with Rich is to import the alternative ``print`` function which takes the same arguments as the built-in ``print`` and may be used as a drop-in replacement. Here's how you would do that:: + + from rich import print + +You can then print strings or objects to the terminal in the usual way. Rich will do some basic syntax highlighting and format data structures to make them easier to read. + +Strings may contain :ref:`console_markup` which can be used to insert color and styles in to the output. + +The following demonstrates both console markup and pretty formatting of Python objects:: + + >>> print("[italic red]Hello[/italic red] World!", locals()) + +This writes the following output to the terminal (including all the colors and styles): + +.. raw:: html + + <pre style="font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"><span style="color: #800000; font-style: italic">Hello</span> World! + <span style="font-weight: bold">{</span> + <span style="color: #008000">'__annotations__'</span>: <span style="font-weight: bold">{}</span>, + <span style="color: #008000">'__builtins__'</span>: <span style="font-weight: bold"><</span><span style="color: #ff00ff">module</span><span style="color: #000000"> </span><span style="color: #008000">'builtins'</span><span style="color: #000000"> </span><span style="color: #000000; font-weight: bold">(</span><span style="color: #000000">built-in</span><span style="color: #000000; font-weight: bold">)</span><span style="font-weight: bold">></span>, + <span style="color: #008000">'__doc__'</span>: <span style="color: #800080; font-style: italic">None</span>, + <span style="color: #008000">'__loader__'</span>: <span style="font-weight: bold"><</span><span style="color: #ff00ff">class</span><span style="color: #000000"> </span><span style="color: #008000">'_frozen_importlib.BuiltinImporter'</span><span style="font-weight: bold">></span>, + <span style="color: #008000">'__name__'</span>: <span style="color: #008000">'__main__'</span>, + <span style="color: #008000">'__package__'</span>: <span style="color: #800080; font-style: italic">None</span>, + <span style="color: #008000">'__spec__'</span>: <span style="color: #800080; font-style: italic">None</span>, + <span style="color: #008000">'print'</span>: <span style="font-weight: bold"><</span><span style="color: #ff00ff">function</span><span style="color: #000000"> print at </span><span style="color: #000080; font-weight: bold">0x1027fd4c0</span><span style="font-weight: bold">></span>, + <span style="font-weight: bold">}</span> </pre> + + +If you would rather not shadow Python's builtin print, you can import ``rich.print`` as ``rprint`` (for example):: + + from rich import print as rprint + +Continue reading to learn about the more advanced features of Rich. + +Python in the REPL +------------------ + +Rich may be installed in the REPL so that Python data structures are automatically pretty printed with syntax highlighting. Here's how:: + + >>> from rich import pretty + >>> pretty.install() + >>> ["Rich and pretty", True] + +You can also use this feature to try out Rich *renderables*. Here's an example:: + + >>> from rich.panel import Panel + >>> Panel.fit("[bold yellow]Hi, I'm a Panel", border_style="red") + +Read on to learn more about Rich renderables. + + +Rich Inspector +-------------- + +Rich has an :meth:`~rich.inspect` function which can generate a report on any Python object. It is a fantastic debug aid, and a good example of the output that Rich can generate. Here is a simple example:: + + >>> from rich import inspect + >>> from rich.color import Color + >>> color = Color.parse("red") + >>> inspect(color, methods=True)
\ No newline at end of file diff --git a/docs/source/layout.rst b/docs/source/layout.rst new file mode 100644 index 0000000..9c99829 --- /dev/null +++ b/docs/source/layout.rst @@ -0,0 +1,143 @@ +Layout +====== + +Rich offers a :class:`~rich.layout.Layout` class which can be used to divide the screen area in to parts, where each part may contain independent content. It can be used with :ref:`Live` to create full-screen "applications" but may be used standalone. + +To see an example of a Layout, run the following from the command line:: + + python -m rich.layout + +Creating layouts +---------------- + +To define a layout, construct a Layout object and print it:: + + from rich import print + from rich.layout import Layout + + layout = Layout() + print(layout) + +This will draw a box the size of the terminal with some information regarding the layout. The box is a "placeholder" because we have yet to add any content to it. Before we do that, let's create a more interesting layout by calling the :meth:`~rich.layout.Layout.split` method to divide the layout in to two sub-layouts:: + + layout.split( + Layout(name="upper"), + Layout(name="lower") + ) + print(layout) + +This will divide the terminal screen in to two equal sized portions, one on top of the other. The ``name`` attribute is an internal identifier we can use to look up the sub-layout later. Let's use that to create another split:: + + layout["lower"].split( + Layout(name="left"), + Layout(name="right"), + direction="horizontal" + ) + print(layout) + +The addition of the ``direction="horizontal"`` tells the Layout class to split left-to-right, rather than the default of top-to-bottom. + +You should now see the screen area divided in to 3 portions; an upper half and a lower half that is split in to two quarters. + +.. raw:: html + + <pre style="font-size:90%;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"><span style="color: #000080">╭─────────────────────────────── </span><span style="color: #008000">'upper'</span><span style="color: #000080"> </span><span style="color: #000080; font-weight: bold">(</span><span style="color: #000080; font-weight: bold">84</span><span style="color: #000080"> x </span><span style="color: #000080; font-weight: bold">13</span><span style="color: #000080; font-weight: bold">)</span><span style="color: #000080"> ────────────────────────────────╮</span> + <span style="color: #000080">│</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="font-weight: bold">{</span><span style="color: #008000">'size'</span>: <span style="color: #800080; font-style: italic">None</span>, <span style="color: #008000">'minimum_size'</span>: <span style="color: #000080; font-weight: bold">1</span>, <span style="color: #008000">'ratio'</span>: <span style="color: #000080; font-weight: bold">1</span>, <span style="color: #008000">'name'</span>: <span style="color: #008000">'upper'</span><span style="font-weight: bold">}</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">│</span> + <span style="color: #000080">╰──────────────────────────────────────────────────────────────────────────────────╯</span> + <span style="color: #000080">╭─────────── </span><span style="color: #008000">'left'</span><span style="color: #000080"> </span><span style="color: #000080; font-weight: bold">(</span><span style="color: #000080; font-weight: bold">42</span><span style="color: #000080"> x </span><span style="color: #000080; font-weight: bold">14</span><span style="color: #000080; font-weight: bold">)</span><span style="color: #000080"> ───────────╮╭────────── </span><span style="color: #008000">'right'</span><span style="color: #000080"> </span><span style="color: #000080; font-weight: bold">(</span><span style="color: #000080; font-weight: bold">42</span><span style="color: #000080"> x </span><span style="color: #000080; font-weight: bold">14</span><span style="color: #000080; font-weight: bold">)</span><span style="color: #000080"> ───────────╮</span> + <span style="color: #000080">│</span> <span style="color: #000080">││</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">││</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">││</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="font-weight: bold">{</span> <span style="color: #000080">││</span> <span style="font-weight: bold">{</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #008000">'size'</span>: <span style="color: #800080; font-style: italic">None</span>, <span style="color: #000080">││</span> <span style="color: #008000">'size'</span>: <span style="color: #800080; font-style: italic">None</span>, <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #008000">'minimum_size'</span>: <span style="color: #000080; font-weight: bold">1</span>, <span style="color: #000080">││</span> <span style="color: #008000">'minimum_size'</span>: <span style="color: #000080; font-weight: bold">1</span>, <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #008000">'ratio'</span>: <span style="color: #000080; font-weight: bold">1</span>, <span style="color: #000080">││</span> <span style="color: #008000">'ratio'</span>: <span style="color: #000080; font-weight: bold">1</span>, <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #008000">'name'</span>: <span style="color: #008000">'left'</span> <span style="color: #000080">││</span> <span style="color: #008000">'name'</span>: <span style="color: #008000">'right'</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="font-weight: bold">}</span> <span style="color: #000080">││</span> <span style="font-weight: bold">}</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">││</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">││</span> <span style="color: #000080">│</span> + <span style="color: #000080">│</span> <span style="color: #000080">││</span> <span style="color: #000080">│</span> + <span style="color: #000080">╰────────────────────────────────────────╯╰────────────────────────────────────────╯</span> + </pre> + +You can continue to call split() in this way to create as many parts to the screen as you wish. + +Setting renderables +------------------- + +The first position argument to ``Layout`` can be any Rich renderable, which will be sized to fit within the layout's area. Here's how we might divide the "right" layout in to two panels:: + + layout["right"].split( + Layout(Panel("Hello")), + Layout(Panel("World!)) + ) + +You can also call :meth:`~rich.layout.Layout.update` to set or replace the current renderable:: + + layout["left"].update( + "The mystery of life isn't a problem to solve, but a reality to experience." + ) + print(layout) + +Fixed size +---------- + +You can set a layout to use a fixed size by setting the ``size`` argument on the Layout constructor or by setting the attribute. Here's an example:: + + layout["upper"].size = 10 + print(layout) + +This will set the upper portion to be exactly 10 rows, no matter the size of the terminal. If the parent layout is horizontal rather than vertical, then the size applies to the number of characters rather that rows. + +Ratio +----- + +In addition to a fixed size, you can also make a flexible layout setting the ``ratio`` argument on the constructor or by assigning to the attribute. The ratio defines how much of the screen the layout should occupy in relation to other layouts. For example, lets reset the size and set the ratio of the upper layout to 2:: + + layout["upper"].size = None + layout["upper"].ratio = 2 + print(layout) + +This makes the top layout take up two thirds of the space. This is because the default ratio is 1, giving the upper and lower layouts a combined total of 3. As the upper layout has a ratio of 2, it takes up two thirds of the space, leaving the remaining third for the lower layout. + +A layout with a ratio set may also have a minimum size to prevent it from getting too small. For instance, here's how we could set the minimum size of the lower sub-layout so that it won't shrink beyond 10 rows:: + + layout["lower"].minimum_size = 10 + +Visibility +---------- + +You can make a layout invisible by setting the ``visible`` attribute to False. Here's an example:: + + layout["upper"].visible = False + print(layout) + +The top layout is now invisible, and the "lower" layout will expand to fill the available space. Set ``visible`` to True to bring it back:: + + layout["upper"].visible = True + print(layout) + +You could use this to toggle parts of your interface based on your applications configuration. + +Tree +---- + +To help visualize complex layouts you can print the ``tree`` attribute which will display a summary of the layout as a tree:: + + print(layout.tree) + + +Example +------- + +See `fullscreen.py <https://github.com/willmcgugan/rich/blob/master/examples/fullscreen.py>`_ for an example that combines :class:`~rich.layout.Layout` and :class:`~rich.live.Live` to create a fullscreen "application". diff --git a/docs/source/live.rst b/docs/source/live.rst new file mode 100644 index 0000000..916a2f3 --- /dev/null +++ b/docs/source/live.rst @@ -0,0 +1,166 @@ +.. _live: + +Live Display +============ + +Progress bars and status indicators use a *live* display to animate parts of the terminal. You can build custom live displays with the :class:`~rich.live.Live` class. + +For a demonstration of a live display, running the following command: + + python -m rich.live + +.. note:: + + If you see ellipsis "...", this indicates that the terminal is not tall enough to show the full table. + +Basic usage +~~~~~~~~~~~ + +To create a live display, construct a :class:`~rich.live.Live` object with a renderable and use it has a context manager. The live display will persist for the duration of the context. You can update the renderable to update the display:: + + + import time + + from rich.live import Live + from rich.table import Table + + table = Table() + table.add_column("Row ID") + table.add_column("Description") + table.add_column("Level") + + with Live(table, refresh_per_second=4): # update 4 times a second to feel fluid + for row in range(12): + time.sleep(0.4) # arbitrary delay + # update the renderable internally + table.add_row(f"{row}", f"description {row}", "[red]ERROR") + + +Updating the renderable +~~~~~~~~~~~~~~~~~~~~~~~ + +You can also change the renderable on-the-fly by calling the :meth:`~rich.live.Live.update` method. This may be useful if the information you wish to display is too dynamic to generate by updating a single renderable. Here is an example: + + + import random + import time + + from rich.live import Live + from rich.table import Table + + + def generate_table() -> Table: + """Make a new table.""" + table = Table() + table.add_column("ID") + table.add_column("Value") + table.add_column("Status") + + for row in range(random.randint(2, 6)): + value = random.random() * 100 + table.add_row( + f"{row}", f"{value:3.2f}", "[red]ERROR" if value < 50 else "[green]SUCCESS" + ) + return table + + + with Live(generate_table(), refresh_per_second=4) as live: + for _ in range(40): + time.sleep(0.4) + live.update(generate_table()) + + +Alternate screen +~~~~~~~~~~~~~~~~ + +You can opt to show a Live display in the "alternate screen" by setting ``screen=False`` on the constructor. This will allow your live display to go full screen and restore the command prompt on exit. + +You can use this feature in combination with :ref:`Layout` to display sophisticated terminal "applications". + +Transient display +~~~~~~~~~~~~~~~~~ + +Normally when you exit live context manager (or call :meth:`~rich.live.Live.stop`) the last refreshed item remains in the terminal with the cursor on the following line. +You can also make the live display disappear on exit by setting ``transient=True`` on the Live constructor. + +Auto refresh +~~~~~~~~~~~~ + +By default, the live display will refresh 4 times a second. You can set the refresh rate with the ``refresh_per_second`` argument on the :class:`~rich.live.Live` constructor. +You should set this to something lower than 4 if you know your updates will not be that frequent or higher for a smoother feeling. + +You might want to disable auto-refresh entirely if your updates are not very frequent, which you can do by setting ``auto_refresh=False`` on the constructor. +If you disable auto-refresh you will need to call :meth:`~rich.live.Live.refresh` manually or :meth:`~rich.live.Live.update` with ``refresh=True``. + +Vertical overflow +~~~~~~~~~~~~~~~~~ + +By default, the live display will display ellipsis if the renderable is too large for the terminal. You can adjust this by setting the +``vertical_overflow`` argument on the :class:`~rich.live.Live` constructor. + +- "crop" Show renderable up to the terminal height. The rest is hidden. +- "ellipsis" Similar to crop except last line of the terminal is replaced with "...". This is the default behavior. +- "visible" Will allow the whole renderable to be shown. Note that the display cannot be properly cleared in this mode. + +.. note:: + + Once the live display stops on a non-transient renderable, the last frame will render as **visible** since it doesn't have to be cleared. + + +Print / log +~~~~~~~~~~~ + +The Live class will create an internal Console object which you can access via ``live.console``. If you print or log to this console, the output will be displayed *above* the live display. Here's an example:: + + import time + + from rich.live import Live + from rich.table import Table + + table = Table() + table.add_column("Row ID") + table.add_column("Description") + table.add_column("Level") + + with Live(table, refresh_per_second=4) as live: # update 4 times a second to feel fluid + for row in range(12): + live.console.print("Working on row #{row}") + time.sleep(0.4) + table.add_row(f"{row}", f"description {row}", "[red]ERROR") + + +If you have another Console object you want to use, pass it in to the :class:`~rich.live.Live` constructor. Here's an example:: + + from my_project import my_console + + with Live(console=my_console) as live: + my_console.print("[bold blue]Starting work!") + ... + +.. note:: + + If you are passing in a file console, the live display only show the last item once the live context is left. + +Redirecting stdout / stderr +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To avoid breaking the live display visuals, Rich will redirect ``stdout`` and ``stderr`` so that you can use the builtin ``print`` statement. +This feature is enabled by default, but you can disable by setting ``redirect_stdout`` or ``redirect_stderr`` to ``False``. + +Nesting Lives +------------- + +Note that only a single live context may be active at any one time. The following will raise a :class:`~rich.errors.LiveError` because status also uses Live:: + + with Live(table, console=console): + with console.status("working"): # Will not work + do_work() + +In practice this is rarely a problem because you can display any combination of renderables in a Live context. + +Examples +-------- + +See `table_movie.py <https://github.com/willmcgugan/rich/blob/master/examples/table_movie.py>`_ and +`top_lite_simulator.py <https://github.com/willmcgugan/rich/blob/master/examples/top_lite_simulator.py>`_ +for deeper examples of live displaying. diff --git a/docs/source/logging.rst b/docs/source/logging.rst new file mode 100644 index 0000000..b907718 --- /dev/null +++ b/docs/source/logging.rst @@ -0,0 +1,47 @@ +Logging Handler +=============== + +Rich supplies a :ref:`logging handler<logging>` which will format and colorize text written by Python's logging module. + +Here's an example of how to set up a rich logger:: + + import logging + from rich.logging import RichHandler + + FORMAT = "%(message)s" + logging.basicConfig( + level="NOTSET", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] + ) + + log = logging.getLogger("rich") + log.info("Hello, World!") + +Rich logs won't render :ref:`console_markup` in logging by default as most libraries won't be aware of the need to escape literal square brackets, but you can enable it by setting ``markup=True`` on the handler. Alternatively you can enable it per log message by supplying the ``extra`` argument as follows:: + + log.error("[bold red blink]Server is shutting down![/]", extra={"markup": True}) + + +Handle exceptions +------------------- + +The :class:`~rich.logging.RichHandler` class may be configured to use Rich's :class:`~rich.traceback.Traceback` class to format exceptions, which provides more context than a builtin exception. To get beautiful exceptions in your logs set ``rich_tracebacks=True`` on the handler constructor:: + + + import logging + from rich.logging import RichHandler + + logging.basicConfig( + level="NOTSET", + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler(rich_tracebacks=True)] + ) + + log = logging.getLogger("rich") + try: + print(1 / 0) + except Exception: + log.exception("unable print!") + + +There are a number of other options you can use to configure logging output, see the :class:`~rich.logging.RichHandler` reference for details. diff --git a/docs/source/markdown.rst b/docs/source/markdown.rst new file mode 100644 index 0000000..927c859 --- /dev/null +++ b/docs/source/markdown.rst @@ -0,0 +1,29 @@ +Markdown +======== + +Rich can render Markdown to the console. To render markdown, construct a :class:`~rich.markdown.Markdown` object then print it to the console. Markdown is a great way of adding rich content to your command line applications. Here's an example of use:: + + MARKDOWN = """ + # This is an h1 + + Rich can do a pretty *decent* job of rendering markdown. + + 1. This is a list item + 2. This is another list item + """ + from rich.console import Console + from rich.markdown import Markdown + + console = Console() + md = Markdown(MARKDOWN) + console.print(md) + +Note that code blocks are rendered with full syntax highlighting! + +You can also use the Markdown class from the command line. The following example displays a readme in the terminal:: + + python -m rich.markdown README.md + +Run the following to see the full list of arguments for the markdown command:: + + python -m rich.markdown -h
\ No newline at end of file diff --git a/docs/source/markup.rst b/docs/source/markup.rst new file mode 100644 index 0000000..997e1a7 --- /dev/null +++ b/docs/source/markup.rst @@ -0,0 +1,88 @@ +.. _console_markup: + +Console Markup +============== + +Rich supports a simple markup which you can use to insert color and styles virtually everywhere Rich would accept a string (e.g. :meth:`~rich.console.Console.print` and :meth:`~rich.console.Console.log`). + + +Syntax +------ + +Console markup uses a syntax inspired by `bbcode <https://en.wikipedia.org/wiki/BBCode>`_. If you write the style (see :ref:`styles`) in square brackets, e.g. ``[bold red]``, that style will apply until it is *closed* with a corresponding ``[/bold red]``. + +Here's a simple example:: + + from rich import print + print("[bold red]alert![/bold red] Something happened") + +If you don't close a style, it will apply until the end of the string. Which is sometimes convenient if you want to style a single line. For example:: + + print("[bold italic yellow on red blink]This text is impossible to read") + +There is a shorthand for closing a style. If you omit the style name from the closing tag, Rich will close the last style. For example:: + + print("[bold red]Bold and red[/] not bold or red") + +These markup tags may be use in combination with each other and don't need to be strictly nested. The following examples demonstrates overlapping of markup tags:: + + print("[bold]Bold[italic] bold and italic [/bold]italic[/italic]") + +Errors +~~~~~~ + +Rich will raise :class:`~rich.errors.MarkupError` if the markup contains one of the following errors: + +- Mismatched tags, e.g. ``"[bold]Hello[/red]"`` +- No matching tag for implicit close, e.g. ``"no tags[/]"`` + + +Links +~~~~~ + +Console markup can output hyperlinks with the following syntax: ``[link=URL]text[/link]``. Here's an example:: + + print("Visit my [link=https://www.willmcgugan.com]blog[/link]!") + +If your terminal software supports hyperlinks, you will be able to click the word "blog" which will typically open a browser. If your terminal doesn't support hyperlinks, you will see the text but it won't be clickable. + + +Escaping +~~~~~~~~ + +Occasionally you may want to print something that Rich would interpret as markup. You can *escape* a tag by preceding it with a backslash. Here's an example:: + + >>> from rich import print + >>> print(r"foo\[bar]") + foo[bar] + +Without the backslash, Rich will assume that ``[bar]`` is a tag and remove it from the output if there is no "bar" style. + +.. note:: + If you want to prevent the backslash from escaping the tag and output a literal backslash before a tag you can enter two backslashes. + +The function :func:`~rich.markup.escape` will handle escaping of text for you. + +Escaping is important if you construct console markup dynamically, with ``str.format`` or f strings (for example). Without escaping it may be possible to inject tags where you don't want them. Consider the following function:: + + def greet(name): + console.print(f"Hello {name}!") + +Calling ``greet("Will")`` will print a greeting, but if you were to call ``greet("[blink]Gotcha![/blink]"])`` then you will also get blinking text, which may not be desirable. The solution is to escape the arguments:: + + from rich.markup import escape + def greet(name): + console.print(f"Hello {escape(name)}!") + +Rendering Markup +---------------- + +By default, Rich will render console markup when you explicitly pass a string to :meth:`~rich.console.Print.print` or implicitly when you embed a string in another renderable object such as :class:`~rich.table.Table` or :class:`~rich.panel.Panel`. + +Console markup is convenient, but you may wish to disable it if the syntax clashes with the string you want to print. You can do this by setting ``markup=False`` on the :meth:`~rich.console.Print.print` method or on the :class:`~rich.console.Console` constructor. + + +Markup API +---------- + +You can convert a string to styled text by calling :meth:`~rich.text.Text.from_markup`, which returns a :class:`~rich.text.Text` instance you can print or add more styles to. diff --git a/docs/source/padding.rst b/docs/source/padding.rst new file mode 100644 index 0000000..a72dcbf --- /dev/null +++ b/docs/source/padding.rst @@ -0,0 +1,27 @@ +Padding +======= + +The :class:`~rich.padding.Padding` class may be used to add whitespace around text or other renderable. The following example will print the word "Hello" with a padding of 1 character, so there will be a blank line above and below, and a space on the left and right edges:: + + from rich import print + from rich.padding import Padding + test = Padding("Hello", 1) + print(test) + +You can specify the padding on a more granular level by using a tuple of values rather than a single value. A tuple of 2 values sets the top/bottom and left/right padding, whereas a tuple of 4 values sets the padding for top, right, bottom, and left sides. You may recognize this scheme if you are familiar with CSS. + +For example, the following displays 2 blank lines above and below the text, and a padding of 4 spaces on the left and right sides:: + + from rich import print + from rich.padding import Padding + test = Padding("Hello", (2, 4)) + print(test) + +The Padding class can also accept a ``style`` argument which applies a style to the padding and contents, and an ``expand`` switch which can be set to False to prevent the padding from extending to the full with of the terminal. Here's an example which demonstrates both these arguments:: + + from rich import print + from rich.padding import Padding + test = Padding("Hello", (2, 4), style="on blue", expand=False) + print(test) + +Note that, as with all Rich renderables, you can use Padding any context. For instance, if you want to emphasize an item in a :class:`~rich.table.Table` you could add a Padding object to a row with a padding of 1 and a style of "on red". diff --git a/docs/source/panel.rst b/docs/source/panel.rst new file mode 100644 index 0000000..422f023 --- /dev/null +++ b/docs/source/panel.rst @@ -0,0 +1,24 @@ +Panel +===== + +To draw a border around text or other renderable, construct a :class:`~rich.panel.Panel` with the renderable as the first positional argument. Here's an example:: + + from rich import print + from rich.panel import Panel + print(Panel("Hello, [red]World!")) + +You can change the style of the panel by setting the ``box`` argument to the Panel constructor. See :ref:`appendix_box` for a list of available box styles. + +Panels will extend to the full width of the terminal. You can make panel *fit* the content by setting ``expand=False`` on the constructor, or by creating the Panel with :meth:`~rich.panel.Panel.fit`. For example:: + + from rich import print + from rich.panel import Panel + print(Panel.fit("Hello, [red]World!")) + +The Panel constructor accepts a ``title`` argument which will draw a title within the panel:: + + from rich import print + from rich.panel import Panel + print(Panel("Hello, [red]World!", title="Welcome")) + +See :class:`~rich.panel.Panel` for details how to customize Panels. diff --git a/docs/source/progress.rst b/docs/source/progress.rst new file mode 100644 index 0000000..3b3168d --- /dev/null +++ b/docs/source/progress.rst @@ -0,0 +1,201 @@ +.. _progress: + +Progress Display +================ + +Rich can display continuously updated information regarding the progress of long running tasks / file copies etc. The information displayed is configurable, the default will display a description of the 'task', a progress bar, percentage complete, and estimated time remaining. + +Rich progress display supports multiple tasks, each with a bar and progress information. You can use this to track concurrent tasks where the work is happening in threads or processes. + +To see how the progress display looks, try this from the command line:: + + python -m rich.progress + + +.. note:: + + Progress works with Jupyter notebooks, with the caveat that auto-refresh is disabled. You will need to explicitly call :meth:`~rich.progress.Progress.refresh` or set ``refresh=True`` when calling :meth:`~rich.progress.Progress.update`. Or use the :func:`~rich.progress.track` function which does a refresh automatically on each loop. + +Basic Usage +----------- + +For basic usage call the :func:`~rich.progress.track` function, which accepts a sequence (such as a list or range object) and an optional description of the job you are working on. The track method will yield values from the sequence and update the progress information on each iteration. Here's an example:: + + from rich.progress import track + + for n in track(range(n), description="Processing..."): + do_work(n) + +Advanced usage +-------------- + +If you require multiple tasks in the display, or wish to configure the columns in the progress display, you can work directly with the :class:`~rich.progress.Progress` class. Once you have constructed a Progress object, add task(s) with (:meth:`~rich.progress.Progress.add_task`) and update progress with :meth:`~rich.progress.Progress.update`. + +The Progress class is designed to be used as a *context manager* which will start and stop the progress display automatically. + +Here's a simple example:: + + import time + + from rich.progress import Progress + + with Progress() as progress: + + task1 = progress.add_task("[red]Downloading...", total=1000) + task2 = progress.add_task("[green]Processing...", total=1000) + task3 = progress.add_task("[cyan]Cooking...", total=1000) + + while not progress.finished: + progress.update(task1, advance=0.5) + progress.update(task2, advance=0.3) + progress.update(task3, advance=0.9) + time.sleep(0.02) + +The ``total`` value associated with a task is the number of steps that must be completed for the progress to reach 100%. A *step* in this context is whatever makes sense for your application; it could be number of bytes of a file read, or number of images processed, etc. + + +Updating tasks +~~~~~~~~~~~~~~ + +When you call :meth:`~rich.progress.Progress.add_task` you get back a `Task ID`. Use this ID to call :meth:`~rich.progress.Progress.update` whenever you have completed some work, or any information has changed. Typically you will need to update ``completed`` every time you have completed a step. You can do this by updated ``completed`` directly or by setting ``advance`` which will add to the current ``completed`` value. + +The :meth:`~rich.progress.Progress.update` method collects keyword arguments which are also associated with the task. Use this to supply any additional information you would like to render in the progress display. The additional arguments are stored in ``task.fields`` and may be referenced in :ref:`Column classes<Columns>`. + +Hiding tasks +~~~~~~~~~~~~ + +You can show or hide tasks by updating the tasks ``visible`` value. Tasks are visible by default, but you can also add a invisible task by calling :meth:`~rich.progress.Progress.add_task` with ``visible=False``. + + +Transient progress +~~~~~~~~~~~~~~~~~~ + +Normally when you exit the progress context manager (or call :meth:`~rich.progress.Progress.stop`) the last refreshed display remains in the terminal with the cursor on the following line. You can also make the progress display disappear on exit by setting ``transient=True`` on the Progress constructor. Here's an example:: + + with Progress(transient=True) as progress: + task = progress.add_task("Working", total=100) + do_work(task) + +Transient progress displays are useful if you want more minimal output in the terminal when tasks are complete. + +Indeterminate progress +~~~~~~~~~~~~~~~~~~~~~~ + +When you add a task it is automatically *started*, which means it will show a progress bar at 0% and the time remaining will be calculated from the current time. This may not work well if there is a long delay before you can start updating progress; you may need to wait for a response from a server or count files in a directory (for example). In these cases you can call :meth:`~rich.progress.Progress.add_task` with ``start=False`` which will display a pulsing animation that lets the user know something is working. This is know as an *indeterminate* progress bar. When you have the number of steps you can call :meth:`~rich.progress.Progress.start_task` which will display the progress bar at 0%, then :meth:`~rich.progress.Progress.update` as normal. + +Auto refresh +~~~~~~~~~~~~ + +By default, the progress information will refresh 10 times a second. You can set the refresh rate with the ``refresh_per_second`` argument on the :class:`~rich.progress.Progress` constructor. You should set this to something lower than 10 if you know your updates will not be that frequent. + +You might want to disable auto-refresh entirely if your updates are not very frequent, which you can do by setting ``auto_refresh=False`` on the constructor. If you disable auto-refresh you will need to call :meth:`~rich.progress.Progress.refresh` manually after updating your task(s). + + +Expand +~~~~~~ + +The progress bar(s) will use only as much of the width of the terminal as required to show the task information. If you set the ``expand`` argument on the Progress constructor, then Rich will stretch the progress display to the full available width. + + +Columns +~~~~~~~ + +You may customize the columns in the progress display with the positional arguments to the :class:`~rich.progress.Progress` constructor. The columns are specified as either a format string or a :class:`~rich.progress.ProgressColumn` object. + +Format strings will be rendered with a single value `"task"` which will be a :class:`~rich.progress.Task` instance. For example ``"{task.description}"`` would display the task description in the column, and ``"{task.completed} of {task.total}"`` would display how many of the total steps have been completed. + +The defaults are roughly equivalent to the following:: + + progress = Progress( + "[progress.description]{task.description}", + BarColumn(), + "[progress.percentage]{task.percentage:>3.0f}%", + TimeRemainingColumn(), + ) + +The following column objects are available: + +- :class:`~rich.progress.BarColumn` Displays the bar. +- :class:`~rich.progress.TextColumn` Displays text. +- :class:`~rich.progress.TimeElapsedColumn` Displays the time elapsed. +- :class:`~rich.progress.TimeRemainingColumn` Displays the estimated time remaining. +- :class:`~rich.progress.FileSizeColumn` Displays progress as file size (assumes the steps are bytes). +- :class:`~rich.progress.TotalFileSizeColumn` Displays total file size (assumes the steps are bytes). +- :class:`~rich.progress.DownloadColumn` Displays download progress (assumes the steps are bytes). +- :class:`~rich.progress.TransferSpeedColumn` Displays transfer speed (assumes the steps are bytes. +- :class:`~rich.progress.SpinnerColumn` Displays a "spinner" animation. +- :class:`~rich.progress.RenderableColumn` Displays an arbitrary Rich renderable in the column. + +To implement your own columns, extend the :class:`~rich.progress.ProgressColumn` class and use it as you would the other columns. + +Table Columns +~~~~~~~~~~~~~ + +Rich builds a :class:~rich.table.Table` for the tasks in the Progress instance. You can customize how the columns of this *tasks table* are created by specifying the `table_column` argument in the Column constructor, which should be a :class:`~rich.table.Column` instance. + +The following example demonstrates a progress bar where the description takes one third of the width of the terminal, and the bar takes up the remaining third. + + from time import sleep + + from rich.table import Column + from rich.progress import Progress, BarColumn, TextColumn + + text_column = TextColumn("{task.description}", table_column=Column(ratio=1)) + bar_column = BarColumn(bar_width=None, table_column=Column(ratio=2)) + progress = Progress(text_column, bar_column, expand=True) + + with progress: + for n in progress.track(range(100)): + progress.print(n) + sleep(0.1) + + +Print / log +~~~~~~~~~~~ + +The Progress class will create an internal Console object which you can access via ``progress.console``. If you print or log to this console, the output will be displayed *above* the progress display. Here's an example:: + + with Progress() as progress: + task = progress.add_task("twiddling thumbs", total=10) + for job in range(10): + progress.console.print(f"Working on job #{job}") + run_job(job) + progress.advance(task) + +If you have another Console object you want to use, pass it in to the :class:`~rich.progress.Progress` constructor. Here's an example:: + + from my_project import my_console + + with Progress(console=my_console) as progress: + my_console.print("[bold blue]Starting work!") + do_work(progress) + + +Redirecting stdout / stderr +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To avoid breaking the progress display visuals, Rich will redirect ``stdout`` and ``stderr`` so that you can use the builtin ``print`` statement. This feature is enabled by default, but you can disable by setting ``redirect_stdout`` or ``redirect_stderr`` to ``False`` + + +Customizing +~~~~~~~~~~~ + +If the :class:`~rich.progress.Progress` class doesn't offer exactly what you need in terms of a progress display, you can override the :class:`~rich.progress.Progress.get_renderables` method. For example, the following class will render a :class:`~rich.panel.Panel` around the progress display:: + + from rich.panel import Panel + from rich.progress import Progress + + class MyProgress(Progress): + def get_renderables(self): + yield Panel(self.make_tasks_table(self.tasks)) + +Multiple Progress +----------------- + +You can't have different columns per task with a single Progress instance. However, you can have as many Progress instance as you like in a :ref:`live`. See `live_progress.py <https://github.com/willmcgugan/rich/blob/master/examples/live_progress.py>`_ for an example of using mutiple Progress instances. + +Example +------- + +See `downloader.py <https://github.com/willmcgugan/rich/blob/master/examples/downloader.py>`_ for a realistic application of a progress display. This script can download multiple concurrent files with a progress bar, transfer speed and file size. + diff --git a/docs/source/prompt.rst b/docs/source/prompt.rst new file mode 100644 index 0000000..fa256a0 --- /dev/null +++ b/docs/source/prompt.rst @@ -0,0 +1,33 @@ +Prompt +====== + +Rich has a number of :class:`~rich.prompt.Prompt` classes which ask a user for input and loop until a valid response is received. Here's a simple example:: + + >>> from rich.prompt import Prompt + >>> name = Prompt.ask("Enter your name") + +The prompt may be given as a string (which may contain :ref:`console_markup` and emoji code) or as a :class:`~rich.text.Text` instance. + +You can set a default value which will be returned if the user presses return without entering any text:: + + >>> from rich.prompt import Prompt + >>> name = Prompt.ask("Enter your name", default="Paul Atreides") + +If you supply a list of choices, the prompt will loop until the user enters one of the choices:: + + >>> from rich.prompt import Prompt + >>> name = Prompt.ask("Enter your name", choices=["Paul", "Jessica", "Duncan"], default="Paul") + +In addition to :class:`~rich.prompt.Prompt` which returns strings, you can also use :class:`~rich.prompt.IntPrompt` which asks the user for an integer, and :class:`~rich.prompt.FloatPrompt` for floats. + +The :class:`~rich.prompt.Confirm` class is a specialized prompt which may be used to ask the user a simple yes / no question. Here's an example:: + + >>> from rich.prompt import Confirm + >>> is_rich_great = Confirm.ask("Do you like rich?") + >>> assert is_rich_great + +The Prompt class was designed to be customizable via inheritance. See `prompt.py <https://github.com/willmcgugan/rich/blob/master/rich/prompt.py>`_ for examples. + +To see some of the prompts in action, run the following command from the command line:: + + python -m rich.prompt
\ No newline at end of file diff --git a/docs/source/protocol.rst b/docs/source/protocol.rst new file mode 100644 index 0000000..e37450e --- /dev/null +++ b/docs/source/protocol.rst @@ -0,0 +1,73 @@ + +.. _protocol: + +Console Protocol +================ + +Rich supports a simple protocol to add rich formatting capabilities to custom objects, so you can :meth:`~rich.console.Console.print` your object with color, styles and formatting. + +Use this for presentation or to display additional debugging information that might be hard to parse from a typical ``__repr__`` string. + + +Console Customization +--------------------- + +The easiest way to customize console output for your object is to implement a ``__rich__`` method. This method accepts no arguments, and should return an object that Rich knows how to render, such as a :class:`~rich.text.Text` or :class:`~rich.table.Table`. If you return a plain string it will be rendered as :ref:`console_markup`. Here's an example:: + + class MyObject: + def __rich__(self) -> str: + return "[bold cyan]MyObject()" + +If you were to print or log an instance of ``MyObject`` it would render as ``MyObject()`` in bold cyan. Naturally, you would want to put this to better use, perhaps by adding specialized syntax highlighting. + + +Console Render +-------------- + +The ``__rich__`` method is limited to a single renderable object. For more advanced rendering, add a ``__rich_console__`` method to your class. + +The ``__rich_console__`` method should accept a :class:`~rich.console.Console` and a :class:`~rich.console.ConsoleOptions` instance. It should return an iterable of other renderable objects. Although that means it *could* return a container such as a list, it generally easier implemented by using the ``yield`` statement (making the method a generator). + +Here's an example of a ``__rich_console__`` method:: + + from dataclasses import dataclass + from rich.console import Console, ConsoleOptions, RenderResult + from rich.table import Table + + @dataclass + class Student: + id: int + name: str + age: int + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + yield f"[b]Student:[/b] #{self.id}" + my_table = Table("Attribute", "Value") + my_table.add_row("name", self.name) + my_table.add_row("age", str(self.age)) + yield my_table + +If you were to print a ``Student`` instance, it would render a simple table to the terminal. + + +Low Level Render +~~~~~~~~~~~~~~~~ + +For complete control over how a custom object is rendered to the terminal, you can yield :class:`~rich.segment.Segment` objects. A Segment consists of a piece of text and an optional Style. The following example writes multi-colored text when rendering a ``MyObject`` instance:: + + class MyObject: + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + yield Segment("My", Style(color="magenta")) + yield Segment("Object", Style(color="green")) + yield Segment("()", Style(color="cyan")) + + +Measuring Renderables +~~~~~~~~~~~~~~~~~~~~~ + +Sometimes Rich needs to know how many characters an object will take up when rendering. The :class:`~rich.table.Table` class, for instance, will use this information to calculate the optimal dimensions for the columns. If you aren't using one of the renderable objects in the Rich module, you will need to supply a ``__rich_measure__`` method which accepts a :class:`~rich.console.Console` and the maximum width and returns a :class:`~rich.measure.Measurement` object. The Measurement object should contain the *minimum* and *maximum* number of characters required to render. + +For example, if we are rendering a chess board, it would require a minimum of 8 characters to render. The maximum can be left as the maximum available width (assuming a centered board):: + + class ChessBoard: + def __rich_measure__(self, console: Console, max_width: int) -> Measurement: + return Measurement(8, max_width) diff --git a/docs/source/reference.rst b/docs/source/reference.rst new file mode 100644 index 0000000..d16047a --- /dev/null +++ b/docs/source/reference.rst @@ -0,0 +1,39 @@ +Reference +========= + +.. toctree:: + :maxdepth: 3 + + reference/align.rst + reference/bar.rst + reference/color.rst + reference/columns.rst + reference/console.rst + reference/emoji.rst + reference/highlighter.rst + reference/init.rst + reference/live.rst + reference/logging.rst + reference/markdown.rst + reference/markup.rst + reference/measure.rst + reference/padding.rst + reference/panel.rst + reference/pretty.rst + reference/progress_bar.rst + reference/progress.rst + reference/prompt.rst + reference/protocol.rst + reference/rule.rst + reference/segment.rst + reference/spinner.rst + reference/status.rst + reference/style.rst + reference/styled.rst + reference/syntax.rst + reference/table.rst + reference/text.rst + reference/theme.rst + reference/traceback.rst + reference/tree.rst + reference/abc.rst diff --git a/docs/source/reference/abc.rst b/docs/source/reference/abc.rst new file mode 100644 index 0000000..c4e3758 --- /dev/null +++ b/docs/source/reference/abc.rst @@ -0,0 +1,7 @@ +rich.abc +======== + +.. automodule:: rich.abc + :members: + + diff --git a/docs/source/reference/align.rst b/docs/source/reference/align.rst new file mode 100644 index 0000000..54b0525 --- /dev/null +++ b/docs/source/reference/align.rst @@ -0,0 +1,7 @@ +rich.align +========== + +.. automodule:: rich.align + :members: + + diff --git a/docs/source/reference/bar.rst b/docs/source/reference/bar.rst new file mode 100644 index 0000000..6a247d7 --- /dev/null +++ b/docs/source/reference/bar.rst @@ -0,0 +1,7 @@ +rich.bar +======== + +.. automodule:: rich.bar + :members: + + diff --git a/docs/source/reference/color.rst b/docs/source/reference/color.rst new file mode 100644 index 0000000..87b0e18 --- /dev/null +++ b/docs/source/reference/color.rst @@ -0,0 +1,7 @@ +rich.color +========== + +.. automodule:: rich.color + :members: + + diff --git a/docs/source/reference/columns.rst b/docs/source/reference/columns.rst new file mode 100644 index 0000000..14bcd02 --- /dev/null +++ b/docs/source/reference/columns.rst @@ -0,0 +1,7 @@ +rich.columns +============ + +.. automodule:: rich.columns + :members: + + diff --git a/docs/source/reference/console.rst b/docs/source/reference/console.rst new file mode 100644 index 0000000..26ec6ff --- /dev/null +++ b/docs/source/reference/console.rst @@ -0,0 +1,5 @@ +rich.console +============ + +.. automodule:: rich.console + :members: diff --git a/docs/source/reference/emoji.rst b/docs/source/reference/emoji.rst new file mode 100644 index 0000000..59ca4da --- /dev/null +++ b/docs/source/reference/emoji.rst @@ -0,0 +1,6 @@ +rich.emoji +========== + +.. automodule:: rich.emoji + :members: Emoji + diff --git a/docs/source/reference/highlighter.rst b/docs/source/reference/highlighter.rst new file mode 100644 index 0000000..7f5a11f --- /dev/null +++ b/docs/source/reference/highlighter.rst @@ -0,0 +1,7 @@ +rich.highlighter +================ + +.. automodule:: rich.highlighter + :members: + :special-members: __call__ + diff --git a/docs/source/reference/init.rst b/docs/source/reference/init.rst new file mode 100644 index 0000000..3ac3fda --- /dev/null +++ b/docs/source/reference/init.rst @@ -0,0 +1,7 @@ +rich +==== + +.. automodule:: rich + :members: + + diff --git a/docs/source/reference/live.rst b/docs/source/reference/live.rst new file mode 100644 index 0000000..516ed8f --- /dev/null +++ b/docs/source/reference/live.rst @@ -0,0 +1,5 @@ +rich.live +========= + +.. automodule:: rich.live + :members:
\ No newline at end of file diff --git a/docs/source/reference/logging.rst b/docs/source/reference/logging.rst new file mode 100644 index 0000000..a7caafc --- /dev/null +++ b/docs/source/reference/logging.rst @@ -0,0 +1,8 @@ +.. _logging: + +rich.logging +============ + +.. automodule:: rich.logging + :members: RichHandler + diff --git a/docs/source/reference/markdown.rst b/docs/source/reference/markdown.rst new file mode 100644 index 0000000..b98f558 --- /dev/null +++ b/docs/source/reference/markdown.rst @@ -0,0 +1,7 @@ +rich.markdown +============= + +.. automodule:: rich.markdown + :members: + + diff --git a/docs/source/reference/markup.rst b/docs/source/reference/markup.rst new file mode 100644 index 0000000..ff786f2 --- /dev/null +++ b/docs/source/reference/markup.rst @@ -0,0 +1,5 @@ +rich.markup +=========== + +.. automodule:: rich.markup + :members: diff --git a/docs/source/reference/measure.rst b/docs/source/reference/measure.rst new file mode 100644 index 0000000..c048eb1 --- /dev/null +++ b/docs/source/reference/measure.rst @@ -0,0 +1,5 @@ +rich.measure +============ + +.. automodule:: rich.measure + :members: diff --git a/docs/source/reference/padding.rst b/docs/source/reference/padding.rst new file mode 100644 index 0000000..e047ca9 --- /dev/null +++ b/docs/source/reference/padding.rst @@ -0,0 +1,5 @@ +rich.padding +============ + +.. automodule:: rich.padding + :members: diff --git a/docs/source/reference/panel.rst b/docs/source/reference/panel.rst new file mode 100644 index 0000000..998f290 --- /dev/null +++ b/docs/source/reference/panel.rst @@ -0,0 +1,6 @@ +rich.panel +========== + +.. automodule:: rich.panel + :members: Panel + diff --git a/docs/source/reference/pretty.rst b/docs/source/reference/pretty.rst new file mode 100644 index 0000000..4290dbd --- /dev/null +++ b/docs/source/reference/pretty.rst @@ -0,0 +1,6 @@ +rich.pretty +=========== + +.. automodule:: rich.pretty + :members: + diff --git a/docs/source/reference/progress.rst b/docs/source/reference/progress.rst new file mode 100644 index 0000000..69551b2 --- /dev/null +++ b/docs/source/reference/progress.rst @@ -0,0 +1,5 @@ +rich.progress +============= + +.. automodule:: rich.progress + :members: diff --git a/docs/source/reference/progress_bar.rst b/docs/source/reference/progress_bar.rst new file mode 100644 index 0000000..6e5a201 --- /dev/null +++ b/docs/source/reference/progress_bar.rst @@ -0,0 +1,7 @@ +rich.progress_bar +================= + +.. automodule:: rich.progress_bar + :members: + + diff --git a/docs/source/reference/prompt.rst b/docs/source/reference/prompt.rst new file mode 100644 index 0000000..d5adef4 --- /dev/null +++ b/docs/source/reference/prompt.rst @@ -0,0 +1,5 @@ +rich.prompt +=========== + +.. automodule:: rich.prompt + :members: diff --git a/docs/source/reference/protocol.rst b/docs/source/reference/protocol.rst new file mode 100644 index 0000000..4febcee --- /dev/null +++ b/docs/source/reference/protocol.rst @@ -0,0 +1,5 @@ +rich.protocol +============= + +.. automodule:: rich.protocol + :members: diff --git a/docs/source/reference/rule.rst b/docs/source/reference/rule.rst new file mode 100644 index 0000000..f06eb3a --- /dev/null +++ b/docs/source/reference/rule.rst @@ -0,0 +1,5 @@ +rich.rule +========= + +.. automodule:: rich.rule + :members: diff --git a/docs/source/reference/segment.rst b/docs/source/reference/segment.rst new file mode 100644 index 0000000..fce383c --- /dev/null +++ b/docs/source/reference/segment.rst @@ -0,0 +1,5 @@ +rich.segment +============ + +.. automodule:: rich.segment + :members: diff --git a/docs/source/reference/spinner.rst b/docs/source/reference/spinner.rst new file mode 100644 index 0000000..53315dc --- /dev/null +++ b/docs/source/reference/spinner.rst @@ -0,0 +1,5 @@ +rich.spinner +============ + +.. automodule:: rich.spinner + :members: diff --git a/docs/source/reference/status.rst b/docs/source/reference/status.rst new file mode 100644 index 0000000..ac4704e --- /dev/null +++ b/docs/source/reference/status.rst @@ -0,0 +1,5 @@ +rich.status +============ + +.. automodule:: rich.status + :members: diff --git a/docs/source/reference/style.rst b/docs/source/reference/style.rst new file mode 100644 index 0000000..3d431ac --- /dev/null +++ b/docs/source/reference/style.rst @@ -0,0 +1,7 @@ +rich.style +========== + +.. automodule:: rich.style + :members: + :special-members: __call__ + diff --git a/docs/source/reference/styled.rst b/docs/source/reference/styled.rst new file mode 100644 index 0000000..6720726 --- /dev/null +++ b/docs/source/reference/styled.rst @@ -0,0 +1,7 @@ +rich.styled +=========== + +.. automodule:: rich.styled + :members: + + diff --git a/docs/source/reference/syntax.rst b/docs/source/reference/syntax.rst new file mode 100644 index 0000000..c8e4918 --- /dev/null +++ b/docs/source/reference/syntax.rst @@ -0,0 +1,5 @@ +rich.syntax +=========== + +.. automodule:: rich.syntax + :members: Syntax diff --git a/docs/source/reference/table.rst b/docs/source/reference/table.rst new file mode 100644 index 0000000..40562b0 --- /dev/null +++ b/docs/source/reference/table.rst @@ -0,0 +1,5 @@ +rich.table +========== + +.. automodule:: rich.table + :members: diff --git a/docs/source/reference/text.rst b/docs/source/reference/text.rst new file mode 100644 index 0000000..76b41f4 --- /dev/null +++ b/docs/source/reference/text.rst @@ -0,0 +1,6 @@ +rich.text +========= + +.. automodule:: rich.text + :members: Text + diff --git a/docs/source/reference/theme.rst b/docs/source/reference/theme.rst new file mode 100644 index 0000000..14fb919 --- /dev/null +++ b/docs/source/reference/theme.rst @@ -0,0 +1,6 @@ +rich.theme +========== + +.. automodule:: rich.theme + :members: Theme + diff --git a/docs/source/reference/traceback.rst b/docs/source/reference/traceback.rst new file mode 100644 index 0000000..cdd09fb --- /dev/null +++ b/docs/source/reference/traceback.rst @@ -0,0 +1,6 @@ +rich.traceback +============== + +.. automodule:: rich.traceback + :members: Traceback, install + diff --git a/docs/source/reference/tree.rst b/docs/source/reference/tree.rst new file mode 100644 index 0000000..4024041 --- /dev/null +++ b/docs/source/reference/tree.rst @@ -0,0 +1,5 @@ +rich.tree +========= + +.. automodule:: rich.tree + :members: diff --git a/docs/source/style.rst b/docs/source/style.rst new file mode 100644 index 0000000..b016215 --- /dev/null +++ b/docs/source/style.rst @@ -0,0 +1,159 @@ +.. _styles: + + +Styles +====== + +In various places in the Rich API you can set a "style" which defines the color of the text and various attributes such as bold, italic etc. A style may be given as a string containing a *style definition* or as an instance of a :class:`~rich.style.Style` class. + + +Defining Styles +--------------- + +A style definition is a string containing one or more words to set colors and attributes. + +To specify a foreground color use one of the 256 :ref:`appendix-colors`. For example, to print "Hello" in magenta:: + + console.print("Hello", style="magenta") + +You may also use the color's number (an integer between 0 and 255) with the syntax ``"color(<number>)"``. The following will give the equivalent output:: + + console.print("Hello", style="color(5)") + +Alteratively you can use a CSS-like syntax to specify a color with a "#" followed by three pairs of hex characters, or in RGB form with three decimal integers. The following two lines both print "Hello" in the same color (purple):: + + console.print("Hello", style="#af00ff") + console.print("Hello", style="rgb(175,0,255)") + +The hex and rgb forms allow you to select from the full *truecolor* set of 16.7 million colors. + +.. note:: + Some terminals only support 256 colors. Rich will attempt to pick the closest color it can if your color isn't available. + +By itself, a color will change the *foreground* color. To specify a *background* color, precede the color with the word "on". For example, the following prints text in red on a white background:: + + console.print("DANGER!", style="red on white") + +You can also set a color with the word ``"default"`` which will reset the color to a default managed by your terminal software. This works for backgrounds as well, so the style of ``"default on default"`` is what your terminal starts with. + +You can set a style attribute by adding one or more of the following words: + +* ``"bold"`` or ``"b"`` for bold text. +* ``"blink"`` for text that flashes (use this one sparingly). +* ``"blink2"`` for text that flashes rapidly (not supported by most terminals). +* ``"conceal"`` for *concealed* text (not supported by most terminals). +* ``"italic"`` or ``"i"`` for italic text (not supported on Windows). +* ``"reverse"`` or ``"r"`` for text with foreground and background colors reversed. +* ``"strike"`` or ``"s"`` for text with a line through it. +* ``"underline"`` or ``"u"`` for underlined text. + +Rich also supports the following styles, which are not well supported and may not display in your terminal: + +* ``"underline2"`` or ``"uu"`` for doubly underlined text. +* ``"frame"`` for framed text. +* ``"encircle"`` for encircled text. +* ``"overline"`` or ``"o"`` for overlined text. + +Style attributes and colors may be used in combination with each other. For example:: + + console.print("Danger, Will Robinson!", style="blink bold red underline on white") + +Styles may be negated by prefixing the attribute with the word "not". This can be used to turn off styles if they overlap. For example:: + + console.print("foo [not bold]bar[/not bold] baz", style="bold") + +This will print "foo" and "baz" in bold, but "bar" will be in normal text. + +Styles may also have a ``"link"`` attribute, which will turn any styled text in to a *hyperlink* (if supported by your terminal software). + +To add a link to a style, the definition should contain the word ``"link"`` followed by a URL. The following example will make a clickable link:: + + console.print("Google", style="link https://google.com") + +.. note:: + If you are familiar with HTML you may find applying links in this way a little odd, but the terminal considers a link to be another attribute just like bold, italic etc. + + + +Style Class +----------- + +Ultimately the style definition is parsed and an instance of a :class:`~rich.style.Style` class is created. If you prefer, you can use the Style class in place of the style definition. Here's an example:: + + from rich.style import Style + danger_style = Style(color="red", blink=True, bold=True) + console.print("Danger, Will Robinson!", style=danger_style) + +It is slightly quicker to construct a Style class like this, since a style definition takes a little time to parse -- but only on the first call, as Rich will cache parsed style definitions. + +Styles may be combined by adding them together, which is useful if you want to modify attributes of an existing style. Here's an example:: + + from rich.console import Console + from rich.style import Style + console = Console() + + base_style = Style.parse("cyan") + console.print("Hello, World", style = base_style + Style(underline=True)) + +You can parse a style definition explicitly with the :meth:`~rich.style.Style.parse` method, which accepts the style definition and returns a Style instance. For example, the following two lines are equivalent:: + + style = Style(color="magenta", bgcolor="yellow", italic=True) + style = Style.parse("italic magenta on yellow") + +.. _themes: + + +Style Themes +------------ + +If you re-use styles it can be a maintenance headache if you ever want to modify an attribute or color -- you would have to change every line where the style is used. Rich provides a :class:`~rich.theme.Theme` class which you can use to define custom styles that you can refer to by name. That way you only need update your styles in one place. + +Style themes can make your code more semantic, for instance a style called ``"warning"`` better expresses intent that ``"italic magenta underline"``. + +To use a style theme, construct a :class:`~rich.theme.Theme` instance and pass it to the :class:`~rich.console.Console` constructor. Here's an example:: + + from rich.console import Console + from rich.theme import Theme + custom_theme = Theme({ + "info" : "dim cyan", + "warning": "magenta", + "danger": "bold red" + }) + console = Console(theme=custom_theme) + console.print("This is information", style="info") + console.print("[warning]The pod bay doors are locked[/warning]") + console.print("Something terrible happened!", style="danger") + + +.. note:: + style names must be lower case, start with a letter, and only contain letters or the characters ``"."``, ``"-"``, ``"_"``. + + +Customizing Defaults +~~~~~~~~~~~~~~~~~~~~ + +The Theme class will inherit the default styles builtin to Rich. If your custom theme contains the name of an existing style, it will replace it. This allows you to customize the defaults as easily as you can create your own styles. For instance, here's how you can change how Rich highlights numbers:: + + from rich.console import Console + from rich.theme import Theme + console = Console(theme=Theme({"repr.number": "bold green blink"})) + console.print("The total is 128") + +You can disable inheriting the default theme by setting ``inherit=False`` on the :class:`rich.theme.Theme` constructor. + +To see the default theme, run the following command:: + + python -m rich.theme + + +Loading Themes +~~~~~~~~~~~~~~ + +If you prefer, you can write your styles in an external config file rather than in Python. Here's an example of the format:: + + [styles] + info = dim cyan + warning = magenta + danger = bold red + +You can read these files with the :meth:`~rich.theme.Theme.read` method. diff --git a/docs/source/syntax.rst b/docs/source/syntax.rst new file mode 100644 index 0000000..823d345 --- /dev/null +++ b/docs/source/syntax.rst @@ -0,0 +1,57 @@ +Syntax +====== + +Rich can syntax highlight various programming languages with line numbers. + +To syntax highlight code, construct a :class:`~rich.syntax.Syntax` object and print it to the console. Here's an example:: + + from rich.console import Console + from rich.syntax import Syntax + + console = Console() + with open("syntax.py", "rt") as code_file: + syntax = Syntax(code_file.read(), "python") + console.print(syntax) + +You may also use the :meth:`~rich.syntax.Syntax.from_path` alternative constructor which will load the code from disk and auto-detect the file type. The example above could be re-written as follows:: + + + from rich.console import Console + from rich.syntax import Syntax + + console = Console() + syntax = Syntax.from_path("syntax.py") + console.print(syntax) + + +Line numbers +------------ + +If you set ``line_numbers=True``, Rich will render a column for line numbers:: + + syntax = Syntax.from_path("syntax.py", line_numbers=True) + + +Theme +----- + +The Syntax constructor (and :meth:`~rich.syntax.Syntax.from_path`) accept a ``theme`` attribute which should be the name of a `Pygments theme <https://pygments.org/demo/>`_. It may also be one of the special case theme names "ansi_dark" or "ansi_light" which will use the color theme configured by the terminal. + + +Background color +---------------- + +You can override the background color from the theme by supplying a ``background_color`` argument to the constructor. This should be a string in the same format a style definition accepts, .e.g "red", "#ff0000", "rgb(255,0,0)" etc. You may also set the special value "default" which will use the default background color set in the terminal. + + +Syntax CLI +---------- + +You can use this class from the command line. Here's how you would syntax highlight a file called "syntax.py":: + + python -m rich.syntax syntax.py + +For the full list of arguments, run the following:: + + python -m rich.syntax -h + diff --git a/docs/source/tables.rst b/docs/source/tables.rst new file mode 100644 index 0000000..ac5e3d4 --- /dev/null +++ b/docs/source/tables.rst @@ -0,0 +1,93 @@ +Tables +====== + +Rich's :class:`~rich.table.Table` class offers a variety of ways to render tabular data to the terminal. + +To render a table, construct a :class:`~rich.table.Table` object, add columns with :meth:`~rich.table.Table.add_column`, and rows with :meth:`~rich.table.Table.add_row` -- then print it to the console. + +Here's an example:: + + from rich.console import Console + from rich.table import Table + + table = Table(title="Star Wars Movies") + + table.add_column("Released", justify="right", style="cyan", no_wrap=True) + table.add_column("Title", style="magenta") + table.add_column("Box Office", justify="right", style="green") + + table.add_row("Dec 20, 2019", "Star Wars: The Rise of Skywalker", "$952,110,690") + table.add_row("May 25, 2018", "Solo: A Star Wars Story", "$393,151,347") + table.add_row("Dec 15, 2017", "Star Wars Ep. V111: The Last Jedi", "$1,332,539,889") + table.add_row("Dec 16, 2016", "Rogue One: A Star Wars Story", "$1,332,439,889") + + console = Console() + console.print(table) + +This produces the following output: + +.. raw:: html + + <pre style="font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"><span style="font-style: italic"> Star Wars Movies </span> + ┏━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ + ┃<span style="font-weight: bold"> Released </span>┃<span style="font-weight: bold"> Title </span>┃<span style="font-weight: bold"> Box Office </span>┃ + ┡━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩ + │<span style="color: #008080"> Dec 20, 2019 </span>│<span style="color: #800080"> Star Wars: The Rise of Skywalker </span>│<span style="color: #008000"> $952,110,690 </span>│ + │<span style="color: #008080"> May 25, 2018 </span>│<span style="color: #800080"> Solo: A Star Wars Story </span>│<span style="color: #008000"> $393,151,347 </span>│ + │<span style="color: #008080"> Dec 15, 2017 </span>│<span style="color: #800080"> Star Wars Ep. V111: The Last Jedi </span>│<span style="color: #008000"> $1,332,539,889 </span>│ + │<span style="color: #008080"> Dec 16, 2016 </span>│<span style="color: #800080"> Rogue One: A Star Wars Story </span>│<span style="color: #008000"> $1,332,439,889 </span>│ + └──────────────┴───────────────────────────────────┴────────────────┘ + </pre> + + +Rich is quite smart about rendering the table. It will adjust the column widths to fit the contents and will wrap text if it doesn't fit. You can also add anything that Rich knows how to render as a title or row cell (even another table)! + +You can set the border style by importing one of the preset :class:`~rich.box.Box` objects and setting the ``box`` argument in the table constructor. Here's an example that modifies the look of the Star Wars table:: + + from rich import box + table = Table(title="Star Wars Movies", box=box.MINIMAL_DOUBLE_HEAD) + +See :ref:`appendix_box` for other box styles. + +The :class:`~rich.table.Table` class offers a number of configuration options to set the look and feel of the table, including how borders are rendered and the style and alignment of the columns. + + +Adding columns +~~~~~~~~~~~~~~ + +You may also add columns by specifying them in the positional arguments of the :class:`~rich.table.Table` constructor. For example, we could construct a table with three columns like this:: + + table = Table("Released", "Title", "Box Office", title="Star Wars Movies") + +This allows you to specify the text of the column only. If you want to set other attributes, such as width and style, you can add an :class:`~rich.table.Column` class. Here's an example:: + + from rich.table import Column + table = Table( + "Released", + "Title", + Column(header="Box Office", justify="right"), + title="Star Wars Movies" + ) + +Lines +~~~~~ + +By default, Tables will show a line under the header only. If you want to show lines between all rows add ``show_lines=True`` to the constructor. + +Grids +~~~~~ + +The Table class can also make a great layout tool. If you disable headers and borders you can use it to position content within the terminal. The alternative constructor :meth:`~rich.table.Table.grid` can create such a table for you. + +For instance, the following code displays two pieces of text aligned to both the left and right edges of the terminal on a single line:: + + + from rich import print + from rich.table import Table + + grid = Table.grid(expand=True) + grid.add_column() + grid.add_column(justify="right") + grid.add_row("Raising shields", "[bold magenta]COMPLETED [green]:heavy_check_mark:") + + print(grid) diff --git a/docs/source/text.rst b/docs/source/text.rst new file mode 100644 index 0000000..50113d3 --- /dev/null +++ b/docs/source/text.rst @@ -0,0 +1,55 @@ +.. _rich_text: + +Rich Text +========= + +Rich has a :class:`~rich.text.Text` class you can use to mark up strings with color and style attributes. You can use a Text instance anywhere a string is accepted, which gives you a lot of control over presentation. + +You can consider this class to be like a string with marked up regions of text. Unlike a builtin ``str``, a Text instance is mutable, and most methods operate in-place rather than returning a new instance. + +One way to add a style to Text is the :meth:`~rich.text.Text.stylize` method which applies a style to a start and end offset. Here is an example:: + + from rich.console import Console + from rich.text import Text + + console = Console() + text = Text("Hello, World!") + text.stylize("bold magenta", 0, 6) + console.print(text) + +This will print "Hello, World!" to the terminal, with the first word in bold magenta. + +Alternatively, you can construct styled text by calling :meth:`~rich.text.Text.append` to add a string and style to the end of the Text. Here's an example:: + + text = Text() + text.append("Hello", style="bold magenta") + text.append(" World!") + console.print(text) + +Since building Text instances from parts is a common requirement, Rich offers :meth:`~rich.text.Text.assemble` which will combine strings or pairs of string and Style, and return a Text instance. The follow example is equivalent to the code above:: + + text = Text.assemble(("Hello", "bold magenta"), " World!") + console.print(text) + +You can apply a style to given words in the text with :meth:`~rich.text.Text.highlight_words` or for ultimate control call :meth:`~rich.text.Text.highlight_regex` to highlight text matching a *regular expression*. + + +Text attributes +~~~~~~~~~~~~~~~ + +The Text class has a number of parameters you can set on the constructor to modify how the text is displayed. + +- ``justify`` should be "left", "center", "right", or "full", and will override default justify behavior. +- ``overflow`` should be "fold", "crop", or "ellipsis", and will override default overflow. +- ``no_wrap`` prevents wrapping if the text is longer then the available width. +- ``tab_size`` Sets the number of characters in a tab. + +A Text instance may be used in place of a plain string virtually everywhere in the Rich API, which gives you a lot of control in how text renders within other Rich renderables. For instance, the following example right aligns text within a :class:`rich.panel.Panel`:: + + from rich import print + from rich.panel import Panel + from rich.text import Text + panel = Panel(Text("Hello", justify="right")) + print(panel) + + diff --git a/docs/source/traceback.rst b/docs/source/traceback.rst new file mode 100644 index 0000000..f1d46e3 --- /dev/null +++ b/docs/source/traceback.rst @@ -0,0 +1,26 @@ +Traceback +========= + +Rich can render Python tracebacks with syntax highlighting and formatting. Rich tracebacks are easier to read, and show more code, than standard Python tracebacks. + + +Printing tracebacks +------------------- + +The :meth:`~rich.console.Console.print_exception` method will print a traceback for the current exception being handled. Here's an example:: + + try: + do_something() + except: + console.print_exception() + + +Traceback handler +----------------- + +Rich can be installed as the default traceback handler so that all uncaught exceptions will be rendered with highlighting. Here's how:: + + from rich.traceback import install + install() + +There are a few options to configure the traceback handler, see :func:`~rich.traceback.install` for details.
\ No newline at end of file diff --git a/docs/source/tree.rst b/docs/source/tree.rst new file mode 100644 index 0000000..334c31d --- /dev/null +++ b/docs/source/tree.rst @@ -0,0 +1,45 @@ +Tree +==== + +Rich has a :class:`~rich.tree.Tree` class which can generate a tree view in the terminal. A tree view is a great way of presenting the contents of a filesystem or any other hierarchical data. Each branch of the tree can have a label which may be text or any other Rich renderable. + +Run the following command to see a demonstration of a Rich tree:: + + python -m rich.tree + +The following code creates and prints a tree with a simple text label:: + + from rich.tree import Tree + from rich import print + + tree = Tree("Rich Tree") + print(tree) + +With only a single ``Tree`` instance this will output nothing more than the text "Rich Tree". Things get more interesting when we call :meth:`~rich.tree.Tree.add` to add more branches to the Tree. The following code adds two more branches:: + + tree.add("foo") + tree.add("bar") + print(tree) + +The tree will now have two branches connected to the original tree with guide lines. + +When you call :meth:`~rich.tree.Tree.add` a new Tree instance is returned. You can use this instance to add more branches to, and build up a more complex tree. Let's add a few more levels to the tree:: + + baz_tree = tree.add("baz") + baz_tree.add("[red]Red").add("[green]Green").add("[blue]Blue") + print(tree) + + +Tree Styles +~~~~~~~~~~~ + +The Tree constructor and :meth:`~rich.tree.Tree.add` method allows you to specify a ``style`` argument which sets a style for the entire branch, and ``guide_style`` which sets the style for the guide lines. These styles are inherited by the branches and will apply to any sub-trees as well. + +If you set ``guide_style`` to bold, Rich will select the thicker variations of unicode line characters. Similarly, if you select the "underline2" style you will get double line style of unicode characters. + + +Examples +~~~~~~~~ + +For a more practical demonstration, see `tree.py <https://github.com/willmcgugan/rich/blob/master/examples/tree.py>`_ which can generate a tree view of a directory in your hard drive. + diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..c7024be --- /dev/null +++ b/examples/README.md @@ -0,0 +1,5 @@ +# Examples + +This directory contains various demonstrations various Rich features. To run them, make sure Rich is installed, then enter `python example.py` on the command line. + +Be sure to check the source! diff --git a/examples/bars.py b/examples/bars.py new file mode 100644 index 0000000..4ce7635 --- /dev/null +++ b/examples/bars.py @@ -0,0 +1,21 @@ +""" + +Use Bar to renderer a sort-of circle. + +""" +import math + +from rich.align import Align +from rich.bar import Bar +from rich.color import Color +from rich import print + + +SIZE = 40 + +for row in range(SIZE): + y = (row / (SIZE - 1)) * 2 - 1 + x = math.sqrt(1 - y * y) + color = Color.from_rgb((1 + y) * 127.5, 0, 0) + bar = Bar(2, width=SIZE * 2, begin=1 - x, end=1 + x, color=color) + print(Align.center(bar)) diff --git a/examples/columns.py b/examples/columns.py new file mode 100644 index 0000000..96962b0 --- /dev/null +++ b/examples/columns.py @@ -0,0 +1,28 @@ +""" +This example shows how to display content in columns. + +The data is pulled from https://randomuser.me +""" + +import json +from urllib.request import urlopen + +from rich.console import Console +from rich.columns import Columns +from rich.panel import Panel + + +def get_content(user): + """Extract text from user dict.""" + country = user["location"]["country"] + name = f"{user['name']['first']} {user['name']['last']}" + return f"[b]{name}[/b]\n[yellow]{country}" + + +console = Console() + + +users = json.loads(urlopen("https://randomuser.me/api/?results=30").read())["results"] +console.print(users, overflow="ignore", crop=False) +user_renderables = [Panel(get_content(user), expand=True) for user in users] +console.print(Columns(user_renderables)) diff --git a/examples/downloader.py b/examples/downloader.py new file mode 100644 index 0000000..5120ed5 --- /dev/null +++ b/examples/downloader.py @@ -0,0 +1,64 @@ +""" +A rudimentary URL downloader (like wget or curl) to demonstrate Rich progress bars. +""" + +from concurrent.futures import ThreadPoolExecutor +from functools import partial +import os.path +import sys +from typing import Iterable +from urllib.request import urlopen + +from rich.progress import ( + BarColumn, + DownloadColumn, + TextColumn, + TransferSpeedColumn, + TimeRemainingColumn, + Progress, + TaskID, +) + + +progress = Progress( + TextColumn("[bold blue]{task.fields[filename]}", justify="right"), + BarColumn(bar_width=None), + "[progress.percentage]{task.percentage:>3.1f}%", + "•", + DownloadColumn(), + "•", + TransferSpeedColumn(), + "•", + TimeRemainingColumn(), +) + + +def copy_url(task_id: TaskID, url: str, path: str) -> None: + """Copy data from a url to a local file.""" + response = urlopen(url) + # This will break if the response doesn't contain content length + progress.update(task_id, total=int(response.info()["Content-length"])) + with open(path, "wb") as dest_file: + progress.start_task(task_id) + for data in iter(partial(response.read, 32768), b""): + dest_file.write(data) + progress.update(task_id, advance=len(data)) + + +def download(urls: Iterable[str], dest_dir: str): + """Download multuple files to the given directory.""" + with progress: + with ThreadPoolExecutor(max_workers=4) as pool: + for url in urls: + filename = url.split("/")[-1] + dest_path = os.path.join(dest_dir, filename) + task_id = progress.add_task("download", filename=filename, start=False) + pool.submit(copy_url, task_id, url, dest_path) + + +if __name__ == "__main__": + # Try with https://releases.ubuntu.com/20.04/ubuntu-20.04.1-desktop-amd64.iso + if sys.argv[1:]: + download(sys.argv[1:], "./") + else: + print("Usage:\n\tpython downloader.py URL1 URL2 URL3 (etc)") diff --git a/examples/exception.py b/examples/exception.py new file mode 100644 index 0000000..6bf2a1e --- /dev/null +++ b/examples/exception.py @@ -0,0 +1,36 @@ +""" +Basic example to show how to print an traceback of an exception +""" +from typing import List, Tuple +from rich.console import Console + +console = Console() + + +def divide_by(number: float, divisor: float) -> float: + """Divide any number by zero.""" + # Will throw a ZeroDivisionError if divisor is 0 + result = number / divisor + return result + + +def divide_all(divides: List[Tuple[float, float]]) -> None: + """Do something impossible every day.""" + try: + for number, divisor in divides: + result = divide_by(number, divisor) + console.print(f"{number} divided by {divisor} is {result}") + except Exception: + console.print_exception(extra_lines=5, show_locals=True) + + +DIVIDES = [ + (1000, 200), + (10000, 500), + (0, 1000000), + (3.1427, 2), + (2 ** 32, 2 ** 16), + (1, 0), +] + +divide_all(DIVIDES) diff --git a/examples/fullscreen.py b/examples/fullscreen.py new file mode 100644 index 0000000..507eed1 --- /dev/null +++ b/examples/fullscreen.py @@ -0,0 +1,185 @@ +""" +Demonstrates a Rich "application" using the Layout and Live classes. + +""" + +from datetime import datetime + +from rich import box +from rich.align import Align +from rich.console import Console, RenderGroup +from rich.layout import Layout +from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn +from rich.syntax import Syntax +from rich.table import Table +from rich.text import Text + +console = Console() + + +def make_layout() -> Layout: + """Define the layout.""" + layout = Layout(name="root") + + layout.split( + Layout(name="header", size=3), + Layout(name="main", ratio=1), + Layout(name="footer", size=7), + ) + layout["main"].split( + Layout(name="side"), + Layout(name="body", ratio=2, minimum_size=60), + direction="horizontal", + ) + layout["side"].split(Layout(name="box1"), Layout(name="box2")) + return layout + + +def make_sponsor_message() -> Panel: + """Some example content.""" + sponsor_message = Table.grid(padding=1) + sponsor_message.add_column(style="green", justify="right") + sponsor_message.add_column(no_wrap=True) + sponsor_message.add_row( + "Sponsor me", + "[u blue link=https://github.com/sponsors/willmcgugan]https://github.com/sponsors/willmcgugan", + ) + sponsor_message.add_row( + "Buy me a :coffee:", + "[u blue link=https://ko-fi.com/willmcgugan]https://ko-fi.com/willmcgugan", + ) + sponsor_message.add_row( + "Twitter", + "[u blue link=https://twitter.com/willmcgugan]https://twitter.com/willmcgugan", + ) + sponsor_message.add_row( + "Blog", "[u blue link=https://www.willmcgugan.com]https://www.willmcgugan.com" + ) + + intro_message = Text.from_markup( + """Consider supporting my work via Github Sponsors (ask your company / organization), or buy me a coffee to say thanks. - Will McGugan""" + ) + + message = Table.grid(padding=1) + message.add_column() + message.add_column(no_wrap=True) + message.add_row(intro_message, sponsor_message) + + message_panel = Panel( + Align.center( + RenderGroup(intro_message, "\n", Align.center(sponsor_message)), + vertical="middle", + ), + box=box.ROUNDED, + padding=(1, 2), + title="[b red]Thanks for trying out Rich!", + border_style="bright_blue", + ) + return message_panel + + +class Header: + """Display header with clock.""" + + def __rich__(self) -> Panel: + grid = Table.grid(expand=True) + grid.add_column(justify="center", ratio=1) + grid.add_column(justify="right") + grid.add_row( + "[b]Rich[/b] Layout application", + datetime.now().ctime().replace(":", "[blink]:[/]"), + ) + return Panel(grid, style="white on blue") + + +def make_syntax() -> Syntax: + code = """\ +def ratio_resolve(total: int, edges: List[Edge]) -> List[int]: + sizes = [(edge.size or None) for edge in edges] + + # While any edges haven't been calculated + while any(size is None for size in sizes): + # Get flexible edges and index to map these back on to sizes list + flexible_edges = [ + (index, edge) + for index, (size, edge) in enumerate(zip(sizes, edges)) + if size is None + ] + # Remaining space in total + remaining = total - sum(size or 0 for size in sizes) + if remaining <= 0: + # No room for flexible edges + sizes[:] = [(size or 0) for size in sizes] + break + # Calculate number of characters in a ratio portion + portion = remaining / sum((edge.ratio or 1) for _, edge in flexible_edges) + + # If any edges will be less than their minimum, replace size with the minimum + for index, edge in flexible_edges: + if portion * edge.ratio <= edge.minimum_size: + sizes[index] = edge.minimum_size + break + else: + # Distribute flexible space and compensate for rounding error + # Since edge sizes can only be integers we need to add the remainder + # to the following line + _modf = modf + remainder = 0.0 + for index, edge in flexible_edges: + remainder, size = _modf(portion * edge.ratio + remainder) + sizes[index] = int(size) + break + # Sizes now contains integers only + return cast(List[int], sizes) + """ + syntax = Syntax(code, "python", line_numbers=True) + return syntax + + +job_progress = Progress( + "{task.description}", + SpinnerColumn(), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), +) +job_progress.add_task("[green]Cooking") +job_progress.add_task("[magenta]Baking", total=200) +job_progress.add_task("[cyan]Mixing", total=400) + +total = sum(task.total for task in job_progress.tasks) +overall_progress = Progress() +overall_task = overall_progress.add_task("All Jobs", total=int(total)) + +progress_table = Table.grid(expand=True) +progress_table.add_row( + Panel( + overall_progress, + title="Overall Progress", + border_style="green", + padding=(2, 2), + ), + Panel(job_progress, title="[b]Jobs", border_style="red", padding=(1, 2)), +) + + +layout = make_layout() +layout["header"].update(Header()) +layout["body"].update(make_sponsor_message()) +layout["box2"].update(Panel(make_syntax(), border_style="green")) +layout["box1"].update(Panel(layout.tree, border_style="red")) +layout["footer"].update(progress_table) + + +from rich.live import Live +from time import sleep + +with Live(layout, refresh_per_second=10, screen=True): + while not overall_progress.finished: + sleep(0.1) + for job in job_progress.tasks: + if not job.finished: + job_progress.advance(job.id) + + completed = sum(task.completed for task in job_progress.tasks) + overall_progress.update(overall_task, completed=completed) diff --git a/examples/group.py b/examples/group.py new file mode 100644 index 0000000..a61f830 --- /dev/null +++ b/examples/group.py @@ -0,0 +1,9 @@ +from rich import print +from rich.console import RenderGroup +from rich.panel import Panel + +panel_group = RenderGroup( + Panel("Hello", style="on blue"), + Panel("World", style="on red"), +) +print(Panel(panel_group)) diff --git a/examples/group2.py b/examples/group2.py new file mode 100644 index 0000000..13be070 --- /dev/null +++ b/examples/group2.py @@ -0,0 +1,12 @@ +from rich import print +from rich.console import render_group +from rich.panel import Panel + + +@render_group() +def get_panels(): + yield Panel("Hello", style="on blue") + yield Panel("World", style="on red") + + +print(Panel(get_panels())) diff --git a/examples/highlighter.py b/examples/highlighter.py new file mode 100644 index 0000000..a667d0d --- /dev/null +++ b/examples/highlighter.py @@ -0,0 +1,20 @@ +""" +This example demonstrates a simple text highlighter. +""" + +from rich.console import Console +from rich.highlighter import RegexHighlighter +from rich.theme import Theme + + +class EmailHighlighter(RegexHighlighter): + """Apply style to anything that looks like an email.""" + + base_style = "example." + highlights = [r"(?P<email>[\w-]+@([\w-]+\.)+[\w-]+)"] + + +theme = Theme({"example.email": "bold magenta"}) +console = Console(highlighter=EmailHighlighter(), theme=theme) + +console.print("Send funds to money@example.org") diff --git a/examples/jobs.py b/examples/jobs.py new file mode 100644 index 0000000..6286b56 --- /dev/null +++ b/examples/jobs.py @@ -0,0 +1,31 @@ +from time import sleep +from rich.panel import Panel +from rich.progress import Progress + + +JOBS = [100, 150, 25, 70, 110, 90] + +progress = Progress(auto_refresh=False) +master_task = progress.add_task("overall", total=sum(JOBS)) +jobs_task = progress.add_task("jobs") + +progress.console.print( + Panel( + "[bold blue]A demonstration of progress with a current task and overall progress.", + padding=1, + ) +) + +with progress: + for job_no, job in enumerate(JOBS): + progress.log(f"Starting job #{job_no}") + sleep(0.2) + progress.reset(jobs_task, total=job, description=f"job [bold yellow]#{job_no}") + progress.start_task(jobs_task) + for wait in progress.track(range(job), task_id=jobs_task): + sleep(0.01) + progress.advance(master_task, job) + progress.log(f"Job #{job_no} is complete") + progress.log( + Panel(":sparkle: All done! :sparkle:", border_style="green", padding=1) + ) diff --git a/examples/justify.py b/examples/justify.py new file mode 100644 index 0000000..6709882 --- /dev/null +++ b/examples/justify.py @@ -0,0 +1,13 @@ +""" +This example demonstrates the justify argument to print. +""" + +from rich.console import Console + +console = Console(width=20) + +style = "bold white on blue" +console.print("Rich", style=style) +console.print("Rich", style=style, justify="left") +console.print("Rich", style=style, justify="center") +console.print("Rich", style=style, justify="right") diff --git a/examples/justify2.py b/examples/justify2.py new file mode 100644 index 0000000..3b9882a --- /dev/null +++ b/examples/justify2.py @@ -0,0 +1,15 @@ +""" +This example demonstrates the justify argument to print. +""" + +from rich.console import Console +from rich.panel import Panel + +console = Console(width=20) + +style = "bold white on blue" +panel = Panel("Rich", style="on red", expand=False) +console.print(panel, style=style) +console.print(panel, style=style, justify="left") +console.print(panel, style=style, justify="center") +console.print(panel, style=style, justify="right") diff --git a/examples/layout.py b/examples/layout.py new file mode 100644 index 0000000..046476b --- /dev/null +++ b/examples/layout.py @@ -0,0 +1,57 @@ +""" + +Demonstrates a dynamic Layout + +""" + +from datetime import datetime + +from time import sleep + +from rich.align import Align +from rich.console import Console +from rich.layout import Layout +from rich.live import Live +from rich.text import Text + +console = Console() +layout = Layout() + +layout.split( + Layout(name="header", size=1), + Layout(ratio=1, name="main"), + Layout(size=10, name="footer"), +) + +layout["main"].split( + Layout(name="side"), Layout(name="body", ratio=2), direction="horizontal" +) + +layout["side"].split(Layout(), Layout()) + +layout["body"].update( + Align.center( + Text( + """This is a demonstration of rich.Layout\n\nHit Ctrl+C to exit""", + justify="center", + ), + vertical="middle", + ) +) + + +class Clock: + """Renders the time in the center of the screen.""" + + def __rich__(self) -> Text: + return Text(datetime.now().ctime(), style="bold magenta", justify="center") + + +layout["header"].update(Clock()) + +with Live(layout, screen=True) as live: + try: + while True: + sleep(1) + except KeyboardInterrupt: + pass diff --git a/examples/link.py b/examples/link.py new file mode 100644 index 0000000..9773c33 --- /dev/null +++ b/examples/link.py @@ -0,0 +1,4 @@ +from rich import print + +print("If your terminal supports links, the following text should be clickable:") +print("[link=https://www.willmcgugan.com][i]Visit [red]my[/red][/i] [yellow]Blog[/]") diff --git a/examples/listdir.py b/examples/listdir.py new file mode 100644 index 0000000..bffc50a --- /dev/null +++ b/examples/listdir.py @@ -0,0 +1,35 @@ +""" +A very simple `ls` clone. + +If your terminal supports hyperlinks you should be able to launch files by clicking the filename +(usually with cmd / ctrl). + +""" + +import os +import sys + +from rich import print +from rich.columns import Columns +from rich.text import Text + +try: + root_path = sys.argv[1] +except IndexError: + print("Usage: python listdir.py DIRECTORY") +else: + + def make_filename_text(filename): + path = os.path.abspath(os.path.join(root_path, filename)) + text = Text(filename, style="bold blue" if os.path.isdir(path) else "default") + text.stylize(f"link file://{path}") + text.highlight_regex(r"\..*?$", "bold") + return text + + filenames = [ + filename for filename in os.listdir(root_path) if not filename.startswith(".") + ] + filenames.sort(key=lambda filename: filename.lower()) + filename_text = [make_filename_text(filename) for filename in filenames] + columns = Columns(filename_text, equal=True, column_first=True) + print(columns) diff --git a/examples/live_progress.py b/examples/live_progress.py new file mode 100644 index 0000000..7ae1056 --- /dev/null +++ b/examples/live_progress.py @@ -0,0 +1,45 @@ +""" + +Demonstrates the use of multiple Progress instances in a single Live display. + +""" + +from time import sleep + +from rich.live import Live +from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn +from rich.table import Table + + +job_progress = Progress( + "{task.description}", + SpinnerColumn(), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), +) +job1 = job_progress.add_task("[green]Cooking") +job2 = job_progress.add_task("[magenta]Baking", total=200) +job3 = job_progress.add_task("[cyan]Mixing", total=400) + +total = sum(task.total for task in job_progress.tasks) +overall_progress = Progress() +overall_task = overall_progress.add_task("All Jobs", total=int(total)) + +progress_table = Table.grid() +progress_table.add_row( + Panel.fit( + overall_progress, title="Overall Progress", border_style="green", padding=(2, 2) + ), + Panel.fit(job_progress, title="[b]Jobs", border_style="red", padding=(1, 2)), +) + +with Live(progress_table, refresh_per_second=10): + while not overall_progress.finished: + sleep(0.1) + for job in job_progress.tasks: + if not job.finished: + job_progress.advance(job.id) + + completed = sum(task.completed for task in job_progress.tasks) + overall_progress.update(overall_task, completed=completed) diff --git a/examples/log.py b/examples/log.py new file mode 100644 index 0000000..5fe54e8 --- /dev/null +++ b/examples/log.py @@ -0,0 +1,77 @@ +""" +A simulation of Rich console logging. +""" + +import time +from rich.console import Console +from rich.style import Style +from rich.theme import Theme +from rich.highlighter import RegexHighlighter + + +class RequestHighlighter(RegexHighlighter): + base_style = "req." + highlights = [ + r"^(?P<protocol>\w+) (?P<method>\w+) (?P<path>\S+) (?P<result>\w+) (?P<stats>\[.+\])$", + r"\/(?P<filename>\w+\..{3,4})", + ] + + +theme = Theme( + { + "req.protocol": Style.parse("dim bold green"), + "req.method": Style.parse("bold cyan"), + "req.path": Style.parse("magenta"), + "req.filename": Style.parse("bright_magenta"), + "req.result": Style.parse("yellow"), + "req.stats": Style.parse("dim"), + } +) +console = Console(theme=theme) + +console.log("Server starting...") +console.log("Serving on http://127.0.0.1:8000") + +time.sleep(1) + +request_highlighter = RequestHighlighter() + +console.log( + request_highlighter("HTTP GET /foo/bar/baz/egg.html 200 [0.57, 127.0.0.1:59076]"), +) + +console.log( + request_highlighter( + "HTTP GET /foo/bar/baz/background.jpg 200 [0.57, 127.0.0.1:59076]" + ), +) + + +time.sleep(1) + + +def test_locals(): + foo = (1, 2, 3) + movies = ["Deadpool", "Rise of the Skywalker"] + console = Console() + + console.log( + "[b]JSON[/b] RPC [i]batch[/i]", + [ + {"jsonrpc": "2.0", "method": "sum", "params": [1, 2, 4], "id": "1"}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, + {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}, + {"foo": "boo"}, + { + "jsonrpc": "2.0", + "method": "foo.get", + "params": {"name": "myself", "enable": False, "grommits": None}, + "id": "5", + }, + {"jsonrpc": "2.0", "method": "get_data", "id": "9"}, + ], + log_locals=True, + ) + + +test_locals() diff --git a/examples/overflow.py b/examples/overflow.py new file mode 100644 index 0000000..5adaa3d --- /dev/null +++ b/examples/overflow.py @@ -0,0 +1,11 @@ +from typing import List +from rich.console import Console, OverflowMethod + +console = Console(width=14) +supercali = "supercalifragilisticexpialidocious" + +overflow_methods: List[OverflowMethod] = ["fold", "crop", "ellipsis"] +for overflow in overflow_methods: + console.rule(overflow) + console.print(supercali, overflow=overflow, style="bold blue") + console.print() diff --git a/examples/padding.py b/examples/padding.py new file mode 100644 index 0000000..b01d274 --- /dev/null +++ b/examples/padding.py @@ -0,0 +1,5 @@ +from rich import print +from rich.padding import Padding + +test = Padding("Hello", (2, 4), style="on blue", expand=False) +print(test) diff --git a/examples/rainbow.py b/examples/rainbow.py new file mode 100644 index 0000000..2121a78 --- /dev/null +++ b/examples/rainbow.py @@ -0,0 +1,20 @@ +""" + +This example demonstrates how to write a custom highlighter. + +""" + +from random import randint + +from rich import print +from rich.highlighter import Highlighter + + +class RainbowHighlighter(Highlighter): + def highlight(self, text): + for index in range(len(text)): + text.stylize(f"color({randint(16, 255)})", index, index + 1) + + +rainbow = RainbowHighlighter() +print(rainbow("I must not fear. Fear is the mind-killer.")) diff --git a/examples/screen.py b/examples/screen.py new file mode 100644 index 0000000..90f1028 --- /dev/null +++ b/examples/screen.py @@ -0,0 +1,21 @@ +""" +Demonstration of Console.screen() +""" + +from time import sleep + +from rich.console import Console +from rich.align import Align +from rich.text import Text +from rich.panel import Panel + +console = Console() + +with console.screen(style="bold white on red") as screen: + for count in range(5, 0, -1): + text = Align.center( + Text.from_markup(f"[blink]Don't Panic![/blink]\n{count}", justify="center"), + vertical="middle", + ) + screen.update(Panel(text)) + sleep(1) diff --git a/examples/spinners.py b/examples/spinners.py new file mode 100644 index 0000000..9daf847 --- /dev/null +++ b/examples/spinners.py @@ -0,0 +1,23 @@ +from time import sleep + +from rich.columns import Columns +from rich.panel import Panel +from rich.live import Live +from rich.text import Text +from rich.spinner import Spinner, SPINNERS + +all_spinners = Columns( + [ + Spinner(spinner_name, text=Text(repr(spinner_name), style="green")) + for spinner_name in sorted(SPINNERS) + ], + column_first=True, + expand=True, +) + +with Live( + Panel(all_spinners, title="Spinners", border_style="blue"), + refresh_per_second=20, +) as live: + while True: + sleep(0.1) diff --git a/examples/status.py b/examples/status.py new file mode 100644 index 0000000..88d1679 --- /dev/null +++ b/examples/status.py @@ -0,0 +1,13 @@ +from time import sleep +from rich.console import Console + +console = Console() +console.print() + +tasks = [f"task {n}" for n in range(1, 11)] + +with console.status("[bold green]Working on tasks...") as status: + while tasks: + task = tasks.pop(0) + sleep(1) + console.log(f"{task} complete") diff --git a/examples/table.py b/examples/table.py new file mode 100644 index 0000000..6ffa7ee --- /dev/null +++ b/examples/table.py @@ -0,0 +1,20 @@ +""" +Demonstrates how to render a table. +""" + +from rich.console import Console +from rich.table import Table + +table = Table(title="Star Wars Movies") + +table.add_column("Released", style="cyan", no_wrap=True) +table.add_column("Title", style="magenta") +table.add_column("Box Office", justify="right", style="green") + +table.add_row("Dec 20, 2019", "Star Wars: The Rise of Skywalker", "$952,110,690") +table.add_row("May 25, 2018", "Solo: A Star Wars Story", "$393,151,347") +table.add_row("Dec 15, 2017", "Star Wars Ep. V111: The Last Jedi", "$1,332,539,889") +table.add_row("Dec 16, 2016", "Rogue One: A Star Wars Story", "$1,332,439,889") + +console = Console() +console.print(table, justify="center") diff --git a/examples/table_movie.py b/examples/table_movie.py new file mode 100644 index 0000000..eaa35cd --- /dev/null +++ b/examples/table_movie.py @@ -0,0 +1,197 @@ +"""Same as the table_movie.py but uses Live to update""" +import time +from contextlib import contextmanager + +from rich import box +from rich.align import Align +from rich.console import Console +from rich.live import Live +from rich.measure import Measurement +from rich.table import Table +from rich.text import Text + +TABLE_DATA = [ + [ + "May 25, 1977", + "Star Wars Ep. [b]IV[/]: [i]A New Hope", + "$11,000,000", + "$1,554,475", + "$775,398,007", + ], + [ + "May 21, 1980", + "Star Wars Ep. [b]V[/]: [i]The Empire Strikes Back", + "$23,000,000", + "$4,910,483", + "$547,969,004", + ], + [ + "May 25, 1983", + "Star Wars Ep. [b]VI[/b]: [i]Return of the Jedi", + "$32,500,000", + "$23,019,618", + "$475,106,177", + ], + [ + "May 19, 1999", + "Star Wars Ep. [b]I[/b]: [i]The phantom Menace", + "$115,000,000", + "$64,810,870", + "$1,027,044,677", + ], + [ + "May 16, 2002", + "Star Wars Ep. [b]II[/b]: [i]Attack of the Clones", + "$115,000,000", + "$80,027,814", + "$656,695,615", + ], + [ + "May 19, 2005", + "Star Wars Ep. [b]III[/b]: [i]Revenge of the Sith", + "$115,500,000", + "$380,270,577", + "$848,998,877", + ], +] + +console = Console() + +BEAT_TIME = 0.04 + + +@contextmanager +def beat(length: int = 1) -> None: + yield + time.sleep(length * BEAT_TIME) + + +table = Table(show_footer=False) +table_centered = Align.center(table) + +console.clear() + +with Live(table_centered, console=console, screen=False, refresh_per_second=20): + with beat(10): + table.add_column("Release Date", no_wrap=True) + + with beat(10): + table.add_column("Title", Text.from_markup("[b]Total", justify="right")) + + with beat(10): + table.add_column("Budget", "[u]$412,000,000", no_wrap=True) + + with beat(10): + table.add_column("Opening Weekend", "[u]$577,703,455", no_wrap=True) + + with beat(10): + table.add_column("Box Office", "[u]$4,331,212,357", no_wrap=True) + + with beat(10): + table.title = "Star Wars Box Office" + + with beat(10): + table.title = ( + "[not italic]:popcorn:[/] Star Wars Box Office [not italic]:popcorn:[/]" + ) + + with beat(10): + table.caption = "Made with Rich" + + with beat(10): + table.caption = "Made with [b]Rich[/b]" + + with beat(10): + table.caption = "Made with [b magenta not dim]Rich[/]" + + for row in TABLE_DATA: + with beat(10): + table.add_row(*row) + + with beat(10): + table.show_footer = True + + table_width = Measurement.get(console, table, console.width).maximum + + with beat(10): + table.columns[2].justify = "right" + + with beat(10): + table.columns[3].justify = "right" + + with beat(10): + table.columns[4].justify = "right" + + with beat(10): + table.columns[2].header_style = "bold red" + + with beat(10): + table.columns[3].header_style = "bold green" + + with beat(10): + table.columns[4].header_style = "bold blue" + + with beat(10): + table.columns[2].style = "red" + + with beat(10): + table.columns[3].style = "green" + + with beat(10): + table.columns[4].style = "blue" + + with beat(10): + table.columns[0].style = "cyan" + table.columns[0].header_style = "bold cyan" + + with beat(10): + table.columns[1].style = "magenta" + table.columns[1].header_style = "bold magenta" + + with beat(10): + table.columns[2].footer_style = "bright_red" + + with beat(10): + table.columns[3].footer_style = "bright_green" + + with beat(10): + table.columns[4].footer_style = "bright_blue" + + with beat(10): + table.row_styles = ["none", "dim"] + + with beat(10): + table.border_style = "bright_yellow" + + for box in [ + box.SQUARE, + box.MINIMAL, + box.SIMPLE, + box.SIMPLE_HEAD, + ]: + with beat(10): + table.box = box + + with beat(10): + table.pad_edge = False + + original_width = Measurement.get(console, table).maximum + + for width in range(original_width, console.width, 2): + with beat(1): + table.width = width + + for width in range(console.width, original_width, -2): + with beat(1): + table.width = width + + for width in range(original_width, 90, -2): + with beat(1): + table.width = width + + for width in range(90, original_width + 1, 2): + with beat(1): + table.width = width + + with beat(2): + table.width = None diff --git a/examples/top_lite_simulator.py b/examples/top_lite_simulator.py new file mode 100644 index 0000000..e4a10f5 --- /dev/null +++ b/examples/top_lite_simulator.py @@ -0,0 +1,81 @@ +"""Lite simulation of the top linux command.""" + +import datetime +import random +import time +from dataclasses import dataclass + +from rich import box +from rich.console import Console +from rich.live import Live +from rich.table import Table +from typing_extensions import Literal + + +@dataclass +class Process: + pid: int + command: str + cpu_percent: float + memory: int + start_time: datetime.datetime + thread_count: int + state: Literal["running", "sleeping"] + + @property + def memory_str(self) -> str: + if self.memory > 1e6: + return f"{int(self.memory/1e6)}M" + if self.memory > 1e3: + return f"{int(self.memory/1e3)}K" + return str(self.memory) + + @property + def time_str(self) -> str: + return str(datetime.datetime.now() - self.start_time) + + +def generate_process(pid: int) -> Process: + return Process( + pid=pid, + command=f"Process {pid}", + cpu_percent=random.random() * 20, + memory=random.randint(10, 200) ** 3, + start_time=datetime.datetime.now() + - datetime.timedelta(seconds=random.randint(0, 500) ** 2), + thread_count=random.randint(1, 32), + state="running" if random.randint(0, 10) < 8 else "sleeping", + ) + + +def create_process_table(height: int) -> Table: + + processes = sorted( + [generate_process(pid) for pid in range(height)], + key=lambda p: p.cpu_percent, + reverse=True, + ) + table = Table( + "PID", "Command", "CPU %", "Memory", "Time", "Thread #", "State", box=box.SIMPLE + ) + + for process in processes: + table.add_row( + str(process.pid), + process.command, + f"{process.cpu_percent:.1f}", + process.memory_str, + process.time_str, + str(process.thread_count), + process.state, + ) + + return table + + +console = Console() + +with Live(console=console, screen=True, auto_refresh=False) as live: + while True: + live.update(create_process_table(console.size.height - 4), refresh=True) + time.sleep(1) diff --git a/examples/tree.py b/examples/tree.py new file mode 100644 index 0000000..9927719 --- /dev/null +++ b/examples/tree.py @@ -0,0 +1,55 @@ +""" +Demonstrates how to display a tree of files / directories with the Tree renderable. +""" + +import os +import pathlib +import sys + +from rich import print +from rich.filesize import decimal +from rich.markup import escape +from rich.text import Text +from rich.tree import Tree + + +def walk_directory(directory: pathlib.Path, tree: Tree) -> None: + """Recursively build a Tree with directory contents.""" + # Sort dirs first then by filename + paths = sorted( + pathlib.Path(directory).iterdir(), + key=lambda path: (path.is_file(), path.name.lower()), + ) + for path in paths: + # Remove hidden files + if path.name.startswith("."): + continue + if path.is_dir(): + style = "dim" if path.name.startswith("__") else "" + branch = tree.add( + f"[bold magenta]:open_file_folder: [link file://{path}]{escape(path.name)}", + style=style, + guide_style=style, + ) + walk_directory(path, branch) + else: + text_filename = Text(path.name, "green") + text_filename.highlight_regex(r"\..*$", "bold red") + text_filename.stylize(f"link file://{path}") + file_size = path.stat().st_size + text_filename.append(f" ({decimal(file_size)})", "blue") + icon = "🐍 " if path.suffix == ".py" else "📄 " + tree.add(Text(icon) + text_filename) + + +try: + directory = os.path.abspath(sys.argv[1]) +except IndexError: + print("[b]Usage:[/] python tree.py <DIRECTORY>") +else: + tree = Tree( + f":open_file_folder: [link file://{directory}]{directory}", + guide_style="bold bright_blue", + ) + walk_directory(pathlib.Path(directory), tree) + print(tree) diff --git a/imgs/columns.png b/imgs/columns.png Binary files differnew file mode 100644 index 0000000..328bbd9 --- /dev/null +++ b/imgs/columns.png diff --git a/imgs/downloader.gif b/imgs/downloader.gif Binary files differnew file mode 100644 index 0000000..2033d7b --- /dev/null +++ b/imgs/downloader.gif diff --git a/imgs/features.png b/imgs/features.png Binary files differnew file mode 100644 index 0000000..fe40a7d --- /dev/null +++ b/imgs/features.png diff --git a/imgs/hello_world.png b/imgs/hello_world.png Binary files differnew file mode 100644 index 0000000..80ad36e --- /dev/null +++ b/imgs/hello_world.png diff --git a/imgs/log.png b/imgs/log.png Binary files differnew file mode 100644 index 0000000..b29bd69 --- /dev/null +++ b/imgs/log.png diff --git a/imgs/logging.png b/imgs/logging.png Binary files differnew file mode 100644 index 0000000..08c6eba --- /dev/null +++ b/imgs/logging.png diff --git a/imgs/markdown.png b/imgs/markdown.png Binary files differnew file mode 100644 index 0000000..442ea06 --- /dev/null +++ b/imgs/markdown.png diff --git a/imgs/print.png b/imgs/print.png Binary files differnew file mode 100644 index 0000000..3c4c086 --- /dev/null +++ b/imgs/print.png diff --git a/imgs/progress.gif b/imgs/progress.gif Binary files differnew file mode 100644 index 0000000..e145478 --- /dev/null +++ b/imgs/progress.gif diff --git a/imgs/progress.png b/imgs/progress.png Binary files differnew file mode 100644 index 0000000..3f74402 --- /dev/null +++ b/imgs/progress.png diff --git a/imgs/repl.png b/imgs/repl.png Binary files differnew file mode 100644 index 0000000..da065b9 --- /dev/null +++ b/imgs/repl.png diff --git a/imgs/spinners.gif b/imgs/spinners.gif Binary files differnew file mode 100644 index 0000000..b98685c --- /dev/null +++ b/imgs/spinners.gif diff --git a/imgs/status.gif b/imgs/status.gif Binary files differnew file mode 100644 index 0000000..ccb7ceb --- /dev/null +++ b/imgs/status.gif diff --git a/imgs/syntax.png b/imgs/syntax.png Binary files differnew file mode 100644 index 0000000..b90b50d --- /dev/null +++ b/imgs/syntax.png diff --git a/imgs/table.png b/imgs/table.png Binary files differnew file mode 100644 index 0000000..649279b --- /dev/null +++ b/imgs/table.png diff --git a/imgs/table2.png b/imgs/table2.png Binary files differnew file mode 100644 index 0000000..2b0d250 --- /dev/null +++ b/imgs/table2.png diff --git a/imgs/table_movie.gif b/imgs/table_movie.gif Binary files differnew file mode 100644 index 0000000..3a0ffbf --- /dev/null +++ b/imgs/table_movie.gif diff --git a/imgs/traceback.png b/imgs/traceback.png Binary files differnew file mode 100644 index 0000000..55b2e79 --- /dev/null +++ b/imgs/traceback.png diff --git a/imgs/tree.png b/imgs/tree.png Binary files differnew file mode 100644 index 0000000..04a30a5 --- /dev/null +++ b/imgs/tree.png diff --git a/imgs/where_there_is_a_will.png b/imgs/where_there_is_a_will.png Binary files differnew file mode 100644 index 0000000..70005f8 --- /dev/null +++ b/imgs/where_there_is_a_will.png diff --git a/make.bat b/make.bat new file mode 100644 index 0000000..6247f7e --- /dev/null +++ b/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..e84d13e --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1290 @@ +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "appnope" +version = "0.1.0" +description = "Disable App Nap on OS X 10.9" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "19.3.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] +dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] +docs = ["sphinx", "zope.interface"] +tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "black" +version = "20.8b1" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +appdirs = "*" +click = ">=7.1.2" +dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} +mypy-extensions = ">=0.4.3" +pathspec = ">=0.6,<1" +regex = ">=2020.1.8" +toml = ">=0.10.1" +typed-ast = ">=1.4.0" +typing-extensions = ">=3.7.4" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] + +[[package]] +name = "bleach" +version = "3.1.5" +description = "An easy safelist-based HTML-sanitizing tool." +category = "main" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +packaging = "*" +six = ">=1.9.0" +webencodings = "*" + +[[package]] +name = "click" +version = "7.1.2" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "commonmark" +version = "0.9.1" +description = "Python parser for the CommonMark Markdown spec" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] + +[[package]] +name = "coverage" +version = "5.3" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +toml = ["toml"] + +[[package]] +name = "dataclasses" +version = "0.8" +description = "A backport of the dataclasses module for Python 3.6" +category = "main" +optional = false +python-versions = ">=3.6, <3.7" + +[[package]] +name = "decorator" +version = "4.4.2" +description = "Decorators for Humans" +category = "main" +optional = true +python-versions = ">=2.6, !=3.0.*, !=3.1.*" + +[[package]] +name = "defusedxml" +version = "0.6.0" +description = "XML bomb protection for Python stdlib modules" +category = "main" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "entrypoints" +version = "0.3" +description = "Discover and load entry points from installed packages." +category = "main" +optional = true +python-versions = ">=2.7" + +[[package]] +name = "importlib-metadata" +version = "1.7.0" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "rst.linker"] +testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "ipykernel" +version = "5.3.2" +description = "IPython Kernel for Jupyter" +category = "main" +optional = true +python-versions = ">=3.5" + +[package.dependencies] +appnope = {version = "*", markers = "platform_system == \"Darwin\""} +ipython = ">=5.0.0" +jupyter-client = "*" +tornado = ">=4.2" +traitlets = ">=4.1.0" + +[package.extras] +test = ["pytest (!=5.3.4)", "pytest-cov", "flaky", "nose"] + +[[package]] +name = "ipython" +version = "7.16.1" +description = "IPython: Productive Interactive Computing" +category = "main" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.10" +pexpect = {version = "*", markers = "sys_platform != \"win32\""} +pickleshare = "*" +prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" +pygments = "*" +traitlets = ">=4.2" + +[package.extras] +all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"] +doc = ["Sphinx (>=1.3)"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["notebook", "ipywidgets"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"] + +[[package]] +name = "ipython-genutils" +version = "0.2.0" +description = "Vestigial utilities from IPython" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "ipywidgets" +version = "7.6.3" +description = "IPython HTML widgets for Jupyter" +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +ipykernel = ">=4.5.1" +ipython = {version = ">=4.0.0", markers = "python_version >= \"3.3\""} +jupyterlab-widgets = {version = ">=1.0.0", markers = "python_version >= \"3.6\""} +nbformat = ">=4.2.0" +traitlets = ">=4.3.1" +widgetsnbextension = ">=3.5.0,<3.6.0" + +[package.extras] +test = ["pytest (>=3.6.0)", "pytest-cov", "mock"] + +[[package]] +name = "jedi" +version = "0.17.1" +description = "An autocompletion tool for Python that can be used for text editors." +category = "main" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +parso = ">=0.7.0,<0.8.0" + +[package.extras] +qa = ["flake8 (==3.7.9)"] +testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"] + +[[package]] +name = "jinja2" +version = "2.11.2" +description = "A very fast and expressive template engine." +category = "main" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +MarkupSafe = ">=0.23" + +[package.extras] +i18n = ["Babel (>=0.8)"] + +[[package]] +name = "jsonschema" +version = "3.2.0" +description = "An implementation of JSON Schema validation for Python" +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +attrs = ">=17.4.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +pyrsistent = ">=0.14.0" +six = ">=1.11.0" + +[package.extras] +format = ["idna", "jsonpointer (>1.13)", "rfc3987", "strict-rfc3339", "webcolors"] +format_nongpl = ["idna", "jsonpointer (>1.13)", "webcolors", "rfc3986-validator (>0.1.0)", "rfc3339-validator"] + +[[package]] +name = "jupyter-client" +version = "6.1.5" +description = "Jupyter protocol implementation and client libraries" +category = "main" +optional = true +python-versions = ">=3.5" + +[package.dependencies] +jupyter-core = ">=4.6.0" +python-dateutil = ">=2.1" +pyzmq = ">=13" +tornado = ">=4.1" +traitlets = "*" + +[package.extras] +test = ["ipykernel", "ipython", "mock", "pytest"] + +[[package]] +name = "jupyter-core" +version = "4.6.3" +description = "Jupyter core package. A base package on which Jupyter projects rely." +category = "main" +optional = true +python-versions = "!=3.0,!=3.1,!=3.2,!=3.3,!=3.4,>=2.7" + +[package.dependencies] +pywin32 = {version = ">=1.0", markers = "sys_platform == \"win32\""} +traitlets = "*" + +[[package]] +name = "jupyterlab-widgets" +version = "1.0.0" +description = "A JupyterLab extension." +category = "main" +optional = true +python-versions = ">=3.6" + +[[package]] +name = "markupsafe" +version = "1.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = true +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" + +[[package]] +name = "mistune" +version = "0.8.4" +description = "The fastest markdown parser in pure Python" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "mypy" +version = "0.800" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "nbconvert" +version = "5.6.1" +description = "Converting Jupyter Notebooks" +category = "main" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +bleach = "*" +defusedxml = "*" +entrypoints = ">=0.2.2" +jinja2 = ">=2.4" +jupyter-core = "*" +mistune = ">=0.8.1,<2" +nbformat = ">=4.4" +pandocfilters = ">=1.4.1" +pygments = "*" +testpath = "*" +traitlets = ">=4.2" + +[package.extras] +all = ["pytest", "pytest-cov", "ipykernel", "jupyter-client (>=5.3.1)", "ipywidgets (>=7)", "pebble", "tornado (>=4.0)", "sphinx (>=1.5.1)", "sphinx-rtd-theme", "nbsphinx (>=0.2.12)", "sphinxcontrib-github-alt", "ipython", "mock"] +docs = ["sphinx (>=1.5.1)", "sphinx-rtd-theme", "nbsphinx (>=0.2.12)", "sphinxcontrib-github-alt", "ipython", "jupyter-client (>=5.3.1)"] +execute = ["jupyter-client (>=5.3.1)"] +serve = ["tornado (>=4.0)"] +test = ["pytest", "pytest-cov", "ipykernel", "jupyter-client (>=5.3.1)", "ipywidgets (>=7)", "pebble", "mock"] + +[[package]] +name = "nbformat" +version = "5.0.7" +description = "The Jupyter Notebook format" +category = "main" +optional = true +python-versions = ">=3.5" + +[package.dependencies] +ipython-genutils = "*" +jsonschema = ">=2.4,<2.5.0 || >2.5.0" +jupyter-core = "*" +traitlets = ">=4.1" + +[package.extras] +test = ["pytest", "pytest-cov", "testpath"] + +[[package]] +name = "notebook" +version = "6.0.3" +description = "A web-based notebook environment for interactive computing" +category = "main" +optional = true +python-versions = ">=3.5" + +[package.dependencies] +ipykernel = "*" +ipython-genutils = "*" +jinja2 = "*" +jupyter-client = ">=5.3.4" +jupyter-core = ">=4.6.1" +nbconvert = "*" +nbformat = "*" +prometheus-client = "*" +pyzmq = ">=17" +Send2Trash = "*" +terminado = ">=0.8.1" +tornado = ">=5.0" +traitlets = ">=4.2.1" + +[package.extras] +test = ["nose", "coverage", "requests", "nose-warnings-filters", "nbval", "nose-exclude", "selenium", "pytest", "pytest-cov", "nose-exclude"] + +[[package]] +name = "packaging" +version = "20.4" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +name = "pandocfilters" +version = "1.4.2" +description = "Utilities for writing pandoc filters in python" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "parso" +version = "0.7.0" +description = "A Python Parser" +category = "main" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +testing = ["docopt", "pytest (>=3.0.7)"] + +[[package]] +name = "pathspec" +version = "0.8.1" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "prometheus-client" +version = "0.8.0" +description = "Python client for the Prometheus monitoring system." +category = "main" +optional = true +python-versions = "*" + +[package.extras] +twisted = ["twisted"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.3" +description = "Library for building powerful interactive command lines in Python" +category = "main" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.6.0" +description = "Run a subprocess in a pseudo terminal" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "py" +version = "1.9.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pygments" +version = "2.8.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "pyrsistent" +version = "0.16.0" +description = "Persistent/Functional/Immutable data structures" +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +six = "*" + +[[package]] +name = "pytest" +version = "6.2.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<1.0.0a1" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "2.11.1" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +coverage = ">=5.2.1" +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] + +[[package]] +name = "python-dateutil" +version = "2.8.1" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = true +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pywin32" +version = "228" +description = "Python for Window Extensions" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "pywinpty" +version = "0.5.7" +description = "Python bindings for the winpty library" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "pyzmq" +version = "19.0.1" +description = "Python bindings for 0MQ" +category = "main" +optional = true +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*" + +[[package]] +name = "regex" +version = "2020.11.13" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "send2trash" +version = "1.5.0" +description = "Send file to trash natively under Mac OS X, Windows and Linux." +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "six" +version = "1.15.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "terminado" +version = "0.8.3" +description = "Terminals served to xterm.js using Tornado websockets" +category = "main" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +ptyprocess = {version = "*", markers = "os_name != \"nt\""} +pywinpty = {version = ">=0.5", markers = "os_name == \"nt\""} +tornado = ">=4" + +[[package]] +name = "testpath" +version = "0.4.4" +description = "Test utilities for code working with files and commands" +category = "main" +optional = true +python-versions = "*" + +[package.extras] +test = ["pathlib2"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tornado" +version = "6.0.4" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +category = "main" +optional = true +python-versions = ">= 3.5" + +[[package]] +name = "traitlets" +version = "4.3.3" +description = "Traitlets Python config system" +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +decorator = "*" +ipython-genutils = "*" +six = "*" + +[package.extras] +test = ["pytest", "mock"] + +[[package]] +name = "typed-ast" +version = "1.4.1" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typing-extensions" +version = "3.7.4.3" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "widgetsnbextension" +version = "3.5.1" +description = "IPython HTML widgets for Jupyter" +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +notebook = ">=4.4.1" + +[[package]] +name = "zipp" +version = "3.1.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["jaraco.itertools", "func-timeout"] + +[extras] +jupyter = ["ipywidgets"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.6" +content-hash = "b6d6ad40385a0c73a57c3d35b5edc84bac30fb69e5ab93bb59f54138a21f6c2a" + +[metadata.files] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +appnope = [ + {file = "appnope-0.1.0-py2.py3-none-any.whl", hash = "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0"}, + {file = "appnope-0.1.0.tar.gz", hash = "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, + {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, +] +backcall = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] +black = [ + {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, +] +bleach = [ + {file = "bleach-3.1.5-py2.py3-none-any.whl", hash = "sha256:2bce3d8fab545a6528c8fa5d9f9ae8ebc85a56da365c7f85180bfe96a35ef22f"}, + {file = "bleach-3.1.5.tar.gz", hash = "sha256:3c4c520fdb9db59ef139915a5db79f8b51bc2a7257ea0389f30c846883430a4b"}, +] +click = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +commonmark = [ + {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, + {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, +] +coverage = [ + {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"}, + {file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729"}, + {file = "coverage-5.3-cp27-cp27m-win32.whl", hash = "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d"}, + {file = "coverage-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5"}, + {file = "coverage-5.3-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9"}, + {file = "coverage-5.3-cp35-cp35m-win32.whl", hash = "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636"}, + {file = "coverage-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f"}, + {file = "coverage-5.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7"}, + {file = "coverage-5.3-cp36-cp36m-win32.whl", hash = "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a"}, + {file = "coverage-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d"}, + {file = "coverage-5.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c"}, + {file = "coverage-5.3-cp37-cp37m-win32.whl", hash = "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751"}, + {file = "coverage-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709"}, + {file = "coverage-5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259"}, + {file = "coverage-5.3-cp38-cp38-win32.whl", hash = "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82"}, + {file = "coverage-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221"}, + {file = "coverage-5.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24"}, + {file = "coverage-5.3-cp39-cp39-win32.whl", hash = "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7"}, + {file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"}, + {file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"}, +] +dataclasses = [ + {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, + {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, +] +decorator = [ + {file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"}, + {file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"}, +] +defusedxml = [ + {file = "defusedxml-0.6.0-py2.py3-none-any.whl", hash = "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93"}, + {file = "defusedxml-0.6.0.tar.gz", hash = "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5"}, +] +entrypoints = [ + {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, + {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, +] +importlib-metadata = [ + {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, + {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +ipykernel = [ + {file = "ipykernel-5.3.2-py3-none-any.whl", hash = "sha256:0a5f1fc6f63241b9710b5960d314ffe44d8a18bf6674e3f28d2542b192fa318c"}, + {file = "ipykernel-5.3.2.tar.gz", hash = "sha256:89dc4bd19c7781f6d7eef0e666c59ce57beac56bb39b511544a71397b7b31cbb"}, +] +ipython = [ + {file = "ipython-7.16.1-py3-none-any.whl", hash = "sha256:2dbcc8c27ca7d3cfe4fcdff7f45b27f9a8d3edfa70ff8024a71c7a8eb5f09d64"}, + {file = "ipython-7.16.1.tar.gz", hash = "sha256:9f4fcb31d3b2c533333893b9172264e4821c1ac91839500f31bd43f2c59b3ccf"}, +] +ipython-genutils = [ + {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, + {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"}, +] +ipywidgets = [ + {file = "ipywidgets-7.6.3-py2.py3-none-any.whl", hash = "sha256:e6513cfdaf5878de30f32d57f6dc2474da395a2a2991b94d487406c0ab7f55ca"}, + {file = "ipywidgets-7.6.3.tar.gz", hash = "sha256:9f1a43e620530f9e570e4a493677d25f08310118d315b00e25a18f12913c41f0"}, +] +jedi = [ + {file = "jedi-0.17.1-py2.py3-none-any.whl", hash = "sha256:1ddb0ec78059e8e27ec9eb5098360b4ea0a3dd840bedf21415ea820c21b40a22"}, + {file = "jedi-0.17.1.tar.gz", hash = "sha256:807d5d4f96711a2bcfdd5dfa3b1ae6d09aa53832b182090b222b5efb81f52f63"}, +] +jinja2 = [ + {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, + {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, +] +jsonschema = [ + {file = "jsonschema-3.2.0-py2.py3-none-any.whl", hash = "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163"}, + {file = "jsonschema-3.2.0.tar.gz", hash = "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"}, +] +jupyter-client = [ + {file = "jupyter_client-6.1.5-py3-none-any.whl", hash = "sha256:9f0092a0951d878e7521924899e1fba6f689c7a99d43735a4c0bc05c6f311452"}, + {file = "jupyter_client-6.1.5.tar.gz", hash = "sha256:5099cda1ac86b27b655a715c51e15bdc8bd9595b2b17adb41a2bd446bbbafc4a"}, +] +jupyter-core = [ + {file = "jupyter_core-4.6.3-py2.py3-none-any.whl", hash = "sha256:a4ee613c060fe5697d913416fc9d553599c05e4492d58fac1192c9a6844abb21"}, + {file = "jupyter_core-4.6.3.tar.gz", hash = "sha256:394fd5dd787e7c8861741880bdf8a00ce39f95de5d18e579c74b882522219e7e"}, +] +jupyterlab-widgets = [ + {file = "jupyterlab_widgets-1.0.0-py3-none-any.whl", hash = "sha256:caeaf3e6103180e654e7d8d2b81b7d645e59e432487c1d35a41d6d3ee56b3fef"}, + {file = "jupyterlab_widgets-1.0.0.tar.gz", hash = "sha256:5c1a29a84d3069208cb506b10609175b249b6486d6b1cbae8fcde2a11584fb78"}, +] +markupsafe = [ + {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-win32.whl", hash = "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8"}, + {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, +] +mistune = [ + {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, + {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, +] +mypy = [ + {file = "mypy-0.800-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:e1c84c65ff6d69fb42958ece5b1255394714e0aac4df5ffe151bc4fe19c7600a"}, + {file = "mypy-0.800-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:947126195bfe4709c360e89b40114c6746ae248f04d379dca6f6ab677aa07641"}, + {file = "mypy-0.800-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:b95068a3ce3b50332c40e31a955653be245666a4bc7819d3c8898aa9fb9ea496"}, + {file = "mypy-0.800-cp35-cp35m-win_amd64.whl", hash = "sha256:ca7ad5aed210841f1e77f5f2f7d725b62c78fa77519312042c719ed2ab937876"}, + {file = "mypy-0.800-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e32b7b282c4ed4e378bba8b8dfa08e1cfa6f6574067ef22f86bee5b1039de0c9"}, + {file = "mypy-0.800-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e497a544391f733eca922fdcb326d19e894789cd4ff61d48b4b195776476c5cf"}, + {file = "mypy-0.800-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:5615785d3e2f4f03ab7697983d82c4b98af5c321614f51b8f1034eb9ebe48363"}, + {file = "mypy-0.800-cp36-cp36m-win_amd64.whl", hash = "sha256:2b216eacca0ec0ee124af9429bfd858d5619a0725ee5f88057e6e076f9eb1a7b"}, + {file = "mypy-0.800-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e3b8432f8df19e3c11235c4563a7250666dc9aa7cdda58d21b4177b20256ca9f"}, + {file = "mypy-0.800-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d16c54b0dffb861dc6318a8730952265876d90c5101085a4bc56913e8521ba19"}, + {file = "mypy-0.800-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0d2fc8beb99cd88f2d7e20d69131353053fbecea17904ee6f0348759302c52fa"}, + {file = "mypy-0.800-cp37-cp37m-win_amd64.whl", hash = "sha256:aa9d4901f3ee1a986a3a79fe079ffbf7f999478c281376f48faa31daaa814e86"}, + {file = "mypy-0.800-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:319ee5c248a7c3f94477f92a729b7ab06bf8a6d04447ef3aa8c9ba2aa47c6dcf"}, + {file = "mypy-0.800-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:74f5aa50d0866bc6fb8e213441c41e466c86678c800700b87b012ed11c0a13e0"}, + {file = "mypy-0.800-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:a301da58d566aca05f8f449403c710c50a9860782148332322decf73a603280b"}, + {file = "mypy-0.800-cp38-cp38-win_amd64.whl", hash = "sha256:b9150db14a48a8fa114189bfe49baccdff89da8c6639c2717750c7ae62316738"}, + {file = "mypy-0.800-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5fdf935a46aa20aa937f2478480ebf4be9186e98e49cc3843af9a5795a49a25"}, + {file = "mypy-0.800-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6f8425fecd2ba6007e526209bb985ce7f49ed0d2ac1cc1a44f243380a06a84fb"}, + {file = "mypy-0.800-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5ff616787122774f510caeb7b980542a7cc2222be3f00837a304ea85cd56e488"}, + {file = "mypy-0.800-cp39-cp39-win_amd64.whl", hash = "sha256:90b6f46dc2181d74f80617deca611925d7e63007cf416397358aa42efb593e07"}, + {file = "mypy-0.800-py3-none-any.whl", hash = "sha256:3e0c159a7853e3521e3f582adb1f3eac66d0b0639d434278e2867af3a8c62653"}, + {file = "mypy-0.800.tar.gz", hash = "sha256:e0202e37756ed09daf4b0ba64ad2c245d357659e014c3f51d8cd0681ba66940a"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +nbconvert = [ + {file = "nbconvert-5.6.1-py2.py3-none-any.whl", hash = "sha256:f0d6ec03875f96df45aa13e21fd9b8450c42d7e1830418cccc008c0df725fcee"}, + {file = "nbconvert-5.6.1.tar.gz", hash = "sha256:21fb48e700b43e82ba0e3142421a659d7739b65568cc832a13976a77be16b523"}, +] +nbformat = [ + {file = "nbformat-5.0.7-py3-none-any.whl", hash = "sha256:ea55c9b817855e2dfcd3f66d74857342612a60b1f09653440f4a5845e6e3523f"}, + {file = "nbformat-5.0.7.tar.gz", hash = "sha256:54d4d6354835a936bad7e8182dcd003ca3dc0cedfee5a306090e04854343b340"}, +] +notebook = [ + {file = "notebook-6.0.3-py3-none-any.whl", hash = "sha256:3edc616c684214292994a3af05eaea4cc043f6b4247d830f3a2f209fa7639a80"}, + {file = "notebook-6.0.3.tar.gz", hash = "sha256:47a9092975c9e7965ada00b9a20f0cf637d001db60d241d479f53c0be117ad48"}, +] +packaging = [ + {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, + {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, +] +pandocfilters = [ + {file = "pandocfilters-1.4.2.tar.gz", hash = "sha256:b3dd70e169bb5449e6bc6ff96aea89c5eea8c5f6ab5e207fc2f521a2cf4a0da9"}, +] +parso = [ + {file = "parso-0.7.0-py2.py3-none-any.whl", hash = "sha256:158c140fc04112dc45bca311633ae5033c2c2a7b732fa33d0955bad8152a8dd0"}, + {file = "parso-0.7.0.tar.gz", hash = "sha256:908e9fae2144a076d72ae4e25539143d40b8e3eafbaeae03c1bfe226f4cdf12c"}, +] +pathspec = [ + {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, + {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, +] +pexpect = [ + {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, + {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, +] +pickleshare = [ + {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, + {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +prometheus-client = [ + {file = "prometheus_client-0.8.0-py2.py3-none-any.whl", hash = "sha256:983c7ac4b47478720db338f1491ef67a100b474e3bc7dafcbaefb7d0b8f9b01c"}, + {file = "prometheus_client-0.8.0.tar.gz", hash = "sha256:c6e6b706833a6bd1fd51711299edee907857be10ece535126a158f911ee80915"}, +] +prompt-toolkit = [ + {file = "prompt_toolkit-3.0.3-py3-none-any.whl", hash = "sha256:c93e53af97f630f12f5f62a3274e79527936ed466f038953dfa379d4941f651a"}, + {file = "prompt_toolkit-3.0.3.tar.gz", hash = "sha256:a402e9bf468b63314e37460b68ba68243d55b2f8c4d0192f85a019af3945050e"}, +] +ptyprocess = [ + {file = "ptyprocess-0.6.0-py2.py3-none-any.whl", hash = "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"}, + {file = "ptyprocess-0.6.0.tar.gz", hash = "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0"}, +] +py = [ + {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, + {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, +] +pygments = [ + {file = "Pygments-2.8.0-py3-none-any.whl", hash = "sha256:b21b072d0ccdf29297a82a2363359d99623597b8a265b8081760e4d0f7153c88"}, + {file = "Pygments-2.8.0.tar.gz", hash = "sha256:37a13ba168a02ac54cc5891a42b1caec333e59b66addb7fa633ea8a6d73445c0"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pyrsistent = [ + {file = "pyrsistent-0.16.0.tar.gz", hash = "sha256:28669905fe725965daa16184933676547c5bb40a5153055a8dee2a4bd7933ad3"}, +] +pytest = [ + {file = "pytest-6.2.2-py3-none-any.whl", hash = "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839"}, + {file = "pytest-6.2.2.tar.gz", hash = "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9"}, +] +pytest-cov = [ + {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"}, + {file = "pytest_cov-2.11.1-py2.py3-none-any.whl", hash = "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, +] +pywin32 = [ + {file = "pywin32-228-cp27-cp27m-win32.whl", hash = "sha256:37dc9935f6a383cc744315ae0c2882ba1768d9b06700a70f35dc1ce73cd4ba9c"}, + {file = "pywin32-228-cp27-cp27m-win_amd64.whl", hash = "sha256:11cb6610efc2f078c9e6d8f5d0f957620c333f4b23466931a247fb945ed35e89"}, + {file = "pywin32-228-cp35-cp35m-win32.whl", hash = "sha256:1f45db18af5d36195447b2cffacd182fe2d296849ba0aecdab24d3852fbf3f80"}, + {file = "pywin32-228-cp35-cp35m-win_amd64.whl", hash = "sha256:6e38c44097a834a4707c1b63efa9c2435f5a42afabff634a17f563bc478dfcc8"}, + {file = "pywin32-228-cp36-cp36m-win32.whl", hash = "sha256:ec16d44b49b5f34e99eb97cf270806fdc560dff6f84d281eb2fcb89a014a56a9"}, + {file = "pywin32-228-cp36-cp36m-win_amd64.whl", hash = "sha256:a60d795c6590a5b6baeacd16c583d91cce8038f959bd80c53bd9a68f40130f2d"}, + {file = "pywin32-228-cp37-cp37m-win32.whl", hash = "sha256:af40887b6fc200eafe4d7742c48417529a8702dcc1a60bf89eee152d1d11209f"}, + {file = "pywin32-228-cp37-cp37m-win_amd64.whl", hash = "sha256:00eaf43dbd05ba6a9b0080c77e161e0b7a601f9a3f660727a952e40140537de7"}, + {file = "pywin32-228-cp38-cp38-win32.whl", hash = "sha256:fa6ba028909cfc64ce9e24bcf22f588b14871980d9787f1e2002c99af8f1850c"}, + {file = "pywin32-228-cp38-cp38-win_amd64.whl", hash = "sha256:9b3466083f8271e1a5eb0329f4e0d61925d46b40b195a33413e0905dccb285e8"}, + {file = "pywin32-228-cp39-cp39-win32.whl", hash = "sha256:ed74b72d8059a6606f64842e7917aeee99159ebd6b8d6261c518d002837be298"}, + {file = "pywin32-228-cp39-cp39-win_amd64.whl", hash = "sha256:8319bafdcd90b7202c50d6014efdfe4fde9311b3ff15fd6f893a45c0868de203"}, +] +pywinpty = [ + {file = "pywinpty-0.5.7-cp27-cp27m-win32.whl", hash = "sha256:b358cb552c0f6baf790de375fab96524a0498c9df83489b8c23f7f08795e966b"}, + {file = "pywinpty-0.5.7-cp27-cp27m-win_amd64.whl", hash = "sha256:1e525a4de05e72016a7af27836d512db67d06a015aeaf2fa0180f8e6a039b3c2"}, + {file = "pywinpty-0.5.7-cp35-cp35m-win32.whl", hash = "sha256:2740eeeb59297593a0d3f762269b01d0285c1b829d6827445fcd348fb47f7e70"}, + {file = "pywinpty-0.5.7-cp35-cp35m-win_amd64.whl", hash = "sha256:33df97f79843b2b8b8bc5c7aaf54adec08cc1bae94ee99dfb1a93c7a67704d95"}, + {file = "pywinpty-0.5.7-cp36-cp36m-win32.whl", hash = "sha256:e854211df55d107f0edfda8a80b39dfc87015bef52a8fe6594eb379240d81df2"}, + {file = "pywinpty-0.5.7-cp36-cp36m-win_amd64.whl", hash = "sha256:dbd838de92de1d4ebf0dce9d4d5e4fc38d0b7b1de837947a18b57a882f219139"}, + {file = "pywinpty-0.5.7-cp37-cp37m-win32.whl", hash = "sha256:5fb2c6c6819491b216f78acc2c521b9df21e0f53b9a399d58a5c151a3c4e2a2d"}, + {file = "pywinpty-0.5.7-cp37-cp37m-win_amd64.whl", hash = "sha256:dd22c8efacf600730abe4a46c1388355ce0d4ab75dc79b15d23a7bd87bf05b48"}, + {file = "pywinpty-0.5.7-cp38-cp38-win_amd64.whl", hash = "sha256:8fc5019ff3efb4f13708bd3b5ad327589c1a554cb516d792527361525a7cb78c"}, + {file = "pywinpty-0.5.7.tar.gz", hash = "sha256:2d7e9c881638a72ffdca3f5417dd1563b60f603e1b43e5895674c2a1b01f95a0"}, +] +pyzmq = [ + {file = "pyzmq-19.0.1-cp27-cp27m-macosx_10_9_intel.whl", hash = "sha256:58688a2dfa044fad608a8e70ba8d019d0b872ec2acd75b7b5e37da8905605891"}, + {file = "pyzmq-19.0.1-cp27-cp27m-win32.whl", hash = "sha256:87c78f6936e2654397ca2979c1d323ee4a889eef536cc77a938c6b5be33351a7"}, + {file = "pyzmq-19.0.1-cp27-cp27m-win_amd64.whl", hash = "sha256:97b6255ae77328d0e80593681826a0479cb7bac0ba8251b4dd882f5145a2293a"}, + {file = "pyzmq-19.0.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:15b4cb21118f4589c4db8be4ac12b21c8b4d0d42b3ee435d47f686c32fe2e91f"}, + {file = "pyzmq-19.0.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:931339ac2000d12fe212e64f98ce291e81a7ec6c73b125f17cf08415b753c087"}, + {file = "pyzmq-19.0.1-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:2a88b8fabd9cc35bd59194a7723f3122166811ece8b74018147a4ed8489e6421"}, + {file = "pyzmq-19.0.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:bafd651b557dd81d89bd5f9c678872f3e7b7255c1c751b78d520df2caac80230"}, + {file = "pyzmq-19.0.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:8952f6ba6ae598e792703f3134af5a01af8f5c7cf07e9a148f05a12b02412cea"}, + {file = "pyzmq-19.0.1-cp35-cp35m-win32.whl", hash = "sha256:54aa24fd60c4262286fc64ca632f9e747c7cc3a3a1144827490e1dc9b8a3a960"}, + {file = "pyzmq-19.0.1-cp35-cp35m-win_amd64.whl", hash = "sha256:dcbc3f30c11c60d709c30a213dc56e88ac016fe76ac6768e64717bd976072566"}, + {file = "pyzmq-19.0.1-cp36-cp36m-macosx_10_9_intel.whl", hash = "sha256:6ca519309703e95d55965735a667809bbb65f52beda2fdb6312385d3e7a6d234"}, + {file = "pyzmq-19.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4ee0bfd82077a3ff11c985369529b12853a4064320523f8e5079b630f9551448"}, + {file = "pyzmq-19.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ba6f24431b569aec674ede49cad197cad59571c12deed6ad8e3c596da8288217"}, + {file = "pyzmq-19.0.1-cp36-cp36m-win32.whl", hash = "sha256:956775444d01331c7eb412c5fb9bb62130dfaac77e09f32764ea1865234e2ca9"}, + {file = "pyzmq-19.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b08780e3a55215873b3b8e6e7ca8987f14c902a24b6ac081b344fd430d6ca7cd"}, + {file = "pyzmq-19.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21f7d91f3536f480cb2c10d0756bfa717927090b7fb863e6323f766e5461ee1c"}, + {file = "pyzmq-19.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bfff5ffff051f5aa47ba3b379d87bd051c3196b0c8a603e8b7ed68a6b4f217ec"}, + {file = "pyzmq-19.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:07fb8fe6826a229dada876956590135871de60dbc7de5a18c3bcce2ed1f03c98"}, + {file = "pyzmq-19.0.1-cp37-cp37m-win32.whl", hash = "sha256:342fb8a1dddc569bc361387782e8088071593e7eaf3e3ecf7d6bd4976edff112"}, + {file = "pyzmq-19.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:faee2604f279d31312bc455f3d024f160b6168b9c1dde22bf62d8c88a4deca8e"}, + {file = "pyzmq-19.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b9d21fc56c8aacd2e6d14738021a9d64f3f69b30578a99325a728e38a349f85"}, + {file = "pyzmq-19.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:af0c02cf49f4f9eedf38edb4f3b6bb621d83026e7e5d76eb5526cc5333782fd6"}, + {file = "pyzmq-19.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5f1f2eb22aab606f808163eb1d537ac9a0ba4283fbeb7a62eb48d9103cf015c2"}, + {file = "pyzmq-19.0.1-cp38-cp38-win32.whl", hash = "sha256:f9d7e742fb0196992477415bb34366c12e9bb9a0699b8b3f221ff93b213d7bec"}, + {file = "pyzmq-19.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:5b99c2ae8089ef50223c28bac57510c163bfdff158c9e90764f812b94e69a0e6"}, + {file = "pyzmq-19.0.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:cf5d689ba9513b9753959164cf500079383bc18859f58bf8ce06d8d4bef2b054"}, + {file = "pyzmq-19.0.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:aaa8b40b676576fd7806839a5de8e6d5d1b74981e6376d862af6c117af2a3c10"}, + {file = "pyzmq-19.0.1.tar.gz", hash = "sha256:13a5638ab24d628a6ade8f794195e1a1acd573496c3b85af2f1183603b7bf5e0"}, +] +regex = [ + {file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6"}, + {file = "regex-2020.11.13-cp36-cp36m-win32.whl", hash = "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e"}, + {file = "regex-2020.11.13-cp36-cp36m-win_amd64.whl", hash = "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884"}, + {file = "regex-2020.11.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538"}, + {file = "regex-2020.11.13-cp37-cp37m-win32.whl", hash = "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4"}, + {file = "regex-2020.11.13-cp37-cp37m-win_amd64.whl", hash = "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444"}, + {file = "regex-2020.11.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b"}, + {file = "regex-2020.11.13-cp38-cp38-win32.whl", hash = "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c"}, + {file = "regex-2020.11.13-cp38-cp38-win_amd64.whl", hash = "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683"}, + {file = "regex-2020.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c"}, + {file = "regex-2020.11.13-cp39-cp39-win32.whl", hash = "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f"}, + {file = "regex-2020.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d"}, + {file = "regex-2020.11.13.tar.gz", hash = "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562"}, +] +send2trash = [ + {file = "Send2Trash-1.5.0-py3-none-any.whl", hash = "sha256:f1691922577b6fa12821234aeb57599d887c4900b9ca537948d2dac34aea888b"}, + {file = "Send2Trash-1.5.0.tar.gz", hash = "sha256:60001cc07d707fe247c94f74ca6ac0d3255aabcb930529690897ca2a39db28b2"}, +] +six = [ + {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, + {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, +] +terminado = [ + {file = "terminado-0.8.3-py2.py3-none-any.whl", hash = "sha256:a43dcb3e353bc680dd0783b1d9c3fc28d529f190bc54ba9a229f72fe6e7a54d7"}, + {file = "terminado-0.8.3.tar.gz", hash = "sha256:4804a774f802306a7d9af7322193c5390f1da0abb429e082a10ef1d46e6fb2c2"}, +] +testpath = [ + {file = "testpath-0.4.4-py2.py3-none-any.whl", hash = "sha256:bfcf9411ef4bf3db7579063e0546938b1edda3d69f4e1fb8756991f5951f85d4"}, + {file = "testpath-0.4.4.tar.gz", hash = "sha256:60e0a3261c149755f4399a1fff7d37523179a70fdc3abdf78de9fc2604aeec7e"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +tornado = [ + {file = "tornado-6.0.4-cp35-cp35m-win32.whl", hash = "sha256:5217e601700f24e966ddab689f90b7ea4bd91ff3357c3600fa1045e26d68e55d"}, + {file = "tornado-6.0.4-cp35-cp35m-win_amd64.whl", hash = "sha256:c98232a3ac391f5faea6821b53db8db461157baa788f5d6222a193e9456e1740"}, + {file = "tornado-6.0.4-cp36-cp36m-win32.whl", hash = "sha256:5f6a07e62e799be5d2330e68d808c8ac41d4a259b9cea61da4101b83cb5dc673"}, + {file = "tornado-6.0.4-cp36-cp36m-win_amd64.whl", hash = "sha256:c952975c8ba74f546ae6de2e226ab3cc3cc11ae47baf607459a6728585bb542a"}, + {file = "tornado-6.0.4-cp37-cp37m-win32.whl", hash = "sha256:2c027eb2a393d964b22b5c154d1a23a5f8727db6fda837118a776b29e2b8ebc6"}, + {file = "tornado-6.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:5618f72e947533832cbc3dec54e1dffc1747a5cb17d1fd91577ed14fa0dc081b"}, + {file = "tornado-6.0.4-cp38-cp38-win32.whl", hash = "sha256:22aed82c2ea340c3771e3babc5ef220272f6fd06b5108a53b4976d0d722bcd52"}, + {file = "tornado-6.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:c58d56003daf1b616336781b26d184023ea4af13ae143d9dda65e31e534940b9"}, + {file = "tornado-6.0.4.tar.gz", hash = "sha256:0fe2d45ba43b00a41cd73f8be321a44936dc1aba233dee979f17a042b83eb6dc"}, +] +traitlets = [ + {file = "traitlets-4.3.3-py2.py3-none-any.whl", hash = "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44"}, + {file = "traitlets-4.3.3.tar.gz", hash = "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"}, +] +typed-ast = [ + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, + {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, + {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, + {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d"}, + {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, + {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d"}, + {file = "typed_ast-1.4.1-cp39-cp39-win32.whl", hash = "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395"}, + {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, + {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, +] +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +webencodings = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] +widgetsnbextension = [ + {file = "widgetsnbextension-3.5.1-py2.py3-none-any.whl", hash = "sha256:bd314f8ceb488571a5ffea6cc5b9fc6cba0adaf88a9d2386b93a489751938bcd"}, + {file = "widgetsnbextension-3.5.1.tar.gz", hash = "sha256:079f87d87270bce047512400efd70238820751a11d2d8cb137a5a5bdbaf255c7"}, +] +zipp = [ + {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, + {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, +] diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 0000000..74eb726 --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,18 @@ +## Type of changes + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation / docstrings +- [ ] Tests +- [ ] Other + +## Checklist + +- [ ] I've run the latest [black](https://github.com/psf/black) with default args on new code. +- [ ] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate. +- [ ] I've added tests for new code. +- [ ] I accept that @willmcgugan may be pedantic in the code review. + +## Description + +Please describe your changes here. If this fixes a bug, please link to the issue, if possible. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..084acbd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[tool.poetry] +name = "rich" +homepage = "https://github.com/willmcgugan/rich" +documentation = "https://rich.readthedocs.io/en/latest/" +version = "9.11.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +authors = ["Will McGugan <willmcgugan@gmail.com>"] +license = "MIT" +readme = "README.md" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Typing :: Typed" +] +include = ["rich/py.typed"] + + +[tool.poetry.dependencies] +python = "^3.6" +typing-extensions = "^3.7.4" +dataclasses = {version=">=0.7,<0.9", python = "~3.6"} +pygments = "^2.6.0" +commonmark = "^0.9.0" +colorama = "^0.4.0" +ipywidgets = {version = "^7.5.1", optional = true} + + +[tool.poetry.extras] +jupyter = ["ipywidgets"] + +[tool.poetry.dev-dependencies] +pytest = "^6.2.2" +black = "^20.8b1" +mypy = "^0.800" +pytest-cov = "^2.11.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/rich/__init__.py b/rich/__init__.py new file mode 100644 index 0000000..b0e4c8d --- /dev/null +++ b/rich/__init__.py @@ -0,0 +1,120 @@ +"""Rich text and beautiful formatting in the terminal.""" + +import os +from typing import Any, IO, Optional, TYPE_CHECKING + +__all__ = ["get_console", "reconfigure", "print", "inspect"] + +if TYPE_CHECKING: + from .console import Console + +# Global console used by alternative print +_console: Optional["Console"] = None + +_IMPORT_CWD = os.path.abspath(os.getcwd()) + + +def get_console() -> "Console": + """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. + """ + global _console + if _console is None: + from .console import Console + + _console = Console() + + return _console + + +def reconfigure(*args, **kwargs) -> None: + """Reconfigures the global console bu replacing it with another. + + Args: + console (Console): Replacement console instance. + """ + from rich.console import Console + + new_console = Console(*args, **kwargs) + _console.__dict__ = new_console.__dict__ + + +def print(*objects: Any, sep=" ", end="\n", file: IO[str] = None, flush: bool = False): + r"""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) + return write_console.print(*objects, sep=sep, end=end) + + +def inspect( + obj: Any, + *, + console: "Console" = None, + title: str = None, + help: bool = False, + methods: bool = False, + docs: bool = True, + private: bool = False, + dunder: bool = False, + sort: bool = True, + all: bool = False, + value: bool = True +): + """Inspect any Python object. + + * inspect(<OBJECT>) to see summarized info. + * inspect(<OBJECT>, methods=True) to see methods. + * inspect(<OBJECT>, help=True) to see full (non-abbreviated) help. + * inspect(<OBJECT>, private=True) to see private attributes (single underscore). + * inspect(<OBJECT>, dunder=True) to see attributes beginning with double underscore. + * inspect(<OBJECT>, all=True) to see all attributes. + + Args: + obj (Any): An object to inspect. + title (str, optional): Title to display over inspect result, or None use type. Defaults to None. + help (bool, optional): Show full help text rather than just first paragraph. Defaults to False. + methods (bool, optional): Enable inspection of callables. Defaults to False. + docs (bool, optional): Also render doc strings. Defaults to True. + private (bool, optional): Show private attributes (beginning with underscore). Defaults to False. + dunder (bool, optional): Show attributes starting with double underscore. Defaults to False. + sort (bool, optional): Sort attributes alphabetically. Defaults to True. + all (bool, optional): Show all attributes. Defaults to False. + value (bool, optional): Pretty print value. Defaults to True. + """ + _console = console or get_console() + from rich._inspect import Inspect + + # Special case for inspect(inspect) + is_inspect = obj is inspect + + _inspect = Inspect( + obj, + title=title, + help=is_inspect or help, + methods=is_inspect or methods, + docs=is_inspect or docs, + private=private, + dunder=dunder, + sort=sort, + all=all, + value=value, + ) + _console.print(_inspect) + + +if __name__ == "__main__": # pragma: no cover + print("Hello, **World**") diff --git a/rich/__main__.py b/rich/__main__.py new file mode 100644 index 0000000..411c1f5 --- /dev/null +++ b/rich/__main__.py @@ -0,0 +1,277 @@ +import colorsys +import io +from time import process_time + +from rich import box +from rich.color import Color +from rich.console import Console, ConsoleOptions, RenderGroup, RenderResult +from rich.markdown import Markdown +from rich.measure import Measurement +from rich.pretty import Pretty +from rich.segment import Segment +from rich.style import Style +from rich.syntax import Syntax +from rich.table import Table +from rich.text import Text + + +class ColorBox: + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + for y in range(0, 5): + for x in range(options.max_width): + h = x / options.max_width + l = 0.1 + ((y / 5) * 0.7) + r1, g1, b1 = colorsys.hls_to_rgb(h, l, 1.0) + r2, g2, b2 = colorsys.hls_to_rgb(h, l + 0.7 / 10, 1.0) + bgcolor = Color.from_rgb(r1 * 255, g1 * 255, b1 * 255) + color = Color.from_rgb(r2 * 255, g2 * 255, b2 * 255) + yield Segment("▄", Style(color=color, bgcolor=bgcolor)) + yield Segment.line() + + def __rich_measure__(self, console: "Console", max_width: int) -> Measurement: + return Measurement(1, max_width) + + +def make_test_card() -> Table: + """Get a renderable that demonstrates a number of features.""" + table = Table.grid(padding=1, pad_edge=True) + table.title = "Rich features" + table.add_column("Feature", no_wrap=True, justify="center", style="bold red") + table.add_column("Demonstration") + + color_table = Table( + box=None, + expand=False, + show_header=False, + show_edge=False, + pad_edge=False, + ) + color_table.add_row( + # "[bold yellow]256[/] colors or [bold green]16.7 million[/] colors [blue](if supported by your terminal)[/].", + ( + "✓ [bold green]4-bit color[/]\n" + "✓ [bold blue]8-bit color[/]\n" + "✓ [bold magenta]Truecolor (16.7 million)[/]\n" + "✓ [bold yellow]Dumb terminals[/]\n" + "✓ [bold cyan]Automatic color conversion" + ), + ColorBox(), + ) + + table.add_row("Colors", color_table) + + table.add_row( + "Styles", + "All ansi styles: [bold]bold[/], [dim]dim[/], [italic]italic[/italic], [underline]underline[/], [strike]strikethrough[/], [reverse]reverse[/], and even [blink]blink[/].", + ) + + lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque in metus sed sapien ultricies pretium a at justo. Maecenas luctus velit et auctor maximus." + lorem_table = Table.grid(padding=1, collapse_padding=True) + lorem_table.pad_edge = False + lorem_table.add_row( + Text(lorem, justify="left", style="green"), + Text(lorem, justify="center", style="yellow"), + Text(lorem, justify="right", style="blue"), + Text(lorem, justify="full", style="red"), + ) + table.add_row( + "Text", + RenderGroup( + Text.from_markup( + """Word wrap text. Justify [green]left[/], [yellow]center[/], [blue]right[/] or [red]full[/].\n""" + ), + lorem_table, + ), + ) + + def comparison(renderable1, renderable2) -> Table: + table = Table(show_header=False, pad_edge=False, box=None, expand=True) + table.add_column("1", ratio=1) + table.add_column("2", ratio=1) + table.add_row(renderable1, renderable2) + return table + + table.add_row( + "Asian\nlanguage\nsupport", + ":flag_for_china: 该库支持中文,日文和韩文文本!\n:flag_for_japan: ライブラリは中国語、日本語、韓国語のテキストをサポートしています\n:flag_for_south_korea: 이 라이브러리는 중국어, 일본어 및 한국어 텍스트를 지원합니다", + ) + + markup_example = ( + "[bold magenta]Rich[/] supports a simple [i]bbcode[/i] like [b]markup[/b] for [yellow]color[/], [underline]style[/], and emoji! " + ":+1: :apple: :ant: :bear: :baguette_bread: :bus: " + ) + table.add_row("Markup", markup_example) + + example_table = Table( + show_edge=False, + show_header=True, + expand=False, + row_styles=["none", "dim"], + box=box.SIMPLE, + ) + example_table.add_column("[green]Date", style="green", no_wrap=True) + example_table.add_column("[blue]Title", style="blue") + example_table.add_column( + "[cyan]Production Budget", + style="cyan", + justify="right", + no_wrap=True, + ) + example_table.add_column( + "[magenta]Box Office", + style="magenta", + justify="right", + no_wrap=True, + ) + example_table.add_row( + "Dec 20, 2019", + "Star Wars: The Rise of Skywalker", + "$275,000,000", + "$375,126,118", + ) + example_table.add_row( + "May 25, 2018", + "[b]Solo[/]: A Star Wars Story", + "$275,000,000", + "$393,151,347", + ) + example_table.add_row( + "Dec 15, 2017", + "Star Wars Ep. VIII: The Last Jedi", + "$262,000,000", + "[bold]$1,332,539,889[/bold]", + ) + example_table.add_row( + "May 19, 1999", + "Star Wars Ep. [b]I[/b]: [i]The phantom Menace", + "$115,000,000", + "$1,027,044,677", + ) + + table.add_row("Tables", example_table) + + code = '''\ +def iter_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: + """Iterate and generate a tuple with a flag for last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + for value in iter_values: + yield False, previous_value + previous_value = value + yield True, previous_value''' + + pretty_data = { + "foo": [ + 3.1427, + ( + "Paul Atriedies", + "Vladimir Harkonnen", + "Thufir Haway", + ), + ], + "atomic": (False, True, None), + } + table.add_row( + "Syntax\nhighlighting\n&\npretty\nprinting", + comparison( + Syntax(code, "python3", line_numbers=True, indent_guides=True), + Pretty(pretty_data, indent_guides=True), + ), + ) + + markdown_example = """\ +# Markdown + +Supports much of the *markdown*, __syntax__! + +- Headers +- Basic formatting: **bold**, *italic*, `code` +- Block quotes +- Lists, and more... + """ + table.add_row( + "Markdown", comparison("[cyan]" + markdown_example, Markdown(markdown_example)) + ) + + table.add_row( + "+more!", + """Progress bars, columns, styled logging handler, tracebacks, etc...""", + ) + return table + + +if __name__ == "__main__": # pragma: no cover + + console = Console( + file=io.StringIO(), + force_terminal=True, + ) + test_card = make_test_card() + + # Print once to warm cache + console.print(test_card) + console.file = io.StringIO() + + start = process_time() + console.print(test_card) + taken = round((process_time() - start) * 1000.0, 1) + + text = console.file.getvalue() + # https://bugs.python.org/issue37871 + for line in text.splitlines(): + print(line) + + print(f"rendered in {taken}ms") + + from rich.panel import Panel + + console = Console() + + sponsor_message = Table.grid(padding=1) + sponsor_message.add_column(style="green", justify="right") + sponsor_message.add_column(no_wrap=True) + sponsor_message.add_row( + "Sponsor me", + "[u blue link=https://github.com/sponsors/willmcgugan]https://github.com/sponsors/willmcgugan", + ) + sponsor_message.add_row( + "Buy me a :coffee:", + "[u blue link=https://ko-fi.com/willmcgugan]https://ko-fi.com/willmcgugan", + ) + sponsor_message.add_row( + "Twitter", + "[u blue link=https://twitter.com/willmcgugan]https://twitter.com/willmcgugan", + ) + sponsor_message.add_row( + "Blog", "[u blue link=https://www.willmcgugan.com]https://www.willmcgugan.com" + ) + + intro_message = Text.from_markup( + """\ +It takes a lot of time to develop Rich and to provide support. + +Consider supporting my work via Github Sponsors (ask your company / organization), or buy me a coffee to say thanks. + +- Will McGugan""" + ) + + message = Table.grid(padding=2) + message.add_column() + message.add_column(no_wrap=True) + message.add_row(intro_message, sponsor_message) + + console.print( + Panel.fit( + message, + box=box.ROUNDED, + padding=(1, 2), + title="[b red]Thanks for trying out Rich!", + border_style="bright_blue", + ), + justify="center", + ) diff --git a/rich/_cell_widths.py b/rich/_cell_widths.py new file mode 100644 index 0000000..36286df --- /dev/null +++ b/rich/_cell_widths.py @@ -0,0 +1,451 @@ +# Auto generated by make_terminal_widths.py + +CELL_WIDTHS = [ + (0, 0, 0), + (1, 31, -1), + (127, 159, -1), + (768, 879, 0), + (1155, 1161, 0), + (1425, 1469, 0), + (1471, 1471, 0), + (1473, 1474, 0), + (1476, 1477, 0), + (1479, 1479, 0), + (1552, 1562, 0), + (1611, 1631, 0), + (1648, 1648, 0), + (1750, 1756, 0), + (1759, 1764, 0), + (1767, 1768, 0), + (1770, 1773, 0), + (1809, 1809, 0), + (1840, 1866, 0), + (1958, 1968, 0), + (2027, 2035, 0), + (2045, 2045, 0), + (2070, 2073, 0), + (2075, 2083, 0), + (2085, 2087, 0), + (2089, 2093, 0), + (2137, 2139, 0), + (2259, 2273, 0), + (2275, 2306, 0), + (2362, 2362, 0), + (2364, 2364, 0), + (2369, 2376, 0), + (2381, 2381, 0), + (2385, 2391, 0), + (2402, 2403, 0), + (2433, 2433, 0), + (2492, 2492, 0), + (2497, 2500, 0), + (2509, 2509, 0), + (2530, 2531, 0), + (2558, 2558, 0), + (2561, 2562, 0), + (2620, 2620, 0), + (2625, 2626, 0), + (2631, 2632, 0), + (2635, 2637, 0), + (2641, 2641, 0), + (2672, 2673, 0), + (2677, 2677, 0), + (2689, 2690, 0), + (2748, 2748, 0), + (2753, 2757, 0), + (2759, 2760, 0), + (2765, 2765, 0), + (2786, 2787, 0), + (2810, 2815, 0), + (2817, 2817, 0), + (2876, 2876, 0), + (2879, 2879, 0), + (2881, 2884, 0), + (2893, 2893, 0), + (2901, 2902, 0), + (2914, 2915, 0), + (2946, 2946, 0), + (3008, 3008, 0), + (3021, 3021, 0), + (3072, 3072, 0), + (3076, 3076, 0), + (3134, 3136, 0), + (3142, 3144, 0), + (3146, 3149, 0), + (3157, 3158, 0), + (3170, 3171, 0), + (3201, 3201, 0), + (3260, 3260, 0), + (3263, 3263, 0), + (3270, 3270, 0), + (3276, 3277, 0), + (3298, 3299, 0), + (3328, 3329, 0), + (3387, 3388, 0), + (3393, 3396, 0), + (3405, 3405, 0), + (3426, 3427, 0), + (3457, 3457, 0), + (3530, 3530, 0), + (3538, 3540, 0), + (3542, 3542, 0), + (3633, 3633, 0), + (3636, 3642, 0), + (3655, 3662, 0), + (3761, 3761, 0), + (3764, 3772, 0), + (3784, 3789, 0), + (3864, 3865, 0), + (3893, 3893, 0), + (3895, 3895, 0), + (3897, 3897, 0), + (3953, 3966, 0), + (3968, 3972, 0), + (3974, 3975, 0), + (3981, 3991, 0), + (3993, 4028, 0), + (4038, 4038, 0), + (4141, 4144, 0), + (4146, 4151, 0), + (4153, 4154, 0), + (4157, 4158, 0), + (4184, 4185, 0), + (4190, 4192, 0), + (4209, 4212, 0), + (4226, 4226, 0), + (4229, 4230, 0), + (4237, 4237, 0), + (4253, 4253, 0), + (4352, 4447, 2), + (4957, 4959, 0), + (5906, 5908, 0), + (5938, 5940, 0), + (5970, 5971, 0), + (6002, 6003, 0), + (6068, 6069, 0), + (6071, 6077, 0), + (6086, 6086, 0), + (6089, 6099, 0), + (6109, 6109, 0), + (6155, 6157, 0), + (6277, 6278, 0), + (6313, 6313, 0), + (6432, 6434, 0), + (6439, 6440, 0), + (6450, 6450, 0), + (6457, 6459, 0), + (6679, 6680, 0), + (6683, 6683, 0), + (6742, 6742, 0), + (6744, 6750, 0), + (6752, 6752, 0), + (6754, 6754, 0), + (6757, 6764, 0), + (6771, 6780, 0), + (6783, 6783, 0), + (6832, 6848, 0), + (6912, 6915, 0), + (6964, 6964, 0), + (6966, 6970, 0), + (6972, 6972, 0), + (6978, 6978, 0), + (7019, 7027, 0), + (7040, 7041, 0), + (7074, 7077, 0), + (7080, 7081, 0), + (7083, 7085, 0), + (7142, 7142, 0), + (7144, 7145, 0), + (7149, 7149, 0), + (7151, 7153, 0), + (7212, 7219, 0), + (7222, 7223, 0), + (7376, 7378, 0), + (7380, 7392, 0), + (7394, 7400, 0), + (7405, 7405, 0), + (7412, 7412, 0), + (7416, 7417, 0), + (7616, 7673, 0), + (7675, 7679, 0), + (8203, 8207, 0), + (8232, 8238, 0), + (8288, 8291, 0), + (8400, 8432, 0), + (8986, 8987, 2), + (9001, 9002, 2), + (9193, 9196, 2), + (9200, 9200, 2), + (9203, 9203, 2), + (9725, 9726, 2), + (9748, 9749, 2), + (9800, 9811, 2), + (9855, 9855, 2), + (9875, 9875, 2), + (9889, 9889, 2), + (9898, 9899, 2), + (9917, 9918, 2), + (9924, 9925, 2), + (9934, 9934, 2), + (9940, 9940, 2), + (9962, 9962, 2), + (9970, 9971, 2), + (9973, 9973, 2), + (9978, 9978, 2), + (9981, 9981, 2), + (9989, 9989, 2), + (9994, 9995, 2), + (10024, 10024, 2), + (10060, 10060, 2), + (10062, 10062, 2), + (10067, 10069, 2), + (10071, 10071, 2), + (10133, 10135, 2), + (10160, 10160, 2), + (10175, 10175, 2), + (11035, 11036, 2), + (11088, 11088, 2), + (11093, 11093, 2), + (11503, 11505, 0), + (11647, 11647, 0), + (11744, 11775, 0), + (11904, 11929, 2), + (11931, 12019, 2), + (12032, 12245, 2), + (12272, 12283, 2), + (12288, 12329, 2), + (12330, 12333, 0), + (12334, 12350, 2), + (12353, 12438, 2), + (12441, 12442, 0), + (12443, 12543, 2), + (12549, 12591, 2), + (12593, 12686, 2), + (12688, 12771, 2), + (12784, 12830, 2), + (12832, 12871, 2), + (12880, 19903, 2), + (19968, 42124, 2), + (42128, 42182, 2), + (42607, 42610, 0), + (42612, 42621, 0), + (42654, 42655, 0), + (42736, 42737, 0), + (43010, 43010, 0), + (43014, 43014, 0), + (43019, 43019, 0), + (43045, 43046, 0), + (43052, 43052, 0), + (43204, 43205, 0), + (43232, 43249, 0), + (43263, 43263, 0), + (43302, 43309, 0), + (43335, 43345, 0), + (43360, 43388, 2), + (43392, 43394, 0), + (43443, 43443, 0), + (43446, 43449, 0), + (43452, 43453, 0), + (43493, 43493, 0), + (43561, 43566, 0), + (43569, 43570, 0), + (43573, 43574, 0), + (43587, 43587, 0), + (43596, 43596, 0), + (43644, 43644, 0), + (43696, 43696, 0), + (43698, 43700, 0), + (43703, 43704, 0), + (43710, 43711, 0), + (43713, 43713, 0), + (43756, 43757, 0), + (43766, 43766, 0), + (44005, 44005, 0), + (44008, 44008, 0), + (44013, 44013, 0), + (44032, 55203, 2), + (63744, 64255, 2), + (64286, 64286, 0), + (65024, 65039, 0), + (65040, 65049, 2), + (65056, 65071, 0), + (65072, 65106, 2), + (65108, 65126, 2), + (65128, 65131, 2), + (65281, 65376, 2), + (65504, 65510, 2), + (66045, 66045, 0), + (66272, 66272, 0), + (66422, 66426, 0), + (68097, 68099, 0), + (68101, 68102, 0), + (68108, 68111, 0), + (68152, 68154, 0), + (68159, 68159, 0), + (68325, 68326, 0), + (68900, 68903, 0), + (69291, 69292, 0), + (69446, 69456, 0), + (69633, 69633, 0), + (69688, 69702, 0), + (69759, 69761, 0), + (69811, 69814, 0), + (69817, 69818, 0), + (69888, 69890, 0), + (69927, 69931, 0), + (69933, 69940, 0), + (70003, 70003, 0), + (70016, 70017, 0), + (70070, 70078, 0), + (70089, 70092, 0), + (70095, 70095, 0), + (70191, 70193, 0), + (70196, 70196, 0), + (70198, 70199, 0), + (70206, 70206, 0), + (70367, 70367, 0), + (70371, 70378, 0), + (70400, 70401, 0), + (70459, 70460, 0), + (70464, 70464, 0), + (70502, 70508, 0), + (70512, 70516, 0), + (70712, 70719, 0), + (70722, 70724, 0), + (70726, 70726, 0), + (70750, 70750, 0), + (70835, 70840, 0), + (70842, 70842, 0), + (70847, 70848, 0), + (70850, 70851, 0), + (71090, 71093, 0), + (71100, 71101, 0), + (71103, 71104, 0), + (71132, 71133, 0), + (71219, 71226, 0), + (71229, 71229, 0), + (71231, 71232, 0), + (71339, 71339, 0), + (71341, 71341, 0), + (71344, 71349, 0), + (71351, 71351, 0), + (71453, 71455, 0), + (71458, 71461, 0), + (71463, 71467, 0), + (71727, 71735, 0), + (71737, 71738, 0), + (71995, 71996, 0), + (71998, 71998, 0), + (72003, 72003, 0), + (72148, 72151, 0), + (72154, 72155, 0), + (72160, 72160, 0), + (72193, 72202, 0), + (72243, 72248, 0), + (72251, 72254, 0), + (72263, 72263, 0), + (72273, 72278, 0), + (72281, 72283, 0), + (72330, 72342, 0), + (72344, 72345, 0), + (72752, 72758, 0), + (72760, 72765, 0), + (72767, 72767, 0), + (72850, 72871, 0), + (72874, 72880, 0), + (72882, 72883, 0), + (72885, 72886, 0), + (73009, 73014, 0), + (73018, 73018, 0), + (73020, 73021, 0), + (73023, 73029, 0), + (73031, 73031, 0), + (73104, 73105, 0), + (73109, 73109, 0), + (73111, 73111, 0), + (73459, 73460, 0), + (92912, 92916, 0), + (92976, 92982, 0), + (94031, 94031, 0), + (94095, 94098, 0), + (94176, 94179, 2), + (94180, 94180, 0), + (94192, 94193, 2), + (94208, 100343, 2), + (100352, 101589, 2), + (101632, 101640, 2), + (110592, 110878, 2), + (110928, 110930, 2), + (110948, 110951, 2), + (110960, 111355, 2), + (113821, 113822, 0), + (119143, 119145, 0), + (119163, 119170, 0), + (119173, 119179, 0), + (119210, 119213, 0), + (119362, 119364, 0), + (121344, 121398, 0), + (121403, 121452, 0), + (121461, 121461, 0), + (121476, 121476, 0), + (121499, 121503, 0), + (121505, 121519, 0), + (122880, 122886, 0), + (122888, 122904, 0), + (122907, 122913, 0), + (122915, 122916, 0), + (122918, 122922, 0), + (123184, 123190, 0), + (123628, 123631, 0), + (125136, 125142, 0), + (125252, 125258, 0), + (126980, 126980, 2), + (127183, 127183, 2), + (127374, 127374, 2), + (127377, 127386, 2), + (127488, 127490, 2), + (127504, 127547, 2), + (127552, 127560, 2), + (127568, 127569, 2), + (127584, 127589, 2), + (127744, 127776, 2), + (127789, 127797, 2), + (127799, 127868, 2), + (127870, 127891, 2), + (127904, 127946, 2), + (127951, 127955, 2), + (127968, 127984, 2), + (127988, 127988, 2), + (127992, 128062, 2), + (128064, 128064, 2), + (128066, 128252, 2), + (128255, 128317, 2), + (128331, 128334, 2), + (128336, 128359, 2), + (128378, 128378, 2), + (128405, 128406, 2), + (128420, 128420, 2), + (128507, 128591, 2), + (128640, 128709, 2), + (128716, 128716, 2), + (128720, 128722, 2), + (128725, 128727, 2), + (128747, 128748, 2), + (128756, 128764, 2), + (128992, 129003, 2), + (129292, 129338, 2), + (129340, 129349, 2), + (129351, 129400, 2), + (129402, 129483, 2), + (129485, 129535, 2), + (129648, 129652, 2), + (129656, 129658, 2), + (129664, 129670, 2), + (129680, 129704, 2), + (129712, 129718, 2), + (129728, 129730, 2), + (129744, 129750, 2), + (131072, 196605, 2), + (196608, 262141, 2), + (917760, 917999, 0), +] diff --git a/rich/_emoji_codes.py b/rich/_emoji_codes.py new file mode 100644 index 0000000..1f2877b --- /dev/null +++ b/rich/_emoji_codes.py @@ -0,0 +1,3610 @@ +EMOJI = { + "1st_place_medal": "🥇", + "2nd_place_medal": "🥈", + "3rd_place_medal": "🥉", + "ab_button_(blood_type)": "🆎", + "atm_sign": "🏧", + "a_button_(blood_type)": "🅰", + "afghanistan": "🇦🇫", + "albania": "🇦🇱", + "algeria": "🇩🇿", + "american_samoa": "🇦🇸", + "andorra": "🇦🇩", + "angola": "🇦🇴", + "anguilla": "🇦🇮", + "antarctica": "🇦🇶", + "antigua_&_barbuda": "🇦🇬", + "aquarius": "♒", + "argentina": "🇦🇷", + "aries": "♈", + "armenia": "🇦🇲", + "aruba": "🇦🇼", + "ascension_island": "🇦🇨", + "australia": "🇦🇺", + "austria": "🇦🇹", + "azerbaijan": "🇦🇿", + "back_arrow": "🔙", + "b_button_(blood_type)": "🅱", + "bahamas": "🇧🇸", + "bahrain": "🇧🇭", + "bangladesh": "🇧🇩", + "barbados": "🇧🇧", + "belarus": "🇧🇾", + "belgium": "🇧🇪", + "belize": "🇧🇿", + "benin": "🇧🇯", + "bermuda": "🇧🇲", + "bhutan": "🇧🇹", + "bolivia": "🇧🇴", + "bosnia_&_herzegovina": "🇧🇦", + "botswana": "🇧🇼", + "bouvet_island": "🇧🇻", + "brazil": "🇧🇷", + "british_indian_ocean_territory": "🇮🇴", + "british_virgin_islands": "🇻🇬", + "brunei": "🇧🇳", + "bulgaria": "🇧🇬", + "burkina_faso": "🇧🇫", + "burundi": "🇧🇮", + "cl_button": "🆑", + "cool_button": "🆒", + "cambodia": "🇰🇭", + "cameroon": "🇨🇲", + "canada": "🇨🇦", + "canary_islands": "🇮🇨", + "cancer": "♋", + "cape_verde": "🇨🇻", + "capricorn": "♑", + "caribbean_netherlands": "🇧🇶", + "cayman_islands": "🇰🇾", + "central_african_republic": "🇨🇫", + "ceuta_&_melilla": "🇪🇦", + "chad": "🇹🇩", + "chile": "🇨🇱", + "china": "🇨🇳", + "christmas_island": "🇨🇽", + "christmas_tree": "🎄", + "clipperton_island": "🇨🇵", + "cocos_(keeling)_islands": "🇨🇨", + "colombia": "🇨🇴", + "comoros": "🇰🇲", + "congo_-_brazzaville": "🇨🇬", + "congo_-_kinshasa": "🇨🇩", + "cook_islands": "🇨🇰", + "costa_rica": "🇨🇷", + "croatia": "🇭🇷", + "cuba": "🇨🇺", + "curaçao": "🇨🇼", + "cyprus": "🇨🇾", + "czechia": "🇨🇿", + "côte_d’ivoire": "🇨🇮", + "denmark": "🇩🇰", + "diego_garcia": "🇩🇬", + "djibouti": "🇩🇯", + "dominica": "🇩🇲", + "dominican_republic": "🇩🇴", + "end_arrow": "🔚", + "ecuador": "🇪🇨", + "egypt": "🇪🇬", + "el_salvador": "🇸🇻", + "england": "🏴\U000e0067\U000e0062\U000e0065\U000e006e\U000e0067\U000e007f", + "equatorial_guinea": "🇬🇶", + "eritrea": "🇪🇷", + "estonia": "🇪🇪", + "ethiopia": "🇪🇹", + "european_union": "🇪🇺", + "free_button": "🆓", + "falkland_islands": "🇫🇰", + "faroe_islands": "🇫🇴", + "fiji": "🇫🇯", + "finland": "🇫🇮", + "france": "🇫🇷", + "french_guiana": "🇬🇫", + "french_polynesia": "🇵🇫", + "french_southern_territories": "🇹🇫", + "gabon": "🇬🇦", + "gambia": "🇬🇲", + "gemini": "♊", + "georgia": "🇬🇪", + "germany": "🇩🇪", + "ghana": "🇬🇭", + "gibraltar": "🇬🇮", + "greece": "🇬🇷", + "greenland": "🇬🇱", + "grenada": "🇬🇩", + "guadeloupe": "🇬🇵", + "guam": "🇬🇺", + "guatemala": "🇬🇹", + "guernsey": "🇬🇬", + "guinea": "🇬🇳", + "guinea-bissau": "🇬🇼", + "guyana": "🇬🇾", + "haiti": "🇭🇹", + "heard_&_mcdonald_islands": "🇭🇲", + "honduras": "🇭🇳", + "hong_kong_sar_china": "🇭🇰", + "hungary": "🇭🇺", + "id_button": "🆔", + "iceland": "🇮🇸", + "india": "🇮🇳", + "indonesia": "🇮🇩", + "iran": "🇮🇷", + "iraq": "🇮🇶", + "ireland": "🇮🇪", + "isle_of_man": "🇮🇲", + "israel": "🇮🇱", + "italy": "🇮🇹", + "jamaica": "🇯🇲", + "japan": "🗾", + "japanese_acceptable_button": "🉑", + "japanese_application_button": "🈸", + "japanese_bargain_button": "🉐", + "japanese_castle": "🏯", + "japanese_congratulations_button": "㊗", + "japanese_discount_button": "🈹", + "japanese_dolls": "🎎", + "japanese_free_of_charge_button": "🈚", + "japanese_here_button": "🈁", + "japanese_monthly_amount_button": "🈷", + "japanese_no_vacancy_button": "🈵", + "japanese_not_free_of_charge_button": "🈶", + "japanese_open_for_business_button": "🈺", + "japanese_passing_grade_button": "🈴", + "japanese_post_office": "🏣", + "japanese_prohibited_button": "🈲", + "japanese_reserved_button": "🈯", + "japanese_secret_button": "㊙", + "japanese_service_charge_button": "🈂", + "japanese_symbol_for_beginner": "🔰", + "japanese_vacancy_button": "🈳", + "jersey": "🇯🇪", + "jordan": "🇯🇴", + "kazakhstan": "🇰🇿", + "kenya": "🇰🇪", + "kiribati": "🇰🇮", + "kosovo": "🇽🇰", + "kuwait": "🇰🇼", + "kyrgyzstan": "🇰🇬", + "laos": "🇱🇦", + "latvia": "🇱🇻", + "lebanon": "🇱🇧", + "leo": "♌", + "lesotho": "🇱🇸", + "liberia": "🇱🇷", + "libra": "♎", + "libya": "🇱🇾", + "liechtenstein": "🇱🇮", + "lithuania": "🇱🇹", + "luxembourg": "🇱🇺", + "macau_sar_china": "🇲🇴", + "macedonia": "🇲🇰", + "madagascar": "🇲🇬", + "malawi": "🇲🇼", + "malaysia": "🇲🇾", + "maldives": "🇲🇻", + "mali": "🇲🇱", + "malta": "🇲🇹", + "marshall_islands": "🇲🇭", + "martinique": "🇲🇶", + "mauritania": "🇲🇷", + "mauritius": "🇲🇺", + "mayotte": "🇾🇹", + "mexico": "🇲🇽", + "micronesia": "🇫🇲", + "moldova": "🇲🇩", + "monaco": "🇲🇨", + "mongolia": "🇲🇳", + "montenegro": "🇲🇪", + "montserrat": "🇲🇸", + "morocco": "🇲🇦", + "mozambique": "🇲🇿", + "mrs._claus": "🤶", + "mrs._claus_dark_skin_tone": "🤶🏿", + "mrs._claus_light_skin_tone": "🤶🏻", + "mrs._claus_medium-dark_skin_tone": "🤶🏾", + "mrs._claus_medium-light_skin_tone": "🤶🏼", + "mrs._claus_medium_skin_tone": "🤶🏽", + "myanmar_(burma)": "🇲🇲", + "new_button": "🆕", + "ng_button": "🆖", + "namibia": "🇳🇦", + "nauru": "🇳🇷", + "nepal": "🇳🇵", + "netherlands": "🇳🇱", + "new_caledonia": "🇳🇨", + "new_zealand": "🇳🇿", + "nicaragua": "🇳🇮", + "niger": "🇳🇪", + "nigeria": "🇳🇬", + "niue": "🇳🇺", + "norfolk_island": "🇳🇫", + "north_korea": "🇰🇵", + "northern_mariana_islands": "🇲🇵", + "norway": "🇳🇴", + "ok_button": "🆗", + "ok_hand": "👌", + "ok_hand_dark_skin_tone": "👌🏿", + "ok_hand_light_skin_tone": "👌🏻", + "ok_hand_medium-dark_skin_tone": "👌🏾", + "ok_hand_medium-light_skin_tone": "👌🏼", + "ok_hand_medium_skin_tone": "👌🏽", + "on!_arrow": "🔛", + "o_button_(blood_type)": "🅾", + "oman": "🇴🇲", + "ophiuchus": "⛎", + "p_button": "🅿", + "pakistan": "🇵🇰", + "palau": "🇵🇼", + "palestinian_territories": "🇵🇸", + "panama": "🇵🇦", + "papua_new_guinea": "🇵🇬", + "paraguay": "🇵🇾", + "peru": "🇵🇪", + "philippines": "🇵🇭", + "pisces": "♓", + "pitcairn_islands": "🇵🇳", + "poland": "🇵🇱", + "portugal": "🇵🇹", + "puerto_rico": "🇵🇷", + "qatar": "🇶🇦", + "romania": "🇷🇴", + "russia": "🇷🇺", + "rwanda": "🇷🇼", + "réunion": "🇷🇪", + "soon_arrow": "🔜", + "sos_button": "🆘", + "sagittarius": "♐", + "samoa": "🇼🇸", + "san_marino": "🇸🇲", + "santa_claus": "🎅", + "santa_claus_dark_skin_tone": "🎅🏿", + "santa_claus_light_skin_tone": "🎅🏻", + "santa_claus_medium-dark_skin_tone": "🎅🏾", + "santa_claus_medium-light_skin_tone": "🎅🏼", + "santa_claus_medium_skin_tone": "🎅🏽", + "saudi_arabia": "🇸🇦", + "scorpio": "♏", + "scotland": "🏴\U000e0067\U000e0062\U000e0073\U000e0063\U000e0074\U000e007f", + "senegal": "🇸🇳", + "serbia": "🇷🇸", + "seychelles": "🇸🇨", + "sierra_leone": "🇸🇱", + "singapore": "🇸🇬", + "sint_maarten": "🇸🇽", + "slovakia": "🇸🇰", + "slovenia": "🇸🇮", + "solomon_islands": "🇸🇧", + "somalia": "🇸🇴", + "south_africa": "🇿🇦", + "south_georgia_&_south_sandwich_islands": "🇬🇸", + "south_korea": "🇰🇷", + "south_sudan": "🇸🇸", + "spain": "🇪🇸", + "sri_lanka": "🇱🇰", + "st._barthélemy": "🇧🇱", + "st._helena": "🇸🇭", + "st._kitts_&_nevis": "🇰🇳", + "st._lucia": "🇱🇨", + "st._martin": "🇲🇫", + "st._pierre_&_miquelon": "🇵🇲", + "st._vincent_&_grenadines": "🇻🇨", + "statue_of_liberty": "🗽", + "sudan": "🇸🇩", + "suriname": "🇸🇷", + "svalbard_&_jan_mayen": "🇸🇯", + "swaziland": "🇸🇿", + "sweden": "🇸🇪", + "switzerland": "🇨🇭", + "syria": "🇸🇾", + "são_tomé_&_príncipe": "🇸🇹", + "t-rex": "🦖", + "top_arrow": "🔝", + "taiwan": "🇹🇼", + "tajikistan": "🇹🇯", + "tanzania": "🇹🇿", + "taurus": "♉", + "thailand": "🇹🇭", + "timor-leste": "🇹🇱", + "togo": "🇹🇬", + "tokelau": "🇹🇰", + "tokyo_tower": "🗼", + "tonga": "🇹🇴", + "trinidad_&_tobago": "🇹🇹", + "tristan_da_cunha": "🇹🇦", + "tunisia": "🇹🇳", + "turkey": "🦃", + "turkmenistan": "🇹🇲", + "turks_&_caicos_islands": "🇹🇨", + "tuvalu": "🇹🇻", + "u.s._outlying_islands": "🇺🇲", + "u.s._virgin_islands": "🇻🇮", + "up!_button": "🆙", + "uganda": "🇺🇬", + "ukraine": "🇺🇦", + "united_arab_emirates": "🇦🇪", + "united_kingdom": "🇬🇧", + "united_nations": "🇺🇳", + "united_states": "🇺🇸", + "uruguay": "🇺🇾", + "uzbekistan": "🇺🇿", + "vs_button": "🆚", + "vanuatu": "🇻🇺", + "vatican_city": "🇻🇦", + "venezuela": "🇻🇪", + "vietnam": "🇻🇳", + "virgo": "♍", + "wales": "🏴\U000e0067\U000e0062\U000e0077\U000e006c\U000e0073\U000e007f", + "wallis_&_futuna": "🇼🇫", + "western_sahara": "🇪🇭", + "yemen": "🇾🇪", + "zambia": "🇿🇲", + "zimbabwe": "🇿🇼", + "abacus": "🧮", + "adhesive_bandage": "🩹", + "admission_tickets": "🎟", + "adult": "🧑", + "adult_dark_skin_tone": "🧑🏿", + "adult_light_skin_tone": "🧑🏻", + "adult_medium-dark_skin_tone": "🧑🏾", + "adult_medium-light_skin_tone": "🧑🏼", + "adult_medium_skin_tone": "🧑🏽", + "aerial_tramway": "🚡", + "airplane": "✈", + "airplane_arrival": "🛬", + "airplane_departure": "🛫", + "alarm_clock": "⏰", + "alembic": "⚗", + "alien": "👽", + "alien_monster": "👾", + "ambulance": "🚑", + "american_football": "🏈", + "amphora": "🏺", + "anchor": "⚓", + "anger_symbol": "💢", + "angry_face": "😠", + "angry_face_with_horns": "👿", + "anguished_face": "😧", + "ant": "🐜", + "antenna_bars": "📶", + "anxious_face_with_sweat": "😰", + "articulated_lorry": "🚛", + "artist_palette": "🎨", + "astonished_face": "😲", + "atom_symbol": "⚛", + "auto_rickshaw": "🛺", + "automobile": "🚗", + "avocado": "🥑", + "axe": "🪓", + "baby": "👶", + "baby_angel": "👼", + "baby_angel_dark_skin_tone": "👼🏿", + "baby_angel_light_skin_tone": "👼🏻", + "baby_angel_medium-dark_skin_tone": "👼🏾", + "baby_angel_medium-light_skin_tone": "👼🏼", + "baby_angel_medium_skin_tone": "👼🏽", + "baby_bottle": "🍼", + "baby_chick": "🐤", + "baby_dark_skin_tone": "👶🏿", + "baby_light_skin_tone": "👶🏻", + "baby_medium-dark_skin_tone": "👶🏾", + "baby_medium-light_skin_tone": "👶🏼", + "baby_medium_skin_tone": "👶🏽", + "baby_symbol": "🚼", + "backhand_index_pointing_down": "👇", + "backhand_index_pointing_down_dark_skin_tone": "👇🏿", + "backhand_index_pointing_down_light_skin_tone": "👇🏻", + "backhand_index_pointing_down_medium-dark_skin_tone": "👇🏾", + "backhand_index_pointing_down_medium-light_skin_tone": "👇🏼", + "backhand_index_pointing_down_medium_skin_tone": "👇🏽", + "backhand_index_pointing_left": "👈", + "backhand_index_pointing_left_dark_skin_tone": "👈🏿", + "backhand_index_pointing_left_light_skin_tone": "👈🏻", + "backhand_index_pointing_left_medium-dark_skin_tone": "👈🏾", + "backhand_index_pointing_left_medium-light_skin_tone": "👈🏼", + "backhand_index_pointing_left_medium_skin_tone": "👈🏽", + "backhand_index_pointing_right": "👉", + "backhand_index_pointing_right_dark_skin_tone": "👉🏿", + "backhand_index_pointing_right_light_skin_tone": "👉🏻", + "backhand_index_pointing_right_medium-dark_skin_tone": "👉🏾", + "backhand_index_pointing_right_medium-light_skin_tone": "👉🏼", + "backhand_index_pointing_right_medium_skin_tone": "👉🏽", + "backhand_index_pointing_up": "👆", + "backhand_index_pointing_up_dark_skin_tone": "👆🏿", + "backhand_index_pointing_up_light_skin_tone": "👆🏻", + "backhand_index_pointing_up_medium-dark_skin_tone": "👆🏾", + "backhand_index_pointing_up_medium-light_skin_tone": "👆🏼", + "backhand_index_pointing_up_medium_skin_tone": "👆🏽", + "bacon": "🥓", + "badger": "🦡", + "badminton": "🏸", + "bagel": "🥯", + "baggage_claim": "🛄", + "baguette_bread": "🥖", + "balance_scale": "⚖", + "bald": "🦲", + "bald_man": "👨\u200d🦲", + "bald_woman": "👩\u200d🦲", + "ballet_shoes": "🩰", + "balloon": "🎈", + "ballot_box_with_ballot": "🗳", + "ballot_box_with_check": "☑", + "banana": "🍌", + "banjo": "🪕", + "bank": "🏦", + "bar_chart": "📊", + "barber_pole": "💈", + "baseball": "⚾", + "basket": "🧺", + "basketball": "🏀", + "bat": "🦇", + "bathtub": "🛁", + "battery": "🔋", + "beach_with_umbrella": "🏖", + "beaming_face_with_smiling_eyes": "😁", + "bear_face": "🐻", + "bearded_person": "🧔", + "bearded_person_dark_skin_tone": "🧔🏿", + "bearded_person_light_skin_tone": "🧔🏻", + "bearded_person_medium-dark_skin_tone": "🧔🏾", + "bearded_person_medium-light_skin_tone": "🧔🏼", + "bearded_person_medium_skin_tone": "🧔🏽", + "beating_heart": "💓", + "bed": "🛏", + "beer_mug": "🍺", + "bell": "🔔", + "bell_with_slash": "🔕", + "bellhop_bell": "🛎", + "bento_box": "🍱", + "beverage_box": "🧃", + "bicycle": "🚲", + "bikini": "👙", + "billed_cap": "🧢", + "biohazard": "☣", + "bird": "🐦", + "birthday_cake": "🎂", + "black_circle": "⚫", + "black_flag": "🏴", + "black_heart": "🖤", + "black_large_square": "⬛", + "black_medium-small_square": "◾", + "black_medium_square": "◼", + "black_nib": "✒", + "black_small_square": "▪", + "black_square_button": "🔲", + "blond-haired_man": "👱\u200d♂️", + "blond-haired_man_dark_skin_tone": "👱🏿\u200d♂️", + "blond-haired_man_light_skin_tone": "👱🏻\u200d♂️", + "blond-haired_man_medium-dark_skin_tone": "👱🏾\u200d♂️", + "blond-haired_man_medium-light_skin_tone": "👱🏼\u200d♂️", + "blond-haired_man_medium_skin_tone": "👱🏽\u200d♂️", + "blond-haired_person": "👱", + "blond-haired_person_dark_skin_tone": "👱🏿", + "blond-haired_person_light_skin_tone": "👱🏻", + "blond-haired_person_medium-dark_skin_tone": "👱🏾", + "blond-haired_person_medium-light_skin_tone": "👱🏼", + "blond-haired_person_medium_skin_tone": "👱🏽", + "blond-haired_woman": "👱\u200d♀️", + "blond-haired_woman_dark_skin_tone": "👱🏿\u200d♀️", + "blond-haired_woman_light_skin_tone": "👱🏻\u200d♀️", + "blond-haired_woman_medium-dark_skin_tone": "👱🏾\u200d♀️", + "blond-haired_woman_medium-light_skin_tone": "👱🏼\u200d♀️", + "blond-haired_woman_medium_skin_tone": "👱🏽\u200d♀️", + "blossom": "🌼", + "blowfish": "🐡", + "blue_book": "📘", + "blue_circle": "🔵", + "blue_heart": "💙", + "blue_square": "🟦", + "boar": "🐗", + "bomb": "💣", + "bone": "🦴", + "bookmark": "🔖", + "bookmark_tabs": "📑", + "books": "📚", + "bottle_with_popping_cork": "🍾", + "bouquet": "💐", + "bow_and_arrow": "🏹", + "bowl_with_spoon": "🥣", + "bowling": "🎳", + "boxing_glove": "🥊", + "boy": "👦", + "boy_dark_skin_tone": "👦🏿", + "boy_light_skin_tone": "👦🏻", + "boy_medium-dark_skin_tone": "👦🏾", + "boy_medium-light_skin_tone": "👦🏼", + "boy_medium_skin_tone": "👦🏽", + "brain": "🧠", + "bread": "🍞", + "breast-feeding": "🤱", + "breast-feeding_dark_skin_tone": "🤱🏿", + "breast-feeding_light_skin_tone": "🤱🏻", + "breast-feeding_medium-dark_skin_tone": "🤱🏾", + "breast-feeding_medium-light_skin_tone": "🤱🏼", + "breast-feeding_medium_skin_tone": "🤱🏽", + "brick": "🧱", + "bride_with_veil": "👰", + "bride_with_veil_dark_skin_tone": "👰🏿", + "bride_with_veil_light_skin_tone": "👰🏻", + "bride_with_veil_medium-dark_skin_tone": "👰🏾", + "bride_with_veil_medium-light_skin_tone": "👰🏼", + "bride_with_veil_medium_skin_tone": "👰🏽", + "bridge_at_night": "🌉", + "briefcase": "💼", + "briefs": "🩲", + "bright_button": "🔆", + "broccoli": "🥦", + "broken_heart": "💔", + "broom": "🧹", + "brown_circle": "🟤", + "brown_heart": "🤎", + "brown_square": "🟫", + "bug": "🐛", + "building_construction": "🏗", + "bullet_train": "🚅", + "burrito": "🌯", + "bus": "🚌", + "bus_stop": "🚏", + "bust_in_silhouette": "👤", + "busts_in_silhouette": "👥", + "butter": "🧈", + "butterfly": "🦋", + "cactus": "🌵", + "calendar": "📆", + "call_me_hand": "🤙", + "call_me_hand_dark_skin_tone": "🤙🏿", + "call_me_hand_light_skin_tone": "🤙🏻", + "call_me_hand_medium-dark_skin_tone": "🤙🏾", + "call_me_hand_medium-light_skin_tone": "🤙🏼", + "call_me_hand_medium_skin_tone": "🤙🏽", + "camel": "🐫", + "camera": "📷", + "camera_with_flash": "📸", + "camping": "🏕", + "candle": "🕯", + "candy": "🍬", + "canned_food": "🥫", + "canoe": "🛶", + "card_file_box": "🗃", + "card_index": "📇", + "card_index_dividers": "🗂", + "carousel_horse": "🎠", + "carp_streamer": "🎏", + "carrot": "🥕", + "castle": "🏰", + "cat": "🐱", + "cat_face": "🐱", + "cat_face_with_tears_of_joy": "😹", + "cat_face_with_wry_smile": "😼", + "chains": "⛓", + "chair": "🪑", + "chart_decreasing": "📉", + "chart_increasing": "📈", + "chart_increasing_with_yen": "💹", + "cheese_wedge": "🧀", + "chequered_flag": "🏁", + "cherries": "🍒", + "cherry_blossom": "🌸", + "chess_pawn": "♟", + "chestnut": "🌰", + "chicken": "🐔", + "child": "🧒", + "child_dark_skin_tone": "🧒🏿", + "child_light_skin_tone": "🧒🏻", + "child_medium-dark_skin_tone": "🧒🏾", + "child_medium-light_skin_tone": "🧒🏼", + "child_medium_skin_tone": "🧒🏽", + "children_crossing": "🚸", + "chipmunk": "🐿", + "chocolate_bar": "🍫", + "chopsticks": "🥢", + "church": "⛪", + "cigarette": "🚬", + "cinema": "🎦", + "circled_m": "Ⓜ", + "circus_tent": "🎪", + "cityscape": "🏙", + "cityscape_at_dusk": "🌆", + "clamp": "🗜", + "clapper_board": "🎬", + "clapping_hands": "👏", + "clapping_hands_dark_skin_tone": "👏🏿", + "clapping_hands_light_skin_tone": "👏🏻", + "clapping_hands_medium-dark_skin_tone": "👏🏾", + "clapping_hands_medium-light_skin_tone": "👏🏼", + "clapping_hands_medium_skin_tone": "👏🏽", + "classical_building": "🏛", + "clinking_beer_mugs": "🍻", + "clinking_glasses": "🥂", + "clipboard": "📋", + "clockwise_vertical_arrows": "🔃", + "closed_book": "📕", + "closed_mailbox_with_lowered_flag": "📪", + "closed_mailbox_with_raised_flag": "📫", + "closed_umbrella": "🌂", + "cloud": "☁", + "cloud_with_lightning": "🌩", + "cloud_with_lightning_and_rain": "⛈", + "cloud_with_rain": "🌧", + "cloud_with_snow": "🌨", + "clown_face": "🤡", + "club_suit": "♣", + "clutch_bag": "👝", + "coat": "🧥", + "cocktail_glass": "🍸", + "coconut": "🥥", + "coffin": "⚰", + "cold_face": "🥶", + "collision": "💥", + "comet": "☄", + "compass": "🧭", + "computer_disk": "💽", + "computer_mouse": "🖱", + "confetti_ball": "🎊", + "confounded_face": "😖", + "confused_face": "😕", + "construction": "🚧", + "construction_worker": "👷", + "construction_worker_dark_skin_tone": "👷🏿", + "construction_worker_light_skin_tone": "👷🏻", + "construction_worker_medium-dark_skin_tone": "👷🏾", + "construction_worker_medium-light_skin_tone": "👷🏼", + "construction_worker_medium_skin_tone": "👷🏽", + "control_knobs": "🎛", + "convenience_store": "🏪", + "cooked_rice": "🍚", + "cookie": "🍪", + "cooking": "🍳", + "copyright": "©", + "couch_and_lamp": "🛋", + "counterclockwise_arrows_button": "🔄", + "couple_with_heart": "💑", + "couple_with_heart_man_man": "👨\u200d❤️\u200d👨", + "couple_with_heart_woman_man": "👩\u200d❤️\u200d👨", + "couple_with_heart_woman_woman": "👩\u200d❤️\u200d👩", + "cow": "🐮", + "cow_face": "🐮", + "cowboy_hat_face": "🤠", + "crab": "🦀", + "crayon": "🖍", + "credit_card": "💳", + "crescent_moon": "🌙", + "cricket": "🦗", + "cricket_game": "🏏", + "crocodile": "🐊", + "croissant": "🥐", + "cross_mark": "❌", + "cross_mark_button": "❎", + "crossed_fingers": "🤞", + "crossed_fingers_dark_skin_tone": "🤞🏿", + "crossed_fingers_light_skin_tone": "🤞🏻", + "crossed_fingers_medium-dark_skin_tone": "🤞🏾", + "crossed_fingers_medium-light_skin_tone": "🤞🏼", + "crossed_fingers_medium_skin_tone": "🤞🏽", + "crossed_flags": "🎌", + "crossed_swords": "⚔", + "crown": "👑", + "crying_cat_face": "😿", + "crying_face": "😢", + "crystal_ball": "🔮", + "cucumber": "🥒", + "cupcake": "🧁", + "cup_with_straw": "🥤", + "curling_stone": "🥌", + "curly_hair": "🦱", + "curly-haired_man": "👨\u200d🦱", + "curly-haired_woman": "👩\u200d🦱", + "curly_loop": "➰", + "currency_exchange": "💱", + "curry_rice": "🍛", + "custard": "🍮", + "customs": "🛃", + "cut_of_meat": "🥩", + "cyclone": "🌀", + "dagger": "🗡", + "dango": "🍡", + "dashing_away": "💨", + "deaf_person": "🧏", + "deciduous_tree": "🌳", + "deer": "🦌", + "delivery_truck": "🚚", + "department_store": "🏬", + "derelict_house": "🏚", + "desert": "🏜", + "desert_island": "🏝", + "desktop_computer": "🖥", + "detective": "🕵", + "detective_dark_skin_tone": "🕵🏿", + "detective_light_skin_tone": "🕵🏻", + "detective_medium-dark_skin_tone": "🕵🏾", + "detective_medium-light_skin_tone": "🕵🏼", + "detective_medium_skin_tone": "🕵🏽", + "diamond_suit": "♦", + "diamond_with_a_dot": "💠", + "dim_button": "🔅", + "direct_hit": "🎯", + "disappointed_face": "😞", + "diving_mask": "🤿", + "diya_lamp": "🪔", + "dizzy": "💫", + "dizzy_face": "😵", + "dna": "🧬", + "dog": "🐶", + "dog_face": "🐶", + "dollar_banknote": "💵", + "dolphin": "🐬", + "door": "🚪", + "dotted_six-pointed_star": "🔯", + "double_curly_loop": "➿", + "double_exclamation_mark": "‼", + "doughnut": "🍩", + "dove": "🕊", + "down-left_arrow": "↙", + "down-right_arrow": "↘", + "down_arrow": "⬇", + "downcast_face_with_sweat": "😓", + "downwards_button": "🔽", + "dragon": "🐉", + "dragon_face": "🐲", + "dress": "👗", + "drooling_face": "🤤", + "drop_of_blood": "🩸", + "droplet": "💧", + "drum": "🥁", + "duck": "🦆", + "dumpling": "🥟", + "dvd": "📀", + "e-mail": "📧", + "eagle": "🦅", + "ear": "👂", + "ear_dark_skin_tone": "👂🏿", + "ear_light_skin_tone": "👂🏻", + "ear_medium-dark_skin_tone": "👂🏾", + "ear_medium-light_skin_tone": "👂🏼", + "ear_medium_skin_tone": "👂🏽", + "ear_of_corn": "🌽", + "ear_with_hearing_aid": "🦻", + "egg": "🍳", + "eggplant": "🍆", + "eight-pointed_star": "✴", + "eight-spoked_asterisk": "✳", + "eight-thirty": "🕣", + "eight_o’clock": "🕗", + "eject_button": "⏏", + "electric_plug": "🔌", + "elephant": "🐘", + "eleven-thirty": "🕦", + "eleven_o’clock": "🕚", + "elf": "🧝", + "elf_dark_skin_tone": "🧝🏿", + "elf_light_skin_tone": "🧝🏻", + "elf_medium-dark_skin_tone": "🧝🏾", + "elf_medium-light_skin_tone": "🧝🏼", + "elf_medium_skin_tone": "🧝🏽", + "envelope": "✉", + "envelope_with_arrow": "📩", + "euro_banknote": "💶", + "evergreen_tree": "🌲", + "ewe": "🐑", + "exclamation_mark": "❗", + "exclamation_question_mark": "⁉", + "exploding_head": "🤯", + "expressionless_face": "😑", + "eye": "👁", + "eye_in_speech_bubble": "👁️\u200d🗨️", + "eyes": "👀", + "face_blowing_a_kiss": "😘", + "face_savoring_food": "😋", + "face_screaming_in_fear": "😱", + "face_vomiting": "🤮", + "face_with_hand_over_mouth": "🤭", + "face_with_head-bandage": "🤕", + "face_with_medical_mask": "😷", + "face_with_monocle": "🧐", + "face_with_open_mouth": "😮", + "face_with_raised_eyebrow": "🤨", + "face_with_rolling_eyes": "🙄", + "face_with_steam_from_nose": "😤", + "face_with_symbols_on_mouth": "🤬", + "face_with_tears_of_joy": "😂", + "face_with_thermometer": "🤒", + "face_with_tongue": "😛", + "face_without_mouth": "😶", + "factory": "🏭", + "fairy": "🧚", + "fairy_dark_skin_tone": "🧚🏿", + "fairy_light_skin_tone": "🧚🏻", + "fairy_medium-dark_skin_tone": "🧚🏾", + "fairy_medium-light_skin_tone": "🧚🏼", + "fairy_medium_skin_tone": "🧚🏽", + "falafel": "🧆", + "fallen_leaf": "🍂", + "family": "👪", + "family_man_boy": "👨\u200d👦", + "family_man_boy_boy": "👨\u200d👦\u200d👦", + "family_man_girl": "👨\u200d👧", + "family_man_girl_boy": "👨\u200d👧\u200d👦", + "family_man_girl_girl": "👨\u200d👧\u200d👧", + "family_man_man_boy": "👨\u200d👨\u200d👦", + "family_man_man_boy_boy": "👨\u200d👨\u200d👦\u200d👦", + "family_man_man_girl": "👨\u200d👨\u200d👧", + "family_man_man_girl_boy": "👨\u200d👨\u200d👧\u200d👦", + "family_man_man_girl_girl": "👨\u200d👨\u200d👧\u200d👧", + "family_man_woman_boy": "👨\u200d👩\u200d👦", + "family_man_woman_boy_boy": "👨\u200d👩\u200d👦\u200d👦", + "family_man_woman_girl": "👨\u200d👩\u200d👧", + "family_man_woman_girl_boy": "👨\u200d👩\u200d👧\u200d👦", + "family_man_woman_girl_girl": "👨\u200d👩\u200d👧\u200d👧", + "family_woman_boy": "👩\u200d👦", + "family_woman_boy_boy": "👩\u200d👦\u200d👦", + "family_woman_girl": "👩\u200d👧", + "family_woman_girl_boy": "👩\u200d👧\u200d👦", + "family_woman_girl_girl": "👩\u200d👧\u200d👧", + "family_woman_woman_boy": "👩\u200d👩\u200d👦", + "family_woman_woman_boy_boy": "👩\u200d👩\u200d👦\u200d👦", + "family_woman_woman_girl": "👩\u200d👩\u200d👧", + "family_woman_woman_girl_boy": "👩\u200d👩\u200d👧\u200d👦", + "family_woman_woman_girl_girl": "👩\u200d👩\u200d👧\u200d👧", + "fast-forward_button": "⏩", + "fast_down_button": "⏬", + "fast_reverse_button": "⏪", + "fast_up_button": "⏫", + "fax_machine": "📠", + "fearful_face": "😨", + "female_sign": "♀", + "ferris_wheel": "🎡", + "ferry": "⛴", + "field_hockey": "🏑", + "file_cabinet": "🗄", + "file_folder": "📁", + "film_frames": "🎞", + "film_projector": "📽", + "fire": "🔥", + "fire_extinguisher": "🧯", + "firecracker": "🧨", + "fire_engine": "🚒", + "fireworks": "🎆", + "first_quarter_moon": "🌓", + "first_quarter_moon_face": "🌛", + "fish": "🐟", + "fish_cake_with_swirl": "🍥", + "fishing_pole": "🎣", + "five-thirty": "🕠", + "five_o’clock": "🕔", + "flag_in_hole": "⛳", + "flamingo": "🦩", + "flashlight": "🔦", + "flat_shoe": "🥿", + "fleur-de-lis": "⚜", + "flexed_biceps": "💪", + "flexed_biceps_dark_skin_tone": "💪🏿", + "flexed_biceps_light_skin_tone": "💪🏻", + "flexed_biceps_medium-dark_skin_tone": "💪🏾", + "flexed_biceps_medium-light_skin_tone": "💪🏼", + "flexed_biceps_medium_skin_tone": "💪🏽", + "floppy_disk": "💾", + "flower_playing_cards": "🎴", + "flushed_face": "😳", + "flying_disc": "🥏", + "flying_saucer": "🛸", + "fog": "🌫", + "foggy": "🌁", + "folded_hands": "🙏", + "folded_hands_dark_skin_tone": "🙏🏿", + "folded_hands_light_skin_tone": "🙏🏻", + "folded_hands_medium-dark_skin_tone": "🙏🏾", + "folded_hands_medium-light_skin_tone": "🙏🏼", + "folded_hands_medium_skin_tone": "🙏🏽", + "foot": "🦶", + "footprints": "👣", + "fork_and_knife": "🍴", + "fork_and_knife_with_plate": "🍽", + "fortune_cookie": "🥠", + "fountain": "⛲", + "fountain_pen": "🖋", + "four-thirty": "🕟", + "four_leaf_clover": "🍀", + "four_o’clock": "🕓", + "fox_face": "🦊", + "framed_picture": "🖼", + "french_fries": "🍟", + "fried_shrimp": "🍤", + "frog_face": "🐸", + "front-facing_baby_chick": "🐥", + "frowning_face": "☹", + "frowning_face_with_open_mouth": "😦", + "fuel_pump": "⛽", + "full_moon": "🌕", + "full_moon_face": "🌝", + "funeral_urn": "⚱", + "game_die": "🎲", + "garlic": "🧄", + "gear": "⚙", + "gem_stone": "💎", + "genie": "🧞", + "ghost": "👻", + "giraffe": "🦒", + "girl": "👧", + "girl_dark_skin_tone": "👧🏿", + "girl_light_skin_tone": "👧🏻", + "girl_medium-dark_skin_tone": "👧🏾", + "girl_medium-light_skin_tone": "👧🏼", + "girl_medium_skin_tone": "👧🏽", + "glass_of_milk": "🥛", + "glasses": "👓", + "globe_showing_americas": "🌎", + "globe_showing_asia-australia": "🌏", + "globe_showing_europe-africa": "🌍", + "globe_with_meridians": "🌐", + "gloves": "🧤", + "glowing_star": "🌟", + "goal_net": "🥅", + "goat": "🐐", + "goblin": "👺", + "goggles": "🥽", + "gorilla": "🦍", + "graduation_cap": "🎓", + "grapes": "🍇", + "green_apple": "🍏", + "green_book": "📗", + "green_circle": "🟢", + "green_heart": "💚", + "green_salad": "🥗", + "green_square": "🟩", + "grimacing_face": "😬", + "grinning_cat_face": "😺", + "grinning_cat_face_with_smiling_eyes": "😸", + "grinning_face": "😀", + "grinning_face_with_big_eyes": "😃", + "grinning_face_with_smiling_eyes": "😄", + "grinning_face_with_sweat": "😅", + "grinning_squinting_face": "😆", + "growing_heart": "💗", + "guard": "💂", + "guard_dark_skin_tone": "💂🏿", + "guard_light_skin_tone": "💂🏻", + "guard_medium-dark_skin_tone": "💂🏾", + "guard_medium-light_skin_tone": "💂🏼", + "guard_medium_skin_tone": "💂🏽", + "guide_dog": "🦮", + "guitar": "🎸", + "hamburger": "🍔", + "hammer": "🔨", + "hammer_and_pick": "⚒", + "hammer_and_wrench": "🛠", + "hamster_face": "🐹", + "hand_with_fingers_splayed": "🖐", + "hand_with_fingers_splayed_dark_skin_tone": "🖐🏿", + "hand_with_fingers_splayed_light_skin_tone": "🖐🏻", + "hand_with_fingers_splayed_medium-dark_skin_tone": "🖐🏾", + "hand_with_fingers_splayed_medium-light_skin_tone": "🖐🏼", + "hand_with_fingers_splayed_medium_skin_tone": "🖐🏽", + "handbag": "👜", + "handshake": "🤝", + "hatching_chick": "🐣", + "headphone": "🎧", + "hear-no-evil_monkey": "🙉", + "heart_decoration": "💟", + "heart_suit": "♥", + "heart_with_arrow": "💘", + "heart_with_ribbon": "💝", + "heavy_check_mark": "✔", + "heavy_division_sign": "➗", + "heavy_dollar_sign": "💲", + "heavy_heart_exclamation": "❣", + "heavy_large_circle": "⭕", + "heavy_minus_sign": "➖", + "heavy_multiplication_x": "✖", + "heavy_plus_sign": "➕", + "hedgehog": "🦔", + "helicopter": "🚁", + "herb": "🌿", + "hibiscus": "🌺", + "high-heeled_shoe": "👠", + "high-speed_train": "🚄", + "high_voltage": "⚡", + "hiking_boot": "🥾", + "hindu_temple": "🛕", + "hippopotamus": "🦛", + "hole": "🕳", + "honey_pot": "🍯", + "honeybee": "🐝", + "horizontal_traffic_light": "🚥", + "horse": "🐴", + "horse_face": "🐴", + "horse_racing": "🏇", + "horse_racing_dark_skin_tone": "🏇🏿", + "horse_racing_light_skin_tone": "🏇🏻", + "horse_racing_medium-dark_skin_tone": "🏇🏾", + "horse_racing_medium-light_skin_tone": "🏇🏼", + "horse_racing_medium_skin_tone": "🏇🏽", + "hospital": "🏥", + "hot_beverage": "☕", + "hot_dog": "🌭", + "hot_face": "🥵", + "hot_pepper": "🌶", + "hot_springs": "♨", + "hotel": "🏨", + "hourglass_done": "⌛", + "hourglass_not_done": "⏳", + "house": "🏠", + "house_with_garden": "🏡", + "houses": "🏘", + "hugging_face": "🤗", + "hundred_points": "💯", + "hushed_face": "😯", + "ice": "🧊", + "ice_cream": "🍨", + "ice_hockey": "🏒", + "ice_skate": "⛸", + "inbox_tray": "📥", + "incoming_envelope": "📨", + "index_pointing_up": "☝", + "index_pointing_up_dark_skin_tone": "☝🏿", + "index_pointing_up_light_skin_tone": "☝🏻", + "index_pointing_up_medium-dark_skin_tone": "☝🏾", + "index_pointing_up_medium-light_skin_tone": "☝🏼", + "index_pointing_up_medium_skin_tone": "☝🏽", + "infinity": "♾", + "information": "ℹ", + "input_latin_letters": "🔤", + "input_latin_lowercase": "🔡", + "input_latin_uppercase": "🔠", + "input_numbers": "🔢", + "input_symbols": "🔣", + "jack-o-lantern": "🎃", + "jeans": "👖", + "jigsaw": "🧩", + "joker": "🃏", + "joystick": "🕹", + "kaaba": "🕋", + "kangaroo": "🦘", + "key": "🔑", + "keyboard": "⌨", + "keycap_#": "#️⃣", + "keycap_*": "*️⃣", + "keycap_0": "0️⃣", + "keycap_1": "1️⃣", + "keycap_10": "🔟", + "keycap_2": "2️⃣", + "keycap_3": "3️⃣", + "keycap_4": "4️⃣", + "keycap_5": "5️⃣", + "keycap_6": "6️⃣", + "keycap_7": "7️⃣", + "keycap_8": "8️⃣", + "keycap_9": "9️⃣", + "kick_scooter": "🛴", + "kimono": "👘", + "kiss": "💋", + "kiss_man_man": "👨\u200d❤️\u200d💋\u200d👨", + "kiss_mark": "💋", + "kiss_woman_man": "👩\u200d❤️\u200d💋\u200d👨", + "kiss_woman_woman": "👩\u200d❤️\u200d💋\u200d👩", + "kissing_cat_face": "😽", + "kissing_face": "😗", + "kissing_face_with_closed_eyes": "😚", + "kissing_face_with_smiling_eyes": "😙", + "kitchen_knife": "🔪", + "kite": "🪁", + "kiwi_fruit": "🥝", + "koala": "🐨", + "lab_coat": "🥼", + "label": "🏷", + "lacrosse": "🥍", + "lady_beetle": "🐞", + "laptop_computer": "💻", + "large_blue_diamond": "🔷", + "large_orange_diamond": "🔶", + "last_quarter_moon": "🌗", + "last_quarter_moon_face": "🌜", + "last_track_button": "⏮", + "latin_cross": "✝", + "leaf_fluttering_in_wind": "🍃", + "leafy_green": "🥬", + "ledger": "📒", + "left-facing_fist": "🤛", + "left-facing_fist_dark_skin_tone": "🤛🏿", + "left-facing_fist_light_skin_tone": "🤛🏻", + "left-facing_fist_medium-dark_skin_tone": "🤛🏾", + "left-facing_fist_medium-light_skin_tone": "🤛🏼", + "left-facing_fist_medium_skin_tone": "🤛🏽", + "left-right_arrow": "↔", + "left_arrow": "⬅", + "left_arrow_curving_right": "↪", + "left_luggage": "🛅", + "left_speech_bubble": "🗨", + "leg": "🦵", + "lemon": "🍋", + "leopard": "🐆", + "level_slider": "🎚", + "light_bulb": "💡", + "light_rail": "🚈", + "link": "🔗", + "linked_paperclips": "🖇", + "lion_face": "🦁", + "lipstick": "💄", + "litter_in_bin_sign": "🚮", + "lizard": "🦎", + "llama": "🦙", + "lobster": "🦞", + "locked": "🔒", + "locked_with_key": "🔐", + "locked_with_pen": "🔏", + "locomotive": "🚂", + "lollipop": "🍭", + "lotion_bottle": "🧴", + "loudly_crying_face": "😭", + "loudspeaker": "📢", + "love-you_gesture": "🤟", + "love-you_gesture_dark_skin_tone": "🤟🏿", + "love-you_gesture_light_skin_tone": "🤟🏻", + "love-you_gesture_medium-dark_skin_tone": "🤟🏾", + "love-you_gesture_medium-light_skin_tone": "🤟🏼", + "love-you_gesture_medium_skin_tone": "🤟🏽", + "love_hotel": "🏩", + "love_letter": "💌", + "luggage": "🧳", + "lying_face": "🤥", + "mage": "🧙", + "mage_dark_skin_tone": "🧙🏿", + "mage_light_skin_tone": "🧙🏻", + "mage_medium-dark_skin_tone": "🧙🏾", + "mage_medium-light_skin_tone": "🧙🏼", + "mage_medium_skin_tone": "🧙🏽", + "magnet": "🧲", + "magnifying_glass_tilted_left": "🔍", + "magnifying_glass_tilted_right": "🔎", + "mahjong_red_dragon": "🀄", + "male_sign": "♂", + "man": "👨", + "man_and_woman_holding_hands": "👫", + "man_artist": "👨\u200d🎨", + "man_artist_dark_skin_tone": "👨🏿\u200d🎨", + "man_artist_light_skin_tone": "👨🏻\u200d🎨", + "man_artist_medium-dark_skin_tone": "👨🏾\u200d🎨", + "man_artist_medium-light_skin_tone": "👨🏼\u200d🎨", + "man_artist_medium_skin_tone": "👨🏽\u200d🎨", + "man_astronaut": "👨\u200d🚀", + "man_astronaut_dark_skin_tone": "👨🏿\u200d🚀", + "man_astronaut_light_skin_tone": "👨🏻\u200d🚀", + "man_astronaut_medium-dark_skin_tone": "👨🏾\u200d🚀", + "man_astronaut_medium-light_skin_tone": "👨🏼\u200d🚀", + "man_astronaut_medium_skin_tone": "👨🏽\u200d🚀", + "man_biking": "🚴\u200d♂️", + "man_biking_dark_skin_tone": "🚴🏿\u200d♂️", + "man_biking_light_skin_tone": "🚴🏻\u200d♂️", + "man_biking_medium-dark_skin_tone": "🚴🏾\u200d♂️", + "man_biking_medium-light_skin_tone": "🚴🏼\u200d♂️", + "man_biking_medium_skin_tone": "🚴🏽\u200d♂️", + "man_bouncing_ball": "⛹️\u200d♂️", + "man_bouncing_ball_dark_skin_tone": "⛹🏿\u200d♂️", + "man_bouncing_ball_light_skin_tone": "⛹🏻\u200d♂️", + "man_bouncing_ball_medium-dark_skin_tone": "⛹🏾\u200d♂️", + "man_bouncing_ball_medium-light_skin_tone": "⛹🏼\u200d♂️", + "man_bouncing_ball_medium_skin_tone": "⛹🏽\u200d♂️", + "man_bowing": "🙇\u200d♂️", + "man_bowing_dark_skin_tone": "🙇🏿\u200d♂️", + "man_bowing_light_skin_tone": "🙇🏻\u200d♂️", + "man_bowing_medium-dark_skin_tone": "🙇🏾\u200d♂️", + "man_bowing_medium-light_skin_tone": "🙇🏼\u200d♂️", + "man_bowing_medium_skin_tone": "🙇🏽\u200d♂️", + "man_cartwheeling": "🤸\u200d♂️", + "man_cartwheeling_dark_skin_tone": "🤸🏿\u200d♂️", + "man_cartwheeling_light_skin_tone": "🤸🏻\u200d♂️", + "man_cartwheeling_medium-dark_skin_tone": "🤸🏾\u200d♂️", + "man_cartwheeling_medium-light_skin_tone": "🤸🏼\u200d♂️", + "man_cartwheeling_medium_skin_tone": "🤸🏽\u200d♂️", + "man_climbing": "🧗\u200d♂️", + "man_climbing_dark_skin_tone": "🧗🏿\u200d♂️", + "man_climbing_light_skin_tone": "🧗🏻\u200d♂️", + "man_climbing_medium-dark_skin_tone": "🧗🏾\u200d♂️", + "man_climbing_medium-light_skin_tone": "🧗🏼\u200d♂️", + "man_climbing_medium_skin_tone": "🧗🏽\u200d♂️", + "man_construction_worker": "👷\u200d♂️", + "man_construction_worker_dark_skin_tone": "👷🏿\u200d♂️", + "man_construction_worker_light_skin_tone": "👷🏻\u200d♂️", + "man_construction_worker_medium-dark_skin_tone": "👷🏾\u200d♂️", + "man_construction_worker_medium-light_skin_tone": "👷🏼\u200d♂️", + "man_construction_worker_medium_skin_tone": "👷🏽\u200d♂️", + "man_cook": "👨\u200d🍳", + "man_cook_dark_skin_tone": "👨🏿\u200d🍳", + "man_cook_light_skin_tone": "👨🏻\u200d🍳", + "man_cook_medium-dark_skin_tone": "👨🏾\u200d🍳", + "man_cook_medium-light_skin_tone": "👨🏼\u200d🍳", + "man_cook_medium_skin_tone": "👨🏽\u200d🍳", + "man_dancing": "🕺", + "man_dancing_dark_skin_tone": "🕺🏿", + "man_dancing_light_skin_tone": "🕺🏻", + "man_dancing_medium-dark_skin_tone": "🕺🏾", + "man_dancing_medium-light_skin_tone": "🕺🏼", + "man_dancing_medium_skin_tone": "🕺🏽", + "man_dark_skin_tone": "👨🏿", + "man_detective": "🕵️\u200d♂️", + "man_detective_dark_skin_tone": "🕵🏿\u200d♂️", + "man_detective_light_skin_tone": "🕵🏻\u200d♂️", + "man_detective_medium-dark_skin_tone": "🕵🏾\u200d♂️", + "man_detective_medium-light_skin_tone": "🕵🏼\u200d♂️", + "man_detective_medium_skin_tone": "🕵🏽\u200d♂️", + "man_elf": "🧝\u200d♂️", + "man_elf_dark_skin_tone": "🧝🏿\u200d♂️", + "man_elf_light_skin_tone": "🧝🏻\u200d♂️", + "man_elf_medium-dark_skin_tone": "🧝🏾\u200d♂️", + "man_elf_medium-light_skin_tone": "🧝🏼\u200d♂️", + "man_elf_medium_skin_tone": "🧝🏽\u200d♂️", + "man_facepalming": "🤦\u200d♂️", + "man_facepalming_dark_skin_tone": "🤦🏿\u200d♂️", + "man_facepalming_light_skin_tone": "🤦🏻\u200d♂️", + "man_facepalming_medium-dark_skin_tone": "🤦🏾\u200d♂️", + "man_facepalming_medium-light_skin_tone": "🤦🏼\u200d♂️", + "man_facepalming_medium_skin_tone": "🤦🏽\u200d♂️", + "man_factory_worker": "👨\u200d🏭", + "man_factory_worker_dark_skin_tone": "👨🏿\u200d🏭", + "man_factory_worker_light_skin_tone": "👨🏻\u200d🏭", + "man_factory_worker_medium-dark_skin_tone": "👨🏾\u200d🏭", + "man_factory_worker_medium-light_skin_tone": "👨🏼\u200d🏭", + "man_factory_worker_medium_skin_tone": "👨🏽\u200d🏭", + "man_fairy": "🧚\u200d♂️", + "man_fairy_dark_skin_tone": "🧚🏿\u200d♂️", + "man_fairy_light_skin_tone": "🧚🏻\u200d♂️", + "man_fairy_medium-dark_skin_tone": "🧚🏾\u200d♂️", + "man_fairy_medium-light_skin_tone": "🧚🏼\u200d♂️", + "man_fairy_medium_skin_tone": "🧚🏽\u200d♂️", + "man_farmer": "👨\u200d🌾", + "man_farmer_dark_skin_tone": "👨🏿\u200d🌾", + "man_farmer_light_skin_tone": "👨🏻\u200d🌾", + "man_farmer_medium-dark_skin_tone": "👨🏾\u200d🌾", + "man_farmer_medium-light_skin_tone": "👨🏼\u200d🌾", + "man_farmer_medium_skin_tone": "👨🏽\u200d🌾", + "man_firefighter": "👨\u200d🚒", + "man_firefighter_dark_skin_tone": "👨🏿\u200d🚒", + "man_firefighter_light_skin_tone": "👨🏻\u200d🚒", + "man_firefighter_medium-dark_skin_tone": "👨🏾\u200d🚒", + "man_firefighter_medium-light_skin_tone": "👨🏼\u200d🚒", + "man_firefighter_medium_skin_tone": "👨🏽\u200d🚒", + "man_frowning": "🙍\u200d♂️", + "man_frowning_dark_skin_tone": "🙍🏿\u200d♂️", + "man_frowning_light_skin_tone": "🙍🏻\u200d♂️", + "man_frowning_medium-dark_skin_tone": "🙍🏾\u200d♂️", + "man_frowning_medium-light_skin_tone": "🙍🏼\u200d♂️", + "man_frowning_medium_skin_tone": "🙍🏽\u200d♂️", + "man_genie": "🧞\u200d♂️", + "man_gesturing_no": "🙅\u200d♂️", + "man_gesturing_no_dark_skin_tone": "🙅🏿\u200d♂️", + "man_gesturing_no_light_skin_tone": "🙅🏻\u200d♂️", + "man_gesturing_no_medium-dark_skin_tone": "🙅🏾\u200d♂️", + "man_gesturing_no_medium-light_skin_tone": "🙅🏼\u200d♂️", + "man_gesturing_no_medium_skin_tone": "🙅🏽\u200d♂️", + "man_gesturing_ok": "🙆\u200d♂️", + "man_gesturing_ok_dark_skin_tone": "🙆🏿\u200d♂️", + "man_gesturing_ok_light_skin_tone": "🙆🏻\u200d♂️", + "man_gesturing_ok_medium-dark_skin_tone": "🙆🏾\u200d♂️", + "man_gesturing_ok_medium-light_skin_tone": "🙆🏼\u200d♂️", + "man_gesturing_ok_medium_skin_tone": "🙆🏽\u200d♂️", + "man_getting_haircut": "💇\u200d♂️", + "man_getting_haircut_dark_skin_tone": "💇🏿\u200d♂️", + "man_getting_haircut_light_skin_tone": "💇🏻\u200d♂️", + "man_getting_haircut_medium-dark_skin_tone": "💇🏾\u200d♂️", + "man_getting_haircut_medium-light_skin_tone": "💇🏼\u200d♂️", + "man_getting_haircut_medium_skin_tone": "💇🏽\u200d♂️", + "man_getting_massage": "💆\u200d♂️", + "man_getting_massage_dark_skin_tone": "💆🏿\u200d♂️", + "man_getting_massage_light_skin_tone": "💆🏻\u200d♂️", + "man_getting_massage_medium-dark_skin_tone": "💆🏾\u200d♂️", + "man_getting_massage_medium-light_skin_tone": "💆🏼\u200d♂️", + "man_getting_massage_medium_skin_tone": "💆🏽\u200d♂️", + "man_golfing": "🏌️\u200d♂️", + "man_golfing_dark_skin_tone": "🏌🏿\u200d♂️", + "man_golfing_light_skin_tone": "🏌🏻\u200d♂️", + "man_golfing_medium-dark_skin_tone": "🏌🏾\u200d♂️", + "man_golfing_medium-light_skin_tone": "🏌🏼\u200d♂️", + "man_golfing_medium_skin_tone": "🏌🏽\u200d♂️", + "man_guard": "💂\u200d♂️", + "man_guard_dark_skin_tone": "💂🏿\u200d♂️", + "man_guard_light_skin_tone": "💂🏻\u200d♂️", + "man_guard_medium-dark_skin_tone": "💂🏾\u200d♂️", + "man_guard_medium-light_skin_tone": "💂🏼\u200d♂️", + "man_guard_medium_skin_tone": "💂🏽\u200d♂️", + "man_health_worker": "👨\u200d⚕️", + "man_health_worker_dark_skin_tone": "👨🏿\u200d⚕️", + "man_health_worker_light_skin_tone": "👨🏻\u200d⚕️", + "man_health_worker_medium-dark_skin_tone": "👨🏾\u200d⚕️", + "man_health_worker_medium-light_skin_tone": "👨🏼\u200d⚕️", + "man_health_worker_medium_skin_tone": "👨🏽\u200d⚕️", + "man_in_lotus_position": "🧘\u200d♂️", + "man_in_lotus_position_dark_skin_tone": "🧘🏿\u200d♂️", + "man_in_lotus_position_light_skin_tone": "🧘🏻\u200d♂️", + "man_in_lotus_position_medium-dark_skin_tone": "🧘🏾\u200d♂️", + "man_in_lotus_position_medium-light_skin_tone": "🧘🏼\u200d♂️", + "man_in_lotus_position_medium_skin_tone": "🧘🏽\u200d♂️", + "man_in_manual_wheelchair": "👨\u200d🦽", + "man_in_motorized_wheelchair": "👨\u200d🦼", + "man_in_steamy_room": "🧖\u200d♂️", + "man_in_steamy_room_dark_skin_tone": "🧖🏿\u200d♂️", + "man_in_steamy_room_light_skin_tone": "🧖🏻\u200d♂️", + "man_in_steamy_room_medium-dark_skin_tone": "🧖🏾\u200d♂️", + "man_in_steamy_room_medium-light_skin_tone": "🧖🏼\u200d♂️", + "man_in_steamy_room_medium_skin_tone": "🧖🏽\u200d♂️", + "man_in_suit_levitating": "🕴", + "man_in_suit_levitating_dark_skin_tone": "🕴🏿", + "man_in_suit_levitating_light_skin_tone": "🕴🏻", + "man_in_suit_levitating_medium-dark_skin_tone": "🕴🏾", + "man_in_suit_levitating_medium-light_skin_tone": "🕴🏼", + "man_in_suit_levitating_medium_skin_tone": "🕴🏽", + "man_in_tuxedo": "🤵", + "man_in_tuxedo_dark_skin_tone": "🤵🏿", + "man_in_tuxedo_light_skin_tone": "🤵🏻", + "man_in_tuxedo_medium-dark_skin_tone": "🤵🏾", + "man_in_tuxedo_medium-light_skin_tone": "🤵🏼", + "man_in_tuxedo_medium_skin_tone": "🤵🏽", + "man_judge": "👨\u200d⚖️", + "man_judge_dark_skin_tone": "👨🏿\u200d⚖️", + "man_judge_light_skin_tone": "👨🏻\u200d⚖️", + "man_judge_medium-dark_skin_tone": "👨🏾\u200d⚖️", + "man_judge_medium-light_skin_tone": "👨🏼\u200d⚖️", + "man_judge_medium_skin_tone": "👨🏽\u200d⚖️", + "man_juggling": "🤹\u200d♂️", + "man_juggling_dark_skin_tone": "🤹🏿\u200d♂️", + "man_juggling_light_skin_tone": "🤹🏻\u200d♂️", + "man_juggling_medium-dark_skin_tone": "🤹🏾\u200d♂️", + "man_juggling_medium-light_skin_tone": "🤹🏼\u200d♂️", + "man_juggling_medium_skin_tone": "🤹🏽\u200d♂️", + "man_lifting_weights": "🏋️\u200d♂️", + "man_lifting_weights_dark_skin_tone": "🏋🏿\u200d♂️", + "man_lifting_weights_light_skin_tone": "🏋🏻\u200d♂️", + "man_lifting_weights_medium-dark_skin_tone": "🏋🏾\u200d♂️", + "man_lifting_weights_medium-light_skin_tone": "🏋🏼\u200d♂️", + "man_lifting_weights_medium_skin_tone": "🏋🏽\u200d♂️", + "man_light_skin_tone": "👨🏻", + "man_mage": "🧙\u200d♂️", + "man_mage_dark_skin_tone": "🧙🏿\u200d♂️", + "man_mage_light_skin_tone": "🧙🏻\u200d♂️", + "man_mage_medium-dark_skin_tone": "🧙🏾\u200d♂️", + "man_mage_medium-light_skin_tone": "🧙🏼\u200d♂️", + "man_mage_medium_skin_tone": "🧙🏽\u200d♂️", + "man_mechanic": "👨\u200d🔧", + "man_mechanic_dark_skin_tone": "👨🏿\u200d🔧", + "man_mechanic_light_skin_tone": "👨🏻\u200d🔧", + "man_mechanic_medium-dark_skin_tone": "👨🏾\u200d🔧", + "man_mechanic_medium-light_skin_tone": "👨🏼\u200d🔧", + "man_mechanic_medium_skin_tone": "👨🏽\u200d🔧", + "man_medium-dark_skin_tone": "👨🏾", + "man_medium-light_skin_tone": "👨🏼", + "man_medium_skin_tone": "👨🏽", + "man_mountain_biking": "🚵\u200d♂️", + "man_mountain_biking_dark_skin_tone": "🚵🏿\u200d♂️", + "man_mountain_biking_light_skin_tone": "🚵🏻\u200d♂️", + "man_mountain_biking_medium-dark_skin_tone": "🚵🏾\u200d♂️", + "man_mountain_biking_medium-light_skin_tone": "🚵🏼\u200d♂️", + "man_mountain_biking_medium_skin_tone": "🚵🏽\u200d♂️", + "man_office_worker": "👨\u200d💼", + "man_office_worker_dark_skin_tone": "👨🏿\u200d💼", + "man_office_worker_light_skin_tone": "👨🏻\u200d💼", + "man_office_worker_medium-dark_skin_tone": "👨🏾\u200d💼", + "man_office_worker_medium-light_skin_tone": "👨🏼\u200d💼", + "man_office_worker_medium_skin_tone": "👨🏽\u200d💼", + "man_pilot": "👨\u200d✈️", + "man_pilot_dark_skin_tone": "👨🏿\u200d✈️", + "man_pilot_light_skin_tone": "👨🏻\u200d✈️", + "man_pilot_medium-dark_skin_tone": "👨🏾\u200d✈️", + "man_pilot_medium-light_skin_tone": "👨🏼\u200d✈️", + "man_pilot_medium_skin_tone": "👨🏽\u200d✈️", + "man_playing_handball": "🤾\u200d♂️", + "man_playing_handball_dark_skin_tone": "🤾🏿\u200d♂️", + "man_playing_handball_light_skin_tone": "🤾🏻\u200d♂️", + "man_playing_handball_medium-dark_skin_tone": "🤾🏾\u200d♂️", + "man_playing_handball_medium-light_skin_tone": "🤾🏼\u200d♂️", + "man_playing_handball_medium_skin_tone": "🤾🏽\u200d♂️", + "man_playing_water_polo": "🤽\u200d♂️", + "man_playing_water_polo_dark_skin_tone": "🤽🏿\u200d♂️", + "man_playing_water_polo_light_skin_tone": "🤽🏻\u200d♂️", + "man_playing_water_polo_medium-dark_skin_tone": "🤽🏾\u200d♂️", + "man_playing_water_polo_medium-light_skin_tone": "🤽🏼\u200d♂️", + "man_playing_water_polo_medium_skin_tone": "🤽🏽\u200d♂️", + "man_police_officer": "👮\u200d♂️", + "man_police_officer_dark_skin_tone": "👮🏿\u200d♂️", + "man_police_officer_light_skin_tone": "👮🏻\u200d♂️", + "man_police_officer_medium-dark_skin_tone": "👮🏾\u200d♂️", + "man_police_officer_medium-light_skin_tone": "👮🏼\u200d♂️", + "man_police_officer_medium_skin_tone": "👮🏽\u200d♂️", + "man_pouting": "🙎\u200d♂️", + "man_pouting_dark_skin_tone": "🙎🏿\u200d♂️", + "man_pouting_light_skin_tone": "🙎🏻\u200d♂️", + "man_pouting_medium-dark_skin_tone": "🙎🏾\u200d♂️", + "man_pouting_medium-light_skin_tone": "🙎🏼\u200d♂️", + "man_pouting_medium_skin_tone": "🙎🏽\u200d♂️", + "man_raising_hand": "🙋\u200d♂️", + "man_raising_hand_dark_skin_tone": "🙋🏿\u200d♂️", + "man_raising_hand_light_skin_tone": "🙋🏻\u200d♂️", + "man_raising_hand_medium-dark_skin_tone": "🙋🏾\u200d♂️", + "man_raising_hand_medium-light_skin_tone": "🙋🏼\u200d♂️", + "man_raising_hand_medium_skin_tone": "🙋🏽\u200d♂️", + "man_rowing_boat": "🚣\u200d♂️", + "man_rowing_boat_dark_skin_tone": "🚣🏿\u200d♂️", + "man_rowing_boat_light_skin_tone": "🚣🏻\u200d♂️", + "man_rowing_boat_medium-dark_skin_tone": "🚣🏾\u200d♂️", + "man_rowing_boat_medium-light_skin_tone": "🚣🏼\u200d♂️", + "man_rowing_boat_medium_skin_tone": "🚣🏽\u200d♂️", + "man_running": "🏃\u200d♂️", + "man_running_dark_skin_tone": "🏃🏿\u200d♂️", + "man_running_light_skin_tone": "🏃🏻\u200d♂️", + "man_running_medium-dark_skin_tone": "🏃🏾\u200d♂️", + "man_running_medium-light_skin_tone": "🏃🏼\u200d♂️", + "man_running_medium_skin_tone": "🏃🏽\u200d♂️", + "man_scientist": "👨\u200d🔬", + "man_scientist_dark_skin_tone": "👨🏿\u200d🔬", + "man_scientist_light_skin_tone": "👨🏻\u200d🔬", + "man_scientist_medium-dark_skin_tone": "👨🏾\u200d🔬", + "man_scientist_medium-light_skin_tone": "👨🏼\u200d🔬", + "man_scientist_medium_skin_tone": "👨🏽\u200d🔬", + "man_shrugging": "🤷\u200d♂️", + "man_shrugging_dark_skin_tone": "🤷🏿\u200d♂️", + "man_shrugging_light_skin_tone": "🤷🏻\u200d♂️", + "man_shrugging_medium-dark_skin_tone": "🤷🏾\u200d♂️", + "man_shrugging_medium-light_skin_tone": "🤷🏼\u200d♂️", + "man_shrugging_medium_skin_tone": "🤷🏽\u200d♂️", + "man_singer": "👨\u200d🎤", + "man_singer_dark_skin_tone": "👨🏿\u200d🎤", + "man_singer_light_skin_tone": "👨🏻\u200d🎤", + "man_singer_medium-dark_skin_tone": "👨🏾\u200d🎤", + "man_singer_medium-light_skin_tone": "👨🏼\u200d🎤", + "man_singer_medium_skin_tone": "👨🏽\u200d🎤", + "man_student": "👨\u200d🎓", + "man_student_dark_skin_tone": "👨🏿\u200d🎓", + "man_student_light_skin_tone": "👨🏻\u200d🎓", + "man_student_medium-dark_skin_tone": "👨🏾\u200d🎓", + "man_student_medium-light_skin_tone": "👨🏼\u200d🎓", + "man_student_medium_skin_tone": "👨🏽\u200d🎓", + "man_surfing": "🏄\u200d♂️", + "man_surfing_dark_skin_tone": "🏄🏿\u200d♂️", + "man_surfing_light_skin_tone": "🏄🏻\u200d♂️", + "man_surfing_medium-dark_skin_tone": "🏄🏾\u200d♂️", + "man_surfing_medium-light_skin_tone": "🏄🏼\u200d♂️", + "man_surfing_medium_skin_tone": "🏄🏽\u200d♂️", + "man_swimming": "🏊\u200d♂️", + "man_swimming_dark_skin_tone": "🏊🏿\u200d♂️", + "man_swimming_light_skin_tone": "🏊🏻\u200d♂️", + "man_swimming_medium-dark_skin_tone": "🏊🏾\u200d♂️", + "man_swimming_medium-light_skin_tone": "🏊🏼\u200d♂️", + "man_swimming_medium_skin_tone": "🏊🏽\u200d♂️", + "man_teacher": "👨\u200d🏫", + "man_teacher_dark_skin_tone": "👨🏿\u200d🏫", + "man_teacher_light_skin_tone": "👨🏻\u200d🏫", + "man_teacher_medium-dark_skin_tone": "👨🏾\u200d🏫", + "man_teacher_medium-light_skin_tone": "👨🏼\u200d🏫", + "man_teacher_medium_skin_tone": "👨🏽\u200d🏫", + "man_technologist": "👨\u200d💻", + "man_technologist_dark_skin_tone": "👨🏿\u200d💻", + "man_technologist_light_skin_tone": "👨🏻\u200d💻", + "man_technologist_medium-dark_skin_tone": "👨🏾\u200d💻", + "man_technologist_medium-light_skin_tone": "👨🏼\u200d💻", + "man_technologist_medium_skin_tone": "👨🏽\u200d💻", + "man_tipping_hand": "💁\u200d♂️", + "man_tipping_hand_dark_skin_tone": "💁🏿\u200d♂️", + "man_tipping_hand_light_skin_tone": "💁🏻\u200d♂️", + "man_tipping_hand_medium-dark_skin_tone": "💁🏾\u200d♂️", + "man_tipping_hand_medium-light_skin_tone": "💁🏼\u200d♂️", + "man_tipping_hand_medium_skin_tone": "💁🏽\u200d♂️", + "man_vampire": "🧛\u200d♂️", + "man_vampire_dark_skin_tone": "🧛🏿\u200d♂️", + "man_vampire_light_skin_tone": "🧛🏻\u200d♂️", + "man_vampire_medium-dark_skin_tone": "🧛🏾\u200d♂️", + "man_vampire_medium-light_skin_tone": "🧛🏼\u200d♂️", + "man_vampire_medium_skin_tone": "🧛🏽\u200d♂️", + "man_walking": "🚶\u200d♂️", + "man_walking_dark_skin_tone": "🚶🏿\u200d♂️", + "man_walking_light_skin_tone": "🚶🏻\u200d♂️", + "man_walking_medium-dark_skin_tone": "🚶🏾\u200d♂️", + "man_walking_medium-light_skin_tone": "🚶🏼\u200d♂️", + "man_walking_medium_skin_tone": "🚶🏽\u200d♂️", + "man_wearing_turban": "👳\u200d♂️", + "man_wearing_turban_dark_skin_tone": "👳🏿\u200d♂️", + "man_wearing_turban_light_skin_tone": "👳🏻\u200d♂️", + "man_wearing_turban_medium-dark_skin_tone": "👳🏾\u200d♂️", + "man_wearing_turban_medium-light_skin_tone": "👳🏼\u200d♂️", + "man_wearing_turban_medium_skin_tone": "👳🏽\u200d♂️", + "man_with_probing_cane": "👨\u200d🦯", + "man_with_chinese_cap": "👲", + "man_with_chinese_cap_dark_skin_tone": "👲🏿", + "man_with_chinese_cap_light_skin_tone": "👲🏻", + "man_with_chinese_cap_medium-dark_skin_tone": "👲🏾", + "man_with_chinese_cap_medium-light_skin_tone": "👲🏼", + "man_with_chinese_cap_medium_skin_tone": "👲🏽", + "man_zombie": "🧟\u200d♂️", + "mango": "🥭", + "mantelpiece_clock": "🕰", + "manual_wheelchair": "🦽", + "man’s_shoe": "👞", + "map_of_japan": "🗾", + "maple_leaf": "🍁", + "martial_arts_uniform": "🥋", + "mate": "🧉", + "meat_on_bone": "🍖", + "mechanical_arm": "🦾", + "mechanical_leg": "🦿", + "medical_symbol": "⚕", + "megaphone": "📣", + "melon": "🍈", + "memo": "📝", + "men_with_bunny_ears": "👯\u200d♂️", + "men_wrestling": "🤼\u200d♂️", + "menorah": "🕎", + "men’s_room": "🚹", + "mermaid": "🧜\u200d♀️", + "mermaid_dark_skin_tone": "🧜🏿\u200d♀️", + "mermaid_light_skin_tone": "🧜🏻\u200d♀️", + "mermaid_medium-dark_skin_tone": "🧜🏾\u200d♀️", + "mermaid_medium-light_skin_tone": "🧜🏼\u200d♀️", + "mermaid_medium_skin_tone": "🧜🏽\u200d♀️", + "merman": "🧜\u200d♂️", + "merman_dark_skin_tone": "🧜🏿\u200d♂️", + "merman_light_skin_tone": "🧜🏻\u200d♂️", + "merman_medium-dark_skin_tone": "🧜🏾\u200d♂️", + "merman_medium-light_skin_tone": "🧜🏼\u200d♂️", + "merman_medium_skin_tone": "🧜🏽\u200d♂️", + "merperson": "🧜", + "merperson_dark_skin_tone": "🧜🏿", + "merperson_light_skin_tone": "🧜🏻", + "merperson_medium-dark_skin_tone": "🧜🏾", + "merperson_medium-light_skin_tone": "🧜🏼", + "merperson_medium_skin_tone": "🧜🏽", + "metro": "🚇", + "microbe": "🦠", + "microphone": "🎤", + "microscope": "🔬", + "middle_finger": "🖕", + "middle_finger_dark_skin_tone": "🖕🏿", + "middle_finger_light_skin_tone": "🖕🏻", + "middle_finger_medium-dark_skin_tone": "🖕🏾", + "middle_finger_medium-light_skin_tone": "🖕🏼", + "middle_finger_medium_skin_tone": "🖕🏽", + "military_medal": "🎖", + "milky_way": "🌌", + "minibus": "🚐", + "moai": "🗿", + "mobile_phone": "📱", + "mobile_phone_off": "📴", + "mobile_phone_with_arrow": "📲", + "money-mouth_face": "🤑", + "money_bag": "💰", + "money_with_wings": "💸", + "monkey": "🐒", + "monkey_face": "🐵", + "monorail": "🚝", + "moon_cake": "🥮", + "moon_viewing_ceremony": "🎑", + "mosque": "🕌", + "mosquito": "🦟", + "motor_boat": "🛥", + "motor_scooter": "🛵", + "motorcycle": "🏍", + "motorized_wheelchair": "🦼", + "motorway": "🛣", + "mount_fuji": "🗻", + "mountain": "⛰", + "mountain_cableway": "🚠", + "mountain_railway": "🚞", + "mouse": "🐭", + "mouse_face": "🐭", + "mouth": "👄", + "movie_camera": "🎥", + "mushroom": "🍄", + "musical_keyboard": "🎹", + "musical_note": "🎵", + "musical_notes": "🎶", + "musical_score": "🎼", + "muted_speaker": "🔇", + "nail_polish": "💅", + "nail_polish_dark_skin_tone": "💅🏿", + "nail_polish_light_skin_tone": "💅🏻", + "nail_polish_medium-dark_skin_tone": "💅🏾", + "nail_polish_medium-light_skin_tone": "💅🏼", + "nail_polish_medium_skin_tone": "💅🏽", + "name_badge": "📛", + "national_park": "🏞", + "nauseated_face": "🤢", + "nazar_amulet": "🧿", + "necktie": "👔", + "nerd_face": "🤓", + "neutral_face": "😐", + "new_moon": "🌑", + "new_moon_face": "🌚", + "newspaper": "📰", + "next_track_button": "⏭", + "night_with_stars": "🌃", + "nine-thirty": "🕤", + "nine_o’clock": "🕘", + "no_bicycles": "🚳", + "no_entry": "⛔", + "no_littering": "🚯", + "no_mobile_phones": "📵", + "no_one_under_eighteen": "🔞", + "no_pedestrians": "🚷", + "no_smoking": "🚭", + "non-potable_water": "🚱", + "nose": "👃", + "nose_dark_skin_tone": "👃🏿", + "nose_light_skin_tone": "👃🏻", + "nose_medium-dark_skin_tone": "👃🏾", + "nose_medium-light_skin_tone": "👃🏼", + "nose_medium_skin_tone": "👃🏽", + "notebook": "📓", + "notebook_with_decorative_cover": "📔", + "nut_and_bolt": "🔩", + "octopus": "🐙", + "oden": "🍢", + "office_building": "🏢", + "ogre": "👹", + "oil_drum": "🛢", + "old_key": "🗝", + "old_man": "👴", + "old_man_dark_skin_tone": "👴🏿", + "old_man_light_skin_tone": "👴🏻", + "old_man_medium-dark_skin_tone": "👴🏾", + "old_man_medium-light_skin_tone": "👴🏼", + "old_man_medium_skin_tone": "👴🏽", + "old_woman": "👵", + "old_woman_dark_skin_tone": "👵🏿", + "old_woman_light_skin_tone": "👵🏻", + "old_woman_medium-dark_skin_tone": "👵🏾", + "old_woman_medium-light_skin_tone": "👵🏼", + "old_woman_medium_skin_tone": "👵🏽", + "older_adult": "🧓", + "older_adult_dark_skin_tone": "🧓🏿", + "older_adult_light_skin_tone": "🧓🏻", + "older_adult_medium-dark_skin_tone": "🧓🏾", + "older_adult_medium-light_skin_tone": "🧓🏼", + "older_adult_medium_skin_tone": "🧓🏽", + "om": "🕉", + "oncoming_automobile": "🚘", + "oncoming_bus": "🚍", + "oncoming_fist": "👊", + "oncoming_fist_dark_skin_tone": "👊🏿", + "oncoming_fist_light_skin_tone": "👊🏻", + "oncoming_fist_medium-dark_skin_tone": "👊🏾", + "oncoming_fist_medium-light_skin_tone": "👊🏼", + "oncoming_fist_medium_skin_tone": "👊🏽", + "oncoming_police_car": "🚔", + "oncoming_taxi": "🚖", + "one-piece_swimsuit": "🩱", + "one-thirty": "🕜", + "one_o’clock": "🕐", + "onion": "🧅", + "open_book": "📖", + "open_file_folder": "📂", + "open_hands": "👐", + "open_hands_dark_skin_tone": "👐🏿", + "open_hands_light_skin_tone": "👐🏻", + "open_hands_medium-dark_skin_tone": "👐🏾", + "open_hands_medium-light_skin_tone": "👐🏼", + "open_hands_medium_skin_tone": "👐🏽", + "open_mailbox_with_lowered_flag": "📭", + "open_mailbox_with_raised_flag": "📬", + "optical_disk": "💿", + "orange_book": "📙", + "orange_circle": "🟠", + "orange_heart": "🧡", + "orange_square": "🟧", + "orangutan": "🦧", + "orthodox_cross": "☦", + "otter": "🦦", + "outbox_tray": "📤", + "owl": "🦉", + "ox": "🐂", + "oyster": "🦪", + "package": "📦", + "page_facing_up": "📄", + "page_with_curl": "📃", + "pager": "📟", + "paintbrush": "🖌", + "palm_tree": "🌴", + "palms_up_together": "🤲", + "palms_up_together_dark_skin_tone": "🤲🏿", + "palms_up_together_light_skin_tone": "🤲🏻", + "palms_up_together_medium-dark_skin_tone": "🤲🏾", + "palms_up_together_medium-light_skin_tone": "🤲🏼", + "palms_up_together_medium_skin_tone": "🤲🏽", + "pancakes": "🥞", + "panda_face": "🐼", + "paperclip": "📎", + "parrot": "🦜", + "part_alternation_mark": "〽", + "party_popper": "🎉", + "partying_face": "🥳", + "passenger_ship": "🛳", + "passport_control": "🛂", + "pause_button": "⏸", + "paw_prints": "🐾", + "peace_symbol": "☮", + "peach": "🍑", + "peacock": "🦚", + "peanuts": "🥜", + "pear": "🍐", + "pen": "🖊", + "pencil": "📝", + "penguin": "🐧", + "pensive_face": "😔", + "people_holding_hands": "🧑\u200d🤝\u200d🧑", + "people_with_bunny_ears": "👯", + "people_wrestling": "🤼", + "performing_arts": "🎭", + "persevering_face": "😣", + "person_biking": "🚴", + "person_biking_dark_skin_tone": "🚴🏿", + "person_biking_light_skin_tone": "🚴🏻", + "person_biking_medium-dark_skin_tone": "🚴🏾", + "person_biking_medium-light_skin_tone": "🚴🏼", + "person_biking_medium_skin_tone": "🚴🏽", + "person_bouncing_ball": "⛹", + "person_bouncing_ball_dark_skin_tone": "⛹🏿", + "person_bouncing_ball_light_skin_tone": "⛹🏻", + "person_bouncing_ball_medium-dark_skin_tone": "⛹🏾", + "person_bouncing_ball_medium-light_skin_tone": "⛹🏼", + "person_bouncing_ball_medium_skin_tone": "⛹🏽", + "person_bowing": "🙇", + "person_bowing_dark_skin_tone": "🙇🏿", + "person_bowing_light_skin_tone": "🙇🏻", + "person_bowing_medium-dark_skin_tone": "🙇🏾", + "person_bowing_medium-light_skin_tone": "🙇🏼", + "person_bowing_medium_skin_tone": "🙇🏽", + "person_cartwheeling": "🤸", + "person_cartwheeling_dark_skin_tone": "🤸🏿", + "person_cartwheeling_light_skin_tone": "🤸🏻", + "person_cartwheeling_medium-dark_skin_tone": "🤸🏾", + "person_cartwheeling_medium-light_skin_tone": "🤸🏼", + "person_cartwheeling_medium_skin_tone": "🤸🏽", + "person_climbing": "🧗", + "person_climbing_dark_skin_tone": "🧗🏿", + "person_climbing_light_skin_tone": "🧗🏻", + "person_climbing_medium-dark_skin_tone": "🧗🏾", + "person_climbing_medium-light_skin_tone": "🧗🏼", + "person_climbing_medium_skin_tone": "🧗🏽", + "person_facepalming": "🤦", + "person_facepalming_dark_skin_tone": "🤦🏿", + "person_facepalming_light_skin_tone": "🤦🏻", + "person_facepalming_medium-dark_skin_tone": "🤦🏾", + "person_facepalming_medium-light_skin_tone": "🤦🏼", + "person_facepalming_medium_skin_tone": "🤦🏽", + "person_fencing": "🤺", + "person_frowning": "🙍", + "person_frowning_dark_skin_tone": "🙍🏿", + "person_frowning_light_skin_tone": "🙍🏻", + "person_frowning_medium-dark_skin_tone": "🙍🏾", + "person_frowning_medium-light_skin_tone": "🙍🏼", + "person_frowning_medium_skin_tone": "🙍🏽", + "person_gesturing_no": "🙅", + "person_gesturing_no_dark_skin_tone": "🙅🏿", + "person_gesturing_no_light_skin_tone": "🙅🏻", + "person_gesturing_no_medium-dark_skin_tone": "🙅🏾", + "person_gesturing_no_medium-light_skin_tone": "🙅🏼", + "person_gesturing_no_medium_skin_tone": "🙅🏽", + "person_gesturing_ok": "🙆", + "person_gesturing_ok_dark_skin_tone": "🙆🏿", + "person_gesturing_ok_light_skin_tone": "🙆🏻", + "person_gesturing_ok_medium-dark_skin_tone": "🙆🏾", + "person_gesturing_ok_medium-light_skin_tone": "🙆🏼", + "person_gesturing_ok_medium_skin_tone": "🙆🏽", + "person_getting_haircut": "💇", + "person_getting_haircut_dark_skin_tone": "💇🏿", + "person_getting_haircut_light_skin_tone": "💇🏻", + "person_getting_haircut_medium-dark_skin_tone": "💇🏾", + "person_getting_haircut_medium-light_skin_tone": "💇🏼", + "person_getting_haircut_medium_skin_tone": "💇🏽", + "person_getting_massage": "💆", + "person_getting_massage_dark_skin_tone": "💆🏿", + "person_getting_massage_light_skin_tone": "💆🏻", + "person_getting_massage_medium-dark_skin_tone": "💆🏾", + "person_getting_massage_medium-light_skin_tone": "💆🏼", + "person_getting_massage_medium_skin_tone": "💆🏽", + "person_golfing": "🏌", + "person_golfing_dark_skin_tone": "🏌🏿", + "person_golfing_light_skin_tone": "🏌🏻", + "person_golfing_medium-dark_skin_tone": "🏌🏾", + "person_golfing_medium-light_skin_tone": "🏌🏼", + "person_golfing_medium_skin_tone": "🏌🏽", + "person_in_bed": "🛌", + "person_in_bed_dark_skin_tone": "🛌🏿", + "person_in_bed_light_skin_tone": "🛌🏻", + "person_in_bed_medium-dark_skin_tone": "🛌🏾", + "person_in_bed_medium-light_skin_tone": "🛌🏼", + "person_in_bed_medium_skin_tone": "🛌🏽", + "person_in_lotus_position": "🧘", + "person_in_lotus_position_dark_skin_tone": "🧘🏿", + "person_in_lotus_position_light_skin_tone": "🧘🏻", + "person_in_lotus_position_medium-dark_skin_tone": "🧘🏾", + "person_in_lotus_position_medium-light_skin_tone": "🧘🏼", + "person_in_lotus_position_medium_skin_tone": "🧘🏽", + "person_in_steamy_room": "🧖", + "person_in_steamy_room_dark_skin_tone": "🧖🏿", + "person_in_steamy_room_light_skin_tone": "🧖🏻", + "person_in_steamy_room_medium-dark_skin_tone": "🧖🏾", + "person_in_steamy_room_medium-light_skin_tone": "🧖🏼", + "person_in_steamy_room_medium_skin_tone": "🧖🏽", + "person_juggling": "🤹", + "person_juggling_dark_skin_tone": "🤹🏿", + "person_juggling_light_skin_tone": "🤹🏻", + "person_juggling_medium-dark_skin_tone": "🤹🏾", + "person_juggling_medium-light_skin_tone": "🤹🏼", + "person_juggling_medium_skin_tone": "🤹🏽", + "person_kneeling": "🧎", + "person_lifting_weights": "🏋", + "person_lifting_weights_dark_skin_tone": "🏋🏿", + "person_lifting_weights_light_skin_tone": "🏋🏻", + "person_lifting_weights_medium-dark_skin_tone": "🏋🏾", + "person_lifting_weights_medium-light_skin_tone": "🏋🏼", + "person_lifting_weights_medium_skin_tone": "🏋🏽", + "person_mountain_biking": "🚵", + "person_mountain_biking_dark_skin_tone": "🚵🏿", + "person_mountain_biking_light_skin_tone": "🚵🏻", + "person_mountain_biking_medium-dark_skin_tone": "🚵🏾", + "person_mountain_biking_medium-light_skin_tone": "🚵🏼", + "person_mountain_biking_medium_skin_tone": "🚵🏽", + "person_playing_handball": "🤾", + "person_playing_handball_dark_skin_tone": "🤾🏿", + "person_playing_handball_light_skin_tone": "🤾🏻", + "person_playing_handball_medium-dark_skin_tone": "🤾🏾", + "person_playing_handball_medium-light_skin_tone": "🤾🏼", + "person_playing_handball_medium_skin_tone": "🤾🏽", + "person_playing_water_polo": "🤽", + "person_playing_water_polo_dark_skin_tone": "🤽🏿", + "person_playing_water_polo_light_skin_tone": "🤽🏻", + "person_playing_water_polo_medium-dark_skin_tone": "🤽🏾", + "person_playing_water_polo_medium-light_skin_tone": "🤽🏼", + "person_playing_water_polo_medium_skin_tone": "🤽🏽", + "person_pouting": "🙎", + "person_pouting_dark_skin_tone": "🙎🏿", + "person_pouting_light_skin_tone": "🙎🏻", + "person_pouting_medium-dark_skin_tone": "🙎🏾", + "person_pouting_medium-light_skin_tone": "🙎🏼", + "person_pouting_medium_skin_tone": "🙎🏽", + "person_raising_hand": "🙋", + "person_raising_hand_dark_skin_tone": "🙋🏿", + "person_raising_hand_light_skin_tone": "🙋🏻", + "person_raising_hand_medium-dark_skin_tone": "🙋🏾", + "person_raising_hand_medium-light_skin_tone": "🙋🏼", + "person_raising_hand_medium_skin_tone": "🙋🏽", + "person_rowing_boat": "🚣", + "person_rowing_boat_dark_skin_tone": "🚣🏿", + "person_rowing_boat_light_skin_tone": "🚣🏻", + "person_rowing_boat_medium-dark_skin_tone": "🚣🏾", + "person_rowing_boat_medium-light_skin_tone": "🚣🏼", + "person_rowing_boat_medium_skin_tone": "🚣🏽", + "person_running": "🏃", + "person_running_dark_skin_tone": "🏃🏿", + "person_running_light_skin_tone": "🏃🏻", + "person_running_medium-dark_skin_tone": "🏃🏾", + "person_running_medium-light_skin_tone": "🏃🏼", + "person_running_medium_skin_tone": "🏃🏽", + "person_shrugging": "🤷", + "person_shrugging_dark_skin_tone": "🤷🏿", + "person_shrugging_light_skin_tone": "🤷🏻", + "person_shrugging_medium-dark_skin_tone": "🤷🏾", + "person_shrugging_medium-light_skin_tone": "🤷🏼", + "person_shrugging_medium_skin_tone": "🤷🏽", + "person_standing": "🧍", + "person_surfing": "🏄", + "person_surfing_dark_skin_tone": "🏄🏿", + "person_surfing_light_skin_tone": "🏄🏻", + "person_surfing_medium-dark_skin_tone": "🏄🏾", + "person_surfing_medium-light_skin_tone": "🏄🏼", + "person_surfing_medium_skin_tone": "🏄🏽", + "person_swimming": "🏊", + "person_swimming_dark_skin_tone": "🏊🏿", + "person_swimming_light_skin_tone": "🏊🏻", + "person_swimming_medium-dark_skin_tone": "🏊🏾", + "person_swimming_medium-light_skin_tone": "🏊🏼", + "person_swimming_medium_skin_tone": "🏊🏽", + "person_taking_bath": "🛀", + "person_taking_bath_dark_skin_tone": "🛀🏿", + "person_taking_bath_light_skin_tone": "🛀🏻", + "person_taking_bath_medium-dark_skin_tone": "🛀🏾", + "person_taking_bath_medium-light_skin_tone": "🛀🏼", + "person_taking_bath_medium_skin_tone": "🛀🏽", + "person_tipping_hand": "💁", + "person_tipping_hand_dark_skin_tone": "💁🏿", + "person_tipping_hand_light_skin_tone": "💁🏻", + "person_tipping_hand_medium-dark_skin_tone": "💁🏾", + "person_tipping_hand_medium-light_skin_tone": "💁🏼", + "person_tipping_hand_medium_skin_tone": "💁🏽", + "person_walking": "🚶", + "person_walking_dark_skin_tone": "🚶🏿", + "person_walking_light_skin_tone": "🚶🏻", + "person_walking_medium-dark_skin_tone": "🚶🏾", + "person_walking_medium-light_skin_tone": "🚶🏼", + "person_walking_medium_skin_tone": "🚶🏽", + "person_wearing_turban": "👳", + "person_wearing_turban_dark_skin_tone": "👳🏿", + "person_wearing_turban_light_skin_tone": "👳🏻", + "person_wearing_turban_medium-dark_skin_tone": "👳🏾", + "person_wearing_turban_medium-light_skin_tone": "👳🏼", + "person_wearing_turban_medium_skin_tone": "👳🏽", + "petri_dish": "🧫", + "pick": "⛏", + "pie": "🥧", + "pig": "🐷", + "pig_face": "🐷", + "pig_nose": "🐽", + "pile_of_poo": "💩", + "pill": "💊", + "pinching_hand": "🤏", + "pine_decoration": "🎍", + "pineapple": "🍍", + "ping_pong": "🏓", + "pirate_flag": "🏴\u200d☠️", + "pistol": "🔫", + "pizza": "🍕", + "place_of_worship": "🛐", + "play_button": "▶", + "play_or_pause_button": "⏯", + "pleading_face": "🥺", + "police_car": "🚓", + "police_car_light": "🚨", + "police_officer": "👮", + "police_officer_dark_skin_tone": "👮🏿", + "police_officer_light_skin_tone": "👮🏻", + "police_officer_medium-dark_skin_tone": "👮🏾", + "police_officer_medium-light_skin_tone": "👮🏼", + "police_officer_medium_skin_tone": "👮🏽", + "poodle": "🐩", + "pool_8_ball": "🎱", + "popcorn": "🍿", + "post_office": "🏣", + "postal_horn": "📯", + "postbox": "📮", + "pot_of_food": "🍲", + "potable_water": "🚰", + "potato": "🥔", + "poultry_leg": "🍗", + "pound_banknote": "💷", + "pouting_cat_face": "😾", + "pouting_face": "😡", + "prayer_beads": "📿", + "pregnant_woman": "🤰", + "pregnant_woman_dark_skin_tone": "🤰🏿", + "pregnant_woman_light_skin_tone": "🤰🏻", + "pregnant_woman_medium-dark_skin_tone": "🤰🏾", + "pregnant_woman_medium-light_skin_tone": "🤰🏼", + "pregnant_woman_medium_skin_tone": "🤰🏽", + "pretzel": "🥨", + "probing_cane": "🦯", + "prince": "🤴", + "prince_dark_skin_tone": "🤴🏿", + "prince_light_skin_tone": "🤴🏻", + "prince_medium-dark_skin_tone": "🤴🏾", + "prince_medium-light_skin_tone": "🤴🏼", + "prince_medium_skin_tone": "🤴🏽", + "princess": "👸", + "princess_dark_skin_tone": "👸🏿", + "princess_light_skin_tone": "👸🏻", + "princess_medium-dark_skin_tone": "👸🏾", + "princess_medium-light_skin_tone": "👸🏼", + "princess_medium_skin_tone": "👸🏽", + "printer": "🖨", + "prohibited": "🚫", + "purple_circle": "🟣", + "purple_heart": "💜", + "purple_square": "🟪", + "purse": "👛", + "pushpin": "📌", + "question_mark": "❓", + "rabbit": "🐰", + "rabbit_face": "🐰", + "raccoon": "🦝", + "racing_car": "🏎", + "radio": "📻", + "radio_button": "🔘", + "radioactive": "☢", + "railway_car": "🚃", + "railway_track": "🛤", + "rainbow": "🌈", + "rainbow_flag": "🏳️\u200d🌈", + "raised_back_of_hand": "🤚", + "raised_back_of_hand_dark_skin_tone": "🤚🏿", + "raised_back_of_hand_light_skin_tone": "🤚🏻", + "raised_back_of_hand_medium-dark_skin_tone": "🤚🏾", + "raised_back_of_hand_medium-light_skin_tone": "🤚🏼", + "raised_back_of_hand_medium_skin_tone": "🤚🏽", + "raised_fist": "✊", + "raised_fist_dark_skin_tone": "✊🏿", + "raised_fist_light_skin_tone": "✊🏻", + "raised_fist_medium-dark_skin_tone": "✊🏾", + "raised_fist_medium-light_skin_tone": "✊🏼", + "raised_fist_medium_skin_tone": "✊🏽", + "raised_hand": "✋", + "raised_hand_dark_skin_tone": "✋🏿", + "raised_hand_light_skin_tone": "✋🏻", + "raised_hand_medium-dark_skin_tone": "✋🏾", + "raised_hand_medium-light_skin_tone": "✋🏼", + "raised_hand_medium_skin_tone": "✋🏽", + "raising_hands": "🙌", + "raising_hands_dark_skin_tone": "🙌🏿", + "raising_hands_light_skin_tone": "🙌🏻", + "raising_hands_medium-dark_skin_tone": "🙌🏾", + "raising_hands_medium-light_skin_tone": "🙌🏼", + "raising_hands_medium_skin_tone": "🙌🏽", + "ram": "🐏", + "rat": "🐀", + "razor": "🪒", + "ringed_planet": "🪐", + "receipt": "🧾", + "record_button": "⏺", + "recycling_symbol": "♻", + "red_apple": "🍎", + "red_circle": "🔴", + "red_envelope": "🧧", + "red_hair": "🦰", + "red-haired_man": "👨\u200d🦰", + "red-haired_woman": "👩\u200d🦰", + "red_heart": "❤", + "red_paper_lantern": "🏮", + "red_square": "🟥", + "red_triangle_pointed_down": "🔻", + "red_triangle_pointed_up": "🔺", + "registered": "®", + "relieved_face": "😌", + "reminder_ribbon": "🎗", + "repeat_button": "🔁", + "repeat_single_button": "🔂", + "rescue_worker’s_helmet": "⛑", + "restroom": "🚻", + "reverse_button": "◀", + "revolving_hearts": "💞", + "rhinoceros": "🦏", + "ribbon": "🎀", + "rice_ball": "🍙", + "rice_cracker": "🍘", + "right-facing_fist": "🤜", + "right-facing_fist_dark_skin_tone": "🤜🏿", + "right-facing_fist_light_skin_tone": "🤜🏻", + "right-facing_fist_medium-dark_skin_tone": "🤜🏾", + "right-facing_fist_medium-light_skin_tone": "🤜🏼", + "right-facing_fist_medium_skin_tone": "🤜🏽", + "right_anger_bubble": "🗯", + "right_arrow": "➡", + "right_arrow_curving_down": "⤵", + "right_arrow_curving_left": "↩", + "right_arrow_curving_up": "⤴", + "ring": "💍", + "roasted_sweet_potato": "🍠", + "robot_face": "🤖", + "rocket": "🚀", + "roll_of_paper": "🧻", + "rolled-up_newspaper": "🗞", + "roller_coaster": "🎢", + "rolling_on_the_floor_laughing": "🤣", + "rooster": "🐓", + "rose": "🌹", + "rosette": "🏵", + "round_pushpin": "📍", + "rugby_football": "🏉", + "running_shirt": "🎽", + "running_shoe": "👟", + "sad_but_relieved_face": "😥", + "safety_pin": "🧷", + "safety_vest": "🦺", + "salt": "🧂", + "sailboat": "⛵", + "sake": "🍶", + "sandwich": "🥪", + "sari": "🥻", + "satellite": "📡", + "satellite_antenna": "📡", + "sauropod": "🦕", + "saxophone": "🎷", + "scarf": "🧣", + "school": "🏫", + "school_backpack": "🎒", + "scissors": "✂", + "scorpion": "🦂", + "scroll": "📜", + "seat": "💺", + "see-no-evil_monkey": "🙈", + "seedling": "🌱", + "selfie": "🤳", + "selfie_dark_skin_tone": "🤳🏿", + "selfie_light_skin_tone": "🤳🏻", + "selfie_medium-dark_skin_tone": "🤳🏾", + "selfie_medium-light_skin_tone": "🤳🏼", + "selfie_medium_skin_tone": "🤳🏽", + "service_dog": "🐕\u200d🦺", + "seven-thirty": "🕢", + "seven_o’clock": "🕖", + "shallow_pan_of_food": "🥘", + "shamrock": "☘", + "shark": "🦈", + "shaved_ice": "🍧", + "sheaf_of_rice": "🌾", + "shield": "🛡", + "shinto_shrine": "⛩", + "ship": "🚢", + "shooting_star": "🌠", + "shopping_bags": "🛍", + "shopping_cart": "🛒", + "shortcake": "🍰", + "shorts": "🩳", + "shower": "🚿", + "shrimp": "🦐", + "shuffle_tracks_button": "🔀", + "shushing_face": "🤫", + "sign_of_the_horns": "🤘", + "sign_of_the_horns_dark_skin_tone": "🤘🏿", + "sign_of_the_horns_light_skin_tone": "🤘🏻", + "sign_of_the_horns_medium-dark_skin_tone": "🤘🏾", + "sign_of_the_horns_medium-light_skin_tone": "🤘🏼", + "sign_of_the_horns_medium_skin_tone": "🤘🏽", + "six-thirty": "🕡", + "six_o’clock": "🕕", + "skateboard": "🛹", + "skier": "⛷", + "skis": "🎿", + "skull": "💀", + "skull_and_crossbones": "☠", + "skunk": "🦨", + "sled": "🛷", + "sleeping_face": "😴", + "sleepy_face": "😪", + "slightly_frowning_face": "🙁", + "slightly_smiling_face": "🙂", + "slot_machine": "🎰", + "sloth": "🦥", + "small_airplane": "🛩", + "small_blue_diamond": "🔹", + "small_orange_diamond": "🔸", + "smiling_cat_face_with_heart-eyes": "😻", + "smiling_face": "☺", + "smiling_face_with_halo": "😇", + "smiling_face_with_3_hearts": "🥰", + "smiling_face_with_heart-eyes": "😍", + "smiling_face_with_horns": "😈", + "smiling_face_with_smiling_eyes": "😊", + "smiling_face_with_sunglasses": "😎", + "smirking_face": "😏", + "snail": "🐌", + "snake": "🐍", + "sneezing_face": "🤧", + "snow-capped_mountain": "🏔", + "snowboarder": "🏂", + "snowboarder_dark_skin_tone": "🏂🏿", + "snowboarder_light_skin_tone": "🏂🏻", + "snowboarder_medium-dark_skin_tone": "🏂🏾", + "snowboarder_medium-light_skin_tone": "🏂🏼", + "snowboarder_medium_skin_tone": "🏂🏽", + "snowflake": "❄", + "snowman": "☃", + "snowman_without_snow": "⛄", + "soap": "🧼", + "soccer_ball": "⚽", + "socks": "🧦", + "softball": "🥎", + "soft_ice_cream": "🍦", + "spade_suit": "♠", + "spaghetti": "🍝", + "sparkle": "❇", + "sparkler": "🎇", + "sparkles": "✨", + "sparkling_heart": "💖", + "speak-no-evil_monkey": "🙊", + "speaker_high_volume": "🔊", + "speaker_low_volume": "🔈", + "speaker_medium_volume": "🔉", + "speaking_head": "🗣", + "speech_balloon": "💬", + "speedboat": "🚤", + "spider": "🕷", + "spider_web": "🕸", + "spiral_calendar": "🗓", + "spiral_notepad": "🗒", + "spiral_shell": "🐚", + "spoon": "🥄", + "sponge": "🧽", + "sport_utility_vehicle": "🚙", + "sports_medal": "🏅", + "spouting_whale": "🐳", + "squid": "🦑", + "squinting_face_with_tongue": "😝", + "stadium": "🏟", + "star-struck": "🤩", + "star_and_crescent": "☪", + "star_of_david": "✡", + "station": "🚉", + "steaming_bowl": "🍜", + "stethoscope": "🩺", + "stop_button": "⏹", + "stop_sign": "🛑", + "stopwatch": "⏱", + "straight_ruler": "📏", + "strawberry": "🍓", + "studio_microphone": "🎙", + "stuffed_flatbread": "🥙", + "sun": "☀", + "sun_behind_cloud": "⛅", + "sun_behind_large_cloud": "🌥", + "sun_behind_rain_cloud": "🌦", + "sun_behind_small_cloud": "🌤", + "sun_with_face": "🌞", + "sunflower": "🌻", + "sunglasses": "😎", + "sunrise": "🌅", + "sunrise_over_mountains": "🌄", + "sunset": "🌇", + "superhero": "🦸", + "supervillain": "🦹", + "sushi": "🍣", + "suspension_railway": "🚟", + "swan": "🦢", + "sweat_droplets": "💦", + "synagogue": "🕍", + "syringe": "💉", + "t-shirt": "👕", + "taco": "🌮", + "takeout_box": "🥡", + "tanabata_tree": "🎋", + "tangerine": "🍊", + "taxi": "🚕", + "teacup_without_handle": "🍵", + "tear-off_calendar": "📆", + "teddy_bear": "🧸", + "telephone": "☎", + "telephone_receiver": "📞", + "telescope": "🔭", + "television": "📺", + "ten-thirty": "🕥", + "ten_o’clock": "🕙", + "tennis": "🎾", + "tent": "⛺", + "test_tube": "🧪", + "thermometer": "🌡", + "thinking_face": "🤔", + "thought_balloon": "💭", + "thread": "🧵", + "three-thirty": "🕞", + "three_o’clock": "🕒", + "thumbs_down": "👎", + "thumbs_down_dark_skin_tone": "👎🏿", + "thumbs_down_light_skin_tone": "👎🏻", + "thumbs_down_medium-dark_skin_tone": "👎🏾", + "thumbs_down_medium-light_skin_tone": "👎🏼", + "thumbs_down_medium_skin_tone": "👎🏽", + "thumbs_up": "👍", + "thumbs_up_dark_skin_tone": "👍🏿", + "thumbs_up_light_skin_tone": "👍🏻", + "thumbs_up_medium-dark_skin_tone": "👍🏾", + "thumbs_up_medium-light_skin_tone": "👍🏼", + "thumbs_up_medium_skin_tone": "👍🏽", + "ticket": "🎫", + "tiger": "🐯", + "tiger_face": "🐯", + "timer_clock": "⏲", + "tired_face": "😫", + "toolbox": "🧰", + "toilet": "🚽", + "tomato": "🍅", + "tongue": "👅", + "tooth": "🦷", + "top_hat": "🎩", + "tornado": "🌪", + "trackball": "🖲", + "tractor": "🚜", + "trade_mark": "™", + "train": "🚋", + "tram": "🚊", + "tram_car": "🚋", + "triangular_flag": "🚩", + "triangular_ruler": "📐", + "trident_emblem": "🔱", + "trolleybus": "🚎", + "trophy": "🏆", + "tropical_drink": "🍹", + "tropical_fish": "🐠", + "trumpet": "🎺", + "tulip": "🌷", + "tumbler_glass": "🥃", + "turtle": "🐢", + "twelve-thirty": "🕧", + "twelve_o’clock": "🕛", + "two-hump_camel": "🐫", + "two-thirty": "🕝", + "two_hearts": "💕", + "two_men_holding_hands": "👬", + "two_o’clock": "🕑", + "two_women_holding_hands": "👭", + "umbrella": "☂", + "umbrella_on_ground": "⛱", + "umbrella_with_rain_drops": "☔", + "unamused_face": "😒", + "unicorn_face": "🦄", + "unlocked": "🔓", + "up-down_arrow": "↕", + "up-left_arrow": "↖", + "up-right_arrow": "↗", + "up_arrow": "⬆", + "upside-down_face": "🙃", + "upwards_button": "🔼", + "vampire": "🧛", + "vampire_dark_skin_tone": "🧛🏿", + "vampire_light_skin_tone": "🧛🏻", + "vampire_medium-dark_skin_tone": "🧛🏾", + "vampire_medium-light_skin_tone": "🧛🏼", + "vampire_medium_skin_tone": "🧛🏽", + "vertical_traffic_light": "🚦", + "vibration_mode": "📳", + "victory_hand": "✌", + "victory_hand_dark_skin_tone": "✌🏿", + "victory_hand_light_skin_tone": "✌🏻", + "victory_hand_medium-dark_skin_tone": "✌🏾", + "victory_hand_medium-light_skin_tone": "✌🏼", + "victory_hand_medium_skin_tone": "✌🏽", + "video_camera": "📹", + "video_game": "🎮", + "videocassette": "📼", + "violin": "🎻", + "volcano": "🌋", + "volleyball": "🏐", + "vulcan_salute": "🖖", + "vulcan_salute_dark_skin_tone": "🖖🏿", + "vulcan_salute_light_skin_tone": "🖖🏻", + "vulcan_salute_medium-dark_skin_tone": "🖖🏾", + "vulcan_salute_medium-light_skin_tone": "🖖🏼", + "vulcan_salute_medium_skin_tone": "🖖🏽", + "waffle": "🧇", + "waning_crescent_moon": "🌘", + "waning_gibbous_moon": "🌖", + "warning": "⚠", + "wastebasket": "🗑", + "watch": "⌚", + "water_buffalo": "🐃", + "water_closet": "🚾", + "water_wave": "🌊", + "watermelon": "🍉", + "waving_hand": "👋", + "waving_hand_dark_skin_tone": "👋🏿", + "waving_hand_light_skin_tone": "👋🏻", + "waving_hand_medium-dark_skin_tone": "👋🏾", + "waving_hand_medium-light_skin_tone": "👋🏼", + "waving_hand_medium_skin_tone": "👋🏽", + "wavy_dash": "〰", + "waxing_crescent_moon": "🌒", + "waxing_gibbous_moon": "🌔", + "weary_cat_face": "🙀", + "weary_face": "😩", + "wedding": "💒", + "whale": "🐳", + "wheel_of_dharma": "☸", + "wheelchair_symbol": "♿", + "white_circle": "⚪", + "white_exclamation_mark": "❕", + "white_flag": "🏳", + "white_flower": "💮", + "white_hair": "🦳", + "white-haired_man": "👨\u200d🦳", + "white-haired_woman": "👩\u200d🦳", + "white_heart": "🤍", + "white_heavy_check_mark": "✅", + "white_large_square": "⬜", + "white_medium-small_square": "◽", + "white_medium_square": "◻", + "white_medium_star": "⭐", + "white_question_mark": "❔", + "white_small_square": "▫", + "white_square_button": "🔳", + "wilted_flower": "🥀", + "wind_chime": "🎐", + "wind_face": "🌬", + "wine_glass": "🍷", + "winking_face": "😉", + "winking_face_with_tongue": "😜", + "wolf_face": "🐺", + "woman": "👩", + "woman_artist": "👩\u200d🎨", + "woman_artist_dark_skin_tone": "👩🏿\u200d🎨", + "woman_artist_light_skin_tone": "👩🏻\u200d🎨", + "woman_artist_medium-dark_skin_tone": "👩🏾\u200d🎨", + "woman_artist_medium-light_skin_tone": "👩🏼\u200d🎨", + "woman_artist_medium_skin_tone": "👩🏽\u200d🎨", + "woman_astronaut": "👩\u200d🚀", + "woman_astronaut_dark_skin_tone": "👩🏿\u200d🚀", + "woman_astronaut_light_skin_tone": "👩🏻\u200d🚀", + "woman_astronaut_medium-dark_skin_tone": "👩🏾\u200d🚀", + "woman_astronaut_medium-light_skin_tone": "👩🏼\u200d🚀", + "woman_astronaut_medium_skin_tone": "👩🏽\u200d🚀", + "woman_biking": "🚴\u200d♀️", + "woman_biking_dark_skin_tone": "🚴🏿\u200d♀️", + "woman_biking_light_skin_tone": "🚴🏻\u200d♀️", + "woman_biking_medium-dark_skin_tone": "🚴🏾\u200d♀️", + "woman_biking_medium-light_skin_tone": "🚴🏼\u200d♀️", + "woman_biking_medium_skin_tone": "🚴🏽\u200d♀️", + "woman_bouncing_ball": "⛹️\u200d♀️", + "woman_bouncing_ball_dark_skin_tone": "⛹🏿\u200d♀️", + "woman_bouncing_ball_light_skin_tone": "⛹🏻\u200d♀️", + "woman_bouncing_ball_medium-dark_skin_tone": "⛹🏾\u200d♀️", + "woman_bouncing_ball_medium-light_skin_tone": "⛹🏼\u200d♀️", + "woman_bouncing_ball_medium_skin_tone": "⛹🏽\u200d♀️", + "woman_bowing": "🙇\u200d♀️", + "woman_bowing_dark_skin_tone": "🙇🏿\u200d♀️", + "woman_bowing_light_skin_tone": "🙇🏻\u200d♀️", + "woman_bowing_medium-dark_skin_tone": "🙇🏾\u200d♀️", + "woman_bowing_medium-light_skin_tone": "🙇🏼\u200d♀️", + "woman_bowing_medium_skin_tone": "🙇🏽\u200d♀️", + "woman_cartwheeling": "🤸\u200d♀️", + "woman_cartwheeling_dark_skin_tone": "🤸🏿\u200d♀️", + "woman_cartwheeling_light_skin_tone": "🤸🏻\u200d♀️", + "woman_cartwheeling_medium-dark_skin_tone": "🤸🏾\u200d♀️", + "woman_cartwheeling_medium-light_skin_tone": "🤸🏼\u200d♀️", + "woman_cartwheeling_medium_skin_tone": "🤸🏽\u200d♀️", + "woman_climbing": "🧗\u200d♀️", + "woman_climbing_dark_skin_tone": "🧗🏿\u200d♀️", + "woman_climbing_light_skin_tone": "🧗🏻\u200d♀️", + "woman_climbing_medium-dark_skin_tone": "🧗🏾\u200d♀️", + "woman_climbing_medium-light_skin_tone": "🧗🏼\u200d♀️", + "woman_climbing_medium_skin_tone": "🧗🏽\u200d♀️", + "woman_construction_worker": "👷\u200d♀️", + "woman_construction_worker_dark_skin_tone": "👷🏿\u200d♀️", + "woman_construction_worker_light_skin_tone": "👷🏻\u200d♀️", + "woman_construction_worker_medium-dark_skin_tone": "👷🏾\u200d♀️", + "woman_construction_worker_medium-light_skin_tone": "👷🏼\u200d♀️", + "woman_construction_worker_medium_skin_tone": "👷🏽\u200d♀️", + "woman_cook": "👩\u200d🍳", + "woman_cook_dark_skin_tone": "👩🏿\u200d🍳", + "woman_cook_light_skin_tone": "👩🏻\u200d🍳", + "woman_cook_medium-dark_skin_tone": "👩🏾\u200d🍳", + "woman_cook_medium-light_skin_tone": "👩🏼\u200d🍳", + "woman_cook_medium_skin_tone": "👩🏽\u200d🍳", + "woman_dancing": "💃", + "woman_dancing_dark_skin_tone": "💃🏿", + "woman_dancing_light_skin_tone": "💃🏻", + "woman_dancing_medium-dark_skin_tone": "💃🏾", + "woman_dancing_medium-light_skin_tone": "💃🏼", + "woman_dancing_medium_skin_tone": "💃🏽", + "woman_dark_skin_tone": "👩🏿", + "woman_detective": "🕵️\u200d♀️", + "woman_detective_dark_skin_tone": "🕵🏿\u200d♀️", + "woman_detective_light_skin_tone": "🕵🏻\u200d♀️", + "woman_detective_medium-dark_skin_tone": "🕵🏾\u200d♀️", + "woman_detective_medium-light_skin_tone": "🕵🏼\u200d♀️", + "woman_detective_medium_skin_tone": "🕵🏽\u200d♀️", + "woman_elf": "🧝\u200d♀️", + "woman_elf_dark_skin_tone": "🧝🏿\u200d♀️", + "woman_elf_light_skin_tone": "🧝🏻\u200d♀️", + "woman_elf_medium-dark_skin_tone": "🧝🏾\u200d♀️", + "woman_elf_medium-light_skin_tone": "🧝🏼\u200d♀️", + "woman_elf_medium_skin_tone": "🧝🏽\u200d♀️", + "woman_facepalming": "🤦\u200d♀️", + "woman_facepalming_dark_skin_tone": "🤦🏿\u200d♀️", + "woman_facepalming_light_skin_tone": "🤦🏻\u200d♀️", + "woman_facepalming_medium-dark_skin_tone": "🤦🏾\u200d♀️", + "woman_facepalming_medium-light_skin_tone": "🤦🏼\u200d♀️", + "woman_facepalming_medium_skin_tone": "🤦🏽\u200d♀️", + "woman_factory_worker": "👩\u200d🏭", + "woman_factory_worker_dark_skin_tone": "👩🏿\u200d🏭", + "woman_factory_worker_light_skin_tone": "👩🏻\u200d🏭", + "woman_factory_worker_medium-dark_skin_tone": "👩🏾\u200d🏭", + "woman_factory_worker_medium-light_skin_tone": "👩🏼\u200d🏭", + "woman_factory_worker_medium_skin_tone": "👩🏽\u200d🏭", + "woman_fairy": "🧚\u200d♀️", + "woman_fairy_dark_skin_tone": "🧚🏿\u200d♀️", + "woman_fairy_light_skin_tone": "🧚🏻\u200d♀️", + "woman_fairy_medium-dark_skin_tone": "🧚🏾\u200d♀️", + "woman_fairy_medium-light_skin_tone": "🧚🏼\u200d♀️", + "woman_fairy_medium_skin_tone": "🧚🏽\u200d♀️", + "woman_farmer": "👩\u200d🌾", + "woman_farmer_dark_skin_tone": "👩🏿\u200d🌾", + "woman_farmer_light_skin_tone": "👩🏻\u200d🌾", + "woman_farmer_medium-dark_skin_tone": "👩🏾\u200d🌾", + "woman_farmer_medium-light_skin_tone": "👩🏼\u200d🌾", + "woman_farmer_medium_skin_tone": "👩🏽\u200d🌾", + "woman_firefighter": "👩\u200d🚒", + "woman_firefighter_dark_skin_tone": "👩🏿\u200d🚒", + "woman_firefighter_light_skin_tone": "👩🏻\u200d🚒", + "woman_firefighter_medium-dark_skin_tone": "👩🏾\u200d🚒", + "woman_firefighter_medium-light_skin_tone": "👩🏼\u200d🚒", + "woman_firefighter_medium_skin_tone": "👩🏽\u200d🚒", + "woman_frowning": "🙍\u200d♀️", + "woman_frowning_dark_skin_tone": "🙍🏿\u200d♀️", + "woman_frowning_light_skin_tone": "🙍🏻\u200d♀️", + "woman_frowning_medium-dark_skin_tone": "🙍🏾\u200d♀️", + "woman_frowning_medium-light_skin_tone": "🙍🏼\u200d♀️", + "woman_frowning_medium_skin_tone": "🙍🏽\u200d♀️", + "woman_genie": "🧞\u200d♀️", + "woman_gesturing_no": "🙅\u200d♀️", + "woman_gesturing_no_dark_skin_tone": "🙅🏿\u200d♀️", + "woman_gesturing_no_light_skin_tone": "🙅🏻\u200d♀️", + "woman_gesturing_no_medium-dark_skin_tone": "🙅🏾\u200d♀️", + "woman_gesturing_no_medium-light_skin_tone": "🙅🏼\u200d♀️", + "woman_gesturing_no_medium_skin_tone": "🙅🏽\u200d♀️", + "woman_gesturing_ok": "🙆\u200d♀️", + "woman_gesturing_ok_dark_skin_tone": "🙆🏿\u200d♀️", + "woman_gesturing_ok_light_skin_tone": "🙆🏻\u200d♀️", + "woman_gesturing_ok_medium-dark_skin_tone": "🙆🏾\u200d♀️", + "woman_gesturing_ok_medium-light_skin_tone": "🙆🏼\u200d♀️", + "woman_gesturing_ok_medium_skin_tone": "🙆🏽\u200d♀️", + "woman_getting_haircut": "💇\u200d♀️", + "woman_getting_haircut_dark_skin_tone": "💇🏿\u200d♀️", + "woman_getting_haircut_light_skin_tone": "💇🏻\u200d♀️", + "woman_getting_haircut_medium-dark_skin_tone": "💇🏾\u200d♀️", + "woman_getting_haircut_medium-light_skin_tone": "💇🏼\u200d♀️", + "woman_getting_haircut_medium_skin_tone": "💇🏽\u200d♀️", + "woman_getting_massage": "💆\u200d♀️", + "woman_getting_massage_dark_skin_tone": "💆🏿\u200d♀️", + "woman_getting_massage_light_skin_tone": "💆🏻\u200d♀️", + "woman_getting_massage_medium-dark_skin_tone": "💆🏾\u200d♀️", + "woman_getting_massage_medium-light_skin_tone": "💆🏼\u200d♀️", + "woman_getting_massage_medium_skin_tone": "💆🏽\u200d♀️", + "woman_golfing": "🏌️\u200d♀️", + "woman_golfing_dark_skin_tone": "🏌🏿\u200d♀️", + "woman_golfing_light_skin_tone": "🏌🏻\u200d♀️", + "woman_golfing_medium-dark_skin_tone": "🏌🏾\u200d♀️", + "woman_golfing_medium-light_skin_tone": "🏌🏼\u200d♀️", + "woman_golfing_medium_skin_tone": "🏌🏽\u200d♀️", + "woman_guard": "💂\u200d♀️", + "woman_guard_dark_skin_tone": "💂🏿\u200d♀️", + "woman_guard_light_skin_tone": "💂🏻\u200d♀️", + "woman_guard_medium-dark_skin_tone": "💂🏾\u200d♀️", + "woman_guard_medium-light_skin_tone": "💂🏼\u200d♀️", + "woman_guard_medium_skin_tone": "💂🏽\u200d♀️", + "woman_health_worker": "👩\u200d⚕️", + "woman_health_worker_dark_skin_tone": "👩🏿\u200d⚕️", + "woman_health_worker_light_skin_tone": "👩🏻\u200d⚕️", + "woman_health_worker_medium-dark_skin_tone": "👩🏾\u200d⚕️", + "woman_health_worker_medium-light_skin_tone": "👩🏼\u200d⚕️", + "woman_health_worker_medium_skin_tone": "👩🏽\u200d⚕️", + "woman_in_lotus_position": "🧘\u200d♀️", + "woman_in_lotus_position_dark_skin_tone": "🧘🏿\u200d♀️", + "woman_in_lotus_position_light_skin_tone": "🧘🏻\u200d♀️", + "woman_in_lotus_position_medium-dark_skin_tone": "🧘🏾\u200d♀️", + "woman_in_lotus_position_medium-light_skin_tone": "🧘🏼\u200d♀️", + "woman_in_lotus_position_medium_skin_tone": "🧘🏽\u200d♀️", + "woman_in_manual_wheelchair": "👩\u200d🦽", + "woman_in_motorized_wheelchair": "👩\u200d🦼", + "woman_in_steamy_room": "🧖\u200d♀️", + "woman_in_steamy_room_dark_skin_tone": "🧖🏿\u200d♀️", + "woman_in_steamy_room_light_skin_tone": "🧖🏻\u200d♀️", + "woman_in_steamy_room_medium-dark_skin_tone": "🧖🏾\u200d♀️", + "woman_in_steamy_room_medium-light_skin_tone": "🧖🏼\u200d♀️", + "woman_in_steamy_room_medium_skin_tone": "🧖🏽\u200d♀️", + "woman_judge": "👩\u200d⚖️", + "woman_judge_dark_skin_tone": "👩🏿\u200d⚖️", + "woman_judge_light_skin_tone": "👩🏻\u200d⚖️", + "woman_judge_medium-dark_skin_tone": "👩🏾\u200d⚖️", + "woman_judge_medium-light_skin_tone": "👩🏼\u200d⚖️", + "woman_judge_medium_skin_tone": "👩🏽\u200d⚖️", + "woman_juggling": "🤹\u200d♀️", + "woman_juggling_dark_skin_tone": "🤹🏿\u200d♀️", + "woman_juggling_light_skin_tone": "🤹🏻\u200d♀️", + "woman_juggling_medium-dark_skin_tone": "🤹🏾\u200d♀️", + "woman_juggling_medium-light_skin_tone": "🤹🏼\u200d♀️", + "woman_juggling_medium_skin_tone": "🤹🏽\u200d♀️", + "woman_lifting_weights": "🏋️\u200d♀️", + "woman_lifting_weights_dark_skin_tone": "🏋🏿\u200d♀️", + "woman_lifting_weights_light_skin_tone": "🏋🏻\u200d♀️", + "woman_lifting_weights_medium-dark_skin_tone": "🏋🏾\u200d♀️", + "woman_lifting_weights_medium-light_skin_tone": "🏋🏼\u200d♀️", + "woman_lifting_weights_medium_skin_tone": "🏋🏽\u200d♀️", + "woman_light_skin_tone": "👩🏻", + "woman_mage": "🧙\u200d♀️", + "woman_mage_dark_skin_tone": "🧙🏿\u200d♀️", + "woman_mage_light_skin_tone": "🧙🏻\u200d♀️", + "woman_mage_medium-dark_skin_tone": "🧙🏾\u200d♀️", + "woman_mage_medium-light_skin_tone": "🧙🏼\u200d♀️", + "woman_mage_medium_skin_tone": "🧙🏽\u200d♀️", + "woman_mechanic": "👩\u200d🔧", + "woman_mechanic_dark_skin_tone": "👩🏿\u200d🔧", + "woman_mechanic_light_skin_tone": "👩🏻\u200d🔧", + "woman_mechanic_medium-dark_skin_tone": "👩🏾\u200d🔧", + "woman_mechanic_medium-light_skin_tone": "👩🏼\u200d🔧", + "woman_mechanic_medium_skin_tone": "👩🏽\u200d🔧", + "woman_medium-dark_skin_tone": "👩🏾", + "woman_medium-light_skin_tone": "👩🏼", + "woman_medium_skin_tone": "👩🏽", + "woman_mountain_biking": "🚵\u200d♀️", + "woman_mountain_biking_dark_skin_tone": "🚵🏿\u200d♀️", + "woman_mountain_biking_light_skin_tone": "🚵🏻\u200d♀️", + "woman_mountain_biking_medium-dark_skin_tone": "🚵🏾\u200d♀️", + "woman_mountain_biking_medium-light_skin_tone": "🚵🏼\u200d♀️", + "woman_mountain_biking_medium_skin_tone": "🚵🏽\u200d♀️", + "woman_office_worker": "👩\u200d💼", + "woman_office_worker_dark_skin_tone": "👩🏿\u200d💼", + "woman_office_worker_light_skin_tone": "👩🏻\u200d💼", + "woman_office_worker_medium-dark_skin_tone": "👩🏾\u200d💼", + "woman_office_worker_medium-light_skin_tone": "👩🏼\u200d💼", + "woman_office_worker_medium_skin_tone": "👩🏽\u200d💼", + "woman_pilot": "👩\u200d✈️", + "woman_pilot_dark_skin_tone": "👩🏿\u200d✈️", + "woman_pilot_light_skin_tone": "👩🏻\u200d✈️", + "woman_pilot_medium-dark_skin_tone": "👩🏾\u200d✈️", + "woman_pilot_medium-light_skin_tone": "👩🏼\u200d✈️", + "woman_pilot_medium_skin_tone": "👩🏽\u200d✈️", + "woman_playing_handball": "🤾\u200d♀️", + "woman_playing_handball_dark_skin_tone": "🤾🏿\u200d♀️", + "woman_playing_handball_light_skin_tone": "🤾🏻\u200d♀️", + "woman_playing_handball_medium-dark_skin_tone": "🤾🏾\u200d♀️", + "woman_playing_handball_medium-light_skin_tone": "🤾🏼\u200d♀️", + "woman_playing_handball_medium_skin_tone": "🤾🏽\u200d♀️", + "woman_playing_water_polo": "🤽\u200d♀️", + "woman_playing_water_polo_dark_skin_tone": "🤽🏿\u200d♀️", + "woman_playing_water_polo_light_skin_tone": "🤽🏻\u200d♀️", + "woman_playing_water_polo_medium-dark_skin_tone": "🤽🏾\u200d♀️", + "woman_playing_water_polo_medium-light_skin_tone": "🤽🏼\u200d♀️", + "woman_playing_water_polo_medium_skin_tone": "🤽🏽\u200d♀️", + "woman_police_officer": "👮\u200d♀️", + "woman_police_officer_dark_skin_tone": "👮🏿\u200d♀️", + "woman_police_officer_light_skin_tone": "👮🏻\u200d♀️", + "woman_police_officer_medium-dark_skin_tone": "👮🏾\u200d♀️", + "woman_police_officer_medium-light_skin_tone": "👮🏼\u200d♀️", + "woman_police_officer_medium_skin_tone": "👮🏽\u200d♀️", + "woman_pouting": "🙎\u200d♀️", + "woman_pouting_dark_skin_tone": "🙎🏿\u200d♀️", + "woman_pouting_light_skin_tone": "🙎🏻\u200d♀️", + "woman_pouting_medium-dark_skin_tone": "🙎🏾\u200d♀️", + "woman_pouting_medium-light_skin_tone": "🙎🏼\u200d♀️", + "woman_pouting_medium_skin_tone": "🙎🏽\u200d♀️", + "woman_raising_hand": "🙋\u200d♀️", + "woman_raising_hand_dark_skin_tone": "🙋🏿\u200d♀️", + "woman_raising_hand_light_skin_tone": "🙋🏻\u200d♀️", + "woman_raising_hand_medium-dark_skin_tone": "🙋🏾\u200d♀️", + "woman_raising_hand_medium-light_skin_tone": "🙋🏼\u200d♀️", + "woman_raising_hand_medium_skin_tone": "🙋🏽\u200d♀️", + "woman_rowing_boat": "🚣\u200d♀️", + "woman_rowing_boat_dark_skin_tone": "🚣🏿\u200d♀️", + "woman_rowing_boat_light_skin_tone": "🚣🏻\u200d♀️", + "woman_rowing_boat_medium-dark_skin_tone": "🚣🏾\u200d♀️", + "woman_rowing_boat_medium-light_skin_tone": "🚣🏼\u200d♀️", + "woman_rowing_boat_medium_skin_tone": "🚣🏽\u200d♀️", + "woman_running": "🏃\u200d♀️", + "woman_running_dark_skin_tone": "🏃🏿\u200d♀️", + "woman_running_light_skin_tone": "🏃🏻\u200d♀️", + "woman_running_medium-dark_skin_tone": "🏃🏾\u200d♀️", + "woman_running_medium-light_skin_tone": "🏃🏼\u200d♀️", + "woman_running_medium_skin_tone": "🏃🏽\u200d♀️", + "woman_scientist": "👩\u200d🔬", + "woman_scientist_dark_skin_tone": "👩🏿\u200d🔬", + "woman_scientist_light_skin_tone": "👩🏻\u200d🔬", + "woman_scientist_medium-dark_skin_tone": "👩🏾\u200d🔬", + "woman_scientist_medium-light_skin_tone": "👩🏼\u200d🔬", + "woman_scientist_medium_skin_tone": "👩🏽\u200d🔬", + "woman_shrugging": "🤷\u200d♀️", + "woman_shrugging_dark_skin_tone": "🤷🏿\u200d♀️", + "woman_shrugging_light_skin_tone": "🤷🏻\u200d♀️", + "woman_shrugging_medium-dark_skin_tone": "🤷🏾\u200d♀️", + "woman_shrugging_medium-light_skin_tone": "🤷🏼\u200d♀️", + "woman_shrugging_medium_skin_tone": "🤷🏽\u200d♀️", + "woman_singer": "👩\u200d🎤", + "woman_singer_dark_skin_tone": "👩🏿\u200d🎤", + "woman_singer_light_skin_tone": "👩🏻\u200d🎤", + "woman_singer_medium-dark_skin_tone": "👩🏾\u200d🎤", + "woman_singer_medium-light_skin_tone": "👩🏼\u200d🎤", + "woman_singer_medium_skin_tone": "👩🏽\u200d🎤", + "woman_student": "👩\u200d🎓", + "woman_student_dark_skin_tone": "👩🏿\u200d🎓", + "woman_student_light_skin_tone": "👩🏻\u200d🎓", + "woman_student_medium-dark_skin_tone": "👩🏾\u200d🎓", + "woman_student_medium-light_skin_tone": "👩🏼\u200d🎓", + "woman_student_medium_skin_tone": "👩🏽\u200d🎓", + "woman_surfing": "🏄\u200d♀️", + "woman_surfing_dark_skin_tone": "🏄🏿\u200d♀️", + "woman_surfing_light_skin_tone": "🏄🏻\u200d♀️", + "woman_surfing_medium-dark_skin_tone": "🏄🏾\u200d♀️", + "woman_surfing_medium-light_skin_tone": "🏄🏼\u200d♀️", + "woman_surfing_medium_skin_tone": "🏄🏽\u200d♀️", + "woman_swimming": "🏊\u200d♀️", + "woman_swimming_dark_skin_tone": "🏊🏿\u200d♀️", + "woman_swimming_light_skin_tone": "🏊🏻\u200d♀️", + "woman_swimming_medium-dark_skin_tone": "🏊🏾\u200d♀️", + "woman_swimming_medium-light_skin_tone": "🏊🏼\u200d♀️", + "woman_swimming_medium_skin_tone": "🏊🏽\u200d♀️", + "woman_teacher": "👩\u200d🏫", + "woman_teacher_dark_skin_tone": "👩🏿\u200d🏫", + "woman_teacher_light_skin_tone": "👩🏻\u200d🏫", + "woman_teacher_medium-dark_skin_tone": "👩🏾\u200d🏫", + "woman_teacher_medium-light_skin_tone": "👩🏼\u200d🏫", + "woman_teacher_medium_skin_tone": "👩🏽\u200d🏫", + "woman_technologist": "👩\u200d💻", + "woman_technologist_dark_skin_tone": "👩🏿\u200d💻", + "woman_technologist_light_skin_tone": "👩🏻\u200d💻", + "woman_technologist_medium-dark_skin_tone": "👩🏾\u200d💻", + "woman_technologist_medium-light_skin_tone": "👩🏼\u200d💻", + "woman_technologist_medium_skin_tone": "👩🏽\u200d💻", + "woman_tipping_hand": "💁\u200d♀️", + "woman_tipping_hand_dark_skin_tone": "💁🏿\u200d♀️", + "woman_tipping_hand_light_skin_tone": "💁🏻\u200d♀️", + "woman_tipping_hand_medium-dark_skin_tone": "💁🏾\u200d♀️", + "woman_tipping_hand_medium-light_skin_tone": "💁🏼\u200d♀️", + "woman_tipping_hand_medium_skin_tone": "💁🏽\u200d♀️", + "woman_vampire": "🧛\u200d♀️", + "woman_vampire_dark_skin_tone": "🧛🏿\u200d♀️", + "woman_vampire_light_skin_tone": "🧛🏻\u200d♀️", + "woman_vampire_medium-dark_skin_tone": "🧛🏾\u200d♀️", + "woman_vampire_medium-light_skin_tone": "🧛🏼\u200d♀️", + "woman_vampire_medium_skin_tone": "🧛🏽\u200d♀️", + "woman_walking": "🚶\u200d♀️", + "woman_walking_dark_skin_tone": "🚶🏿\u200d♀️", + "woman_walking_light_skin_tone": "🚶🏻\u200d♀️", + "woman_walking_medium-dark_skin_tone": "🚶🏾\u200d♀️", + "woman_walking_medium-light_skin_tone": "🚶🏼\u200d♀️", + "woman_walking_medium_skin_tone": "🚶🏽\u200d♀️", + "woman_wearing_turban": "👳\u200d♀️", + "woman_wearing_turban_dark_skin_tone": "👳🏿\u200d♀️", + "woman_wearing_turban_light_skin_tone": "👳🏻\u200d♀️", + "woman_wearing_turban_medium-dark_skin_tone": "👳🏾\u200d♀️", + "woman_wearing_turban_medium-light_skin_tone": "👳🏼\u200d♀️", + "woman_wearing_turban_medium_skin_tone": "👳🏽\u200d♀️", + "woman_with_headscarf": "🧕", + "woman_with_headscarf_dark_skin_tone": "🧕🏿", + "woman_with_headscarf_light_skin_tone": "🧕🏻", + "woman_with_headscarf_medium-dark_skin_tone": "🧕🏾", + "woman_with_headscarf_medium-light_skin_tone": "🧕🏼", + "woman_with_headscarf_medium_skin_tone": "🧕🏽", + "woman_with_probing_cane": "👩\u200d🦯", + "woman_zombie": "🧟\u200d♀️", + "woman’s_boot": "👢", + "woman’s_clothes": "👚", + "woman’s_hat": "👒", + "woman’s_sandal": "👡", + "women_with_bunny_ears": "👯\u200d♀️", + "women_wrestling": "🤼\u200d♀️", + "women’s_room": "🚺", + "woozy_face": "🥴", + "world_map": "🗺", + "worried_face": "😟", + "wrapped_gift": "🎁", + "wrench": "🔧", + "writing_hand": "✍", + "writing_hand_dark_skin_tone": "✍🏿", + "writing_hand_light_skin_tone": "✍🏻", + "writing_hand_medium-dark_skin_tone": "✍🏾", + "writing_hand_medium-light_skin_tone": "✍🏼", + "writing_hand_medium_skin_tone": "✍🏽", + "yarn": "🧶", + "yawning_face": "🥱", + "yellow_circle": "🟡", + "yellow_heart": "💛", + "yellow_square": "🟨", + "yen_banknote": "💴", + "yo-yo": "🪀", + "yin_yang": "☯", + "zany_face": "🤪", + "zebra": "🦓", + "zipper-mouth_face": "🤐", + "zombie": "🧟", + "zzz": "💤", + "åland_islands": "🇦🇽", + "keycap_asterisk": "*⃣", + "keycap_digit_eight": "8⃣", + "keycap_digit_five": "5⃣", + "keycap_digit_four": "4⃣", + "keycap_digit_nine": "9⃣", + "keycap_digit_one": "1⃣", + "keycap_digit_seven": "7⃣", + "keycap_digit_six": "6⃣", + "keycap_digit_three": "3⃣", + "keycap_digit_two": "2⃣", + "keycap_digit_zero": "0⃣", + "keycap_number_sign": "#⃣", + "light_skin_tone": "🏻", + "medium_light_skin_tone": "🏼", + "medium_skin_tone": "🏽", + "medium_dark_skin_tone": "🏾", + "dark_skin_tone": "🏿", + "regional_indicator_symbol_letter_a": "🇦", + "regional_indicator_symbol_letter_b": "🇧", + "regional_indicator_symbol_letter_c": "🇨", + "regional_indicator_symbol_letter_d": "🇩", + "regional_indicator_symbol_letter_e": "🇪", + "regional_indicator_symbol_letter_f": "🇫", + "regional_indicator_symbol_letter_g": "🇬", + "regional_indicator_symbol_letter_h": "🇭", + "regional_indicator_symbol_letter_i": "🇮", + "regional_indicator_symbol_letter_j": "🇯", + "regional_indicator_symbol_letter_k": "🇰", + "regional_indicator_symbol_letter_l": "🇱", + "regional_indicator_symbol_letter_m": "🇲", + "regional_indicator_symbol_letter_n": "🇳", + "regional_indicator_symbol_letter_o": "🇴", + "regional_indicator_symbol_letter_p": "🇵", + "regional_indicator_symbol_letter_q": "🇶", + "regional_indicator_symbol_letter_r": "🇷", + "regional_indicator_symbol_letter_s": "🇸", + "regional_indicator_symbol_letter_t": "🇹", + "regional_indicator_symbol_letter_u": "🇺", + "regional_indicator_symbol_letter_v": "🇻", + "regional_indicator_symbol_letter_w": "🇼", + "regional_indicator_symbol_letter_x": "🇽", + "regional_indicator_symbol_letter_y": "🇾", + "regional_indicator_symbol_letter_z": "🇿", + "airplane_arriving": "🛬", + "space_invader": "👾", + "football": "🏈", + "anger": "💢", + "angry": "😠", + "anguished": "😧", + "signal_strength": "📶", + "arrows_counterclockwise": "🔄", + "arrow_heading_down": "⤵", + "arrow_heading_up": "⤴", + "art": "🎨", + "astonished": "😲", + "athletic_shoe": "👟", + "atm": "🏧", + "car": "🚗", + "red_car": "🚗", + "angel": "👼", + "back": "🔙", + "badminton_racquet_and_shuttlecock": "🏸", + "dollar": "💵", + "euro": "💶", + "pound": "💷", + "yen": "💴", + "barber": "💈", + "bath": "🛀", + "bear": "🐻", + "heartbeat": "💓", + "beer": "🍺", + "no_bell": "🔕", + "bento": "🍱", + "bike": "🚲", + "bicyclist": "🚴", + "8ball": "🎱", + "biohazard_sign": "☣", + "birthday": "🎂", + "black_circle_for_record": "⏺", + "clubs": "♣", + "diamonds": "♦", + "arrow_double_down": "⏬", + "hearts": "♥", + "rewind": "⏪", + "black_left__pointing_double_triangle_with_vertical_bar": "⏮", + "arrow_backward": "◀", + "black_medium_small_square": "◾", + "question": "❓", + "fast_forward": "⏩", + "black_right__pointing_double_triangle_with_vertical_bar": "⏭", + "arrow_forward": "▶", + "black_right__pointing_triangle_with_double_vertical_bar": "⏯", + "arrow_right": "➡", + "spades": "♠", + "black_square_for_stop": "⏹", + "sunny": "☀", + "phone": "☎", + "recycle": "♻", + "arrow_double_up": "⏫", + "busstop": "🚏", + "date": "📅", + "flags": "🎏", + "cat2": "🐈", + "joy_cat": "😹", + "smirk_cat": "😼", + "chart_with_downwards_trend": "📉", + "chart_with_upwards_trend": "📈", + "chart": "💹", + "mega": "📣", + "checkered_flag": "🏁", + "accept": "🉑", + "ideograph_advantage": "🉐", + "congratulations": "㊗", + "secret": "㊙", + "m": "Ⓜ", + "city_sunset": "🌆", + "clapper": "🎬", + "clap": "👏", + "beers": "🍻", + "clock830": "🕣", + "clock8": "🕗", + "clock1130": "🕦", + "clock11": "🕚", + "clock530": "🕠", + "clock5": "🕔", + "clock430": "🕟", + "clock4": "🕓", + "clock930": "🕤", + "clock9": "🕘", + "clock130": "🕜", + "clock1": "🕐", + "clock730": "🕢", + "clock7": "🕖", + "clock630": "🕡", + "clock6": "🕕", + "clock1030": "🕥", + "clock10": "🕙", + "clock330": "🕞", + "clock3": "🕒", + "clock1230": "🕧", + "clock12": "🕛", + "clock230": "🕝", + "clock2": "🕑", + "arrows_clockwise": "🔃", + "repeat": "🔁", + "repeat_one": "🔂", + "closed_lock_with_key": "🔐", + "mailbox_closed": "📪", + "mailbox": "📫", + "cloud_with_tornado": "🌪", + "cocktail": "🍸", + "boom": "💥", + "compression": "🗜", + "confounded": "😖", + "confused": "😕", + "rice": "🍚", + "cow2": "🐄", + "cricket_bat_and_ball": "🏏", + "x": "❌", + "cry": "😢", + "curry": "🍛", + "dagger_knife": "🗡", + "dancer": "💃", + "dark_sunglasses": "🕶", + "dash": "💨", + "truck": "🚚", + "derelict_house_building": "🏚", + "diamond_shape_with_a_dot_inside": "💠", + "dart": "🎯", + "disappointed_relieved": "😥", + "disappointed": "😞", + "do_not_litter": "🚯", + "dog2": "🐕", + "flipper": "🐬", + "loop": "➿", + "bangbang": "‼", + "double_vertical_bar": "⏸", + "dove_of_peace": "🕊", + "small_red_triangle_down": "🔻", + "arrow_down_small": "🔽", + "arrow_down": "⬇", + "dromedary_camel": "🐪", + "e__mail": "📧", + "corn": "🌽", + "ear_of_rice": "🌾", + "earth_americas": "🌎", + "earth_asia": "🌏", + "earth_africa": "🌍", + "eight_pointed_black_star": "✴", + "eight_spoked_asterisk": "✳", + "eject_symbol": "⏏", + "bulb": "💡", + "emoji_modifier_fitzpatrick_type__1__2": "🏻", + "emoji_modifier_fitzpatrick_type__3": "🏼", + "emoji_modifier_fitzpatrick_type__4": "🏽", + "emoji_modifier_fitzpatrick_type__5": "🏾", + "emoji_modifier_fitzpatrick_type__6": "🏿", + "end": "🔚", + "email": "✉", + "european_castle": "🏰", + "european_post_office": "🏤", + "interrobang": "⁉", + "expressionless": "😑", + "eyeglasses": "👓", + "massage": "💆", + "yum": "😋", + "scream": "😱", + "kissing_heart": "😘", + "sweat": "😓", + "face_with_head__bandage": "🤕", + "triumph": "😤", + "mask": "😷", + "no_good": "🙅", + "ok_woman": "🙆", + "open_mouth": "😮", + "cold_sweat": "😰", + "stuck_out_tongue": "😛", + "stuck_out_tongue_closed_eyes": "😝", + "stuck_out_tongue_winking_eye": "😜", + "joy": "😂", + "no_mouth": "😶", + "santa": "🎅", + "fax": "📠", + "fearful": "😨", + "field_hockey_stick_and_ball": "🏑", + "first_quarter_moon_with_face": "🌛", + "fish_cake": "🍥", + "fishing_pole_and_fish": "🎣", + "facepunch": "👊", + "punch": "👊", + "flag_for_afghanistan": "🇦🇫", + "flag_for_albania": "🇦🇱", + "flag_for_algeria": "🇩🇿", + "flag_for_american_samoa": "🇦🇸", + "flag_for_andorra": "🇦🇩", + "flag_for_angola": "🇦🇴", + "flag_for_anguilla": "🇦🇮", + "flag_for_antarctica": "🇦🇶", + "flag_for_antigua_&_barbuda": "🇦🇬", + "flag_for_argentina": "🇦🇷", + "flag_for_armenia": "🇦🇲", + "flag_for_aruba": "🇦🇼", + "flag_for_ascension_island": "🇦🇨", + "flag_for_australia": "🇦🇺", + "flag_for_austria": "🇦🇹", + "flag_for_azerbaijan": "🇦🇿", + "flag_for_bahamas": "🇧🇸", + "flag_for_bahrain": "🇧🇭", + "flag_for_bangladesh": "🇧🇩", + "flag_for_barbados": "🇧🇧", + "flag_for_belarus": "🇧🇾", + "flag_for_belgium": "🇧🇪", + "flag_for_belize": "🇧🇿", + "flag_for_benin": "🇧🇯", + "flag_for_bermuda": "🇧🇲", + "flag_for_bhutan": "🇧🇹", + "flag_for_bolivia": "🇧🇴", + "flag_for_bosnia_&_herzegovina": "🇧🇦", + "flag_for_botswana": "🇧🇼", + "flag_for_bouvet_island": "🇧🇻", + "flag_for_brazil": "🇧🇷", + "flag_for_british_indian_ocean_territory": "🇮🇴", + "flag_for_british_virgin_islands": "🇻🇬", + "flag_for_brunei": "🇧🇳", + "flag_for_bulgaria": "🇧🇬", + "flag_for_burkina_faso": "🇧🇫", + "flag_for_burundi": "🇧🇮", + "flag_for_cambodia": "🇰🇭", + "flag_for_cameroon": "🇨🇲", + "flag_for_canada": "🇨🇦", + "flag_for_canary_islands": "🇮🇨", + "flag_for_cape_verde": "🇨🇻", + "flag_for_caribbean_netherlands": "🇧🇶", + "flag_for_cayman_islands": "🇰🇾", + "flag_for_central_african_republic": "🇨🇫", + "flag_for_ceuta_&_melilla": "🇪🇦", + "flag_for_chad": "🇹🇩", + "flag_for_chile": "🇨🇱", + "flag_for_china": "🇨🇳", + "flag_for_christmas_island": "🇨🇽", + "flag_for_clipperton_island": "🇨🇵", + "flag_for_cocos__islands": "🇨🇨", + "flag_for_colombia": "🇨🇴", + "flag_for_comoros": "🇰🇲", + "flag_for_congo____brazzaville": "🇨🇬", + "flag_for_congo____kinshasa": "🇨🇩", + "flag_for_cook_islands": "🇨🇰", + "flag_for_costa_rica": "🇨🇷", + "flag_for_croatia": "🇭🇷", + "flag_for_cuba": "🇨🇺", + "flag_for_curaçao": "🇨🇼", + "flag_for_cyprus": "🇨🇾", + "flag_for_czech_republic": "🇨🇿", + "flag_for_côte_d’ivoire": "🇨🇮", + "flag_for_denmark": "🇩🇰", + "flag_for_diego_garcia": "🇩🇬", + "flag_for_djibouti": "🇩🇯", + "flag_for_dominica": "🇩🇲", + "flag_for_dominican_republic": "🇩🇴", + "flag_for_ecuador": "🇪🇨", + "flag_for_egypt": "🇪🇬", + "flag_for_el_salvador": "🇸🇻", + "flag_for_equatorial_guinea": "🇬🇶", + "flag_for_eritrea": "🇪🇷", + "flag_for_estonia": "🇪🇪", + "flag_for_ethiopia": "🇪🇹", + "flag_for_european_union": "🇪🇺", + "flag_for_falkland_islands": "🇫🇰", + "flag_for_faroe_islands": "🇫🇴", + "flag_for_fiji": "🇫🇯", + "flag_for_finland": "🇫🇮", + "flag_for_france": "🇫🇷", + "flag_for_french_guiana": "🇬🇫", + "flag_for_french_polynesia": "🇵🇫", + "flag_for_french_southern_territories": "🇹🇫", + "flag_for_gabon": "🇬🇦", + "flag_for_gambia": "🇬🇲", + "flag_for_georgia": "🇬🇪", + "flag_for_germany": "🇩🇪", + "flag_for_ghana": "🇬🇭", + "flag_for_gibraltar": "🇬🇮", + "flag_for_greece": "🇬🇷", + "flag_for_greenland": "🇬🇱", + "flag_for_grenada": "🇬🇩", + "flag_for_guadeloupe": "🇬🇵", + "flag_for_guam": "🇬🇺", + "flag_for_guatemala": "🇬🇹", + "flag_for_guernsey": "🇬🇬", + "flag_for_guinea": "🇬🇳", + "flag_for_guinea__bissau": "🇬🇼", + "flag_for_guyana": "🇬🇾", + "flag_for_haiti": "🇭🇹", + "flag_for_heard_&_mcdonald_islands": "🇭🇲", + "flag_for_honduras": "🇭🇳", + "flag_for_hong_kong": "🇭🇰", + "flag_for_hungary": "🇭🇺", + "flag_for_iceland": "🇮🇸", + "flag_for_india": "🇮🇳", + "flag_for_indonesia": "🇮🇩", + "flag_for_iran": "🇮🇷", + "flag_for_iraq": "🇮🇶", + "flag_for_ireland": "🇮🇪", + "flag_for_isle_of_man": "🇮🇲", + "flag_for_israel": "🇮🇱", + "flag_for_italy": "🇮🇹", + "flag_for_jamaica": "🇯🇲", + "flag_for_japan": "🇯🇵", + "flag_for_jersey": "🇯🇪", + "flag_for_jordan": "🇯🇴", + "flag_for_kazakhstan": "🇰🇿", + "flag_for_kenya": "🇰🇪", + "flag_for_kiribati": "🇰🇮", + "flag_for_kosovo": "🇽🇰", + "flag_for_kuwait": "🇰🇼", + "flag_for_kyrgyzstan": "🇰🇬", + "flag_for_laos": "🇱🇦", + "flag_for_latvia": "🇱🇻", + "flag_for_lebanon": "🇱🇧", + "flag_for_lesotho": "🇱🇸", + "flag_for_liberia": "🇱🇷", + "flag_for_libya": "🇱🇾", + "flag_for_liechtenstein": "🇱🇮", + "flag_for_lithuania": "🇱🇹", + "flag_for_luxembourg": "🇱🇺", + "flag_for_macau": "🇲🇴", + "flag_for_macedonia": "🇲🇰", + "flag_for_madagascar": "🇲🇬", + "flag_for_malawi": "🇲🇼", + "flag_for_malaysia": "🇲🇾", + "flag_for_maldives": "🇲🇻", + "flag_for_mali": "🇲🇱", + "flag_for_malta": "🇲🇹", + "flag_for_marshall_islands": "🇲🇭", + "flag_for_martinique": "🇲🇶", + "flag_for_mauritania": "🇲🇷", + "flag_for_mauritius": "🇲🇺", + "flag_for_mayotte": "🇾🇹", + "flag_for_mexico": "🇲🇽", + "flag_for_micronesia": "🇫🇲", + "flag_for_moldova": "🇲🇩", + "flag_for_monaco": "🇲🇨", + "flag_for_mongolia": "🇲🇳", + "flag_for_montenegro": "🇲🇪", + "flag_for_montserrat": "🇲🇸", + "flag_for_morocco": "🇲🇦", + "flag_for_mozambique": "🇲🇿", + "flag_for_myanmar": "🇲🇲", + "flag_for_namibia": "🇳🇦", + "flag_for_nauru": "🇳🇷", + "flag_for_nepal": "🇳🇵", + "flag_for_netherlands": "🇳🇱", + "flag_for_new_caledonia": "🇳🇨", + "flag_for_new_zealand": "🇳🇿", + "flag_for_nicaragua": "🇳🇮", + "flag_for_niger": "🇳🇪", + "flag_for_nigeria": "🇳🇬", + "flag_for_niue": "🇳🇺", + "flag_for_norfolk_island": "🇳🇫", + "flag_for_north_korea": "🇰🇵", + "flag_for_northern_mariana_islands": "🇲🇵", + "flag_for_norway": "🇳🇴", + "flag_for_oman": "🇴🇲", + "flag_for_pakistan": "🇵🇰", + "flag_for_palau": "🇵🇼", + "flag_for_palestinian_territories": "🇵🇸", + "flag_for_panama": "🇵🇦", + "flag_for_papua_new_guinea": "🇵🇬", + "flag_for_paraguay": "🇵🇾", + "flag_for_peru": "🇵🇪", + "flag_for_philippines": "🇵🇭", + "flag_for_pitcairn_islands": "🇵🇳", + "flag_for_poland": "🇵🇱", + "flag_for_portugal": "🇵🇹", + "flag_for_puerto_rico": "🇵🇷", + "flag_for_qatar": "🇶🇦", + "flag_for_romania": "🇷🇴", + "flag_for_russia": "🇷🇺", + "flag_for_rwanda": "🇷🇼", + "flag_for_réunion": "🇷🇪", + "flag_for_samoa": "🇼🇸", + "flag_for_san_marino": "🇸🇲", + "flag_for_saudi_arabia": "🇸🇦", + "flag_for_senegal": "🇸🇳", + "flag_for_serbia": "🇷🇸", + "flag_for_seychelles": "🇸🇨", + "flag_for_sierra_leone": "🇸🇱", + "flag_for_singapore": "🇸🇬", + "flag_for_sint_maarten": "🇸🇽", + "flag_for_slovakia": "🇸🇰", + "flag_for_slovenia": "🇸🇮", + "flag_for_solomon_islands": "🇸🇧", + "flag_for_somalia": "🇸🇴", + "flag_for_south_africa": "🇿🇦", + "flag_for_south_georgia_&_south_sandwich_islands": "🇬🇸", + "flag_for_south_korea": "🇰🇷", + "flag_for_south_sudan": "🇸🇸", + "flag_for_spain": "🇪🇸", + "flag_for_sri_lanka": "🇱🇰", + "flag_for_st._barthélemy": "🇧🇱", + "flag_for_st._helena": "🇸🇭", + "flag_for_st._kitts_&_nevis": "🇰🇳", + "flag_for_st._lucia": "🇱🇨", + "flag_for_st._martin": "🇲🇫", + "flag_for_st._pierre_&_miquelon": "🇵🇲", + "flag_for_st._vincent_&_grenadines": "🇻🇨", + "flag_for_sudan": "🇸🇩", + "flag_for_suriname": "🇸🇷", + "flag_for_svalbard_&_jan_mayen": "🇸🇯", + "flag_for_swaziland": "🇸🇿", + "flag_for_sweden": "🇸🇪", + "flag_for_switzerland": "🇨🇭", + "flag_for_syria": "🇸🇾", + "flag_for_são_tomé_&_príncipe": "🇸🇹", + "flag_for_taiwan": "🇹🇼", + "flag_for_tajikistan": "🇹🇯", + "flag_for_tanzania": "🇹🇿", + "flag_for_thailand": "🇹🇭", + "flag_for_timor__leste": "🇹🇱", + "flag_for_togo": "🇹🇬", + "flag_for_tokelau": "🇹🇰", + "flag_for_tonga": "🇹🇴", + "flag_for_trinidad_&_tobago": "🇹🇹", + "flag_for_tristan_da_cunha": "🇹🇦", + "flag_for_tunisia": "🇹🇳", + "flag_for_turkey": "🇹🇷", + "flag_for_turkmenistan": "🇹🇲", + "flag_for_turks_&_caicos_islands": "🇹🇨", + "flag_for_tuvalu": "🇹🇻", + "flag_for_u.s._outlying_islands": "🇺🇲", + "flag_for_u.s._virgin_islands": "🇻🇮", + "flag_for_uganda": "🇺🇬", + "flag_for_ukraine": "🇺🇦", + "flag_for_united_arab_emirates": "🇦🇪", + "flag_for_united_kingdom": "🇬🇧", + "flag_for_united_states": "🇺🇸", + "flag_for_uruguay": "🇺🇾", + "flag_for_uzbekistan": "🇺🇿", + "flag_for_vanuatu": "🇻🇺", + "flag_for_vatican_city": "🇻🇦", + "flag_for_venezuela": "🇻🇪", + "flag_for_vietnam": "🇻🇳", + "flag_for_wallis_&_futuna": "🇼🇫", + "flag_for_western_sahara": "🇪🇭", + "flag_for_yemen": "🇾🇪", + "flag_for_zambia": "🇿🇲", + "flag_for_zimbabwe": "🇿🇼", + "flag_for_åland_islands": "🇦🇽", + "golf": "⛳", + "fleur__de__lis": "⚜", + "muscle": "💪", + "flushed": "😳", + "frame_with_picture": "🖼", + "fries": "🍟", + "frog": "🐸", + "hatched_chick": "🐥", + "frowning": "😦", + "fuelpump": "⛽", + "full_moon_with_face": "🌝", + "gem": "💎", + "star2": "🌟", + "golfer": "🏌", + "mortar_board": "🎓", + "grimacing": "😬", + "smile_cat": "😸", + "grinning": "😀", + "grin": "😁", + "heartpulse": "💗", + "guardsman": "💂", + "haircut": "💇", + "hamster": "🐹", + "raising_hand": "🙋", + "headphones": "🎧", + "hear_no_evil": "🙉", + "cupid": "💘", + "gift_heart": "💝", + "heart": "❤", + "exclamation": "❗", + "heavy_exclamation_mark": "❗", + "heavy_heart_exclamation_mark_ornament": "❣", + "o": "⭕", + "helm_symbol": "⎈", + "helmet_with_white_cross": "⛑", + "high_heel": "👠", + "bullettrain_side": "🚄", + "bullettrain_front": "🚅", + "high_brightness": "🔆", + "zap": "⚡", + "hocho": "🔪", + "knife": "🔪", + "bee": "🐝", + "traffic_light": "🚥", + "racehorse": "🐎", + "coffee": "☕", + "hotsprings": "♨", + "hourglass": "⌛", + "hourglass_flowing_sand": "⏳", + "house_buildings": "🏘", + "100": "💯", + "hushed": "😯", + "ice_hockey_stick_and_puck": "🏒", + "imp": "👿", + "information_desk_person": "💁", + "information_source": "ℹ", + "capital_abcd": "🔠", + "abc": "🔤", + "abcd": "🔡", + "1234": "🔢", + "symbols": "🔣", + "izakaya_lantern": "🏮", + "lantern": "🏮", + "jack_o_lantern": "🎃", + "dolls": "🎎", + "japanese_goblin": "👺", + "japanese_ogre": "👹", + "beginner": "🔰", + "zero": "0️⃣", + "one": "1️⃣", + "ten": "🔟", + "two": "2️⃣", + "three": "3️⃣", + "four": "4️⃣", + "five": "5️⃣", + "six": "6️⃣", + "seven": "7️⃣", + "eight": "8️⃣", + "nine": "9️⃣", + "couplekiss": "💏", + "kissing_cat": "😽", + "kissing": "😗", + "kissing_closed_eyes": "😚", + "kissing_smiling_eyes": "😙", + "beetle": "🐞", + "large_blue_circle": "🔵", + "last_quarter_moon_with_face": "🌜", + "leaves": "🍃", + "mag": "🔍", + "left_right_arrow": "↔", + "leftwards_arrow_with_hook": "↩", + "arrow_left": "⬅", + "lock": "🔒", + "lock_with_ink_pen": "🔏", + "sob": "😭", + "low_brightness": "🔅", + "lower_left_ballpoint_pen": "🖊", + "lower_left_crayon": "🖍", + "lower_left_fountain_pen": "🖋", + "lower_left_paintbrush": "🖌", + "mahjong": "🀄", + "couple": "👫", + "man_in_business_suit_levitating": "🕴", + "man_with_gua_pi_mao": "👲", + "man_with_turban": "👳", + "mans_shoe": "👞", + "shoe": "👞", + "menorah_with_nine_branches": "🕎", + "mens": "🚹", + "minidisc": "💽", + "iphone": "📱", + "calling": "📲", + "money__mouth_face": "🤑", + "moneybag": "💰", + "rice_scene": "🎑", + "mountain_bicyclist": "🚵", + "mouse2": "🐁", + "lips": "👄", + "moyai": "🗿", + "notes": "🎶", + "nail_care": "💅", + "ab": "🆎", + "negative_squared_cross_mark": "❎", + "a": "🅰", + "b": "🅱", + "o2": "🅾", + "parking": "🅿", + "new_moon_with_face": "🌚", + "no_entry_sign": "🚫", + "underage": "🔞", + "non__potable_water": "🚱", + "arrow_upper_right": "↗", + "arrow_upper_left": "↖", + "office": "🏢", + "older_man": "👴", + "older_woman": "👵", + "om_symbol": "🕉", + "on": "🔛", + "book": "📖", + "unlock": "🔓", + "mailbox_with_no_mail": "📭", + "mailbox_with_mail": "📬", + "cd": "💿", + "tada": "🎉", + "feet": "🐾", + "walking": "🚶", + "pencil2": "✏", + "pensive": "😔", + "persevere": "😣", + "bow": "🙇", + "raised_hands": "🙌", + "person_with_ball": "⛹", + "person_with_blond_hair": "👱", + "pray": "🙏", + "person_with_pouting_face": "🙎", + "computer": "💻", + "pig2": "🐖", + "hankey": "💩", + "poop": "💩", + "shit": "💩", + "bamboo": "🎍", + "gun": "🔫", + "black_joker": "🃏", + "rotating_light": "🚨", + "cop": "👮", + "stew": "🍲", + "pouch": "👝", + "pouting_cat": "😾", + "rage": "😡", + "put_litter_in_its_place": "🚮", + "rabbit2": "🐇", + "racing_motorcycle": "🏍", + "radioactive_sign": "☢", + "fist": "✊", + "hand": "✋", + "raised_hand_with_fingers_splayed": "🖐", + "raised_hand_with_part_between_middle_and_ring_fingers": "🖖", + "blue_car": "🚙", + "apple": "🍎", + "relieved": "😌", + "reversed_hand_with_middle_finger_extended": "🖕", + "mag_right": "🔎", + "arrow_right_hook": "↪", + "sweet_potato": "🍠", + "robot": "🤖", + "rolled__up_newspaper": "🗞", + "rowboat": "🚣", + "runner": "🏃", + "running": "🏃", + "running_shirt_with_sash": "🎽", + "boat": "⛵", + "scales": "⚖", + "school_satchel": "🎒", + "scorpius": "♏", + "see_no_evil": "🙈", + "sheep": "🐑", + "stars": "🌠", + "cake": "🍰", + "six_pointed_star": "🔯", + "ski": "🎿", + "sleeping_accommodation": "🛌", + "sleeping": "😴", + "sleepy": "😪", + "sleuth_or_spy": "🕵", + "heart_eyes_cat": "😻", + "smiley_cat": "😺", + "innocent": "😇", + "heart_eyes": "😍", + "smiling_imp": "😈", + "smiley": "😃", + "sweat_smile": "😅", + "smile": "😄", + "laughing": "😆", + "satisfied": "😆", + "blush": "😊", + "smirk": "😏", + "smoking": "🚬", + "snow_capped_mountain": "🏔", + "soccer": "⚽", + "icecream": "🍦", + "soon": "🔜", + "arrow_lower_right": "↘", + "arrow_lower_left": "↙", + "speak_no_evil": "🙊", + "speaker": "🔈", + "mute": "🔇", + "sound": "🔉", + "loud_sound": "🔊", + "speaking_head_in_silhouette": "🗣", + "spiral_calendar_pad": "🗓", + "spiral_note_pad": "🗒", + "shell": "🐚", + "sweat_drops": "💦", + "u5272": "🈹", + "u5408": "🈴", + "u55b6": "🈺", + "u6307": "🈯", + "u6708": "🈷", + "u6709": "🈶", + "u6e80": "🈵", + "u7121": "🈚", + "u7533": "🈸", + "u7981": "🈲", + "u7a7a": "🈳", + "cl": "🆑", + "cool": "🆒", + "free": "🆓", + "id": "🆔", + "koko": "🈁", + "sa": "🈂", + "new": "🆕", + "ng": "🆖", + "ok": "🆗", + "sos": "🆘", + "up": "🆙", + "vs": "🆚", + "steam_locomotive": "🚂", + "ramen": "🍜", + "partly_sunny": "⛅", + "city_sunrise": "🌇", + "surfer": "🏄", + "swimmer": "🏊", + "shirt": "👕", + "tshirt": "👕", + "table_tennis_paddle_and_ball": "🏓", + "tea": "🍵", + "tv": "📺", + "three_button_mouse": "🖱", + "+1": "👍", + "thumbsup": "👍", + "__1": "👎", + "-1": "👎", + "thumbsdown": "👎", + "thunder_cloud_and_rain": "⛈", + "tiger2": "🐅", + "tophat": "🎩", + "top": "🔝", + "tm": "™", + "train2": "🚆", + "triangular_flag_on_post": "🚩", + "trident": "🔱", + "twisted_rightwards_arrows": "🔀", + "unamused": "😒", + "small_red_triangle": "🔺", + "arrow_up_small": "🔼", + "arrow_up_down": "↕", + "upside__down_face": "🙃", + "arrow_up": "⬆", + "v": "✌", + "vhs": "📼", + "wc": "🚾", + "ocean": "🌊", + "waving_black_flag": "🏴", + "wave": "👋", + "waving_white_flag": "🏳", + "moon": "🌔", + "scream_cat": "🙀", + "weary": "😩", + "weight_lifter": "🏋", + "whale2": "🐋", + "wheelchair": "♿", + "point_down": "👇", + "grey_exclamation": "❕", + "white_frowning_face": "☹", + "white_check_mark": "✅", + "point_left": "👈", + "white_medium_small_square": "◽", + "star": "⭐", + "grey_question": "❔", + "point_right": "👉", + "relaxed": "☺", + "white_sun_behind_cloud": "🌥", + "white_sun_behind_cloud_with_rain": "🌦", + "white_sun_with_small_cloud": "🌤", + "point_up_2": "👆", + "point_up": "☝", + "wind_blowing_face": "🌬", + "wink": "😉", + "wolf": "🐺", + "dancers": "👯", + "boot": "👢", + "womans_clothes": "👚", + "womans_hat": "👒", + "sandal": "👡", + "womens": "🚺", + "worried": "😟", + "gift": "🎁", + "zipper__mouth_face": "🤐", + "regional_indicator_a": "🇦", + "regional_indicator_b": "🇧", + "regional_indicator_c": "🇨", + "regional_indicator_d": "🇩", + "regional_indicator_e": "🇪", + "regional_indicator_f": "🇫", + "regional_indicator_g": "🇬", + "regional_indicator_h": "🇭", + "regional_indicator_i": "🇮", + "regional_indicator_j": "🇯", + "regional_indicator_k": "🇰", + "regional_indicator_l": "🇱", + "regional_indicator_m": "🇲", + "regional_indicator_n": "🇳", + "regional_indicator_o": "🇴", + "regional_indicator_p": "🇵", + "regional_indicator_q": "🇶", + "regional_indicator_r": "🇷", + "regional_indicator_s": "🇸", + "regional_indicator_t": "🇹", + "regional_indicator_u": "🇺", + "regional_indicator_v": "🇻", + "regional_indicator_w": "🇼", + "regional_indicator_x": "🇽", + "regional_indicator_y": "🇾", + "regional_indicator_z": "🇿", +} diff --git a/rich/_emoji_replace.py b/rich/_emoji_replace.py new file mode 100644 index 0000000..ee8cde0 --- /dev/null +++ b/rich/_emoji_replace.py @@ -0,0 +1,17 @@ +from typing import Match + +import re + +from ._emoji_codes import EMOJI + + +def _emoji_replace(text: str, _emoji_sub=re.compile(r"(:(\S*?):)").sub) -> str: + """Replace emoji code in text.""" + get_emoji = EMOJI.get + + def do_replace(match: Match[str]) -> str: + """Called by re.sub to do the replacement.""" + emoji_code, emoji_name = match.groups() + return get_emoji(emoji_name.lower(), emoji_code) + + return _emoji_sub(do_replace, text) diff --git a/rich/_inspect.py b/rich/_inspect.py new file mode 100644 index 0000000..c1c9eec --- /dev/null +++ b/rich/_inspect.py @@ -0,0 +1,251 @@ +from __future__ import absolute_import + +from inspect import cleandoc, getdoc, getfile, isclass, ismodule, signature +from typing import Any, Iterable, Optional, Tuple + +from .console import RenderableType, RenderGroup +from .highlighter import ReprHighlighter +from .jupyter import JupyterMixin +from .panel import Panel +from .pretty import Pretty +from .table import Table +from .text import Text, TextType + + +def _first_paragraph(doc: str) -> str: + """Get the first paragraph from a docstring.""" + paragraph, _, _ = doc.partition("\n\n") + return paragraph + + +def _reformat_doc(doc: str) -> str: + """Reformat docstring.""" + doc = cleandoc(doc).strip() + return doc + + +class Inspect(JupyterMixin): + """A renderable to inspect any Python Object. + + Args: + obj (Any): An object to inspect. + title (str, optional): Title to display over inspect result, or None use type. Defaults to None. + help (bool, optional): Show full help text rather than just first paragraph. Defaults to False. + methods (bool, optional): Enable inspection of callables. Defaults to False. + docs (bool, optional): Also render doc strings. Defaults to True. + private (bool, optional): Show private attributes (beginning with underscore). Defaults to False. + dunder (bool, optional): Show attributes starting with double underscore. Defaults to False. + sort (bool, optional): Sort attributes alphabetically. Defaults to True. + all (bool, optional): Show all attributes. Defaults to False. + value (bool, optional): Pretty print value of object. Defaults to True. + """ + + def __init__( + self, + obj: Any, + *, + title: TextType = None, + help: bool = False, + methods: bool = False, + docs: bool = True, + private: bool = False, + dunder: bool = False, + sort: bool = True, + all: bool = True, + value: bool = True, + ) -> None: + self.highlighter = ReprHighlighter() + self.obj = obj + self.title = title or self._make_title(obj) + if all: + methods = private = dunder = True + self.help = help + self.methods = methods + self.docs = docs or help + self.private = private or dunder + self.dunder = dunder + self.sort = sort + self.value = value + + def _make_title(self, obj: Any) -> Text: + """Make a default title.""" + title_str = ( + str(obj) + if (isclass(obj) or callable(obj) or ismodule(obj)) + else str(type(obj)) + ) + title_text = self.highlighter(title_str) + return title_text + + def __rich__(self) -> Panel: + return Panel.fit( + RenderGroup(*self._render()), + title=self.title, + border_style="scope.border", + padding=(0, 1), + ) + + def _get_signature(self, name: str, obj: Any) -> Optional[Text]: + """Get a signature for a callable.""" + try: + _signature = str(signature(obj)) + ":" + except ValueError: + _signature = "(...)" + except TypeError: + return None + + source_filename: Optional[str] = None + try: + source_filename = getfile(obj) + except TypeError: + pass + + callable_name = Text(name, style="inspect.callable") + if source_filename: + callable_name.stylize(f"link file://{source_filename}") + signature_text = self.highlighter(_signature) + + qualname = name or getattr(obj, "__qualname__", name) + qual_signature = Text.assemble( + ("def ", "inspect.def"), (qualname, "inspect.callable"), signature_text + ) + + return qual_signature + + def _render(self) -> Iterable[RenderableType]: + """Render object.""" + + def sort_items(item: Tuple[str, Any]) -> Tuple[bool, str]: + key, (_error, value) = item + return (callable(value), key.strip("_").lower()) + + def safe_getattr(attr_name: str) -> Tuple[Any, Any]: + """Get attribute or any exception.""" + try: + return (None, getattr(obj, attr_name)) + except Exception as error: + return (error, None) + + obj = self.obj + keys = dir(obj) + total_items = len(keys) + if not self.dunder: + keys = [key for key in keys if not key.startswith("__")] + if not self.private: + keys = [key for key in keys if not key.startswith("_")] + not_shown_count = total_items - len(keys) + items = [(key, safe_getattr(key)) for key in keys] + if self.sort: + items.sort(key=sort_items) + + items_table = Table.grid(padding=(0, 1), expand=False) + items_table.add_column(justify="right") + add_row = items_table.add_row + highlighter = self.highlighter + + if callable(obj): + signature = self._get_signature("", obj) + if signature is not None: + yield signature + yield "" + + _doc = getdoc(obj) + if _doc is not None: + if not self.help: + _doc = _first_paragraph(_doc) + doc_text = Text(_reformat_doc(_doc), style="inspect.help") + doc_text = highlighter(doc_text) + yield doc_text + yield "" + + if self.value and not (isclass(obj) or callable(obj) or ismodule(obj)): + yield Panel( + Pretty(obj, indent_guides=True, max_length=10, max_string=60), + border_style="inspect.value.border", + ) + yield "" + + for key, (error, value) in items: + key_text = Text.assemble( + ( + key, + "inspect.attr.dunder" if key.startswith("__") else "inspect.attr", + ), + (" =", "inspect.equals"), + ) + if error is not None: + warning = key_text.copy() + warning.stylize("inspect.error") + add_row(warning, highlighter(repr(error))) + continue + + if callable(value): + if not self.methods: + continue + + _signature_text = self._get_signature(key, value) + if _signature_text is None: + add_row(key_text, Pretty(value, highlighter=highlighter)) + else: + if self.docs: + docs = getdoc(value) + if docs is not None: + _doc = _reformat_doc(str(docs)) + if not self.help: + _doc = _first_paragraph(_doc) + _signature_text.append("\n" if "\n" in _doc else " ") + doc = highlighter(_doc) + doc.stylize("inspect.doc") + _signature_text.append(doc) + + add_row(key_text, _signature_text) + else: + add_row(key_text, Pretty(value, highlighter=highlighter)) + if items_table.row_count: + yield items_table + else: + yield self.highlighter( + Text.from_markup( + f"[i][b]{not_shown_count}[/b] attribute(s) not shown.[/i] Run [b][red]inspect[/red]([not b]inspect[/])[/b] for options." + ) + ) + + +if __name__ == "__main__": # type: ignore + from rich import print + + inspect = Inspect({}, docs=True, methods=True, dunder=True) + print(inspect) + + t = Text("Hello, World") + print(Inspect(t)) + + from rich.style import Style + from rich.color import Color + + print(Inspect(Style.parse("bold red on black"), methods=True, docs=True)) + print(Inspect(Color.parse("#ffe326"), methods=True, docs=True)) + + from rich import get_console + + print(Inspect(get_console(), methods=False)) + + print(Inspect(open("foo.txt", "wt"), methods=False)) + + print(Inspect("Hello", methods=False, dunder=True)) + print(Inspect(inspect, methods=False, dunder=False, docs=False)) + + class Foo: + @property + def broken(self): + 1 / 0 + + f = Foo() + print(Inspect(f)) + + print(Inspect(object, dunder=True)) + + print(Inspect(None, dunder=False)) + + print(Inspect(str, help=True)) + print(Inspect(1, help=False)) diff --git a/rich/_log_render.py b/rich/_log_render.py new file mode 100644 index 0000000..2caad0b --- /dev/null +++ b/rich/_log_render.py @@ -0,0 +1,88 @@ +from datetime import datetime +from typing import Iterable, List, Optional, TYPE_CHECKING, Union, Callable + + +from .text import Text, TextType + +if TYPE_CHECKING: + from .console import Console, ConsoleRenderable, RenderableType + from .table import Table + +FormatTimeCallable = Callable[[datetime], Text] + + +class LogRender: + def __init__( + self, + show_time: bool = True, + show_level: bool = False, + show_path: bool = True, + time_format: Union[str, FormatTimeCallable] = "[%x %X]", + level_width: Optional[int] = 8, + ) -> None: + self.show_time = show_time + self.show_level = show_level + self.show_path = show_path + self.time_format = time_format + self.level_width = level_width + self._last_time: Optional[Text] = None + + def __call__( + self, + console: "Console", + renderables: Iterable["ConsoleRenderable"], + log_time: datetime = None, + time_format: Union[str, FormatTimeCallable] = None, + level: TextType = "", + path: str = None, + line_no: int = None, + link_path: str = None, + ) -> "Table": + from .containers import Renderables + from .table import Table + + output = Table.grid(padding=(0, 1)) + output.expand = True + if self.show_time: + output.add_column(style="log.time") + if self.show_level: + output.add_column(style="log.level", width=self.level_width) + output.add_column(ratio=1, style="log.message", overflow="fold") + if self.show_path and path: + output.add_column(style="log.path") + row: List["RenderableType"] = [] + if self.show_time: + log_time = log_time or console.get_datetime() + time_format = time_format or self.time_format + if callable(time_format): + log_time_display = time_format(log_time) + else: + log_time_display = Text(log_time.strftime(time_format)) + if log_time_display == self._last_time: + row.append(Text(" " * len(log_time_display))) + else: + row.append(log_time_display) + self._last_time = log_time_display + if self.show_level: + row.append(level) + + row.append(Renderables(renderables)) + if self.show_path and path: + path_text = Text() + path_text.append( + path, style=f"link file://{link_path}" if link_path else "" + ) + if line_no: + path_text.append(f":{line_no}") + row.append(path_text) + + output.add_row(*row) + return output + + +if __name__ == "__main__": # pragma: no cover + from rich.console import Console + + c = Console() + c.print("[on blue]Hello", justify="right") + c.log("[on blue]hello", justify="right") diff --git a/rich/_loop.py b/rich/_loop.py new file mode 100644 index 0000000..01c6caf --- /dev/null +++ b/rich/_loop.py @@ -0,0 +1,43 @@ +from typing import Iterable, Tuple, TypeVar + +T = TypeVar("T") + + +def loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: + """Iterate and generate a tuple with a flag for first value.""" + iter_values = iter(values) + try: + value = next(iter_values) + except StopIteration: + return + yield True, value + for value in iter_values: + yield False, value + + +def loop_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: + """Iterate and generate a tuple with a flag for last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + for value in iter_values: + yield False, previous_value + previous_value = value + yield True, previous_value + + +def loop_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: + """Iterate and generate a tuple with a flag for first and last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + first = True + for value in iter_values: + yield first, False, previous_value + first = False + previous_value = value + yield first, True, previous_value diff --git a/rich/_lru_cache.py b/rich/_lru_cache.py new file mode 100644 index 0000000..b77c337 --- /dev/null +++ b/rich/_lru_cache.py @@ -0,0 +1,34 @@ +from collections import OrderedDict +from typing import Dict, Generic, TypeVar + + +CacheKey = TypeVar("CacheKey") +CacheValue = TypeVar("CacheValue") + + +class LRUCache(Generic[CacheKey, CacheValue], OrderedDict): + """ + A dictionary-like container that stores a given maximum items. + + If an additional item is added when the LRUCache is full, the least + recently used key is discarded to make room for the new item. + + """ + + def __init__(self, cache_size: int) -> None: + self.cache_size = cache_size + super(LRUCache, self).__init__() + + def __setitem__(self, key: CacheKey, value: CacheValue) -> None: + """Store a new views, potentially discarding an old value.""" + if key not in self: + if len(self) >= self.cache_size: + self.popitem(last=False) + OrderedDict.__setitem__(self, key, value) + + def __getitem__(self: Dict[CacheKey, CacheValue], key: CacheKey) -> CacheValue: + """Gets the item, but also makes it most recent.""" + value: CacheValue = OrderedDict.__getitem__(self, key) + OrderedDict.__delitem__(self, key) + OrderedDict.__setitem__(self, key, value) + return value diff --git a/rich/_palettes.py b/rich/_palettes.py new file mode 100644 index 0000000..3c748d3 --- /dev/null +++ b/rich/_palettes.py @@ -0,0 +1,309 @@ +from .palette import Palette + + +# Taken from https://en.wikipedia.org/wiki/ANSI_escape_code (Windows 10 column) +WINDOWS_PALETTE = Palette( + [ + (12, 12, 12), + (197, 15, 31), + (19, 161, 14), + (193, 156, 0), + (0, 55, 218), + (136, 23, 152), + (58, 150, 221), + (204, 204, 204), + (118, 118, 118), + (231, 72, 86), + (22, 198, 12), + (249, 241, 165), + (59, 120, 255), + (180, 0, 158), + (97, 214, 214), + (242, 242, 242), + ] +) + +# # The standard ansi colors (including bright variants) +STANDARD_PALETTE = Palette( + [ + (0, 0, 0), + (170, 0, 0), + (0, 170, 0), + (170, 85, 0), + (0, 0, 170), + (170, 0, 170), + (0, 170, 170), + (170, 170, 170), + (85, 85, 85), + (255, 85, 85), + (85, 255, 85), + (255, 255, 85), + (85, 85, 255), + (255, 85, 255), + (85, 255, 255), + (255, 255, 255), + ] +) + + +# The 256 color palette +EIGHT_BIT_PALETTE = Palette( + [ + (0, 0, 0), + (128, 0, 0), + (0, 128, 0), + (128, 128, 0), + (0, 0, 128), + (128, 0, 128), + (0, 128, 128), + (192, 192, 192), + (128, 128, 128), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), + (0, 0, 0), + (0, 0, 95), + (0, 0, 135), + (0, 0, 175), + (0, 0, 215), + (0, 0, 255), + (0, 95, 0), + (0, 95, 95), + (0, 95, 135), + (0, 95, 175), + (0, 95, 215), + (0, 95, 255), + (0, 135, 0), + (0, 135, 95), + (0, 135, 135), + (0, 135, 175), + (0, 135, 215), + (0, 135, 255), + (0, 175, 0), + (0, 175, 95), + (0, 175, 135), + (0, 175, 175), + (0, 175, 215), + (0, 175, 255), + (0, 215, 0), + (0, 215, 95), + (0, 215, 135), + (0, 215, 175), + (0, 215, 215), + (0, 215, 255), + (0, 255, 0), + (0, 255, 95), + (0, 255, 135), + (0, 255, 175), + (0, 255, 215), + (0, 255, 255), + (95, 0, 0), + (95, 0, 95), + (95, 0, 135), + (95, 0, 175), + (95, 0, 215), + (95, 0, 255), + (95, 95, 0), + (95, 95, 95), + (95, 95, 135), + (95, 95, 175), + (95, 95, 215), + (95, 95, 255), + (95, 135, 0), + (95, 135, 95), + (95, 135, 135), + (95, 135, 175), + (95, 135, 215), + (95, 135, 255), + (95, 175, 0), + (95, 175, 95), + (95, 175, 135), + (95, 175, 175), + (95, 175, 215), + (95, 175, 255), + (95, 215, 0), + (95, 215, 95), + (95, 215, 135), + (95, 215, 175), + (95, 215, 215), + (95, 215, 255), + (95, 255, 0), + (95, 255, 95), + (95, 255, 135), + (95, 255, 175), + (95, 255, 215), + (95, 255, 255), + (135, 0, 0), + (135, 0, 95), + (135, 0, 135), + (135, 0, 175), + (135, 0, 215), + (135, 0, 255), + (135, 95, 0), + (135, 95, 95), + (135, 95, 135), + (135, 95, 175), + (135, 95, 215), + (135, 95, 255), + (135, 135, 0), + (135, 135, 95), + (135, 135, 135), + (135, 135, 175), + (135, 135, 215), + (135, 135, 255), + (135, 175, 0), + (135, 175, 95), + (135, 175, 135), + (135, 175, 175), + (135, 175, 215), + (135, 175, 255), + (135, 215, 0), + (135, 215, 95), + (135, 215, 135), + (135, 215, 175), + (135, 215, 215), + (135, 215, 255), + (135, 255, 0), + (135, 255, 95), + (135, 255, 135), + (135, 255, 175), + (135, 255, 215), + (135, 255, 255), + (175, 0, 0), + (175, 0, 95), + (175, 0, 135), + (175, 0, 175), + (175, 0, 215), + (175, 0, 255), + (175, 95, 0), + (175, 95, 95), + (175, 95, 135), + (175, 95, 175), + (175, 95, 215), + (175, 95, 255), + (175, 135, 0), + (175, 135, 95), + (175, 135, 135), + (175, 135, 175), + (175, 135, 215), + (175, 135, 255), + (175, 175, 0), + (175, 175, 95), + (175, 175, 135), + (175, 175, 175), + (175, 175, 215), + (175, 175, 255), + (175, 215, 0), + (175, 215, 95), + (175, 215, 135), + (175, 215, 175), + (175, 215, 215), + (175, 215, 255), + (175, 255, 0), + (175, 255, 95), + (175, 255, 135), + (175, 255, 175), + (175, 255, 215), + (175, 255, 255), + (215, 0, 0), + (215, 0, 95), + (215, 0, 135), + (215, 0, 175), + (215, 0, 215), + (215, 0, 255), + (215, 95, 0), + (215, 95, 95), + (215, 95, 135), + (215, 95, 175), + (215, 95, 215), + (215, 95, 255), + (215, 135, 0), + (215, 135, 95), + (215, 135, 135), + (215, 135, 175), + (215, 135, 215), + (215, 135, 255), + (215, 175, 0), + (215, 175, 95), + (215, 175, 135), + (215, 175, 175), + (215, 175, 215), + (215, 175, 255), + (215, 215, 0), + (215, 215, 95), + (215, 215, 135), + (215, 215, 175), + (215, 215, 215), + (215, 215, 255), + (215, 255, 0), + (215, 255, 95), + (215, 255, 135), + (215, 255, 175), + (215, 255, 215), + (215, 255, 255), + (255, 0, 0), + (255, 0, 95), + (255, 0, 135), + (255, 0, 175), + (255, 0, 215), + (255, 0, 255), + (255, 95, 0), + (255, 95, 95), + (255, 95, 135), + (255, 95, 175), + (255, 95, 215), + (255, 95, 255), + (255, 135, 0), + (255, 135, 95), + (255, 135, 135), + (255, 135, 175), + (255, 135, 215), + (255, 135, 255), + (255, 175, 0), + (255, 175, 95), + (255, 175, 135), + (255, 175, 175), + (255, 175, 215), + (255, 175, 255), + (255, 215, 0), + (255, 215, 95), + (255, 215, 135), + (255, 215, 175), + (255, 215, 215), + (255, 215, 255), + (255, 255, 0), + (255, 255, 95), + (255, 255, 135), + (255, 255, 175), + (255, 255, 215), + (255, 255, 255), + (8, 8, 8), + (18, 18, 18), + (28, 28, 28), + (38, 38, 38), + (48, 48, 48), + (58, 58, 58), + (68, 68, 68), + (78, 78, 78), + (88, 88, 88), + (98, 98, 98), + (108, 108, 108), + (118, 118, 118), + (128, 128, 128), + (138, 138, 138), + (148, 148, 148), + (158, 158, 158), + (168, 168, 168), + (178, 178, 178), + (188, 188, 188), + (198, 198, 198), + (208, 208, 208), + (218, 218, 218), + (228, 228, 228), + (238, 238, 238), + ] +) diff --git a/rich/_pick.py b/rich/_pick.py new file mode 100644 index 0000000..4f6d8b2 --- /dev/null +++ b/rich/_pick.py @@ -0,0 +1,17 @@ +from typing import Optional + + +def pick_bool(*values: Optional[bool]) -> bool: + """Pick the first non-none bool or return the last value. + + Args: + *values (bool): Any number of boolean or None values. + + Returns: + bool: First non-none boolean. + """ + assert values, "1 or more values required" + for value in values: + if value is not None: + return value + return bool(value) diff --git a/rich/_ratio.py b/rich/_ratio.py new file mode 100644 index 0000000..f293aba --- /dev/null +++ b/rich/_ratio.py @@ -0,0 +1,134 @@ +from math import ceil, modf +from typing import cast, List, Optional, Sequence +from typing_extensions import Protocol + + +class Edge(Protocol): + """Any object that defines an edge (such as Layout).""" + + size: Optional[int] = None + ratio: int = 1 + minimum_size: int = 1 + + +def ratio_resolve(total: int, edges: Sequence[Edge]) -> List[int]: + """Divide total space to satisfy size, ratio, and minimum_size, constraints. + + The returned list of integers should add up to total in most cases, unless it is + impossible to satisfy all the constraints. For instance, if there are two edges + with a minimum size of 20 each and `total` is 30 then the returned list will be + greater than total. In practice, this would mean that a Layout object would + clip the rows that would overflow the screen height. + + Args: + total (int): Total number of characters. + edges (List[Edge]): Edges within total space. + + Returns: + List[int]: Number of characters for each edge. + """ + # Size of edge or None for yet to be determined + sizes = [(edge.size or None) for edge in edges] + + # While any edges haven't been calculated + while None in sizes: + # Get flexible edges and index to map these back on to sizes list + flexible_edges = [ + (index, edge) + for index, (size, edge) in enumerate(zip(sizes, edges)) + if size is None + ] + # Remaining space in total + remaining = total - sum(size or 0 for size in sizes) + if remaining <= 0: + # No room for flexible edges + return [(size or 1) for size in sizes] + # Calculate number of characters in a ratio portion + portion = remaining / sum((edge.ratio or 1) for _, edge in flexible_edges) + + # If any edges will be less than their minimum, replace size with the minimum + for index, edge in flexible_edges: + if portion * edge.ratio <= edge.minimum_size: + sizes[index] = edge.minimum_size + # New fixed size will invalidate calculations, so we need to repeat the process + break + else: + # Distribute flexible space and compensate for rounding error + # Since edge sizes can only be integers we need to add the remainder + # to the following line + _modf = modf + remainder = 0.0 + for index, edge in flexible_edges: + remainder, size = _modf(portion * edge.ratio + remainder) + sizes[index] = int(size) + break + # Sizes now contains integers only + return cast(List[int], sizes) + + +def ratio_reduce( + total: int, ratios: List[int], maximums: List[int], values: List[int] +) -> List[int]: + """Divide an integer total in to parts based on ratios. + + Args: + total (int): The total to divide. + ratios (List[int]): A list of integer ratios. + maximums (List[int]): List of maximums values for each slot. + values (List[int]): List of values + + Returns: + List[int]: A list of integers guaranteed to sum to total. + """ + ratios = [ratio if _max else 0 for ratio, _max in zip(ratios, maximums)] + total_ratio = sum(ratios) + if not total_ratio: + return values[:] + total_remaining = total + result: List[int] = [] + append = result.append + for ratio, maximum, value in zip(ratios, maximums, values): + if ratio and total_ratio > 0: + distributed = min(maximum, round(ratio * total_remaining / total_ratio)) + append(value - distributed) + total_remaining -= distributed + total_ratio -= ratio + else: + append(value) + return result + + +def ratio_distribute( + total: int, ratios: List[int], minimums: List[int] = None +) -> List[int]: + """Distribute an integer total in to parts based on ratios. + + Args: + total (int): The total to divide. + ratios (List[int]): A list of integer ratios. + minimums (List[int]): List of minimum values for each slot. + + Returns: + List[int]: A list of integers guaranteed to sum to total. + """ + if minimums: + ratios = [ratio if _min else 0 for ratio, _min in zip(ratios, minimums)] + total_ratio = sum(ratios) + assert total_ratio > 0, "Sum of ratios must be > 0" + + total_remaining = total + distributed_total: List[int] = [] + append = distributed_total.append + if minimums is None: + _minimums = [0] * len(ratios) + else: + _minimums = minimums + for ratio, minimum in zip(ratios, _minimums): + if total_ratio > 0: + distributed = max(minimum, ceil(ratio * total_remaining / total_ratio)) + else: + distributed = total_remaining + append(distributed) + total_ratio -= ratio + total_remaining -= distributed + return distributed_total diff --git a/rich/_spinners.py b/rich/_spinners.py new file mode 100644 index 0000000..dc1db07 --- /dev/null +++ b/rich/_spinners.py @@ -0,0 +1,848 @@ +""" +Spinners are from: +* cli-spinners: + MIT License + Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com) + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE + FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. +""" + +SPINNERS = { + "dots": { + "interval": 80, + "frames": ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + }, + "dots2": {"interval": 80, "frames": ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]}, + "dots3": { + "interval": 80, + "frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"], + }, + "dots4": { + "interval": 80, + "frames": [ + "⠄", + "⠆", + "⠇", + "⠋", + "⠙", + "⠸", + "⠰", + "⠠", + "⠰", + "⠸", + "⠙", + "⠋", + "⠇", + "⠆", + ], + }, + "dots5": { + "interval": 80, + "frames": [ + "⠋", + "⠙", + "⠚", + "⠒", + "⠂", + "⠂", + "⠒", + "⠲", + "⠴", + "⠦", + "⠖", + "⠒", + "⠐", + "⠐", + "⠒", + "⠓", + "⠋", + ], + }, + "dots6": { + "interval": 80, + "frames": [ + "⠁", + "⠉", + "⠙", + "⠚", + "⠒", + "⠂", + "⠂", + "⠒", + "⠲", + "⠴", + "⠤", + "⠄", + "⠄", + "⠤", + "⠴", + "⠲", + "⠒", + "⠂", + "⠂", + "⠒", + "⠚", + "⠙", + "⠉", + "⠁", + ], + }, + "dots7": { + "interval": 80, + "frames": [ + "⠈", + "⠉", + "⠋", + "⠓", + "⠒", + "⠐", + "⠐", + "⠒", + "⠖", + "⠦", + "⠤", + "⠠", + "⠠", + "⠤", + "⠦", + "⠖", + "⠒", + "⠐", + "⠐", + "⠒", + "⠓", + "⠋", + "⠉", + "⠈", + ], + }, + "dots8": { + "interval": 80, + "frames": [ + "⠁", + "⠁", + "⠉", + "⠙", + "⠚", + "⠒", + "⠂", + "⠂", + "⠒", + "⠲", + "⠴", + "⠤", + "⠄", + "⠄", + "⠤", + "⠠", + "⠠", + "⠤", + "⠦", + "⠖", + "⠒", + "⠐", + "⠐", + "⠒", + "⠓", + "⠋", + "⠉", + "⠈", + "⠈", + ], + }, + "dots9": {"interval": 80, "frames": ["⢹", "⢺", "⢼", "⣸", "⣇", "⡧", "⡗", "⡏"]}, + "dots10": {"interval": 80, "frames": ["⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"]}, + "dots11": {"interval": 100, "frames": ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"]}, + "dots12": { + "interval": 80, + "frames": [ + "⢀⠀", + "⡀⠀", + "⠄⠀", + "⢂⠀", + "⡂⠀", + "⠅⠀", + "⢃⠀", + "⡃⠀", + "⠍⠀", + "⢋⠀", + "⡋⠀", + "⠍⠁", + "⢋⠁", + "⡋⠁", + "⠍⠉", + "⠋⠉", + "⠋⠉", + "⠉⠙", + "⠉⠙", + "⠉⠩", + "⠈⢙", + "⠈⡙", + "⢈⠩", + "⡀⢙", + "⠄⡙", + "⢂⠩", + "⡂⢘", + "⠅⡘", + "⢃⠨", + "⡃⢐", + "⠍⡐", + "⢋⠠", + "⡋⢀", + "⠍⡁", + "⢋⠁", + "⡋⠁", + "⠍⠉", + "⠋⠉", + "⠋⠉", + "⠉⠙", + "⠉⠙", + "⠉⠩", + "⠈⢙", + "⠈⡙", + "⠈⠩", + "⠀⢙", + "⠀⡙", + "⠀⠩", + "⠀⢘", + "⠀⡘", + "⠀⠨", + "⠀⢐", + "⠀⡐", + "⠀⠠", + "⠀⢀", + "⠀⡀", + ], + }, + "dots8Bit": { + "interval": 80, + "frames": [ + "⠀", + "⠁", + "⠂", + "⠃", + "⠄", + "⠅", + "⠆", + "⠇", + "⡀", + "⡁", + "⡂", + "⡃", + "⡄", + "⡅", + "⡆", + "⡇", + "⠈", + "⠉", + "⠊", + "⠋", + "⠌", + "⠍", + "⠎", + "⠏", + "⡈", + "⡉", + "⡊", + "⡋", + "⡌", + "⡍", + "⡎", + "⡏", + "⠐", + "⠑", + "⠒", + "⠓", + "⠔", + "⠕", + "⠖", + "⠗", + "⡐", + "⡑", + "⡒", + "⡓", + "⡔", + "⡕", + "⡖", + "⡗", + "⠘", + "⠙", + "⠚", + "⠛", + "⠜", + "⠝", + "⠞", + "⠟", + "⡘", + "⡙", + "⡚", + "⡛", + "⡜", + "⡝", + "⡞", + "⡟", + "⠠", + "⠡", + "⠢", + "⠣", + "⠤", + "⠥", + "⠦", + "⠧", + "⡠", + "⡡", + "⡢", + "⡣", + "⡤", + "⡥", + "⡦", + "⡧", + "⠨", + "⠩", + "⠪", + "⠫", + "⠬", + "⠭", + "⠮", + "⠯", + "⡨", + "⡩", + "⡪", + "⡫", + "⡬", + "⡭", + "⡮", + "⡯", + "⠰", + "⠱", + "⠲", + "⠳", + "⠴", + "⠵", + "⠶", + "⠷", + "⡰", + "⡱", + "⡲", + "⡳", + "⡴", + "⡵", + "⡶", + "⡷", + "⠸", + "⠹", + "⠺", + "⠻", + "⠼", + "⠽", + "⠾", + "⠿", + "⡸", + "⡹", + "⡺", + "⡻", + "⡼", + "⡽", + "⡾", + "⡿", + "⢀", + "⢁", + "⢂", + "⢃", + "⢄", + "⢅", + "⢆", + "⢇", + "⣀", + "⣁", + "⣂", + "⣃", + "⣄", + "⣅", + "⣆", + "⣇", + "⢈", + "⢉", + "⢊", + "⢋", + "⢌", + "⢍", + "⢎", + "⢏", + "⣈", + "⣉", + "⣊", + "⣋", + "⣌", + "⣍", + "⣎", + "⣏", + "⢐", + "⢑", + "⢒", + "⢓", + "⢔", + "⢕", + "⢖", + "⢗", + "⣐", + "⣑", + "⣒", + "⣓", + "⣔", + "⣕", + "⣖", + "⣗", + "⢘", + "⢙", + "⢚", + "⢛", + "⢜", + "⢝", + "⢞", + "⢟", + "⣘", + "⣙", + "⣚", + "⣛", + "⣜", + "⣝", + "⣞", + "⣟", + "⢠", + "⢡", + "⢢", + "⢣", + "⢤", + "⢥", + "⢦", + "⢧", + "⣠", + "⣡", + "⣢", + "⣣", + "⣤", + "⣥", + "⣦", + "⣧", + "⢨", + "⢩", + "⢪", + "⢫", + "⢬", + "⢭", + "⢮", + "⢯", + "⣨", + "⣩", + "⣪", + "⣫", + "⣬", + "⣭", + "⣮", + "⣯", + "⢰", + "⢱", + "⢲", + "⢳", + "⢴", + "⢵", + "⢶", + "⢷", + "⣰", + "⣱", + "⣲", + "⣳", + "⣴", + "⣵", + "⣶", + "⣷", + "⢸", + "⢹", + "⢺", + "⢻", + "⢼", + "⢽", + "⢾", + "⢿", + "⣸", + "⣹", + "⣺", + "⣻", + "⣼", + "⣽", + "⣾", + "⣿", + ], + }, + "line": {"interval": 130, "frames": ["-", "\\", "|", "/"]}, + "line2": {"interval": 100, "frames": ["⠂", "-", "–", "—", "–", "-"]}, + "pipe": {"interval": 100, "frames": ["┤", "┘", "┴", "└", "├", "┌", "┬", "┐"]}, + "simpleDots": {"interval": 400, "frames": [". ", ".. ", "...", " "]}, + "simpleDotsScrolling": { + "interval": 200, + "frames": [". ", ".. ", "...", " ..", " .", " "], + }, + "star": {"interval": 70, "frames": ["✶", "✸", "✹", "✺", "✹", "✷"]}, + "star2": {"interval": 80, "frames": ["+", "x", "*"]}, + "flip": { + "interval": 70, + "frames": ["_", "_", "_", "-", "`", "`", "'", "´", "-", "_", "_", "_"], + }, + "hamburger": {"interval": 100, "frames": ["☱", "☲", "☴"]}, + "growVertical": { + "interval": 120, + "frames": ["▁", "▃", "▄", "▅", "▆", "▇", "▆", "▅", "▄", "▃"], + }, + "growHorizontal": { + "interval": 120, + "frames": ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "▊", "▋", "▌", "▍", "▎"], + }, + "balloon": {"interval": 140, "frames": [" ", ".", "o", "O", "@", "*", " "]}, + "balloon2": {"interval": 120, "frames": [".", "o", "O", "°", "O", "o", "."]}, + "noise": {"interval": 100, "frames": ["▓", "▒", "░"]}, + "bounce": {"interval": 120, "frames": ["⠁", "⠂", "⠄", "⠂"]}, + "boxBounce": {"interval": 120, "frames": ["▖", "▘", "▝", "▗"]}, + "boxBounce2": {"interval": 100, "frames": ["▌", "▀", "▐", "▄"]}, + "triangle": {"interval": 50, "frames": ["◢", "◣", "◤", "◥"]}, + "arc": {"interval": 100, "frames": ["◜", "◠", "◝", "◞", "◡", "◟"]}, + "circle": {"interval": 120, "frames": ["◡", "⊙", "◠"]}, + "squareCorners": {"interval": 180, "frames": ["◰", "◳", "◲", "◱"]}, + "circleQuarters": {"interval": 120, "frames": ["◴", "◷", "◶", "◵"]}, + "circleHalves": {"interval": 50, "frames": ["◐", "◓", "◑", "◒"]}, + "squish": {"interval": 100, "frames": ["╫", "╪"]}, + "toggle": {"interval": 250, "frames": ["⊶", "⊷"]}, + "toggle2": {"interval": 80, "frames": ["▫", "▪"]}, + "toggle3": {"interval": 120, "frames": ["□", "■"]}, + "toggle4": {"interval": 100, "frames": ["■", "□", "▪", "▫"]}, + "toggle5": {"interval": 100, "frames": ["▮", "▯"]}, + "toggle6": {"interval": 300, "frames": ["ဝ", "၀"]}, + "toggle7": {"interval": 80, "frames": ["⦾", "⦿"]}, + "toggle8": {"interval": 100, "frames": ["◍", "◌"]}, + "toggle9": {"interval": 100, "frames": ["◉", "◎"]}, + "toggle10": {"interval": 100, "frames": ["㊂", "㊀", "㊁"]}, + "toggle11": {"interval": 50, "frames": ["⧇", "⧆"]}, + "toggle12": {"interval": 120, "frames": ["☗", "☖"]}, + "toggle13": {"interval": 80, "frames": ["=", "*", "-"]}, + "arrow": {"interval": 100, "frames": ["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"]}, + "arrow2": { + "interval": 80, + "frames": ["⬆️ ", "↗️ ", "➡️ ", "↘️ ", "⬇️ ", "↙️ ", "⬅️ ", "↖️ "], + }, + "arrow3": { + "interval": 120, + "frames": ["▹▹▹▹▹", "▸▹▹▹▹", "▹▸▹▹▹", "▹▹▸▹▹", "▹▹▹▸▹", "▹▹▹▹▸"], + }, + "bouncingBar": { + "interval": 80, + "frames": [ + "[ ]", + "[= ]", + "[== ]", + "[=== ]", + "[ ===]", + "[ ==]", + "[ =]", + "[ ]", + "[ =]", + "[ ==]", + "[ ===]", + "[====]", + "[=== ]", + "[== ]", + "[= ]", + ], + }, + "bouncingBall": { + "interval": 80, + "frames": [ + "( ● )", + "( ● )", + "( ● )", + "( ● )", + "( ●)", + "( ● )", + "( ● )", + "( ● )", + "( ● )", + "(● )", + ], + }, + "smiley": {"interval": 200, "frames": ["😄 ", "😝 "]}, + "monkey": {"interval": 300, "frames": ["🙈 ", "🙈 ", "🙉 ", "🙊 "]}, + "hearts": {"interval": 100, "frames": ["💛 ", "💙 ", "💜 ", "💚 ", "❤️ "]}, + "clock": { + "interval": 100, + "frames": [ + "🕛 ", + "🕐 ", + "🕑 ", + "🕒 ", + "🕓 ", + "🕔 ", + "🕕 ", + "🕖 ", + "🕗 ", + "🕘 ", + "🕙 ", + "🕚 ", + ], + }, + "earth": {"interval": 180, "frames": ["🌍 ", "🌎 ", "🌏 "]}, + "material": { + "interval": 17, + "frames": [ + "█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁", + "██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁", + "███▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁", + "████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁", + "██████▁▁▁▁▁▁▁▁▁▁▁▁▁▁", + "██████▁▁▁▁▁▁▁▁▁▁▁▁▁▁", + "███████▁▁▁▁▁▁▁▁▁▁▁▁▁", + "████████▁▁▁▁▁▁▁▁▁▁▁▁", + "█████████▁▁▁▁▁▁▁▁▁▁▁", + "█████████▁▁▁▁▁▁▁▁▁▁▁", + "██████████▁▁▁▁▁▁▁▁▁▁", + "███████████▁▁▁▁▁▁▁▁▁", + "█████████████▁▁▁▁▁▁▁", + "██████████████▁▁▁▁▁▁", + "██████████████▁▁▁▁▁▁", + "▁██████████████▁▁▁▁▁", + "▁██████████████▁▁▁▁▁", + "▁██████████████▁▁▁▁▁", + "▁▁██████████████▁▁▁▁", + "▁▁▁██████████████▁▁▁", + "▁▁▁▁█████████████▁▁▁", + "▁▁▁▁██████████████▁▁", + "▁▁▁▁██████████████▁▁", + "▁▁▁▁▁██████████████▁", + "▁▁▁▁▁██████████████▁", + "▁▁▁▁▁██████████████▁", + "▁▁▁▁▁▁██████████████", + "▁▁▁▁▁▁██████████████", + "▁▁▁▁▁▁▁█████████████", + "▁▁▁▁▁▁▁█████████████", + "▁▁▁▁▁▁▁▁████████████", + "▁▁▁▁▁▁▁▁████████████", + "▁▁▁▁▁▁▁▁▁███████████", + "▁▁▁▁▁▁▁▁▁███████████", + "▁▁▁▁▁▁▁▁▁▁██████████", + "▁▁▁▁▁▁▁▁▁▁██████████", + "▁▁▁▁▁▁▁▁▁▁▁▁████████", + "▁▁▁▁▁▁▁▁▁▁▁▁▁███████", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁██████", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████", + "█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████", + "██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███", + "██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███", + "███▁▁▁▁▁▁▁▁▁▁▁▁▁▁███", + "████▁▁▁▁▁▁▁▁▁▁▁▁▁▁██", + "█████▁▁▁▁▁▁▁▁▁▁▁▁▁▁█", + "█████▁▁▁▁▁▁▁▁▁▁▁▁▁▁█", + "██████▁▁▁▁▁▁▁▁▁▁▁▁▁█", + "████████▁▁▁▁▁▁▁▁▁▁▁▁", + "█████████▁▁▁▁▁▁▁▁▁▁▁", + "█████████▁▁▁▁▁▁▁▁▁▁▁", + "█████████▁▁▁▁▁▁▁▁▁▁▁", + "█████████▁▁▁▁▁▁▁▁▁▁▁", + "███████████▁▁▁▁▁▁▁▁▁", + "████████████▁▁▁▁▁▁▁▁", + "████████████▁▁▁▁▁▁▁▁", + "██████████████▁▁▁▁▁▁", + "██████████████▁▁▁▁▁▁", + "▁██████████████▁▁▁▁▁", + "▁██████████████▁▁▁▁▁", + "▁▁▁█████████████▁▁▁▁", + "▁▁▁▁▁████████████▁▁▁", + "▁▁▁▁▁████████████▁▁▁", + "▁▁▁▁▁▁███████████▁▁▁", + "▁▁▁▁▁▁▁▁█████████▁▁▁", + "▁▁▁▁▁▁▁▁█████████▁▁▁", + "▁▁▁▁▁▁▁▁▁█████████▁▁", + "▁▁▁▁▁▁▁▁▁█████████▁▁", + "▁▁▁▁▁▁▁▁▁▁█████████▁", + "▁▁▁▁▁▁▁▁▁▁▁████████▁", + "▁▁▁▁▁▁▁▁▁▁▁████████▁", + "▁▁▁▁▁▁▁▁▁▁▁▁███████▁", + "▁▁▁▁▁▁▁▁▁▁▁▁███████▁", + "▁▁▁▁▁▁▁▁▁▁▁▁▁███████", + "▁▁▁▁▁▁▁▁▁▁▁▁▁███████", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁", + "▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁", + ], + }, + "moon": { + "interval": 80, + "frames": ["🌑 ", "🌒 ", "🌓 ", "🌔 ", "🌕 ", "🌖 ", "🌗 ", "🌘 "], + }, + "runner": {"interval": 140, "frames": ["🚶 ", "🏃 "]}, + "pong": { + "interval": 80, + "frames": [ + "▐⠂ ▌", + "▐⠈ ▌", + "▐ ⠂ ▌", + "▐ ⠠ ▌", + "▐ ⡀ ▌", + "▐ ⠠ ▌", + "▐ ⠂ ▌", + "▐ ⠈ ▌", + "▐ ⠂ ▌", + "▐ ⠠ ▌", + "▐ ⡀ ▌", + "▐ ⠠ ▌", + "▐ ⠂ ▌", + "▐ ⠈ ▌", + "▐ ⠂▌", + "▐ ⠠▌", + "▐ ⡀▌", + "▐ ⠠ ▌", + "▐ ⠂ ▌", + "▐ ⠈ ▌", + "▐ ⠂ ▌", + "▐ ⠠ ▌", + "▐ ⡀ ▌", + "▐ ⠠ ▌", + "▐ ⠂ ▌", + "▐ ⠈ ▌", + "▐ ⠂ ▌", + "▐ ⠠ ▌", + "▐ ⡀ ▌", + "▐⠠ ▌", + ], + }, + "shark": { + "interval": 120, + "frames": [ + "▐|\\____________▌", + "▐_|\\___________▌", + "▐__|\\__________▌", + "▐___|\\_________▌", + "▐____|\\________▌", + "▐_____|\\_______▌", + "▐______|\\______▌", + "▐_______|\\_____▌", + "▐________|\\____▌", + "▐_________|\\___▌", + "▐__________|\\__▌", + "▐___________|\\_▌", + "▐____________|\\▌", + "▐____________/|▌", + "▐___________/|_▌", + "▐__________/|__▌", + "▐_________/|___▌", + "▐________/|____▌", + "▐_______/|_____▌", + "▐______/|______▌", + "▐_____/|_______▌", + "▐____/|________▌", + "▐___/|_________▌", + "▐__/|__________▌", + "▐_/|___________▌", + "▐/|____________▌", + ], + }, + "dqpb": {"interval": 100, "frames": ["d", "q", "p", "b"]}, + "weather": { + "interval": 100, + "frames": [ + "☀️ ", + "☀️ ", + "☀️ ", + "🌤 ", + "⛅️ ", + "🌥 ", + "☁️ ", + "🌧 ", + "🌨 ", + "🌧 ", + "🌨 ", + "🌧 ", + "🌨 ", + "⛈ ", + "🌨 ", + "🌧 ", + "🌨 ", + "☁️ ", + "🌥 ", + "⛅️ ", + "🌤 ", + "☀️ ", + "☀️ ", + ], + }, + "christmas": {"interval": 400, "frames": ["🌲", "🎄"]}, + "grenade": { + "interval": 80, + "frames": [ + "، ", + "′ ", + " ´ ", + " ‾ ", + " ⸌", + " ⸊", + " |", + " ⁎", + " ⁕", + " ෴ ", + " ⁓", + " ", + " ", + " ", + ], + }, + "point": {"interval": 125, "frames": ["∙∙∙", "●∙∙", "∙●∙", "∙∙●", "∙∙∙"]}, + "layer": {"interval": 150, "frames": ["-", "=", "≡"]}, + "betaWave": { + "interval": 80, + "frames": [ + "ρββββββ", + "βρβββββ", + "ββρββββ", + "βββρβββ", + "ββββρββ", + "βββββρβ", + "ββββββρ", + ], + }, + "aesthetic": { + "interval": 80, + "frames": [ + "▰▱▱▱▱▱▱", + "▰▰▱▱▱▱▱", + "▰▰▰▱▱▱▱", + "▰▰▰▰▱▱▱", + "▰▰▰▰▰▱▱", + "▰▰▰▰▰▰▱", + "▰▰▰▰▰▰▰", + "▰▱▱▱▱▱▱", + ], + }, +} diff --git a/rich/_stack.py b/rich/_stack.py new file mode 100644 index 0000000..194564e --- /dev/null +++ b/rich/_stack.py @@ -0,0 +1,16 @@ +from typing import List, TypeVar + +T = TypeVar("T") + + +class Stack(List[T]): + """A small shim over builtin list.""" + + @property + def top(self) -> T: + """Get top of stack.""" + return self[-1] + + def push(self, item: T) -> None: + """Push an item on to the stack (append in stack nomenclature).""" + self.append(item) diff --git a/rich/_timer.py b/rich/_timer.py new file mode 100644 index 0000000..b30d374 --- /dev/null +++ b/rich/_timer.py @@ -0,0 +1,18 @@ +""" +Timer context manager, only used in debug. + +""" + +from time import time + +import contextlib + + +@contextlib.contextmanager +def timer(subject: str = "time"): + """print the elapsed time. (only used in debugging)""" + start = time() + yield + elapsed = time() - start + elapsed_ms = elapsed * 1000 + print(f"{subject} elapsed {elapsed_ms:.1f}ms") diff --git a/rich/_windows.py b/rich/_windows.py new file mode 100644 index 0000000..d252d1f --- /dev/null +++ b/rich/_windows.py @@ -0,0 +1,71 @@ +import sys + +from dataclasses import dataclass + + +@dataclass +class WindowsConsoleFeatures: + """Windows features available.""" + + vt: bool = False + """The console supports VT codes.""" + truecolor: bool = False + """The console supports truecolor.""" + + +try: + import ctypes + from ctypes import wintypes + from ctypes import LibraryLoader + + windll = LibraryLoader(ctypes.WinDLL) # type: ignore +except (AttributeError, ImportError, ValueError): + + # Fallback if we can't load the Windows DLL + def get_windows_console_features() -> WindowsConsoleFeatures: + features = WindowsConsoleFeatures() + return features + + +else: + + STDOUT = -11 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 + _GetConsoleMode = windll.kernel32.GetConsoleMode + _GetConsoleMode.argtypes = [wintypes.HANDLE, wintypes.LPDWORD] + _GetConsoleMode.restype = wintypes.BOOL + + _GetStdHandle = windll.kernel32.GetStdHandle + _GetStdHandle.argtypes = [ + wintypes.DWORD, + ] + _GetStdHandle.restype = wintypes.HANDLE + + def get_windows_console_features() -> WindowsConsoleFeatures: + """Get windows console features. + + Returns: + WindowsConsoleFeatures: An instance of WindowsConsoleFeatures. + """ + handle = _GetStdHandle(STDOUT) + console_mode = wintypes.DWORD() + result = _GetConsoleMode(handle, console_mode) + vt = bool(result and console_mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) + truecolor = False + if vt: + win_version = sys.getwindowsversion() # type: ignore + truecolor = win_version.major > 10 or ( + win_version.major == 10 and win_version.build >= 15063 + ) + features = WindowsConsoleFeatures(vt=vt, truecolor=truecolor) + return features + + +if __name__ == "__main__": + import platform + + features = get_windows_console_features() + from rich import print + + print(f'platform="{platform.system()}"') + print(repr(features)) diff --git a/rich/_wrap.py b/rich/_wrap.py new file mode 100644 index 0000000..b537757 --- /dev/null +++ b/rich/_wrap.py @@ -0,0 +1,55 @@ +import re +from typing import Iterable, List, Tuple + +from .cells import cell_len, chop_cells +from ._loop import loop_last + +re_word = re.compile(r"\s*\S+\s*") + + +def words(text: str) -> Iterable[Tuple[int, int, str]]: + position = 0 + word_match = re_word.match(text, position) + while word_match is not None: + start, end = word_match.span() + word = word_match.group(0) + yield start, end, word + word_match = re_word.match(text, end) + + +def divide_line(text: str, width: int, fold: bool = True) -> List[int]: + divides: List[int] = [] + append = divides.append + line_position = 0 + _cell_len = cell_len + for start, _end, word in words(text): + word_length = _cell_len(word.rstrip()) + if line_position + word_length > width: + if word_length > width: + if fold: + for last, line in loop_last( + chop_cells(word, width, position=line_position) + ): + if last: + line_position = _cell_len(line) + else: + start += len(line) + append(start) + else: + if start: + append(start) + line_position = _cell_len(word) + elif line_position and start: + append(start) + line_position = _cell_len(word) + else: + line_position += _cell_len(word) + return divides + + +if __name__ == "__main__": # pragma: no cover + from .console import Console + + console = Console(width=10) + console.print("12345 abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWXYZ 12345") + print(chop_cells("abcdefghijklmnopqrstuvwxyz", 10, position=2)) diff --git a/rich/abc.py b/rich/abc.py new file mode 100644 index 0000000..42db7c0 --- /dev/null +++ b/rich/abc.py @@ -0,0 +1,33 @@ +from abc import ABC + + +class RichRenderable(ABC): + """An abstract base class for Rich renderables. + + Note that there is no need to extend this class, the intended use is to check if an + object supports the Rich renderable protocol. For example:: + + if isinstance(my_object, RichRenderable): + console.print(my_object) + + """ + + @classmethod + def __subclasshook__(cls, other: type) -> bool: + """Check if this class supports the rich render protocol.""" + return hasattr(other, "__rich_console__") or hasattr(other, "__rich__") + + +if __name__ == "__main__": # pragma: no cover + from rich.text import Text + + t = Text() + print(isinstance(Text, RichRenderable)) + print(isinstance(t, RichRenderable)) + + class Foo: + pass + + f = Foo() + print(isinstance(f, RichRenderable)) + print(isinstance("", RichRenderable)) diff --git a/rich/align.py b/rich/align.py new file mode 100644 index 0000000..05657d7 --- /dev/null +++ b/rich/align.py @@ -0,0 +1,304 @@ +from itertools import chain +from typing import Iterable, TYPE_CHECKING + +from typing_extensions import Literal +from .constrain import Constrain +from .jupyter import JupyterMixin +from .measure import Measurement +from .segment import Segment +from .style import StyleType + + +if TYPE_CHECKING: + from .console import Console, ConsoleOptions, RenderResult, RenderableType + +AlignMethod = Literal["left", "center", "right"] +VerticalAlignMethod = Literal["top", "middle", "bottom"] +AlignValues = AlignMethod # TODO: deprecate AlignValues + + +class Align(JupyterMixin): + """Align a renderable by adding spaces if necessary. + + Args: + renderable (RenderableType): A console renderable. + align (AlignMethod): One of "left", "center", or "right"" + style (StyleType, optional): An optional style to apply to the background. + vertical (Optional[VerticalAlginMethod], optional): Optional vertical align, one of "top", "middle", or "bottom". Defaults to None. + pad (bool, optional): Pad the right with spaces. Defaults to True. + width (int, optional): Restrict contents to given width, or None to use default width. Defaults to None. + height (int, optional): Set height of align renderable, or None to fit to contents. Defaults to None. + + Raises: + ValueError: if ``align`` is not one of the expected values. + """ + + def __init__( + self, + renderable: "RenderableType", + align: AlignMethod = "left", + style: StyleType = None, + *, + vertical: VerticalAlignMethod = None, + pad: bool = True, + width: int = None, + height: int = None, + ) -> None: + if align not in ("left", "center", "right"): + raise ValueError( + f'invalid value for align, expected "left", "center", or "right" (not {align!r})' + ) + if vertical is not None and vertical not in ("top", "middle", "bottom"): + raise ValueError( + f'invalid value for vertical, expected "top", "middle", or "bottom" (not {vertical!r})' + ) + self.renderable = renderable + self.align = align + self.style = style + self.vertical = vertical + self.pad = pad + self.width = width + self.height = height + + def __repr__(self) -> str: + return f"Align({self.renderable!r}, {self.align!r})" + + @classmethod + def left( + cls, + renderable: "RenderableType", + style: StyleType = None, + *, + vertical: VerticalAlignMethod = None, + pad: bool = True, + width: int = None, + height: int = None, + ) -> "Align": + """Align a renderable to the left.""" + return cls( + renderable, + "left", + style=style, + vertical=vertical, + pad=pad, + width=width, + height=height, + ) + + @classmethod + def center( + cls, + renderable: "RenderableType", + style: StyleType = None, + *, + vertical: VerticalAlignMethod = None, + pad: bool = True, + width: int = None, + height: int = None, + ) -> "Align": + """Align a renderable to the center.""" + return cls( + renderable, + "center", + style=style, + vertical=vertical, + pad=pad, + width=width, + height=height, + ) + + @classmethod + def right( + cls, + renderable: "RenderableType", + style: StyleType = None, + *, + vertical: VerticalAlignMethod = None, + pad: bool = True, + width: int = None, + height: int = None, + ) -> "Align": + """Align a renderable to the right.""" + return cls( + renderable, + "right", + style=style, + vertical=vertical, + pad=pad, + width=width, + height=height, + ) + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + align = self.align + width = Measurement.get(console, self.renderable).maximum + rendered = console.render( + Constrain( + self.renderable, width if self.width is None else min(width, self.width) + ), + options, + ) + lines = list(Segment.split_lines(rendered)) + width, height = Segment.get_shape(lines) + lines = Segment.set_shape(lines, width, height) + new_line = Segment.line() + excess_space = options.max_width - width + style = console.get_style(self.style) if self.style is not None else None + + def generate_segments() -> Iterable[Segment]: + if excess_space <= 0: + # Exact fit + for line in lines: + yield from line + yield new_line + + elif align == "left": + # Pad on the right + pad = Segment(" " * excess_space, style) if self.pad else None + for line in lines: + yield from line + if pad: + yield pad + yield new_line + + elif align == "center": + # Pad left and right + left = excess_space // 2 + pad = Segment(" " * left, style) + pad_right = ( + Segment(" " * (excess_space - left), style) if self.pad else None + ) + for line in lines: + if left: + yield pad + yield from line + if pad_right: + yield pad_right + yield new_line + + elif align == "right": + # Padding on left + pad = Segment(" " * excess_space, style) + for line in lines: + yield pad + yield from line + yield new_line + + blank_line = ( + Segment(f"{' ' * (self.width or options.max_width)}\n", style) + if self.pad + else Segment("\n") + ) + + def blank_lines(count) -> Iterable[Segment]: + if count > 0: + for _ in range(count): + yield blank_line + + vertical_height = self.height or options.height + iter_segments: Iterable[Segment] + if self.vertical and vertical_height is not None: + if self.vertical == "top": + bottom_space = vertical_height - height + iter_segments = chain(generate_segments(), blank_lines(bottom_space)) + elif self.vertical == "middle": + top_space = (vertical_height - height) // 2 + bottom_space = vertical_height - top_space - height + iter_segments = chain( + blank_lines(top_space), + generate_segments(), + blank_lines(bottom_space), + ) + else: # self.vertical == "bottom": + top_space = vertical_height - height + iter_segments = chain(blank_lines(top_space), generate_segments()) + else: + iter_segments = generate_segments() + if self.style is not None: + style = console.get_style(self.style) + iter_segments = Segment.apply_style(iter_segments, style) + yield from iter_segments + + def __rich_measure__(self, console: "Console", max_width: int) -> Measurement: + measurement = Measurement.get(console, self.renderable, max_width) + return measurement + + +class VerticalCenter(JupyterMixin): + """Vertically aligns a renderable. + + Warn: + This class is deprecated and may be removed in a future version. Use Align class with + `vertical="middle"`. + + Args: + renderable (RenderableType): A renderable object. + """ + + def __init__( + self, + renderable: "RenderableType", + style: StyleType = None, + ) -> None: + self.renderable = renderable + self.style = style + + def __repr__(self) -> str: + return f"VerticalCenter({self.renderable!r})" + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + style = console.get_style(self.style) if self.style is not None else None + lines = console.render_lines( + self.renderable, options.update(height=None), pad=False + ) + width, _height = Segment.get_shape(lines) + new_line = Segment.line() + height = options.height or options.size.height + top_space = (height - len(lines)) // 2 + bottom_space = height - top_space - len(lines) + blank_line = Segment(f"{' ' * width}", style) + + def blank_lines(count) -> Iterable[Segment]: + for _ in range(count): + yield blank_line + yield new_line + + if top_space > 0: + yield from blank_lines(top_space) + for line in lines: + yield from line + yield new_line + if bottom_space > 0: + yield from blank_lines(bottom_space) + + def __rich_measure__(self, console: "Console", max_width: int) -> Measurement: + measurement = Measurement.get(console, self.renderable, max_width) + return measurement + + +if __name__ == "__main__": # pragma: no cover + from rich.console import Console, RenderGroup + from rich.highlighter import ReprHighlighter + from rich.panel import Panel + + highlighter = ReprHighlighter() + console = Console() + + panel = Panel( + RenderGroup( + Align.left(highlighter("align='left'")), + Align.center(highlighter("align='center'")), + Align.right(highlighter("align='right'")), + ), + width=60, + style="on dark_blue", + title="Algin", + ) + + console.print( + Align.center(panel, vertical="middle", style="on red", height=console.height) + ) diff --git a/rich/ansi.py b/rich/ansi.py new file mode 100644 index 0000000..85410de --- /dev/null +++ b/rich/ansi.py @@ -0,0 +1,228 @@ +from contextlib import suppress +import re +from typing import Iterable, NamedTuple + +from .color import Color +from .style import Style +from .text import Text + +re_ansi = re.compile(r"(?:\x1b\[(.*?)m)|(?:\x1b\](.*?)\x1b\\)") +re_csi = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + + +class _AnsiToken(NamedTuple): + """Result of ansi tokenized string.""" + + plain: str = "" + sgr: str = "" + osc: str = "" + + +def _ansi_tokenize(ansi_text: str) -> Iterable[_AnsiToken]: + """Tokenize a string in to plain text and ANSI codes. + + Args: + ansi_text (str): A String containing ANSI codes. + + Yields: + AnsiToken: A named tuple of (plain, sgr, osc) + """ + + def remove_csi(ansi_text: str) -> str: + """Remove unknown CSI sequences.""" + return re_csi.sub("", ansi_text) + + position = 0 + for match in re_ansi.finditer(ansi_text): + start, end = match.span(0) + sgr, osc = match.groups() + if start > position: + yield _AnsiToken(remove_csi(ansi_text[position:start])) + yield _AnsiToken("", sgr, osc) + position = end + if position < len(ansi_text): + yield _AnsiToken(remove_csi(ansi_text[position:])) + + +SGR_STYLE_MAP = { + 1: "bold", + 2: "dim", + 3: "italic", + 4: "underline", + 5: "blink", + 6: "blink2", + 7: "reverse", + 8: "conceal", + 9: "strike", + 21: "underline2", + 22: "not dim not bold", + 23: "not italic", + 24: "not underline", + 25: "not blink", + 26: "not blink2", + 27: "not reverse", + 28: "not conceal", + 29: "not strike", + 30: "color(0)", + 31: "color(1)", + 32: "color(2)", + 33: "color(3)", + 34: "color(4)", + 35: "color(5)", + 36: "color(6)", + 37: "color(7)", + 39: "default", + 40: "on color(0)", + 41: "on color(1)", + 42: "on color(2)", + 43: "on color(3)", + 44: "on color(4)", + 45: "on color(5)", + 46: "on color(6)", + 47: "on color(7)", + 49: "on default", + 51: "frame", + 52: "encircle", + 53: "overline", + 54: "not frame not encircle", + 55: "not overline", + 90: "color(8)", + 91: "color(9)", + 92: "color(10)", + 93: "color(11)", + 94: "color(12)", + 95: "color(13)", + 96: "color(14)", + 97: "color(15)", + 100: "on color(8)", + 101: "on color(9)", + 102: "on color(10)", + 103: "on color(11)", + 104: "on color(12)", + 105: "on color(13)", + 106: "on color(14)", + 107: "on color(15)", +} + + +class AnsiDecoder: + """Translate ANSI code in to styled Text.""" + + def __init__(self) -> None: + self.style = Style.null() + + def decode(self, terminal_text: str) -> Iterable[Text]: + """Decode ANSI codes in an interable of lines. + + Args: + lines (Iterable[str]): An iterable of lines of terminal output. + + Yields: + Text: Marked up Text. + """ + for line in terminal_text.splitlines(): + yield self.decode_line(line) + + def decode_line(self, line: str) -> Text: + """Decode a line containing ansi codes. + + Args: + line (str): A line of terminal output. + + Returns: + Text: A Text instance marked up according to ansi codes. + """ + from_ansi = Color.from_ansi + from_rgb = Color.from_rgb + _Style = Style + text = Text() + append = text.append + line = line.rsplit("\r", 1)[-1] + for token in _ansi_tokenize(line): + plain_text, sgr, osc = token + if plain_text: + append(plain_text, self.style or None) + elif osc: + if osc.startswith("8;"): + _params, semicolon, link = osc[2:].partition(";") + if semicolon: + self.style = self.style.update_link(link or None) + elif sgr: + # Translate in to semi-colon separated codes + # Ignore invalid codes, because we want to be lenient + codes = [ + min(255, int(_code)) for _code in sgr.split(";") if _code.isdigit() + ] + iter_codes = iter(codes) + for code in iter_codes: + if code == 0: + # reset + self.style = _Style.null() + elif code in SGR_STYLE_MAP: + # styles + self.style += _Style.parse(SGR_STYLE_MAP[code]) + elif code == 38: + # Foreground + with suppress(StopIteration): + color_type = next(iter_codes) + if color_type == 5: + self.style += _Style.from_color( + from_ansi(next(iter_codes)) + ) + elif color_type == 2: + self.style += _Style.from_color( + from_rgb( + next(iter_codes), + next(iter_codes), + next(iter_codes), + ) + ) + elif code == 48: + # Background + with suppress(StopIteration): + color_type = next(iter_codes) + if color_type == 5: + self.style += _Style.from_color( + None, from_ansi(next(iter_codes)) + ) + elif color_type == 2: + self.style += _Style.from_color( + None, + from_rgb( + next(iter_codes), + next(iter_codes), + next(iter_codes), + ), + ) + + return text + + +if __name__ == "__main__": # pragma: no cover + import pty + import io + import os + import sys + + decoder = AnsiDecoder() + + stdout = io.BytesIO() + + def read(fd): + data = os.read(fd, 1024) + stdout.write(data) + return data + + pty.spawn(sys.argv[1:], read) + + from .console import Console + + console = Console(record=True) + + stdout_result = stdout.getvalue().decode("utf-8") + print(stdout_result) + + for line in decoder.decode(stdout_result): + console.print(line) + + console.save_html("stdout.html") diff --git a/rich/bar.py b/rich/bar.py new file mode 100644 index 0000000..5764b85 --- /dev/null +++ b/rich/bar.py @@ -0,0 +1,89 @@ +from typing import Union + +from .color import Color +from .console import Console, ConsoleOptions, RenderResult +from .jupyter import JupyterMixin +from .measure import Measurement +from .segment import Segment +from .style import Style + +# There are left-aligned characters for 1/8 to 7/8, but +# the right-aligned characters exist only for 1/8 and 4/8. +BEGIN_BLOCK_ELEMENTS = ["█", "█", "█", "▐", "▐", "▐", "▕", "▕"] +END_BLOCK_ELEMENTS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"] +FULL_BLOCK = "█" + + +class Bar(JupyterMixin): + """Renders a solid block bar. + + Args: + size (float): Value for the end of the bar. + begin (float): Begin point (between 0 and size, inclusive). + end (float): End point (between 0 and size, inclusive). + width (int, optional): Width of the bar, or ``None`` for maximum width. Defaults to None. + color (Union[Color, str], optional): Color of the bar. Defaults to "default". + bgcolor (Union[Color, str], optional): Color of bar background. Defaults to "default". + """ + + def __init__( + self, + size: float, + begin: float, + end: float, + *, + width: int = None, + color: Union[Color, str] = "default", + bgcolor: Union[Color, str] = "default", + ): + self.size = size + self.begin = max(begin, 0) + self.end = min(end, size) + self.width = width + self.style = Style(color=color, bgcolor=bgcolor) + + def __repr__(self) -> str: + return f"Bar({self.size}, {self.begin}, {self.end})" + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + + width = min(self.width or options.max_width, options.max_width) + + if self.begin >= self.end: + yield Segment(" " * width, self.style) + yield Segment.line() + return + + prefix_complete_eights = int(width * 8 * self.begin / self.size) + prefix_bar_count = prefix_complete_eights // 8 + prefix_eights_count = prefix_complete_eights % 8 + + body_complete_eights = int(width * 8 * self.end / self.size) + body_bar_count = body_complete_eights // 8 + body_eights_count = body_complete_eights % 8 + + # When start and end fall into the same cell, we ideally should render + # a symbol that's "center-aligned", but there is no good symbol in Unicode. + # In this case, we fall back to right-aligned block symbol for simplicity. + + prefix = " " * prefix_bar_count + if prefix_eights_count: + prefix += BEGIN_BLOCK_ELEMENTS[prefix_eights_count] + + body = FULL_BLOCK * body_bar_count + if body_eights_count: + body += END_BLOCK_ELEMENTS[body_eights_count] + + suffix = " " * (width - len(body)) + + yield Segment(prefix + body[len(prefix) :] + suffix, self.style) + yield Segment.line() + + def __rich_measure__(self, console: Console, max_width: int) -> Measurement: + return ( + Measurement(self.width, self.width) + if self.width is not None + else Measurement(4, max_width) + ) diff --git a/rich/box.py b/rich/box.py new file mode 100644 index 0000000..b70b5c2 --- /dev/null +++ b/rich/box.py @@ -0,0 +1,478 @@ +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. + + ┌─┬┐ top + │ ││ head + ├─┼┤ head_row + │ ││ mid + ├─┼┤ row + ├─┼┤ foot_row + │ ││ 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, *, 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) + # head + self.head_left, _, self.head_vertical, self.head_right = iter(line2) + # head_row + ( + self.head_row_left, + self.head_row_horizontal, + self.head_row_cross, + self.head_row_right, + ) = iter(line3) + + # mid + self.mid_left, _, self.mid_vertical, self.mid_right = iter(line4) + # row + self.row_left, self.row_horizontal, self.row_cross, self.row_right = iter(line5) + # foot_row + ( + self.foot_row_left, + self.foot_row_horizontal, + self.foot_row_cross, + self.foot_row_right, + ) = iter(line6) + # foot + self.foot_left, _, self.foot_vertical, self.foot_right = iter(line7) + # bottom + self.bottom_left, self.bottom, self.bottom_divider, self.bottom_right = iter( + line8 + ) + + def __repr__(self) -> str: + return "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 and not box.ascii: + box = ASCII + return box + + def get_top(self, widths: Iterable[int]) -> str: + """Get the top of a simple box. + + Args: + widths (List[int]): Widths of columns. + + Returns: + str: A string of box characters. + """ + + parts: List[str] = [] + append = parts.append + append(self.top_left) + for last, width in loop_last(widths): + append(self.top * width) + if not last: + append(self.top_divider) + append(self.top_right) + return "".join(parts) + + def get_row( + self, + widths: Iterable[int], + level: Literal["head", "row", "foot", "mid"] = "row", + edge: bool = True, + ) -> str: + """Get the top of a simple box. + + Args: + width (List[int]): Widths of columns. + + Returns: + str: A string of box characters. + """ + if level == "head": + left = self.head_row_left + horizontal = self.head_row_horizontal + cross = self.head_row_cross + right = self.head_row_right + elif level == "row": + left = self.row_left + horizontal = self.row_horizontal + cross = self.row_cross + right = self.row_right + elif level == "mid": + left = self.mid_left + horizontal = " " + cross = self.mid_vertical + right = self.mid_right + elif level == "foot": + left = self.foot_row_left + horizontal = self.foot_row_horizontal + cross = self.foot_row_cross + right = self.foot_row_right + else: + raise ValueError("level must be 'head', 'row' or 'foot'") + + parts: List[str] = [] + append = parts.append + if edge: + append(left) + for last, width in loop_last(widths): + append(horizontal * width) + if not last: + append(cross) + if edge: + append(right) + return "".join(parts) + + def get_bottom(self, widths: Iterable[int]) -> str: + """Get the bottom of a simple box. + + Args: + widths (List[int]): Widths of columns. + + Returns: + str: A string of box characters. + """ + + parts: List[str] = [] + append = parts.append + append(self.bottom_left) + for last, width in loop_last(widths): + append(self.bottom * width) + if not last: + append(self.bottom_divider) + append(self.bottom_right) + return "".join(parts) + + +ASCII: Box = Box( + """\ ++--+ +| || +|-+| +| || +|-+| +|-+| +| || ++--+ +""", + ascii=True, +) + +ASCII2: Box = Box( + """\ ++-++ +| || ++-++ +| || ++-++ ++-++ +| || ++-++ +""", + ascii=True, +) + +ASCII_DOUBLE_HEAD: Box = Box( + """\ ++-++ +| || ++=++ +| || ++-++ ++-++ +| || ++-++ +""", + ascii=True, +) + +SQUARE: Box = Box( + """\ +┌─┬┐ +│ ││ +├─┼┤ +│ ││ +├─┼┤ +├─┼┤ +│ ││ +└─┴┘ +""" +) + +SQUARE_DOUBLE_HEAD: Box = Box( + """\ +┌─┬┐ +│ ││ +╞═╪╡ +│ ││ +├─┼┤ +├─┼┤ +│ ││ +└─┴┘ +""" +) + +MINIMAL: Box = Box( + """\ + ╷ + │ +╶─┼╴ + │ +╶─┼╴ +╶─┼╴ + │ + ╵ +""" +) + + +MINIMAL_HEAVY_HEAD: Box = Box( + """\ + ╷ + │ +╺━┿╸ + │ +╶─┼╴ +╶─┼╴ + │ + ╵ +""" +) + +MINIMAL_DOUBLE_HEAD: Box = Box( + """\ + ╷ + │ + ═╪ + │ + ─┼ + ─┼ + │ + ╵ +""" +) + + +SIMPLE: Box = Box( + """\ + + + ── + + + ── + + +""" +) + +SIMPLE_HEAD: Box = Box( + """\ + + + ── + + + + + +""" +) + + +SIMPLE_HEAVY: Box = Box( + """\ + + + ━━ + + + ━━ + + +""" +) + + +HORIZONTALS: Box = Box( + """\ + ── + + ── + + ── + ── + + ── +""" +) + +ROUNDED: Box = Box( + """\ +╭─┬╮ +│ ││ +├─┼┤ +│ ││ +├─┼┤ +├─┼┤ +│ ││ +╰─┴╯ +""" +) + +HEAVY: Box = Box( + """\ +┏━┳┓ +┃ ┃┃ +┣━╋┫ +┃ ┃┃ +┣━╋┫ +┣━╋┫ +┃ ┃┃ +┗━┻┛ +""" +) + +HEAVY_EDGE: Box = Box( + """\ +┏━┯┓ +┃ │┃ +┠─┼┨ +┃ │┃ +┠─┼┨ +┠─┼┨ +┃ │┃ +┗━┷┛ +""" +) + +HEAVY_HEAD: Box = Box( + """\ +┏━┳┓ +┃ ┃┃ +┡━╇┩ +│ ││ +├─┼┤ +├─┼┤ +│ ││ +└─┴┘ +""" +) + +DOUBLE: Box = Box( + """\ +╔═╦╗ +║ ║║ +╠═╬╣ +║ ║║ +╠═╬╣ +╠═╬╣ +║ ║║ +╚═╩╝ +""" +) + +DOUBLE_EDGE: Box = Box( + """\ +╔═╤╗ +║ │║ +╟─┼╢ +║ │║ +╟─┼╢ +╟─┼╢ +║ │║ +╚═╧╝ +""" +) + +# Map Boxes that don't render with raster fonts on to equivalent that do +LEGACY_WINDOWS_SUBSTITUTIONS = { + ROUNDED: SQUARE, + MINIMAL_HEAVY_HEAD: MINIMAL, + SIMPLE_HEAVY: SIMPLE, + HEAVY: SQUARE, + HEAVY_EDGE: SQUARE, + HEAVY_HEAD: SQUARE, +} + + +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 + + console = Console(record=True) + + BOXES = [ + "ASCII", + "ASCII2", + "ASCII_DOUBLE_HEAD", + "SQUARE", + "SQUARE_DOUBLE_HEAD", + "MINIMAL", + "MINIMAL_HEAVY_HEAD", + "MINIMAL_DOUBLE_HEAD", + "SIMPLE", + "SIMPLE_HEAD", + "SIMPLE_HEAVY", + "HORIZONTALS", + "ROUNDED", + "HEAVY", + "HEAVY_EDGE", + "HEAVY_HEAD", + "DOUBLE", + "DOUBLE_EDGE", + ] + + console.print(Panel("[bold green]Box Constants", style="green"), justify="center") + console.print() + + columns = Columns(expand=True, padding=2) + for box_name in sorted(BOXES): + table = Table( + show_footer=True, style="dim", border_style="not dim", expand=True + ) + table.add_column("Header 1", "Footer 1") + table.add_column("Header 2", "Footer 2") + table.add_row("Cell", "Cell") + table.add_row("Cell", "Cell") + table.box = getattr(box, box_name) + table.title = Text(f"box.{box_name}", style="magenta") + columns.add_renderable(table) + console.print(columns) + + # console.save_html("box.html", inline_styles=True) diff --git a/rich/cells.py b/rich/cells.py new file mode 100644 index 0000000..1a0ebcc --- /dev/null +++ b/rich/cells.py @@ -0,0 +1,124 @@ +from functools import lru_cache +from typing import Dict, List + +from ._cell_widths import CELL_WIDTHS +from ._lru_cache import LRUCache + + +def cell_len(text: str, _cache: Dict[str, int] = LRUCache(1024 * 4)) -> int: + """Get the number of cells required to display text. + + Args: + text (str): Text to display. + + Returns: + int: Number of cells required to display the text. + """ + cached_result = _cache.get(text, None) + if cached_result is not None: + return cached_result + + _get_size = get_character_cell_size + total_size = sum(_get_size(character) for character in text) + if len(text) <= 64: + _cache[text] = total_size + return total_size + + +def get_character_cell_size(character: str) -> int: + """Get the cell size of a character. + + Args: + character (str): A single character. + + Returns: + int: Number of cells (0, 1 or 2) occupied by that character. + """ + + codepoint = ord(character) + if 127 > codepoint > 31: + # Shortcut for ascii + return 1 + return _get_codepoint_cell_size(codepoint) + + +@lru_cache(maxsize=4096) +def _get_codepoint_cell_size(codepoint: int) -> int: + """Get the cell size of a character. + + Args: + character (str): A single character. + + Returns: + int: Number of cells (0, 1 or 2) occupied by that character. + """ + + _table = CELL_WIDTHS + lower_bound = 0 + upper_bound = len(_table) - 1 + index = (lower_bound + upper_bound) // 2 + while True: + start, end, width = _table[index] + if codepoint < start: + upper_bound = index - 1 + elif codepoint > end: + lower_bound = index + 1 + else: + return 0 if width == -1 else width + if upper_bound < lower_bound: + break + index = (lower_bound + upper_bound) // 2 + return 1 + + +def set_cell_size(text: str, total: int) -> str: + """Set the length of a string to fit within given number of cells.""" + cell_size = cell_len(text) + if cell_size == total: + return text + if cell_size < total: + return text + " " * (total - cell_size) + + _get_character_cell_size = get_character_cell_size + character_sizes = [_get_character_cell_size(character) for character in text] + excess = cell_size - total + pop = character_sizes.pop + while excess > 0 and character_sizes: + excess -= pop() + text = text[: len(character_sizes)] + if excess == -1: + text += " " + return text + + +def chop_cells(text: str, max_size: int, position: int = 0) -> List[str]: + """Break text in to equal (cell) length strings.""" + _get_character_cell_size = get_character_cell_size + characters = [ + (character, _get_character_cell_size(character)) for character in text + ][::-1] + total_size = position + lines: List[List[str]] = [[]] + append = lines[-1].append + + pop = characters.pop + while characters: + character, size = pop() + if total_size + size > max_size: + lines.append([character]) + append = lines[-1].append + total_size = size + else: + total_size += size + append(character) + return ["".join(line) for line in lines] + + +if __name__ == "__main__": # pragma: no cover + + print(get_character_cell_size("😽")) + for line in chop_cells("""这是对亚洲语言支持的测试。面对模棱两可的想法,拒绝猜测的诱惑。""", 8): + print(line) + for n in range(80, 1, -1): + print(set_cell_size("""这是对亚洲语言支持的测试。面对模棱两可的想法,拒绝猜测的诱惑。""", n) + "|") + print("x" * n) diff --git a/rich/color.py b/rich/color.py new file mode 100644 index 0000000..3421061 --- /dev/null +++ b/rich/color.py @@ -0,0 +1,575 @@ +import platform +import re +from colorsys import rgb_to_hls +from enum import IntEnum +from functools import lru_cache +from typing import TYPE_CHECKING, NamedTuple, Optional, Tuple + +from ._palettes import EIGHT_BIT_PALETTE, STANDARD_PALETTE, WINDOWS_PALETTE +from .color_triplet import ColorTriplet +from .terminal_theme import DEFAULT_TERMINAL_THEME + +if TYPE_CHECKING: # pragma: no cover + from .terminal_theme import TerminalTheme + from .text import Text + + +WINDOWS = platform.system() == "Windows" + + +class ColorSystem(IntEnum): + """One of the 3 color system supported by terminals.""" + + STANDARD = 1 + EIGHT_BIT = 2 + TRUECOLOR = 3 + WINDOWS = 4 + + +class ColorType(IntEnum): + """Type of color stored in Color class.""" + + DEFAULT = 0 + STANDARD = 1 + EIGHT_BIT = 2 + TRUECOLOR = 3 + WINDOWS = 4 + + +ANSI_COLOR_NAMES = { + "black": 0, + "red": 1, + "green": 2, + "yellow": 3, + "blue": 4, + "magenta": 5, + "cyan": 6, + "white": 7, + "bright_black": 8, + "bright_red": 9, + "bright_green": 10, + "bright_yellow": 11, + "bright_blue": 12, + "bright_magenta": 13, + "bright_cyan": 14, + "bright_white": 15, + "grey0": 16, + "navy_blue": 17, + "dark_blue": 18, + "blue3": 20, + "blue1": 21, + "dark_green": 22, + "deep_sky_blue4": 25, + "dodger_blue3": 26, + "dodger_blue2": 27, + "green4": 28, + "spring_green4": 29, + "turquoise4": 30, + "deep_sky_blue3": 32, + "dodger_blue1": 33, + "green3": 40, + "spring_green3": 41, + "dark_cyan": 36, + "light_sea_green": 37, + "deep_sky_blue2": 38, + "deep_sky_blue1": 39, + "spring_green2": 47, + "cyan3": 43, + "dark_turquoise": 44, + "turquoise2": 45, + "green1": 46, + "spring_green1": 48, + "medium_spring_green": 49, + "cyan2": 50, + "cyan1": 51, + "dark_red": 88, + "deep_pink4": 125, + "purple4": 55, + "purple3": 56, + "blue_violet": 57, + "orange4": 94, + "grey37": 59, + "medium_purple4": 60, + "slate_blue3": 62, + "royal_blue1": 63, + "chartreuse4": 64, + "dark_sea_green4": 71, + "pale_turquoise4": 66, + "steel_blue": 67, + "steel_blue3": 68, + "cornflower_blue": 69, + "chartreuse3": 76, + "cadet_blue": 73, + "sky_blue3": 74, + "steel_blue1": 81, + "pale_green3": 114, + "sea_green3": 78, + "aquamarine3": 79, + "medium_turquoise": 80, + "chartreuse2": 112, + "sea_green2": 83, + "sea_green1": 85, + "aquamarine1": 122, + "dark_slate_gray2": 87, + "dark_magenta": 91, + "dark_violet": 128, + "purple": 129, + "light_pink4": 95, + "plum4": 96, + "medium_purple3": 98, + "slate_blue1": 99, + "yellow4": 106, + "wheat4": 101, + "grey53": 102, + "light_slate_grey": 103, + "medium_purple": 104, + "light_slate_blue": 105, + "dark_olive_green3": 149, + "dark_sea_green": 108, + "light_sky_blue3": 110, + "sky_blue2": 111, + "dark_sea_green3": 150, + "dark_slate_gray3": 116, + "sky_blue1": 117, + "chartreuse1": 118, + "light_green": 120, + "pale_green1": 156, + "dark_slate_gray1": 123, + "red3": 160, + "medium_violet_red": 126, + "magenta3": 164, + "dark_orange3": 166, + "indian_red": 167, + "hot_pink3": 168, + "medium_orchid3": 133, + "medium_orchid": 134, + "medium_purple2": 140, + "dark_goldenrod": 136, + "light_salmon3": 173, + "rosy_brown": 138, + "grey63": 139, + "medium_purple1": 141, + "gold3": 178, + "dark_khaki": 143, + "navajo_white3": 144, + "grey69": 145, + "light_steel_blue3": 146, + "light_steel_blue": 147, + "yellow3": 184, + "dark_sea_green2": 157, + "light_cyan3": 152, + "light_sky_blue1": 153, + "green_yellow": 154, + "dark_olive_green2": 155, + "dark_sea_green1": 193, + "pale_turquoise1": 159, + "deep_pink3": 162, + "magenta2": 200, + "hot_pink2": 169, + "orchid": 170, + "medium_orchid1": 207, + "orange3": 172, + "light_pink3": 174, + "pink3": 175, + "plum3": 176, + "violet": 177, + "light_goldenrod3": 179, + "tan": 180, + "misty_rose3": 181, + "thistle3": 182, + "plum2": 183, + "khaki3": 185, + "light_goldenrod2": 222, + "light_yellow3": 187, + "grey84": 188, + "light_steel_blue1": 189, + "yellow2": 190, + "dark_olive_green1": 192, + "honeydew2": 194, + "light_cyan1": 195, + "red1": 196, + "deep_pink2": 197, + "deep_pink1": 199, + "magenta1": 201, + "orange_red1": 202, + "indian_red1": 204, + "hot_pink": 206, + "dark_orange": 208, + "salmon1": 209, + "light_coral": 210, + "pale_violet_red1": 211, + "orchid2": 212, + "orchid1": 213, + "orange1": 214, + "sandy_brown": 215, + "light_salmon1": 216, + "light_pink1": 217, + "pink1": 218, + "plum1": 219, + "gold1": 220, + "navajo_white1": 223, + "misty_rose1": 224, + "thistle1": 225, + "yellow1": 226, + "light_goldenrod1": 227, + "khaki1": 228, + "wheat1": 229, + "cornsilk1": 230, + "grey100": 231, + "grey3": 232, + "grey7": 233, + "grey11": 234, + "grey15": 235, + "grey19": 236, + "grey23": 237, + "grey27": 238, + "grey30": 239, + "grey35": 240, + "grey39": 241, + "grey42": 242, + "grey46": 243, + "grey50": 244, + "grey54": 245, + "grey58": 246, + "grey62": 247, + "grey66": 248, + "grey70": 249, + "grey74": 250, + "grey78": 251, + "grey82": 252, + "grey85": 253, + "grey89": 254, + "grey93": 255, +} + + +class ColorParseError(Exception): + """The color could not be parsed.""" + + +RE_COLOR = re.compile( + r"""^ +\#([0-9a-f]{6})$| +color\(([0-9]{1,3})\)$| +rgb\(([\d\s,]+)\)$ +""", + re.VERBOSE, +) + + +class Color(NamedTuple): + """Terminal color definition.""" + + name: str + """The name of the color (typically the input to Color.parse).""" + type: ColorType + """The type of the color.""" + number: Optional[int] = None + """The color number, if a standard color, or None.""" + triplet: Optional[ColorTriplet] = None + """A triplet of color components, if an RGB color.""" + + def __repr__(self) -> str: + return ( + f"<color {self.name!r} ({self.type.name.lower()})>" + if self.number is None + else f"<color {self.name!r} {self.number} ({self.type.name.lower()})>" + ) + + def __rich__(self) -> "Text": + """Dispays the actual color if Rich printed.""" + from .text import Text + from .style import Style + + return Text.assemble( + f"<color {self.name!r} ({self.type.name.lower()})", + ("⬤", Style(color=self)), + " >", + ) + + @property + def system(self) -> ColorSystem: + """Get the native color system for this color.""" + if self.type == ColorType.DEFAULT: + return ColorSystem.STANDARD + return ColorSystem(int(self.type)) + + @property + def is_system_defined(self) -> bool: + """Check if the color is ultimately defined by the system.""" + return self.system not in (ColorSystem.EIGHT_BIT, ColorSystem.TRUECOLOR) + + @property + def is_default(self) -> bool: + """Check if the color is a default color.""" + return self.type == ColorType.DEFAULT + + def get_truecolor( + self, theme: "TerminalTheme" = None, foreground=True + ) -> ColorTriplet: + """Get an equivalent color triplet for this color. + + Args: + theme (TerminalTheme, optional): Optional terminal theme, or None to use default. Defaults to None. + foreground (bool, optional): True for a foreground color, or False for background. Defaults to True. + + Returns: + ColorTriplet: A color triplet containing RGB components. + """ + + if theme is None: + theme = DEFAULT_TERMINAL_THEME + if self.type == ColorType.TRUECOLOR: + assert self.triplet is not None + return self.triplet + elif self.type == ColorType.EIGHT_BIT: + assert self.number is not None + return EIGHT_BIT_PALETTE[self.number] + elif self.type == ColorType.STANDARD: + assert self.number is not None + return theme.ansi_colors[self.number] + elif self.type == ColorType.WINDOWS: + assert self.number is not None + return WINDOWS_PALETTE[self.number] + else: # self.type == ColorType.DEFAULT: + assert self.number is None + return theme.foreground_color if foreground else theme.background_color + + @classmethod + def from_ansi(cls, number: int) -> "Color": + """Create a Color number from it's 8-bit ansi number. + + Args: + number (int): A number between 0-255 inclusive. + + Returns: + Color: A new Color instance. + """ + return cls( + name=f"color({number})", + type=(ColorType.STANDARD if number < 16 else ColorType.EIGHT_BIT), + number=number, + ) + + @classmethod + def from_triplet(cls, triplet: "ColorTriplet") -> "Color": + """Create a truecolor RGB color from a triplet of values. + + Args: + triplet (ColorTriplet): A color triplet containing red, green and blue components. + + Returns: + Color: A new color object. + """ + return cls(name=triplet.hex, type=ColorType.TRUECOLOR, triplet=triplet) + + @classmethod + def from_rgb(cls, red: float, green: float, blue: float) -> "Color": + """Create a truecolor from three color components in the range(0->255). + + Args: + red (float): Red component in range 0-255. + green (float): Green component in range 0-255. + blue (float): Blue component in range 0-255. + + Returns: + Color: A new color object. + """ + return cls.from_triplet(ColorTriplet(int(red), int(green), int(blue))) + + @classmethod + def default(cls) -> "Color": + """Get a Color instance representing the default color. + + Returns: + Color: Default color. + """ + return cls(name="default", type=ColorType.DEFAULT) + + @classmethod + @lru_cache(maxsize=1024) + def parse(cls, color: str) -> "Color": + """Parse a color definition.""" + original_color = color + color = color.lower().strip() + + if color == "default": + return cls(color, type=ColorType.DEFAULT) + + color_number = ANSI_COLOR_NAMES.get(color) + if color_number is not None: + return cls( + color, + type=(ColorType.STANDARD if color_number < 16 else ColorType.EIGHT_BIT), + number=color_number, + ) + + color_match = RE_COLOR.match(color) + if color_match is None: + raise ColorParseError(f"{original_color!r} is not a valid color") + + color_24, color_8, color_rgb = color_match.groups() + if color_24: + triplet = ColorTriplet( + int(color_24[0:2], 16), int(color_24[2:4], 16), int(color_24[4:6], 16) + ) + return cls(color, ColorType.TRUECOLOR, triplet=triplet) + + elif color_8: + number = int(color_8) + if number > 255: + raise ColorParseError(f"color number must be <= 255 in {color!r}") + return cls( + color, + type=(ColorType.STANDARD if number < 16 else ColorType.EIGHT_BIT), + number=number, + ) + + else: # color_rgb: + components = color_rgb.split(",") + if len(components) != 3: + raise ColorParseError( + f"expected three components in {original_color!r}" + ) + red, green, blue = components + triplet = ColorTriplet(int(red), int(green), int(blue)) + if not all(component <= 255 for component in triplet): + raise ColorParseError( + f"color components must be <= 255 in {original_color!r}" + ) + return cls(color, ColorType.TRUECOLOR, triplet=triplet) + + @lru_cache(maxsize=1024) + def get_ansi_codes(self, foreground: bool = True) -> Tuple[str, ...]: + """Get the ANSI escape codes for this color.""" + _type = self.type + if _type == ColorType.DEFAULT: + return ("39" if foreground else "49",) + + elif _type == ColorType.WINDOWS: + number = self.number + assert number is not None + fore, back = (30, 40) if number < 8 else (82, 92) + return (str(fore + number if foreground else back + number),) + + elif _type == ColorType.STANDARD: + number = self.number + assert number is not None + fore, back = (30, 40) if number < 8 else (82, 92) + return (str(fore + number if foreground else back + number),) + + elif _type == ColorType.EIGHT_BIT: + assert self.number is not None + return ("38" if foreground else "48", "5", str(self.number)) + + else: # self.standard == ColorStandard.TRUECOLOR: + assert self.triplet is not None + red, green, blue = self.triplet + return ("38" if foreground else "48", "2", str(red), str(green), str(blue)) + + @lru_cache(maxsize=1024) + def downgrade(self, system: ColorSystem) -> "Color": + """Downgrade a color system to a system with fewer colors.""" + + if self.type == ColorType.DEFAULT or self.type == system: + return self + # Convert to 8-bit color from truecolor color + if system == ColorSystem.EIGHT_BIT and self.system == ColorSystem.TRUECOLOR: + assert self.triplet is not None + red, green, blue = self.triplet.normalized + _h, l, s = rgb_to_hls(red, green, blue) + # If saturation is under 10% assume it is grayscale + if s < 0.1: + gray = round(l * 25.0) + if gray == 0: + color_number = 16 + elif gray == 25: + color_number = 231 + else: + color_number = 231 + gray + return Color(self.name, ColorType.EIGHT_BIT, number=color_number) + + color_number = ( + 16 + 36 * round(red * 5.0) + 6 * round(green * 5.0) + round(blue * 5.0) + ) + return Color(self.name, ColorType.EIGHT_BIT, number=color_number) + + # Convert to standard from truecolor or 8-bit + elif system == ColorSystem.STANDARD: + if self.system == ColorSystem.TRUECOLOR: + assert self.triplet is not None + triplet = self.triplet + else: # self.system == ColorSystem.EIGHT_BIT + assert self.number is not None + triplet = ColorTriplet(*EIGHT_BIT_PALETTE[self.number]) + + color_number = STANDARD_PALETTE.match(triplet) + return Color(self.name, ColorType.STANDARD, number=color_number) + + elif system == ColorSystem.WINDOWS: + if self.system == ColorSystem.TRUECOLOR: + assert self.triplet is not None + triplet = self.triplet + else: # self.system == ColorSystem.EIGHT_BIT + assert self.number is not None + if self.number < 16: + return Color(self.name, ColorType.WINDOWS, number=self.number) + triplet = ColorTriplet(*EIGHT_BIT_PALETTE[self.number]) + + color_number = WINDOWS_PALETTE.match(triplet) + return Color(self.name, ColorType.WINDOWS, number=color_number) + + return self + + +def parse_rgb_hex(hex_color: str) -> ColorTriplet: + """Parse six hex characters in to RGB triplet.""" + assert len(hex_color) == 6, "must be 6 characters" + color = ColorTriplet( + int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + ) + return color + + +def blend_rgb( + color1: ColorTriplet, color2: ColorTriplet, cross_fade: float = 0.5 +) -> ColorTriplet: + """Blend one RGB color in to another.""" + r1, g1, b1 = color1 + r2, g2, b2 = color2 + new_color = ColorTriplet( + int(r1 + (r2 - r1) * cross_fade), + int(g1 + (g2 - g1) * cross_fade), + int(b1 + (b2 - b1) * cross_fade), + ) + return new_color + + +if __name__ == "__main__": # pragma: no cover + + from .console import Console + from .table import Table + from .text import Text + from . import box + + console = Console() + + table = Table(show_footer=False, show_edge=True) + table.add_column("Color", width=10, overflow="ellipsis") + table.add_column("Number", justify="right", style="yellow") + table.add_column("Name", style="green") + table.add_column("Hex", style="blue") + table.add_column("RGB", style="magenta") + + colors = sorted((v, k) for k, v in ANSI_COLOR_NAMES.items()) + for color_number, name in colors: + color_cell = Text(" " * 10, style=f"on {name}") + if color_number < 16: + table.add_row(color_cell, f"{color_number}", Text(f'"{name}"')) + else: + color = EIGHT_BIT_PALETTE[color_number] # type: ignore + table.add_row( + color_cell, str(color_number), Text(f'"{name}"'), color.hex, color.rgb + ) + + console.print(table) diff --git a/rich/color_triplet.py b/rich/color_triplet.py new file mode 100644 index 0000000..75c03d2 --- /dev/null +++ b/rich/color_triplet.py @@ -0,0 +1,38 @@ +from typing import NamedTuple, Tuple + + +class ColorTriplet(NamedTuple): + """The red, green, and blue components of a color.""" + + red: int + """Red component in 0 to 255 range.""" + green: int + """Green component in 0 to 255 range.""" + blue: int + """Blue component in 0 to 255 range.""" + + @property + def hex(self) -> str: + """get the color triplet in CSS style.""" + red, green, blue = self + return f"#{red:02x}{green:02x}{blue:02x}" + + @property + def rgb(self) -> str: + """The color in RGB format. + + Returns: + str: An rgb color, e.g. ``"rgb(100,23,255)"``. + """ + red, green, blue = self + return f"rgb({red},{green},{blue})" + + @property + def normalized(self) -> Tuple[float, float, float]: + """Covert components in to floats between 0 and 1. + + Returns: + Tuple[float, float, float]: A tuple of three normalized colour components. + """ + red, green, blue = self + return red / 255.0, green / 255.0, blue / 255.0 diff --git a/rich/columns.py b/rich/columns.py new file mode 100644 index 0000000..d152dcd --- /dev/null +++ b/rich/columns.py @@ -0,0 +1,189 @@ +from collections import defaultdict +from itertools import chain +from operator import itemgetter +from typing import Dict, Iterable, List, Optional, Tuple + +from .align import Align, AlignMethod +from .console import Console, ConsoleOptions, RenderableType, RenderResult +from .constrain import Constrain +from .measure import Measurement +from .padding import Padding, PaddingDimensions +from .table import Table +from .text import TextType +from .jupyter import JupyterMixin + + +class Columns(JupyterMixin): + """Display renderables in neat columns. + + Args: + renderables (Iterable[RenderableType]): Any number of Rich renderables (including str). + width (int, optional): The desired width of the columns, or None to auto detect. Defaults to None. + padding (PaddingDimensions, optional): Optional padding around cells. Defaults to (0, 1). + expand (bool, optional): Expand columns to full width. Defaults to False. + equal (bool, optional): Arrange in to equal sized columns. Defaults to False. + column_first (bool, optional): Align items from top to bottom (rather than left to right). Defaults to False. + right_to_left (bool, optional): Start column from right hand side. Defaults to False. + align (str, optional): Align value ("left", "right", or "center") or None for default. Defaults to None. + title (TextType, optional): Optional title for Columns. + """ + + def __init__( + self, + renderables: Iterable[RenderableType] = None, + padding: PaddingDimensions = (0, 1), + *, + width: int = None, + expand: bool = False, + equal: bool = False, + column_first: bool = False, + right_to_left: bool = False, + align: AlignMethod = None, + title: TextType = None, + ) -> None: + self.renderables = list(renderables or []) + self.width = width + self.padding = padding + self.expand = expand + self.equal = equal + self.column_first = column_first + self.right_to_left = right_to_left + self.align = align + self.title = title + + def add_renderable(self, renderable: RenderableType) -> None: + """Add a renderable to the columns. + + Args: + renderable (RenderableType): Any renderable object. + """ + self.renderables.append(renderable) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + render_str = console.render_str + renderables = [ + render_str(renderable) if isinstance(renderable, str) else renderable + for renderable in self.renderables + ] + if not renderables: + return + _top, right, _bottom, left = Padding.unpack(self.padding) + width_padding = max(left, right) + max_width = options.max_width + widths: Dict[int, int] = defaultdict(int) + column_count = len(renderables) + + get_measurement = Measurement.get + renderable_widths = [ + get_measurement(console, renderable, max_width).maximum + for renderable in renderables + ] + if self.equal: + renderable_widths = [max(renderable_widths)] * len(renderable_widths) + + def iter_renderables( + column_count: int, + ) -> Iterable[Tuple[int, Optional[RenderableType]]]: + item_count = len(renderables) + if self.column_first: + width_renderables = list(zip(renderable_widths, renderables)) + + column_lengths: List[int] = [item_count // column_count] * column_count + for col_no in range(item_count % column_count): + column_lengths[col_no] += 1 + + row_count = (item_count + column_count - 1) // column_count + cells = [[-1] * column_count for _ in range(row_count)] + row = col = 0 + for index in range(item_count): + cells[row][col] = index + column_lengths[col] -= 1 + if column_lengths[col]: + row += 1 + else: + col += 1 + row = 0 + for index in chain.from_iterable(cells): + if index == -1: + break + yield width_renderables[index] + else: + yield from zip(renderable_widths, renderables) + # Pad odd elements with spaces + if item_count % column_count: + for _ in range(column_count - (item_count % column_count)): + yield 0, None + + table = Table.grid(padding=self.padding, collapse_padding=True, pad_edge=False) + table.expand = self.expand + table.title = self.title + + if self.width is not None: + column_count = (max_width) // (self.width + width_padding) + for _ in range(column_count): + table.add_column(width=self.width) + else: + while column_count > 1: + widths.clear() + column_no = 0 + for renderable_width, _ in iter_renderables(column_count): + widths[column_no] = max(widths[column_no], renderable_width) + total_width = sum(widths.values()) + width_padding * ( + len(widths) - 1 + ) + if total_width > max_width: + column_count = len(widths) - 1 + break + else: + column_no = (column_no + 1) % column_count + else: + break + + get_renderable = itemgetter(1) + _renderables = [ + get_renderable(_renderable) + for _renderable in iter_renderables(column_count) + ] + if self.equal: + _renderables = [ + None + if renderable is None + else Constrain(renderable, renderable_widths[0]) + for renderable in _renderables + ] + if self.align: + align = self.align + _Align = Align + _renderables = [ + None if renderable is None else _Align(renderable, align) + for renderable in _renderables + ] + + right_to_left = self.right_to_left + add_row = table.add_row + for start in range(0, len(_renderables), column_count): + row = _renderables[start : start + column_count] + if right_to_left: + row = row[::-1] + add_row(*row) + yield table + + +if __name__ == "__main__": # pragma: no cover + import os + + console = Console() + + from rich.panel import Panel + + files = [f"{i} {s}" for i, s in enumerate(sorted(os.listdir()))] + columns = Columns(files, padding=(0, 1), expand=False, equal=False) + console.print(columns) + console.rule() + columns.column_first = True + console.print(columns) + columns.right_to_left = True + console.rule() + console.print(columns) diff --git a/rich/console.py b/rich/console.py new file mode 100644 index 0000000..a59dbd6 --- /dev/null +++ b/rich/console.py @@ -0,0 +1,1821 @@ +import inspect +import os +import platform +import shutil +import sys +import threading +from abc import ABC, abstractmethod +from collections import abc +from dataclasses import dataclass, field, replace +from datetime import datetime +from functools import wraps +from getpass import getpass +from itertools import islice +from time import monotonic +from typing import ( + IO, + TYPE_CHECKING, + Any, + Callable, + Dict, + Iterable, + List, + NamedTuple, + Optional, + TextIO, + Tuple, + Union, + cast, +) + +from typing_extensions import Literal, Protocol, runtime_checkable + +from . import errors, themes +from ._emoji_replace import _emoji_replace +from ._log_render import LogRender, FormatTimeCallable +from .align import Align, AlignMethod +from .color import ColorSystem +from .control import Control +from .highlighter import NullHighlighter, ReprHighlighter +from .markup import render as render_markup +from .measure import Measurement, measure_renderables +from .pager import Pager, SystemPager +from .pretty import Pretty +from .scope import render_scope +from .screen import Screen +from .segment import Segment +from .style import Style, StyleType +from .styled import Styled +from .terminal_theme import DEFAULT_TERMINAL_THEME, TerminalTheme +from .text import Text, TextType +from .theme import Theme, ThemeStack + +if TYPE_CHECKING: + from ._windows import WindowsConsoleFeatures + from .live import Live + from .status import Status + +WINDOWS = platform.system() == "Windows" + +HighlighterType = Callable[[Union[str, "Text"]], "Text"] +JustifyMethod = Literal["default", "left", "center", "right", "full"] +OverflowMethod = Literal["fold", "crop", "ellipsis", "ignore"] + + +class NoChange: + pass + + +NO_CHANGE = NoChange() + + +CONSOLE_HTML_FORMAT = """\ +<!DOCTYPE html> +<head> +<meta charset="UTF-8"> +<style> +{stylesheet} +body {{ + color: {foreground}; + background-color: {background}; +}} +</style> +</head> +<html> +<body> + <code> + <pre style="font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace">{code}</pre> + </code> +</body> +</html> +""" + +_TERM_COLORS = {"256color": ColorSystem.EIGHT_BIT, "16color": ColorSystem.STANDARD} + + +class ConsoleDimensions(NamedTuple): + """Size of the terminal.""" + + width: int + """The width of the console in 'cells'.""" + height: int + """The height of the console in lines.""" + + +@dataclass +class ConsoleOptions: + """Options for __rich_console__ method.""" + + size: ConsoleDimensions + """Size of console.""" + legacy_windows: bool + """legacy_windows: flag for legacy windows.""" + min_width: int + """Minimum width of renderable.""" + max_width: int + """Maximum width of renderable.""" + is_terminal: bool + """True if the target is a terminal, otherwise False.""" + encoding: str + """Encoding of terminal.""" + justify: Optional[JustifyMethod] = None + """Justify value override for renderable.""" + overflow: Optional[OverflowMethod] = None + """Overflow value override for renderable.""" + no_wrap: Optional[bool] = False + """Disable wrapping for text.""" + highlight: Optional[bool] = None + """Highlight override for render_str.""" + height: Optional[int] = None + """Height available, or None for no height limit.""" + + @property + def ascii_only(self) -> bool: + """Check if renderables should use ascii only.""" + return not self.encoding.startswith("utf") + + def update( + self, + width: Union[int, NoChange] = NO_CHANGE, + min_width: Union[int, NoChange] = NO_CHANGE, + max_width: Union[int, NoChange] = NO_CHANGE, + justify: Union[Optional[JustifyMethod], NoChange] = NO_CHANGE, + overflow: Union[Optional[OverflowMethod], NoChange] = NO_CHANGE, + no_wrap: Union[Optional[bool], NoChange] = NO_CHANGE, + highlight: Union[Optional[bool], NoChange] = NO_CHANGE, + height: Union[Optional[int], NoChange] = NO_CHANGE, + ) -> "ConsoleOptions": + """Update values, return a copy.""" + options = replace(self) + if not isinstance(width, NoChange): + options.min_width = options.max_width = width + if not isinstance(min_width, NoChange): + options.min_width = min_width + if not isinstance(max_width, NoChange): + options.max_width = max_width + if not isinstance(justify, NoChange): + options.justify = justify + if not isinstance(overflow, NoChange): + options.overflow = overflow + if not isinstance(no_wrap, NoChange): + options.no_wrap = no_wrap + if not isinstance(highlight, NoChange): + options.highlight = highlight + if not isinstance(height, NoChange): + options.height = height + return options + + def update_width(self, width: int) -> "ConsoleOptions": + """Update just the width, return a copy. + + Args: + width (int): New width (sets both min_width and max_width) + + Returns: + ~ConsoleOptions: New console options instance + """ + options = replace(self, min_width=width, max_width=width) + return options + + +@runtime_checkable +class RichCast(Protocol): + """An object that may be 'cast' to a console renderable.""" + + def __rich__(self) -> Union["ConsoleRenderable", str]: # pragma: no cover + ... + + +@runtime_checkable +class ConsoleRenderable(Protocol): + """An object that supports the console protocol.""" + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": # pragma: no cover + ... + + +RenderableType = Union[ConsoleRenderable, RichCast, str] +"""A type that may be rendered by Console.""" + +RenderResult = Iterable[Union[RenderableType, Segment]] +"""The result of calling a __rich_console__ method.""" + + +_null_highlighter = NullHighlighter() + + +class CaptureError(Exception): + """An error in the Capture context manager.""" + + +class Capture: + """Context manager to capture the result of printing to the console. + See :meth:`~rich.console.Console.capture` for how to use. + + Args: + console (Console): A console instance to capture output. + """ + + def __init__(self, console: "Console") -> None: + self._console = console + self._result: Optional[str] = None + + def __enter__(self) -> "Capture": + self._console.begin_capture() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self._result = self._console.end_capture() + + def get(self) -> str: + """Get the result of the capture.""" + if self._result is None: + raise CaptureError( + "Capture result is not available until context manager exits." + ) + return self._result + + +class ThemeContext: + """A context manager to use a temporary theme. See :meth:`~rich.console.Console.use_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 PagerContext: + """A context manager that 'pages' content. See :meth:`~rich.console.Console.pager` for usage.""" + + def __init__( + self, + console: "Console", + pager: Pager = None, + styles: bool = False, + links: bool = False, + ) -> None: + self._console = console + self.pager = SystemPager() if pager is None else pager + self.styles = styles + self.links = links + + def __enter__(self) -> "PagerContext": + self._console._enter_buffer() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + if exc_type is None: + with self._console._lock: + buffer: List[Segment] = self._console._buffer[:] + del self._console._buffer[:] + segments: Iterable[Segment] = buffer + if not self.styles: + segments = Segment.strip_styles(segments) + elif not self.links: + segments = Segment.strip_links(segments) + content = self._console._render_buffer(segments) + self.pager.show(content) + self._console._exit_buffer() + + +class ScreenContext: + """A context manager that enables an alternative screen. See :meth:`~rich.console.Console.screen` for usage.""" + + def __init__( + self, console: "Console", hide_cursor: bool, style: StyleType = "" + ) -> None: + self.console = console + self.hide_cursor = hide_cursor + self.screen = Screen(style=style) + self._changed = False + + def update( + self, renderable: RenderableType = None, style: StyleType = None + ) -> None: + """Update the screen. + + Args: + renderable (RenderableType, optional): Optional renderable to replace current renderable, + or None for no change. Defaults to None. + style: (Style, optional): Replacement style, or None for no change. Defaults to None. + """ + if renderable is not None: + self.screen.renderable = renderable + if style is not None: + self.screen.style = style + self.console.print(self.screen, end="") + + def __enter__(self) -> "ScreenContext": + self._changed = self.console.set_alt_screen(True) + if self._changed and self.hide_cursor: + self.console.show_cursor(False) + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + if self._changed: + self.console.set_alt_screen(False) + if self.hide_cursor: + self.console.show_cursor(True) + + +class RenderGroup: + """Takes a group of renderables and returns a renderable object that renders the group. + + Args: + renderables (Iterable[RenderableType]): An iterable of renderable objects. + fit (bool, optional): Fit dimension of group to contents, or fill available space. Defaults to True. + """ + + def __init__(self, *renderables: "RenderableType", fit: bool = True) -> None: + self._renderables = renderables + self.fit = fit + self._render: Optional[List[RenderableType]] = None + + @property + def renderables(self) -> List["RenderableType"]: + if self._render is None: + self._render = list(self._renderables) + return self._render + + def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement": + if self.fit: + return measure_renderables(console, self.renderables, max_width) + else: + return Measurement(max_width, max_width) + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> RenderResult: + yield from self.renderables + + +def render_group(fit: bool = True) -> Callable: + """A decorator that turns an iterable of renderables in to a group. + + Args: + fit (bool, optional): Fit dimension of group to contents, or fill available space. Defaults to True. + """ + + def decorator(method): + """Convert a method that returns an iterable of renderables in to a RenderGroup.""" + + @wraps(method) + def _replace(*args, **kwargs): + renderables = method(*args, **kwargs) + return RenderGroup(*renderables, fit=fit) + + return _replace + + return decorator + + +def _is_jupyter() -> bool: # pragma: no cover + """Check if we're running in a Jupyter notebook.""" + try: + get_ipython # type: ignore + except NameError: + return False + shell = get_ipython().__class__.__name__ # type: ignore + if shell == "ZMQInteractiveShell": + return True # Jupyter notebook or qtconsole + elif shell == "TerminalInteractiveShell": + return False # Terminal running IPython + else: + return False # Other type (?) + + +COLOR_SYSTEMS = { + "standard": ColorSystem.STANDARD, + "256": ColorSystem.EIGHT_BIT, + "truecolor": ColorSystem.TRUECOLOR, + "windows": ColorSystem.WINDOWS, +} + + +_COLOR_SYSTEMS_NAMES = {system: name for name, system in COLOR_SYSTEMS.items()} + + +@dataclass +class ConsoleThreadLocals(threading.local): + """Thread local values for Console context.""" + + theme_stack: ThemeStack + buffer: List[Segment] = field(default_factory=list) + buffer_index: int = 0 + + +class RenderHook(ABC): + """Provides hooks in to the render process.""" + + @abstractmethod + def process_renderables( + self, renderables: List[ConsoleRenderable] + ) -> List[ConsoleRenderable]: + """Called with a list of objects to render. + + This method can return a new list of renderables, or modify and return the same list. + + Args: + renderables (List[ConsoleRenderable]): A number of renderable objects. + + Returns: + List[ConsoleRenderable]: A replacement list of renderables. + """ + + +_windows_console_features: Optional["WindowsConsoleFeatures"] = None + + +def get_windows_console_features() -> "WindowsConsoleFeatures": # pragma: no cover + global _windows_console_features + if _windows_console_features is not None: + return _windows_console_features + from ._windows import get_windows_console_features + + _windows_console_features = get_windows_console_features() + return _windows_console_features + + +def detect_legacy_windows() -> bool: + """Detect legacy Windows.""" + return WINDOWS and not get_windows_console_features().vt + + +if detect_legacy_windows(): # pragma: no cover + from colorama import init + + init() + + +class Console: + """A high level console interface. + + Args: + color_system (str, optional): The color system supported by your terminal, + either ``"standard"``, ``"256"`` or ``"truecolor"``. Leave as ``"auto"`` to autodetect. + 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. + force_interactive (Optional[bool], optional): Enable/disable interactive mode, or None to auto detect. Defaults to None. + soft_wrap (Optional[bool], optional): Set soft wrap default on print method. Defaults to False. + theme (Theme, optional): An optional style theme object, or ``None`` for default theme. + stderr (bool, optional): Use stderr rather than stdout if ``file`` is not specified. Defaults to False. + file (IO, optional): A file object where the console should write to. Defaults to stdout. + quiet (bool, Optional): Boolean to suppress all output. Defaults to False. + 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. + style (StyleType, optional): Style to apply to all output, or None for no style. Defaults to None. + no_color (Optional[bool], optional): Enabled no color mode, or None to auto detect. Defaults to None. + tab_size (int, optional): Number of spaces used to replace a tab character. Defaults to 8. + record (bool, optional): Boolean to enable recording of terminal output, + required to call :meth:`export_html` and :meth:`export_text`. Defaults to False. + markup (bool, optional): Boolean to enable :ref:`console_markup`. Defaults to True. + emoji (bool, optional): Enable emoji code. Defaults to True. + highlight (bool, optional): Enable automatic highlighting. Defaults to True. + log_time (bool, optional): Boolean to enable logging of time by :meth:`log` methods. Defaults to True. + log_path (bool, optional): Boolean to enable the logging of the caller by :meth:`log`. Defaults to True. + log_time_format (Union[str, TimeFormatterCallable], optional): If ``log_time`` is enabled, either string for strftime or callable that formats the time. Defaults to "[%X] ". + highlighter (HighlighterType, optional): Default highlighter. + legacy_windows (bool, optional): Enable legacy Windows mode, or ``None`` to auto detect. Defaults to ``None``. + safe_box (bool, optional): Restrict box options that don't render on legacy Windows. + get_datetime (Callable[[], datetime], optional): Callable that gets the current time as a datetime.datetime object (used by Console.log), + or None for datetime.now. + get_time (Callable[[], time], optional): Callable that gets the current time in seconds, default uses time.monotonic. + """ + + def __init__( + self, + *, + color_system: Optional[ + Literal["auto", "standard", "256", "truecolor", "windows"] + ] = "auto", + force_terminal: bool = None, + force_jupyter: bool = None, + force_interactive: bool = None, + soft_wrap: bool = False, + theme: Theme = None, + stderr: bool = False, + file: IO[str] = None, + quiet: bool = False, + width: int = None, + height: int = None, + style: StyleType = None, + no_color: bool = None, + tab_size: int = 8, + record: bool = False, + markup: bool = True, + emoji: bool = True, + highlight: bool = True, + log_time: bool = True, + log_path: bool = True, + log_time_format: Union[str, FormatTimeCallable] = "[%X]", + highlighter: Optional["HighlighterType"] = ReprHighlighter(), + legacy_windows: bool = None, + safe_box: bool = True, + get_datetime: Callable[[], datetime] = None, + get_time: Callable[[], float] = None, + _environ: Dict[str, str] = None, + ): + # Copy of os.environ allows us to replace it for testing + self._environ = os.environ if _environ is None else _environ + + self.is_jupyter = _is_jupyter() if force_jupyter is None else force_jupyter + if self.is_jupyter: + width = width or 93 + height = height or 100 + self.soft_wrap = soft_wrap + self._width = width + self._height = height + self.tab_size = tab_size + self.record = record + self._markup = markup + self._emoji = emoji + self._highlight = highlight + self.legacy_windows: bool = ( + (detect_legacy_windows() and not self.is_jupyter) + if legacy_windows is None + else legacy_windows + ) + + self._color_system: Optional[ColorSystem] + self._force_terminal = force_terminal + self._file = file + self.quiet = quiet + self.stderr = stderr + + if color_system is None: + self._color_system = None + elif color_system == "auto": + self._color_system = self._detect_color_system() + else: + self._color_system = COLOR_SYSTEMS[color_system] + + self._lock = threading.RLock() + self._log_render = LogRender( + show_time=log_time, + show_path=log_path, + time_format=log_time_format, + ) + self.highlighter: HighlighterType = highlighter or _null_highlighter + self.safe_box = safe_box + self.get_datetime = get_datetime or datetime.now + self.get_time = get_time or monotonic + self.style = style + self.no_color = ( + no_color if no_color is not None else "NO_COLOR" in self._environ + ) + self.is_interactive = ( + (self.is_terminal and not self.is_dumb_terminal) + if force_interactive is None + else force_interactive + ) + + self._record_buffer_lock = threading.RLock() + self._thread_locals = ConsoleThreadLocals( + theme_stack=ThemeStack(themes.DEFAULT if theme is None else theme) + ) + self._record_buffer: List[Segment] = [] + self._render_hooks: List[RenderHook] = [] + self._live: Optional["Live"] = None + + def __repr__(self) -> str: + return f"<console width={self.width} {str(self._color_system)}>" + + @property + def file(self) -> IO[str]: + """Get the file object to write to.""" + file = self._file or (sys.stderr if self.stderr else sys.stdout) + file = getattr(file, "rich_proxied_file", file) + return file + + @file.setter + def file(self, new_file: IO[str]) -> None: + """Set a new file object.""" + self._file = new_file + + @property + def _buffer(self) -> List[Segment]: + """Get a thread local buffer.""" + return self._thread_locals.buffer + + @property + def _buffer_index(self) -> int: + """Get a thread local buffer.""" + return self._thread_locals.buffer_index + + @_buffer_index.setter + def _buffer_index(self, value: int) -> None: + self._thread_locals.buffer_index = value + + @property + def _theme_stack(self) -> ThemeStack: + """Get the thread local theme stack.""" + return self._thread_locals.theme_stack + + def _detect_color_system(self) -> Optional[ColorSystem]: + """Detect color system from env vars.""" + if self.is_jupyter: + return ColorSystem.TRUECOLOR + if not self.is_terminal or self.is_dumb_terminal: + return None + if WINDOWS: # pragma: no cover + if self.legacy_windows: # pragma: no cover + return ColorSystem.WINDOWS + windows_console_features = get_windows_console_features() + return ( + ColorSystem.TRUECOLOR + if windows_console_features.truecolor + else ColorSystem.EIGHT_BIT + ) + else: + color_term = self._environ.get("COLORTERM", "").strip().lower() + if color_term in ("truecolor", "24bit"): + return ColorSystem.TRUECOLOR + term = self._environ.get("TERM", "").strip().lower() + _term_name, _hyphen, colors = term.partition("-") + color_system = _TERM_COLORS.get(colors, ColorSystem.STANDARD) + return color_system + + def _enter_buffer(self) -> None: + """Enter in to a buffer context, and buffer all output.""" + self._buffer_index += 1 + + def _exit_buffer(self) -> None: + """Leave buffer context, and render content if required.""" + self._buffer_index -= 1 + self._check_buffer() + + def set_live(self, live: "Live") -> None: + """Set Live instance. Used by Live context manager. + + Args: + live (Live): Live instance using this Console. + + Raises: + errors.LiveError: If this Console has a Live context currently active. + """ + with self._lock: + if self._live is not None: + raise errors.LiveError("Only one live display may be active at once") + self._live = live + + def clear_live(self) -> None: + """Clear the Live instance.""" + with self._lock: + self._live = None + + def push_render_hook(self, hook: RenderHook) -> None: + """Add a new render hook to the stack. + + Args: + hook (RenderHook): Render hook instance. + """ + + self._render_hooks.append(hook) + + def pop_render_hook(self) -> None: + """Pop the last renderhook from the stack.""" + self._render_hooks.pop() + + def __enter__(self) -> "Console": + """Own context manager to enter buffer context.""" + self._enter_buffer() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + """Exit buffer context.""" + self._exit_buffer() + + def begin_capture(self) -> None: + """Begin capturing console output. Call :meth:`end_capture` to exit capture mode and return output.""" + self._enter_buffer() + + def end_capture(self) -> str: + """End capture mode and return captured string. + + Returns: + str: Console output. + """ + render_result = self._render_buffer(self._buffer) + del self._buffer[:] + 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, replacing the styles from the previous theme. + Generally speaking, you should call :meth:`~rich.console.Console.use_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 use_theme(self, theme: Theme, *, inherit: bool = True) -> ThemeContext: + """Use a different theme for the duration of the context manager. + + Args: + theme (Theme): Theme instance to user. + inherit (bool, optional): Inherit existing console styles. Defaults to True. + + Returns: + ThemeContext: [description] + """ + return ThemeContext(self, theme, inherit) + + @property + def color_system(self) -> Optional[str]: + """Get color system string. + + Returns: + Optional[str]: "standard", "256" or "truecolor". + """ + + if self._color_system is not None: + return _COLOR_SYSTEMS_NAMES[self._color_system] + else: + return None + + @property + def encoding(self) -> str: + """Get the encoding of the console file, e.g. ``"utf-8"``. + + Returns: + str: A standard encoding string. + """ + return (getattr(self.file, "encoding", "utf-8") or "utf-8").lower() + + @property + def is_terminal(self) -> bool: + """Check if the console is writing to a terminal. + + Returns: + bool: True if the console writing to a device capable of + understanding terminal codes, otherwise False. + """ + if self._force_terminal is not None: + return self._force_terminal + isatty = getattr(self.file, "isatty", None) + return False if isatty is None else isatty() + + @property + def is_dumb_terminal(self) -> bool: + """Detect dumb terminal. + + Returns: + bool: True if writing to a dumb terminal, otherwise False. + + """ + _term = self._environ.get("TERM", "") + is_dumb = _term.lower() in ("dumb", "unknown") + return self.is_terminal and is_dumb + + @property + def options(self) -> ConsoleOptions: + """Get default console options.""" + return ConsoleOptions( + size=self.size, + legacy_windows=self.legacy_windows, + min_width=1, + max_width=self.width, + encoding=self.encoding, + is_terminal=self.is_terminal, + ) + + @property + def size(self) -> ConsoleDimensions: + """Get the size of the console. + + Returns: + ConsoleDimensions: A named tuple containing the dimensions. + """ + + if self._width is not None and self._height is not None: + return ConsoleDimensions(self._width, self._height) + + if self.is_dumb_terminal: + return ConsoleDimensions(80, 25) + + width: Optional[int] = None + height: Optional[int] = None + if WINDOWS: # pragma: no cover + width, height = shutil.get_terminal_size() + else: + try: + width, height = os.get_terminal_size(sys.stdin.fileno()) + except (AttributeError, ValueError, OSError): + try: + width, height = os.get_terminal_size(sys.stdout.fileno()) + except (AttributeError, ValueError, OSError): + pass + + # get_terminal_size can report 0, 0 if run from pseudo-terminal + width = width or 80 + height = height or 25 + return ConsoleDimensions( + (width - self.legacy_windows) if self._width is None else self._width, + height if self._height is None else self._height, + ) + + @property + def width(self) -> int: + """Get the width of the console. + + Returns: + int: The width (in characters) of the console. + """ + width, _ = self.size + return width + + @property + def height(self) -> int: + """Get the height of the console. + + Returns: + int: The height (in lines) of the console. + """ + _, height = self.size + return height + + def bell(self) -> None: + """Play a 'bell' sound (if supported by the terminal).""" + self.control("\x07") + + def capture(self) -> Capture: + """A context manager to *capture* the result of print() or log() in a string, + rather than writing it to the console. + + Example: + >>> from rich.console import Console + >>> console = Console() + >>> with console.capture() as capture: + ... console.print("[bold magenta]Hello World[/]") + >>> print(capture.get()) + + Returns: + Capture: Context manager with disables writing to the terminal. + """ + capture = Capture(self) + return capture + + def pager( + self, pager: Pager = None, styles: bool = False, links: bool = False + ) -> PagerContext: + """A context manager to display anything printed within a "pager". The pager application + is defined by the system and will typically support at least pressing a key to scroll. + + Args: + pager (Pager, optional): A pager object, or None to use :class:~rich.pager.SystemPager`. Defaults to None. + styles (bool, optional): Show styles in pager. Defaults to False. + links (bool, optional): Show links in pager. Defaults to False. + + Example: + >>> from rich.console import Console + >>> from rich.__main__ import make_test_card + >>> console = Console() + >>> with console.pager(): + console.print(make_test_card()) + + Returns: + PagerContext: A context manager. + """ + return PagerContext(self, pager=pager, styles=styles, links=links) + + def line(self, count: int = 1) -> None: + """Write new line(s). + + Args: + count (int, optional): Number of new lines. Defaults to 1. + """ + + assert count >= 0, "count must be >= 0" + if count: + self._buffer.append(Segment("\n" * count)) + self._check_buffer() + + def clear(self, home: bool = True) -> None: + """Clear the screen. + + Args: + home (bool, optional): Also move the cursor to 'home' position. Defaults to True. + """ + self.control("\033[2J\033[H" if home else "\033[2J") + + def status( + self, + status: RenderableType, + *, + spinner: str = "dots", + spinner_style: str = "status.spinner", + speed: float = 1.0, + refresh_per_second: float = 12.5, + ) -> "Status": + """Display a status and spinner. + + Args: + status (RenderableType): A status renderable (str or Text typically). + console (Console, optional): Console instance to use, or None for global console. Defaults to None. + spinner (str, optional): Name of spinner animation (see python -m rich.spinner). Defaults to "dots". + spinner_style (StyleType, optional): Style of spinner. Defaults to "status.spinner". + speed (float, optional): Speed factor for spinner animation. Defaults to 1.0. + refresh_per_second (float, optional): Number of refreshes per second. Defaults to 12.5. + + Returns: + Status: A Status object that may be used as a context manager. + """ + from .status import Status + + status_renderable = Status( + status, + console=self, + spinner=spinner, + spinner_style=spinner_style, + speed=speed, + refresh_per_second=refresh_per_second, + ) + return status_renderable + + def show_cursor(self, show: bool = True) -> bool: + """Show or hide the cursor. + + Args: + show (bool, optional): Set visibility of the cursor. + """ + if self.is_terminal and not self.legacy_windows: + self.control("\033[?25h" if show else "\033[?25l") + return True + return False + + def set_alt_screen(self, enable: bool = True) -> bool: + """Enables alternative screen mode. + + Note, if you enable this mode, you should ensure that is disabled before + the application exits. See :meth:`~rich.Console.screen` for a context manager + that handles this for you. + + Args: + enable (bool, optional): Enable (True) or disable (False) alternate screen. Defaults to True. + + Returns: + bool: True if the control codes were written. + + """ + changed = False + if self.is_terminal and not self.legacy_windows: + self.control("\033[?1049h\033[H" if enable else "\033[?1049l") + changed = True + return changed + + def screen( + self, hide_cursor: bool = True, style: StyleType = None + ) -> "ScreenContext": + """Context manager to enable and disable 'alternative screen' mode. + + Args: + hide_cursor (bool, optional): Also hide the cursor. Defaults to False. + style (Style, optional): Optional style for screen. Defaults to None. + + Returns: + ~ScreenContext: Context which enables alternate screen on enter, and disables it on exit. + """ + return ScreenContext(self, hide_cursor=hide_cursor, style=style or "") + + def render( + self, renderable: RenderableType, options: ConsoleOptions = None + ) -> Iterable[Segment]: + """Render an object in to an iterable of `Segment` instances. + + This method contains the logic for rendering objects with the console protocol. + You are unlikely to need to use it directly, unless you are extending the library. + + Args: + renderable (RenderableType): An object supporting the console protocol, or + an object that may be converted to a string. + options (ConsoleOptions, optional): An options object, or None to use self.options. Defaults to None. + + Returns: + Iterable[Segment]: An iterable of segments that may be rendered. + """ + + _options = options or self.options + if _options.max_width < 1: + # No space to render anything. This prevents potential recursion errors. + return + render_iterable: RenderResult + if isinstance(renderable, RichCast): + renderable = renderable.__rich__() + if isinstance(renderable, ConsoleRenderable): + render_iterable = renderable.__rich_console__(self, _options) + elif isinstance(renderable, str): + yield from self.render( + self.render_str(renderable, highlight=_options.highlight), _options + ) + return + else: + raise errors.NotRenderableError( + f"Unable to render {renderable!r}; " + "A str, Segment or object with __rich_console__ method is required" + ) + + try: + iter_render = iter(render_iterable) + except TypeError: + raise errors.NotRenderableError( + f"object {render_iterable!r} is not renderable" + ) + for render_output in iter_render: + if isinstance(render_output, Segment): + yield render_output + else: + yield from self.render(render_output, _options) + + def render_lines( + self, + renderable: RenderableType, + options: Optional[ConsoleOptions] = None, + *, + style: Optional[Style] = None, + pad: bool = True, + ) -> List[List[Segment]]: + """Render objects in to a list of lines. + + The output of render_lines is useful when further formatting of rendered console text + is required, such as the Panel class which draws a border around any renderable object. + + Args: + renderable (RenderableType): Any object renderable in the console. + options (Optional[ConsoleOptions], optional): Console options, or None to use self.options. Default to ``None``. + style (Style, optional): Optional style to apply to renderables. Defaults to ``None``. + pad (bool, optional): Pad lines shorter than render width. Defaults to ``True``. + range (Optional[Tuple[int, int]], optional): Range of lines to render, or ``None`` for all line. Defaults to ``None`` + + Returns: + List[List[Segment]]: A list of lines, where a line is a list of Segment objects. + """ + render_options = options or self.options + _rendered = self.render(renderable, render_options) + if style is not None: + _rendered = Segment.apply_style(_rendered, style) + lines = list( + Segment.split_and_crop_lines( + _rendered, render_options.max_width, include_new_lines=False, pad=pad + ) + ) + if render_options.height is not None: + lines = Segment.set_shape( + lines, render_options.max_width, render_options.height, style=style + ) + return lines + + def render_str( + self, + text: str, + *, + style: Union[str, Style] = "", + justify: JustifyMethod = None, + overflow: OverflowMethod = None, + emoji: bool = None, + markup: bool = None, + highlight: bool = None, + highlighter: HighlighterType = None, + ) -> "Text": + """Convert a string to a Text instance. This is is called automatically if + you print or log a string. + + Args: + text (str): Text to render. + style (Union[str, Style], optional): Style to apply to rendered text. + justify (str, optional): Justify method: "default", "left", "center", "full", or "right". Defaults to ``None``. + overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to ``None``. + emoji (Optional[bool], optional): Enable emoji, or ``None`` to use Console default. + markup (Optional[bool], optional): Enable markup, or ``None`` to use Console default. + highlight (Optional[bool], optional): Enable highlighting, or ``None`` to use Console default. + highlighter (HighlighterType, optional): Optional highlighter to apply. + Returns: + ConsoleRenderable: Renderable object. + + """ + emoji_enabled = emoji or (emoji is None and self._emoji) + markup_enabled = markup or (markup is None and self._markup) + highlight_enabled = highlight or (highlight is None and self._highlight) + + if markup_enabled: + rich_text = render_markup(text, style=style, emoji=emoji_enabled) + rich_text.justify = justify + rich_text.overflow = overflow + else: + rich_text = Text( + _emoji_replace(text) if emoji_enabled else text, + justify=justify, + overflow=overflow, + style=style, + ) + + _highlighter = (highlighter or self.highlighter) if highlight_enabled else None + if _highlighter is not None: + highlight_text = _highlighter(str(rich_text)) + highlight_text.copy_styles(rich_text) + return highlight_text + + return rich_text + + def get_style( + self, name: Union[str, Style], *, default: Union[Style, str] = None + ) -> Style: + """Get a Style instance by it's theme name or parse a definition. + + Args: + name (str): The name of a style or a style definition. + + Returns: + Style: A Style object. + + Raises: + MissingStyle: If no style could be parsed from name. + + """ + if isinstance(name, Style): + return name + + try: + style = self._theme_stack.get(name) + if style is None: + style = Style.parse(name) + return style.copy() if style.link else style + except errors.StyleSyntaxError as error: + if default is not None: + return self.get_style(default) + raise errors.MissingStyle( + f"Failed to get style {name!r}; {error}" + ) from None + + def _collect_renderables( + self, + objects: Iterable[Any], + sep: str, + end: str, + *, + justify: JustifyMethod = None, + emoji: bool = None, + markup: bool = None, + highlight: bool = None, + ) -> List[ConsoleRenderable]: + """Combine a number of renderables and text into one renderable. + + Args: + objects (Iterable[Any]): Anything that Rich can render. + sep (str): String to write between print data. + end (str): String to write at end of print data. + justify (str, optional): One of "left", "right", "center", or "full". Defaults to ``None``. + emoji (Optional[bool], optional): Enable emoji code, or ``None`` to use console default. + markup (Optional[bool], optional): Enable markup, or ``None`` to use console default. + highlight (Optional[bool], optional): Enable automatic highlighting, or ``None`` to use console default. + + Returns: + List[ConsoleRenderable]: A list of things to render. + """ + renderables: List[ConsoleRenderable] = [] + _append = renderables.append + text: List[Text] = [] + append_text = text.append + + append = _append + if justify in ("left", "center", "right"): + + def align_append(renderable: RenderableType) -> None: + _append(Align(renderable, cast(AlignMethod, justify))) + + append = align_append + + _highlighter: HighlighterType = _null_highlighter + if highlight or (highlight is None and self._highlight): + _highlighter = self.highlighter + + def check_text() -> None: + if text: + sep_text = Text(sep, justify=justify, end=end) + append(sep_text.join(text)) + del text[:] + + for renderable in objects: + # I promise this is sane + # This detects an object which claims to have all attributes, such as MagicMock.mock_calls + if hasattr( + renderable, "jwevpw_eors4dfo6mwo345ermk7kdnfnwerwer" + ): # pragma: no cover + renderable = repr(renderable) + rich_cast = getattr(renderable, "__rich__", None) + if rich_cast: + renderable = rich_cast() + if isinstance(renderable, str): + append_text( + self.render_str( + renderable, emoji=emoji, markup=markup, highlighter=_highlighter + ) + ) + elif isinstance(renderable, ConsoleRenderable): + check_text() + append(renderable) + elif isinstance(renderable, (abc.Mapping, abc.Sequence, abc.Set)): + check_text() + append(Pretty(renderable, highlighter=_highlighter)) + else: + append_text(_highlighter(str(renderable))) + + check_text() + + if self.style is not None: + style = self.get_style(self.style) + renderables = [Styled(renderable, style) for renderable in renderables] + + return renderables + + def rule( + self, + title: TextType = "", + *, + characters: str = "─", + style: Union[str, Style] = "rule.line", + align: AlignMethod = "center", + ) -> None: + """Draw a line with optional centered title. + + Args: + title (str, optional): Text to render over the rule. Defaults to "". + characters (str, optional): Character(s) to form the line. Defaults to "─". + style (str, optional): Style of line. Defaults to "rule.line". + align (str, optional): How to align the title, one of "left", "center", or "right". Defaults to "center". + """ + from .rule import Rule + + rule = Rule(title=title, characters=characters, style=style, align=align) + self.print(rule) + + def control(self, control_codes: Union["Control", str]) -> None: + """Insert non-printing control codes. + + Args: + control_codes (str): Control codes, such as those that may move the cursor. + """ + if not self.is_dumb_terminal: + self._buffer.append(Segment.control(str(control_codes))) + self._check_buffer() + + def out( + self, + *objects: Any, + sep=" ", + end="\n", + style: Union[str, Style] = None, + highlight: bool = None, + ) -> None: + """Output to the terminal. This is a low-level way of writing to the terminal which unlike + :meth:`~rich.console.Console.print` won't pretty print, wrap text, or apply markup, but will + optionally apply highlighting and a basic style. + + Args: + sep (str, optional): String to write between print data. Defaults to " ". + end (str, optional): String to write at end of print data. Defaults to "\\\\n". + style (Union[str, Style], optional): A style to apply to output. Defaults to None. + highlight (Optional[bool], optional): Enable automatic highlighting, or ``None`` to use + console default. Defaults to ``None``. + """ + raw_output: str = sep.join(str(_object) for _object in objects) + self.print( + raw_output, + style=style, + highlight=highlight, + emoji=False, + markup=False, + no_wrap=True, + overflow="ignore", + crop=False, + end=end, + ) + + def print( + self, + *objects: Any, + sep=" ", + end="\n", + style: Union[str, Style] = None, + justify: JustifyMethod = None, + overflow: OverflowMethod = None, + no_wrap: bool = None, + emoji: bool = None, + markup: bool = None, + highlight: bool = None, + width: int = None, + height: int = None, + crop: bool = True, + soft_wrap: bool = None, + ) -> None: + """Print to the console. + + Args: + objects (positional args): Objects to log to the terminal. + sep (str, optional): String to write between print data. Defaults to " ". + end (str, optional): String to write at end of print data. Defaults to "\\\\n". + style (Union[str, Style], optional): A style to apply to output. Defaults to None. + justify (str, optional): Justify method: "default", "left", "right", "center", or "full". Defaults to ``None``. + overflow (str, optional): Overflow method: "ignore", "crop", "fold", or "ellipsis". Defaults to None. + no_wrap (Optional[bool], optional): Disable word wrapping. Defaults to None. + emoji (Optional[bool], optional): Enable emoji code, or ``None`` to use console default. Defaults to ``None``. + markup (Optional[bool], optional): Enable markup, or ``None`` to use console default. Defaults to ``None``. + highlight (Optional[bool], optional): Enable automatic highlighting, or ``None`` to use console default. Defaults to ``None``. + width (Optional[int], optional): Width of output, or ``None`` to auto-detect. Defaults to ``None``. + crop (Optional[bool], optional): Crop output to width of terminal. Defaults to True. + soft_wrap (bool, optional): Enable soft wrap mode which disables word wrapping and cropping of text or None for + Console default. Defaults to ``None``. + """ + if not objects: + self.line() + return + + if soft_wrap is None: + soft_wrap = self.soft_wrap + if soft_wrap: + if no_wrap is None: + no_wrap = True + if overflow is None: + overflow = "ignore" + crop = False + + with self: + renderables = self._collect_renderables( + objects, + sep, + end, + justify=justify, + emoji=emoji, + markup=markup, + highlight=highlight, + ) + for hook in self._render_hooks: + renderables = hook.process_renderables(renderables) + render_options = self.options.update( + justify="default", + overflow=overflow, + width=min(width, self.width) if width else NO_CHANGE, + height=height, + no_wrap=no_wrap, + ) + + new_segments: List[Segment] = [] + extend = new_segments.extend + render = self.render + if style is None: + for renderable in renderables: + extend(render(renderable, render_options)) + else: + for renderable in renderables: + extend( + Segment.apply_style( + render(renderable, render_options), self.get_style(style) + ) + ) + if crop: + buffer_extend = self._buffer.extend + for line in Segment.split_and_crop_lines( + new_segments, self.width, pad=False + ): + buffer_extend(line) + else: + self._buffer.extend(new_segments) + + def print_exception( + self, + *, + width: Optional[int] = 100, + extra_lines: int = 3, + theme: Optional[str] = None, + word_wrap: bool = False, + show_locals: bool = False, + ) -> None: + """Prints a rich render of the last exception and traceback. + + Args: + width (Optional[int], optional): Number of characters used to render code. Defaults to 88. + extra_lines (int, optional): Additional lines of code to render. Defaults to 3. + theme (str, optional): Override pygments theme used in traceback + word_wrap (bool, optional): Enable word wrapping of long lines. Defaults to False. + show_locals (bool, optional): Enable display of local variables. Defaults to False. + """ + from .traceback import Traceback + + traceback = Traceback( + width=width, + extra_lines=extra_lines, + theme=theme, + word_wrap=word_wrap, + show_locals=show_locals, + ) + self.print(traceback) + + def log( + self, + *objects: Any, + sep=" ", + end="\n", + style: Union[str, Style] = None, + justify: JustifyMethod = None, + emoji: bool = None, + markup: bool = None, + highlight: bool = None, + log_locals: bool = False, + _stack_offset=1, + ) -> None: + """Log rich content to the terminal. + + Args: + objects (positional args): Objects to log to the terminal. + sep (str, optional): String to write between print data. Defaults to " ". + end (str, optional): String to write at end of print data. Defaults to "\\\\n". + style (Union[str, Style], optional): A style to apply to output. Defaults to None. + justify (str, optional): One of "left", "right", "center", or "full". Defaults to ``None``. + overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None. + emoji (Optional[bool], optional): Enable emoji code, or ``None`` to use console default. Defaults to None. + markup (Optional[bool], optional): Enable markup, or ``None`` to use console default. Defaults to None. + highlight (Optional[bool], optional): Enable automatic highlighting, or ``None`` to use console default. Defaults to None. + log_locals (bool, optional): Boolean to enable logging of locals where ``log()`` + was called. Defaults to False. + _stack_offset (int, optional): Offset of caller from end of call stack. Defaults to 1. + """ + if not objects: + self.line() + return + with self: + renderables = self._collect_renderables( + objects, + sep, + end, + justify=justify, + emoji=emoji, + markup=markup, + highlight=highlight, + ) + if style is not None: + renderables = [Styled(renderable, style) for renderable in renderables] + + caller = inspect.stack()[_stack_offset] + link_path = ( + None + if caller.filename.startswith("<") + else os.path.abspath(caller.filename) + ) + path = caller.filename.rpartition(os.sep)[-1] + line_no = caller.lineno + if log_locals: + locals_map = { + key: value + for key, value in caller.frame.f_locals.items() + if not key.startswith("__") + } + renderables.append(render_scope(locals_map, title="[i]locals")) + + renderables = [ + self._log_render( + self, + renderables, + log_time=self.get_datetime(), + path=path, + line_no=line_no, + link_path=link_path, + ) + ] + for hook in self._render_hooks: + renderables = hook.process_renderables(renderables) + new_segments: List[Segment] = [] + extend = new_segments.extend + render = self.render + render_options = self.options + for renderable in renderables: + extend(render(renderable, render_options)) + buffer_extend = self._buffer.extend + for line in Segment.split_and_crop_lines( + new_segments, self.width, pad=False + ): + buffer_extend(line) + + def _check_buffer(self) -> None: + """Check if the buffer may be rendered.""" + if self.quiet: + del self._buffer[:] + return + with self._lock: + if self._buffer_index == 0: + if self.is_jupyter: # pragma: no cover + from .jupyter import display + + display(self._buffer) + del self._buffer[:] + else: + text = self._render_buffer(self._buffer[:]) + del self._buffer[:] + if text: + try: + if WINDOWS: # pragma: no cover + # https://bugs.python.org/issue37871 + write = self.file.write + for line in text.splitlines(True): + write(line) + else: + self.file.write(text) + self.file.flush() + except UnicodeEncodeError as error: + error.reason = f"{error.reason}\n*** You may need to add PYTHONIOENCODING=utf-8 to your environment ***" + raise + + def _render_buffer(self, buffer: Iterable[Segment]) -> str: + """Render buffered output, and clear buffer.""" + output: List[str] = [] + append = output.append + color_system = self._color_system + legacy_windows = self.legacy_windows + if self.record: + with self._record_buffer_lock: + self._record_buffer.extend(buffer) + not_terminal = not self.is_terminal + if self.no_color and color_system: + buffer = Segment.remove_color(buffer) + for text, style, is_control in buffer: + if style: + append( + style.render( + text, + color_system=color_system, + legacy_windows=legacy_windows, + ) + ) + elif not (not_terminal and is_control): + append(text) + + rendered = "".join(output) + return rendered + + def input( + self, + prompt: TextType = "", + *, + markup: bool = True, + emoji: bool = True, + password: bool = False, + stream: TextIO = None, + ) -> str: + """Displays a prompt and waits for input from the user. The prompt may contain color / style. + + Args: + prompt (Union[str, Text]): Text to render in the prompt. + markup (bool, optional): Enable console markup (requires a str prompt). Defaults to True. + emoji (bool, optional): Enable emoji (requires a str prompt). Defaults to True. + password: (bool, optional): Hide typed text. Defaults to False. + stream: (TextIO, optional): Optional file to read input from (rather than stdin). Defaults to None. + + Returns: + str: Text read from stdin. + """ + prompt_str = "" + if prompt: + with self.capture() as capture: + self.print(prompt, markup=markup, emoji=emoji, end="") + prompt_str = capture.get() + if self.legacy_windows: + # Legacy windows doesn't like ANSI codes in getpass or input (colorama bug)? + self.file.write(prompt_str) + prompt_str = "" + if password: + result = getpass(prompt_str, stream=stream) + else: + if stream: + self.file.write(prompt_str) + result = stream.readline() + else: + result = input(prompt_str) + return result + + def export_text(self, *, clear: bool = True, styles: bool = False) -> str: + """Generate text from console contents (requires record=True argument in constructor). + + Args: + clear (bool, optional): Clear record buffer after exporting. Defaults to ``True``. + styles (bool, optional): If ``True``, ansi escape codes will be included. ``False`` for plain text. + Defaults to ``False``. + + Returns: + str: String containing console contents. + + """ + assert ( + self.record + ), "To export console contents set record=True in the constructor or instance" + + with self._record_buffer_lock: + if styles: + text = "".join( + (style.render(text) if style else text) + for text, style, _ in self._record_buffer + ) + else: + text = "".join( + segment.text + for segment in self._record_buffer + if not segment.is_control + ) + if clear: + del self._record_buffer[:] + return text + + def save_text(self, path: str, *, clear: bool = True, styles: bool = False) -> None: + """Generate text from console and save to a given location (requires record=True argument in constructor). + + Args: + path (str): Path to write text files. + clear (bool, optional): Clear record buffer after exporting. Defaults to ``True``. + styles (bool, optional): If ``True``, ansi style codes will be included. ``False`` for plain text. + Defaults to ``False``. + + """ + text = self.export_text(clear=clear, styles=styles) + with open(path, "wt", encoding="utf-8") as write_file: + write_file.write(text) + + def export_html( + self, + *, + theme: TerminalTheme = None, + clear: bool = True, + code_format: str = None, + inline_styles: bool = False, + ) -> str: + """Generate HTML from console contents (requires record=True argument in constructor). + + Args: + theme (TerminalTheme, optional): TerminalTheme object containing console colors. + clear (bool, optional): Clear record buffer after exporting. Defaults to ``True``. + code_format (str, optional): Format string to render HTML, should contain {foreground} + {background} and {code}. + inline_styles (bool, optional): If ``True`` styles will be inlined in to spans, which makes files + larger but easier to cut and paste markup. If ``False``, styles will be embedded in a style tag. + Defaults to False. + + Returns: + str: String containing console contents as HTML. + """ + assert ( + self.record + ), "To export console contents set record=True in the constructor or instance" + fragments: List[str] = [] + append = fragments.append + _theme = theme or DEFAULT_TERMINAL_THEME + stylesheet = "" + + def escape(text: str) -> str: + """Escape html.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + render_code_format = CONSOLE_HTML_FORMAT if code_format is None else code_format + + with self._record_buffer_lock: + if inline_styles: + for text, style, _ in Segment.filter_control( + Segment.simplify(self._record_buffer) + ): + text = escape(text) + if style: + rule = style.get_html_style(_theme) + text = f'<span style="{rule}">{text}</span>' if rule else text + if style.link: + text = f'<a href="{style.link}">{text}</a>' + append(text) + else: + styles: Dict[str, int] = {} + for text, style, _ in Segment.filter_control( + Segment.simplify(self._record_buffer) + ): + text = escape(text) + if style: + rule = style.get_html_style(_theme) + if rule: + style_number = styles.setdefault(rule, len(styles) + 1) + text = f'<span class="r{style_number}">{text}</span>' + if style.link: + text = f'<a href="{style.link}">{text}</a>' + append(text) + stylesheet_rules: List[str] = [] + stylesheet_append = stylesheet_rules.append + for style_rule, style_number in styles.items(): + if style_rule: + stylesheet_append(f".r{style_number} {{{style_rule}}}") + stylesheet = "\n".join(stylesheet_rules) + + rendered_code = render_code_format.format( + code="".join(fragments), + stylesheet=stylesheet, + foreground=_theme.foreground_color.hex, + background=_theme.background_color.hex, + ) + if clear: + del self._record_buffer[:] + return rendered_code + + def save_html( + self, + path: str, + *, + theme: TerminalTheme = None, + clear: bool = True, + code_format=CONSOLE_HTML_FORMAT, + inline_styles: bool = False, + ) -> None: + """Generate HTML from console contents and write to a file (requires record=True argument in constructor). + + Args: + path (str): Path to write html file. + theme (TerminalTheme, optional): TerminalTheme object containing console colors. + clear (bool, optional): Clear record buffer after exporting. Defaults to ``True``. + code_format (str, optional): Format string to render HTML, should contain {foreground} + {background} and {code}. + inline_styles (bool, optional): If ``True`` styles will be inlined in to spans, which makes files + larger but easier to cut and paste markup. If ``False``, styles will be embedded in a style tag. + Defaults to False. + + """ + html = self.export_html( + theme=theme, + clear=clear, + code_format=code_format, + inline_styles=inline_styles, + ) + with open(path, "wt", encoding="utf-8") as write_file: + write_file.write(html) + + +if __name__ == "__main__": # pragma: no cover + console = Console() + + console.log( + "JSONRPC [i]request[/i]", + 5, + 1.3, + True, + False, + None, + { + "jsonrpc": "2.0", + "method": "subtract", + "params": {"minuend": 42, "subtrahend": 23}, + "id": 3, + }, + ) + + console.log("Hello, World!", "{'a': 1}", repr(console)) + + console.print( + { + "name": None, + "empty": [], + "quiz": { + "sport": { + "answered": True, + "q1": { + "question": "Which one is correct team name in NBA?", + "options": [ + "New York Bulls", + "Los Angeles Kings", + "Golden State Warriors", + "Huston Rocket", + ], + "answer": "Huston Rocket", + }, + }, + "maths": { + "answered": False, + "q1": { + "question": "5 + 7 = ?", + "options": [10, 11, 12, 13], + "answer": 12, + }, + "q2": { + "question": "12 - 8 = ?", + "options": [1, 2, 3, 4], + "answer": 4, + }, + }, + }, + } + ) + console.log("foo") diff --git a/rich/constrain.py b/rich/constrain.py new file mode 100644 index 0000000..c96233a --- /dev/null +++ b/rich/constrain.py @@ -0,0 +1,35 @@ +from typing import Optional, TYPE_CHECKING + +from .jupyter import JupyterMixin +from .measure import Measurement + +if TYPE_CHECKING: + from .console import Console, ConsoleOptions, RenderableType, RenderResult + + +class Constrain(JupyterMixin): + """Constrain the width of a renderable to a given number of characters. + + Args: + renderable (RenderableType): A renderable object. + width (int, optional): The maximum width (in characters) to render. Defaults to 80. + """ + + def __init__(self, renderable: "RenderableType", width: Optional[int] = 80) -> None: + self.renderable = renderable + self.width = width + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + if self.width is None: + yield self.renderable + else: + child_options = options.update(width=min(self.width, options.max_width)) + yield from console.render(self.renderable, child_options) + + def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement": + if self.width is not None: + max_width = min(self.width, max_width) + measurement = Measurement.get(console, self.renderable, max_width) + return measurement diff --git a/rich/containers.py b/rich/containers.py new file mode 100644 index 0000000..c42c814 --- /dev/null +++ b/rich/containers.py @@ -0,0 +1,161 @@ +from itertools import zip_longest +from typing import ( + Iterator, + Iterable, + List, + overload, + TypeVar, + TYPE_CHECKING, +) + +if TYPE_CHECKING: + from .console import ( + Console, + ConsoleOptions, + JustifyMethod, + OverflowMethod, + RenderResult, + RenderableType, + ) + from .text import Text + +from .cells import cell_len +from .measure import Measurement + +T = TypeVar("T") + + +class Renderables: + """A list subclass which renders its contents to the console.""" + + def __init__(self, renderables: Iterable["RenderableType"] = None) -> None: + self._renderables: List["RenderableType"] = ( + list(renderables) if renderables is not None else [] + ) + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + """Console render method to insert line-breaks.""" + yield from self._renderables + + def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement": + dimensions = [ + Measurement.get(console, renderable, max_width) + for renderable in self._renderables + ] + if not dimensions: + return Measurement(1, 1) + _min = max(dimension.minimum for dimension in dimensions) + _max = max(dimension.maximum for dimension in dimensions) + return Measurement(_min, _max) + + def append(self, renderable: "RenderableType") -> None: + self._renderables.append(renderable) + + def __iter__(self) -> Iterable["RenderableType"]: + return iter(self._renderables) + + +class Lines: + """A list subclass which can render to the console.""" + + def __init__(self, lines: Iterable["Text"] = ()) -> None: + self._lines: List["Text"] = list(lines) + + def __repr__(self) -> str: + return f"Lines({self._lines!r})" + + def __iter__(self) -> Iterator["Text"]: + return iter(self._lines) + + @overload + def __getitem__(self, index: int) -> "Text": + ... + + @overload + def __getitem__(self, index: slice) -> "Lines": + ... + + def __getitem__(self, index): + return self._lines[index] + + def __setitem__(self, index: int, value: "Text") -> "Lines": + self._lines[index] = value + return self + + def __len__(self) -> int: + return self._lines.__len__() + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + """Console render method to insert line-breaks.""" + yield from self._lines + + def append(self, line: "Text") -> None: + self._lines.append(line) + + def extend(self, lines: Iterable["Text"]) -> None: + self._lines.extend(lines) + + def pop(self, index=-1) -> "Text": + return self._lines.pop(index) + + def justify( + self, + console: "Console", + width: int, + justify: "JustifyMethod" = "left", + overflow: "OverflowMethod" = "fold", + ) -> None: + """Justify and overflow text to a given width. + + Args: + console (Console): Console instance. + width (int): Number of characters per line. + justify (str, optional): Default justify method for text: "left", "center", "full" or "right". Defaults to "left". + overflow (str, optional): Default overflow for text: "crop", "fold", or "ellipsis". Defaults to "fold". + + """ + from .text import Text + + if justify == "left": + for line in self._lines: + line.truncate(width, overflow=overflow, pad=True) + elif justify == "center": + for line in self._lines: + line.rstrip() + line.truncate(width, overflow=overflow) + line.pad_left((width - cell_len(line.plain)) // 2) + line.pad_right(width - cell_len(line.plain)) + elif justify == "right": + for line in self._lines: + line.rstrip() + line.truncate(width, overflow=overflow) + line.pad_left(width - cell_len(line.plain)) + elif justify == "full": + for line_index, line in enumerate(self._lines): + if line_index == len(self._lines) - 1: + break + words = line.split(" ") + words_size = sum(cell_len(word.plain) for word in words) + num_spaces = len(words) - 1 + spaces = [1 for _ in range(num_spaces)] + index = 0 + if spaces: + while words_size + num_spaces < width: + spaces[len(spaces) - index - 1] += 1 + num_spaces += 1 + index = (index + 1) % len(spaces) + tokens: List[Text] = [] + for index, (word, next_word) in enumerate( + zip_longest(words, words[1:]) + ): + tokens.append(word) + if index < len(spaces): + style = word.get_style_at_offset(console, -1) + next_style = next_word.get_style_at_offset(console, 0) + space_style = style if style == next_style else line.style + tokens.append(Text(" " * spaces[index], style=space_style)) + self[line_index] = Text("").join(tokens) diff --git a/rich/control.py b/rich/control.py new file mode 100644 index 0000000..f4cfd44 --- /dev/null +++ b/rich/control.py @@ -0,0 +1,57 @@ +from typing import TYPE_CHECKING + +from .segment import Segment + +if TYPE_CHECKING: + from .console import Console, ConsoleOptions, RenderResult + +STRIP_CONTROL_CODES = [ + 8, # Backspace + 11, # Vertical tab + 12, # Form feed + 13, # Carriage return +] +_CONTROL_TRANSLATE = {_codepoint: None for _codepoint in STRIP_CONTROL_CODES} + + +class Control: + """A renderable that inserts a control code (non printable but may move cursor). + + Args: + control_codes (str): A string containing control codes. + """ + + __slots__ = ["_control_codes"] + + def __init__(self, control_codes: str) -> None: + self._control_codes = Segment.control(control_codes) + + @classmethod + def home(cls) -> "Control": + """Move cursor to 'home' position.""" + return cls("\033[H") + + def __str__(self) -> str: + return self._control_codes.text + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + yield self._control_codes + + +def strip_control_codes(text: str, _translate_table=_CONTROL_TRANSLATE) -> str: + """Remove control codes from text. + + Args: + text (str): A string possibly contain control codes. + + Returns: + str: String with control codes removed. + """ + return text.translate(_translate_table) + + +if __name__ == "__main__": # pragma: no cover + + print(strip_control_codes("hello\rWorld")) diff --git a/rich/default_styles.py b/rich/default_styles.py new file mode 100644 index 0000000..6413f8e --- /dev/null +++ b/rich/default_styles.py @@ -0,0 +1,154 @@ +from typing import Dict + +from .style import Style + +DEFAULT_STYLES: Dict[str, Style] = { + "none": Style.null(), + "reset": Style( + color="default", + bgcolor="default", + dim=False, + bold=False, + italic=False, + underline=False, + blink=False, + blink2=False, + reverse=False, + conceal=False, + strike=False, + ), + "dim": Style(dim=True), + "bright": Style(dim=False), + "bold": Style(bold=True), + "strong": Style(bold=True), + "code": Style(reverse=True, bold=True), + "italic": Style(italic=True), + "emphasize": Style(italic=True), + "underline": Style(underline=True), + "blink": Style(blink=True), + "blink2": Style(blink2=True), + "reverse": Style(reverse=True), + "strike": Style(strike=True), + "black": Style(color="black"), + "red": Style(color="red"), + "green": Style(color="green"), + "yellow": Style(color="yellow"), + "magenta": Style(color="magenta"), + "cyan": Style(color="cyan"), + "white": Style(color="white"), + "inspect.attr": Style(color="yellow", italic=True), + "inspect.attr.dunder": Style(color="yellow", italic=True, dim=True), + "inspect.callable": Style(bold=True, color="red"), + "inspect.def": Style(italic=True, color="bright_cyan"), + "inspect.error": Style(bold=True, color="red"), + "inspect.equals": Style(), + "inspect.help": Style(color="cyan"), + "inspect.doc": Style(dim=True), + "inspect.value.border": Style(color="green"), + "live.ellipsis": Style(bold=True, color="red"), + "logging.keyword": Style(bold=True, color="yellow"), + "logging.level.notset": Style(dim=True), + "logging.level.debug": Style(color="green"), + "logging.level.info": Style(color="blue"), + "logging.level.warning": Style(color="red"), + "logging.level.error": Style(color="red", bold=True), + "logging.level.critical": Style(color="red", bold=True, reverse=True), + "log.level": Style.null(), + "log.time": Style(color="cyan", dim=True), + "log.message": Style.null(), + "log.path": Style(dim=True), + "repr.ellipsis": Style(color="yellow"), + "repr.indent": Style(color="green", dim=True), + "repr.error": Style(color="red", bold=True), + "repr.str": Style(color="green", italic=False, bold=False), + "repr.brace": Style(bold=True), + "repr.comma": Style(bold=True), + "repr.ipv4": Style(bold=True, color="bright_green"), + "repr.ipv6": Style(bold=True, color="bright_green"), + "repr.eui48": Style(bold=True, color="bright_green"), + "repr.eui64": Style(bold=True, color="bright_green"), + "repr.tag_start": Style(bold=True), + "repr.tag_name": Style(color="bright_magenta", bold=True), + "repr.tag_contents": Style(color="default"), + "repr.tag_end": Style(bold=True), + "repr.attrib_name": Style(color="yellow", italic=False), + "repr.attrib_equal": Style(bold=True), + "repr.attrib_value": Style(color="magenta", italic=False), + "repr.number": Style(color="blue", bold=True, italic=False), + "repr.bool_true": Style(color="bright_green", italic=True), + "repr.bool_false": Style(color="bright_red", italic=True), + "repr.none": Style(color="magenta", italic=True), + "repr.url": Style(underline=True, color="bright_blue", italic=False, bold=False), + "repr.uuid": Style(color="bright_yellow", bold=False), + "rule.line": Style(color="bright_green"), + "rule.text": Style.null(), + "prompt": Style.null(), + "prompt.choices": Style(color="magenta", bold=True), + "prompt.default": Style(color="cyan", bold=True), + "prompt.invalid": Style(color="red"), + "prompt.invalid.choice": Style(color="red"), + "pretty": Style.null(), + "scope.border": Style(color="blue"), + "scope.key": Style(color="yellow", italic=True), + "scope.key.special": Style(color="yellow", italic=True, dim=True), + "scope.equals": Style(color="red"), + "repr.path": Style(color="magenta"), + "repr.filename": Style(color="bright_magenta"), + "table.header": Style(bold=True), + "table.footer": Style(bold=True), + "table.cell": Style.null(), + "table.title": Style(italic=True), + "table.caption": Style(italic=True, dim=True), + "traceback.error": Style(color="red", italic=True), + "traceback.border.syntax_error": Style(color="bright_red"), + "traceback.border": Style(color="red"), + "traceback.text": Style.null(), + "traceback.title": Style(color="red", bold=True), + "traceback.exc_type": Style(color="bright_red", bold=True), + "traceback.exc_value": Style.null(), + "traceback.offset": Style(color="bright_red", bold=True), + "bar.back": Style(color="grey23"), + "bar.complete": Style(color="rgb(249,38,114)"), + "bar.finished": Style(color="rgb(114,156,31)"), + "bar.pulse": Style(color="rgb(249,38,114)"), + "progress.description": Style.null(), + "progress.filesize": Style(color="green"), + "progress.filesize.total": Style(color="green"), + "progress.download": Style(color="green"), + "progress.elapsed": Style(color="yellow"), + "progress.percentage": Style(color="magenta"), + "progress.remaining": Style(color="cyan"), + "progress.data.speed": Style(color="red"), + "progress.spinner": Style(color="green"), + "status.spinner": Style(color="green"), + "tree": Style(), + "tree.line": Style(), +} + +MARKDOWN_STYLES = { + "markdown.paragraph": Style(), + "markdown.text": Style(), + "markdown.emph": Style(italic=True), + "markdown.strong": Style(bold=True), + "markdown.code": Style(bgcolor="black", color="bright_white"), + "markdown.code_block": Style(dim=True, color="cyan", bgcolor="black"), + "markdown.block_quote": Style(color="magenta"), + "markdown.list": Style(color="cyan"), + "markdown.item": Style(), + "markdown.item.bullet": Style(color="yellow", bold=True), + "markdown.item.number": Style(color="yellow", bold=True), + "markdown.hr": Style(color="yellow"), + "markdown.h1.border": Style(), + "markdown.h1": Style(bold=True), + "markdown.h2": Style(bold=True, underline=True), + "markdown.h3": Style(bold=True), + "markdown.h4": Style(bold=True, dim=True), + "markdown.h5": Style(underline=True), + "markdown.h6": Style(italic=True), + "markdown.h7": Style(italic=True, dim=True), + "markdown.link": Style(color="bright_blue"), + "markdown.link_url": Style(color="blue"), +} + + +DEFAULT_STYLES.update(MARKDOWN_STYLES) diff --git a/rich/diagnose.py b/rich/diagnose.py new file mode 100644 index 0000000..455e11d --- /dev/null +++ b/rich/diagnose.py @@ -0,0 +1,6 @@ +if __name__ == "__main__": # pragma: no cover + from rich.console import Console + from rich import inspect + + console = Console() + inspect(console) diff --git a/rich/emoji.py b/rich/emoji.py new file mode 100644 index 0000000..4c1e200 --- /dev/null +++ b/rich/emoji.py @@ -0,0 +1,74 @@ +from typing import Union + +from .console import Console, ConsoleOptions, RenderResult +from .jupyter import JupyterMixin +from .segment import Segment +from .style import Style +from ._emoji_codes import EMOJI +from ._emoji_replace import _emoji_replace + + +class NoEmoji(Exception): + """No emoji by that name.""" + + +class Emoji(JupyterMixin): + __slots__ = ["name", "style", "_char"] + + def __init__(self, name: str, style: Union[str, Style] = "none") -> None: + """A single emoji character. + + Args: + name (str): Name of emoji. + style (Union[str, Style], optional): Optional style. Defaults to None. + + Raises: + NoEmoji: If the emoji doesn't exist. + """ + self.name = name + self.style = style + try: + self._char = EMOJI[name] + except KeyError: + raise NoEmoji(f"No emoji called {name!r}") + + @classmethod + def replace(cls, text: str) -> str: + """Replace emoji markup with corresponding unicode characters. + + Args: + text (str): A string with emojis codes, e.g. "Hello :smiley:!" + + Returns: + str: A string with emoji codes replaces with actual emoji. + """ + return _emoji_replace(text) + + def __repr__(self) -> str: + return f"<emoji {self.name!r}>" + + def __str__(self) -> str: + return self._char + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield Segment(self._char, console.get_style(self.style)) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from rich.columns import Columns + from rich.console import Console + + console = Console(record=True) + + columns = Columns( + (f":{name}: {name}" for name in sorted(EMOJI.keys()) if "\u200D" not in name), + column_first=True, + ) + + console.print(columns) + if len(sys.argv) > 1: + console.save_html(sys.argv[1]) diff --git a/rich/errors.py b/rich/errors.py new file mode 100644 index 0000000..e295fbc --- /dev/null +++ b/rich/errors.py @@ -0,0 +1,30 @@ +class ConsoleError(Exception): + """An error in console operation.""" + + +class StyleError(Exception): + """An error in styles.""" + + +class StyleSyntaxError(ConsoleError): + """Style was badly formatted.""" + + +class MissingStyle(StyleError): + """No such style.""" + + +class StyleStackError(ConsoleError): + """Style stack is invalid.""" + + +class NotRenderableError(ConsoleError): + """Object is not renderable.""" + + +class MarkupError(ConsoleError): + """Markup was badly formatted.""" + + +class LiveError(ConsoleError): + """Error related to Live display.""" diff --git a/rich/file_proxy.py b/rich/file_proxy.py new file mode 100644 index 0000000..99a6922 --- /dev/null +++ b/rich/file_proxy.py @@ -0,0 +1,54 @@ +import io +from typing import List, Any, IO, TYPE_CHECKING + +from .ansi import AnsiDecoder +from .text import Text + +if TYPE_CHECKING: + from .console import Console + + +class FileProxy(io.TextIOBase): + """Wraps a file (e.g. sys.stdout) and redirects writes to a console.""" + + def __init__(self, console: "Console", file: IO[str]) -> None: + self.__console = console + self.__file = file + self.__buffer: List[str] = [] + self.__ansi_decoder = AnsiDecoder() + + @property + def rich_proxied_file(self) -> IO[str]: + """Get proxied file.""" + return self.__file + + def __getattr__(self, name: str) -> Any: + return getattr(self.__file, name) + + def write(self, text: str) -> int: + if not isinstance(text, str): + raise TypeError(f"write() argument must be str, not {type(text).__name__}") + buffer = self.__buffer + lines: List[str] = [] + while text: + line, new_line, text = text.partition("\n") + if new_line: + lines.append("".join(buffer) + line) + del buffer[:] + else: + buffer.append(line) + break + if lines: + console = self.__console + with console: + output = Text("\n").join( + self.__ansi_decoder.decode_line(line) for line in lines + ) + console.print(output, markup=False, emoji=False, highlight=False) + return len(text) + + def flush(self) -> None: + buffer = self.__buffer + if buffer: + self.__console.print("".join(buffer)) + del buffer[:] diff --git a/rich/filesize.py b/rich/filesize.py new file mode 100644 index 0000000..4876823 --- /dev/null +++ b/rich/filesize.py @@ -0,0 +1,62 @@ +# coding: utf-8 +"""Functions for reporting filesizes. Borrowed from https://github.com/PyFilesystem/pyfilesystem2 + +The functions declared in this module should cover the different +usecases needed to generate a string representation of a file size +using several different units. Since there are many standards regarding +file size units, three different functions have been implemented. + +See Also: + * `Wikipedia: Binary prefix <https://en.wikipedia.org/wiki/Binary_prefix>`_ + +""" + +__all__ = ["decimal"] + +from typing import Iterable, List, Tuple + + +def _to_str(size: int, suffixes: Iterable[str], base: int) -> str: + if size == 1: + return "1 byte" + elif size < base: + return "{:,} bytes".format(size) + + for i, suffix in enumerate(suffixes, 2): # noqa: B007 + unit = base ** i + if size < unit: + break + return "{:,.1f} {}".format((base * size / unit), suffix) + + +def pick_unit_and_suffix(size: int, suffixes: List[str], base: int) -> Tuple[int, str]: + """Pick a suffix and base for the given size.""" + for i, suffix in enumerate(suffixes): + unit = base ** i + if size < unit * base: + break + return unit, suffix + + +def decimal(size: int) -> str: + """Convert a filesize in to a string (powers of 1000, SI prefixes). + + In this convention, ``1000 B = 1 kB``. + + This is typically the format used to advertise the storage + capacity of USB flash drives and the like (*256 MB* meaning + actually a storage capacity of more than *256 000 000 B*), + or used by **Mac OS X** since v10.6 to report file sizes. + + Arguments: + int (size): A file size. + + Returns: + `str`: A string containing a abbreviated file size and units. + + Example: + >>> filesize.decimal(30000) + '30.0 kB' + + """ + return _to_str(size, ("kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), 1000) diff --git a/rich/highlighter.py b/rich/highlighter.py new file mode 100644 index 0000000..37b36f6 --- /dev/null +++ b/rich/highlighter.py @@ -0,0 +1,131 @@ +from abc import ABC, abstractmethod +from typing import List, Union + +from .text import Text + + +def _combine_regex(*regexes: str) -> str: + """Combine a number of regexes in to a single regex. + + Returns: + str: New regex with all regexes ORed together. + """ + return "|".join(regexes) + + +class Highlighter(ABC): + """Abstract base class for highlighters.""" + + def __call__(self, text: Union[str, Text]) -> Text: + """Highlight a str or Text instance. + + Args: + text (Union[str, ~Text]): Text to highlight. + + Raises: + TypeError: If not called with text or str. + + Returns: + Text: A test instance with highlighting applied. + """ + if isinstance(text, str): + highlight_text = Text(text) + elif isinstance(text, Text): + highlight_text = text.copy() + else: + raise TypeError(f"str or Text instance required, not {text!r}") + self.highlight(highlight_text) + return highlight_text + + @abstractmethod + def highlight(self, text: Text) -> None: + """Apply highlighting in place to text. + + Args: + text (~Text): A text object highlight. + """ + + +class NullHighlighter(Highlighter): + """A highlighter object that doesn't highlight. + + May be used to disable highlighting entirely. + + """ + + def highlight(self, text: Text) -> None: + """Nothing to do""" + + +class RegexHighlighter(Highlighter): + """Applies highlighting from a list of regular expressions.""" + + highlights: List[str] = [] + base_style: str = "" + + def highlight(self, text: Text) -> None: + """Highlight :class:`rich.text.Text` using regular expressions. + + Args: + text (~Text): Text to highlighted. + + """ + + highlight_regex = text.highlight_regex + for re_highlight in self.highlights: + highlight_regex(re_highlight, style_prefix=self.base_style) + + +class ReprHighlighter(RegexHighlighter): + """Highlights the text typically produced from ``__repr__`` methods.""" + + base_style = "repr." + highlights = [ + r"(?P<tag_start>\<)(?P<tag_name>[\w\-\.\:]*)(?P<tag_contents>.*?)(?P<tag_end>\>)", + r"(?P<attrib_name>[\w_]{1,50})=(?P<attrib_value>\"?[\w_]+\"?)?", + _combine_regex( + r"(?P<ipv4>[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})", + r"(?P<ipv6>([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4})", + r"(?P<eui64>(?:[0-9A-Fa-f]{1,2}-){7}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{1,2}:){7}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{4}\.){3}[0-9A-Fa-f]{4})", + r"(?P<eui48>(?:[0-9A-Fa-f]{1,2}-){5}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{1,2}:){5}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{4}\.){2}[0-9A-Fa-f]{4})", + r"(?P<brace>[\{\[\(\)\]\}])", + r"(?P<bool_true>True)|(?P<bool_false>False)|(?P<none>None)", + r"(?P<ellipsis>\.\.\.)", + r"(?P<number>(?<!\w)\-?[0-9]+\.?[0-9]*(e[\-\+]?\d+?)?\b|0x[0-9a-fA-F]*)", + r"(?P<path>\B(\/[\w\.\-\_\+]+)*\/)(?P<filename>[\w\.\-\_\+]*)?", + r"(?<!\\)(?P<str>b?\'\'\'.*?(?<!\\)\'\'\'|b?\'.*?(?<!\\)\'|b?\"\"\".*?(?<!\\)\"\"\"|b?\".*?(?<!\\)\")", + r"(?P<uuid>[a-fA-F0-9]{8}\-[a-fA-F0-9]{4}\-[a-fA-F0-9]{4}\-[a-fA-F0-9]{4}\-[a-fA-F0-9]{12})", + r"(?P<url>https?:\/\/[0-9a-zA-Z\$\-\_\+\!`\(\)\,\.\?\/\;\:\&\=\%\#]*)", + ), + ] + + +if __name__ == "__main__": # pragma: no cover + from .console import Console + + console = Console() + console.print("[bold green]hello world![/bold green]") + console.print("'[bold green]hello world![/bold green]'") + + console.print(" /foo") + console.print("/foo/") + console.print("/foo/bar") + console.print("foo/bar/baz") + + console.print("/foo/bar/baz?foo=bar+egg&egg=baz") + console.print("/foo/bar/baz/") + console.print("/foo/bar/baz/egg") + console.print("/foo/bar/baz/egg.py") + console.print("/foo/bar/baz/egg.py word") + console.print(" /foo/bar/baz/egg.py word") + console.print("foo /foo/bar/baz/egg.py word") + console.print("foo /foo/bar/ba._++z/egg+.py word") + console.print("https://example.org?foo=bar#header") + + console.print(1234567.34) + console.print(1 / 2) + console.print(-1 / 123123123123) + + console.print( + "127.0.1.1 bar 192.168.1.4 2001:0db8:85a3:0000:0000:8a2e:0370:7334 foo" + ) diff --git a/rich/jupyter.py b/rich/jupyter.py new file mode 100644 index 0000000..f76380f --- /dev/null +++ b/rich/jupyter.py @@ -0,0 +1,79 @@ +from typing import Iterable, List, TYPE_CHECKING + +from . import get_console +from .segment import Segment +from .terminal_theme import DEFAULT_TERMINAL_THEME + +if TYPE_CHECKING: + from .console import RenderableType + +JUPYTER_HTML_FORMAT = """\ +<pre style="white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace">{code}</pre> +""" + + +class JupyterRenderable: + """A shim to write html to Jupyter notebook.""" + + def __init__(self, html: str) -> None: + self.html = html + + @classmethod + def render(cls, rich_renderable: "RenderableType") -> str: + console = get_console() + segments = console.render(rich_renderable, console.options) + html = _render_segments(segments) + return html + + def _repr_html_(self) -> str: + return self.html + + +class JupyterMixin: + """Add to an Rich renderable to make it render in Jupyter notebook.""" + + def _repr_html_(self) -> str: + console = get_console() + segments = list(console.render(self, console.options)) # type: ignore + html = _render_segments(segments) + return html + + +def _render_segments(segments: Iterable[Segment]) -> str: + def escape(text: str) -> str: + """Escape html.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + fragments: List[str] = [] + append_fragment = fragments.append + theme = DEFAULT_TERMINAL_THEME + for text, style, is_control in Segment.simplify(segments): + if is_control: + continue + text = escape(text) + if style: + rule = style.get_html_style(theme) + text = f'<span style="{rule}">{text}</span>' if rule else text + if style.link: + text = f'<a href="{style.link}">{text}</a>' + append_fragment(text) + + code = "".join(fragments) + html = JUPYTER_HTML_FORMAT.format(code=code) + + return html + + +def display(segments: Iterable[Segment]) -> None: + """Render segments to Jupyter.""" + from IPython.display import display as ipython_display + + html = _render_segments(segments) + jupyter_renderable = JupyterRenderable(html) + ipython_display(jupyter_renderable) + + +def print(*args, **kwargs) -> None: + """Proxy for Console print.""" + console = get_console() + return console.print(*args, **kwargs) diff --git a/rich/layout.py b/rich/layout.py new file mode 100644 index 0000000..c528435 --- /dev/null +++ b/rich/layout.py @@ -0,0 +1,238 @@ +from .align import Align +from .console import Console, ConsoleOptions, RenderResult, RenderableType +from .highlighter import ReprHighlighter +from .panel import Panel +from .pretty import Pretty +from ._ratio import ratio_resolve +from .segment import Segment +from .style import StyleType + + +from typing import List, Optional, TYPE_CHECKING +from typing_extensions import Literal + +Direction = Literal["horizontal", "vertical"] + + +if TYPE_CHECKING: + from rich.tree import Tree + + +class _Placeholder: + """An internal renderable used as a Layout placeholder.""" + + highlighter = ReprHighlighter() + + def __init__(self, layout: "Layout", style: StyleType = "") -> None: + self.layout = layout + self.style = style + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + width = options.max_width + height = options.height or options.size.height + layout = self.layout + + layout_info = { + "size": layout.size, + "minimum_size": layout.minimum_size, + "ratio": layout.ratio, + "name": layout.name, + } + + title = ( + f"{layout.name!r} ({width} x {height})" + if layout.name + else f"({width} x {height})" + ) + yield Panel( + Align.center(Pretty(layout_info), vertical="middle"), + style=self.style, + title=self.highlighter(title), + border_style="blue", + ) + + +class Layout: + """A renderable to divide a fixed height in to rows or columns. + + Args: + renderable (RenderableType, optional): Renderable content, or None for placeholder. Defaults to None. + direction (str, optional): Direction of split, one of "vertical" or "horizontal". Defaults to "vertical". + size (int, optional): Optional fixed size of layout. Defaults to None. + minimum_size (int, optional): Minimum size of layout. Defaults to 1. + ratio (int, optional): Optional ratio for flexible layout. Defaults to 1. + name (str, optional): Optional identifier for Layout. Defaults to None. + visible (bool, optional): Visibility of layout. Defaults to True. + """ + + def __init__( + self, + renderable: RenderableType = None, + *, + direction: str = "vertical", + size: int = None, + minimum_size: int = 1, + ratio: int = 1, + name: str = None, + visible: bool = True, + ) -> None: + self._renderable = renderable or _Placeholder(self) + self.direction = direction + self.size = size + self.minimum_size = minimum_size + self.ratio = ratio + self.name = name + self.visible = visible + self._children: List[Layout] = [] + + def __repr__(self) -> str: + return f"Layout(size={self.size!r}, minimum_size={self.size!r}, ratio={self.ratio!r}, name={self.name!r}, visible={self.visible!r})" + + @property + def renderable(self) -> RenderableType: + """Layout renderable.""" + return self if self._children else self._renderable + + @property + def children(self) -> List["Layout"]: + """Gets (visible) layout children.""" + return [child for child in self._children if child.visible] + + def get(self, name: str) -> Optional["Layout"]: + """Get a named layout, or None if it doesn't exist. + + Args: + name (str): Name of layout. + + Returns: + Optional[Layout]: Layout instance or None if no layout was found. + """ + if self.name == name: + return self + else: + for child in self._children: + named_layout = child.get(name) + if named_layout is not None: + return named_layout + return None + + def __getitem__(self, name: str) -> "Layout": + layout = self.get(name) + if layout is None: + raise KeyError(f"No layout with name {name!r}") + return layout + + @property + def tree(self) -> "Tree": + """Get a tree renderable to show layout structure.""" + from rich.highlighter import ReprHighlighter + from rich.text import Text + from rich.tree import Tree + + highlighter = ReprHighlighter() + + def summary(layout) -> "Text": + name = repr(layout.name) + " " if layout.name else "" + direction = ( + ("➡" if layout.direction == "horizontal" else "⬇") + if layout._children + else "■" + ) + if layout.size: + _summary = highlighter(f"{direction} {name}(size={layout.size})") + else: + _summary = highlighter(f"{direction} {name}(ratio={layout.ratio})") + _summary.stylize("" if layout.visible else "dim") + return _summary + + layout = self + tree = Tree(summary(layout), highlight=True) + + def recurse(tree, layout): + for child in layout._children: + recurse(tree.add(summary(child)), child) + + recurse(tree, self) + return tree + + def split(self, *layouts, direction: Direction = None) -> None: + """Split the layout in to multiple sub-layours. + + Args: + *layouts (Layout): Positional arguments should be (sub) Layout instances. + direction (Direction, optional): One of "horizontal" or "vertical" or None for no change. Defaults to None. + """ + if direction is not None: + self.direction = direction + self._children.extend(layouts) + + def update(self, renderable: RenderableType) -> None: + """Update renderable. + + Args: + renderable (RenderableType): New renderable object. + """ + self._renderable = renderable + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + options = options.update(height=options.height or options.size.height) + if not self.children: + yield from console.render(self._renderable or "", options) + elif self.direction == "vertical": + yield from self._render_vertical(console, options) + elif self.direction == "horizontal": + yield from self._render_horizontal(console, options) + + def _render_horizontal( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + render_widths = ratio_resolve(options.max_width, self.children) + renders = [ + console.render_lines(child, options.update(width=render_width)) + for child, render_width in zip(self.children, render_widths) + ] + new_line = Segment.line() + for lines in zip(*renders): + for line in lines: + yield from line + yield new_line + + def _render_vertical( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + render_heights = ratio_resolve(options.height or console.height, self.children) + renders = [ + console.render_lines(child.renderable, options.update(height=render_height)) + for child, render_height in zip(self.children, render_heights) + ] + new_line = Segment.line() + for render in renders: + for line in render: + yield from line + yield new_line + + +if __name__ == "__main__": # type: ignore + from rich.console import Console + from rich.panel import Panel + + console = Console() + layout = Layout() + + layout.split( + Layout(name="header", size=3), + Layout(ratio=1, name="main"), + Layout(size=10, name="footer"), + ) + + layout["main"].split( + Layout(name="side"), Layout(name="body", ratio=2), direction="horizontal" + ) + + layout["side"].split(Layout(), Layout()) + + console.print(layout) diff --git a/rich/live.py b/rich/live.py new file mode 100644 index 0000000..696c2c7 --- /dev/null +++ b/rich/live.py @@ -0,0 +1,366 @@ +import sys +from threading import Event, RLock, Thread +from typing import IO, Any, Callable, List, Optional + +from . import get_console +from .console import Console, ConsoleRenderable, RenderableType, RenderHook +from .control import Control +from .file_proxy import FileProxy +from .jupyter import JupyterMixin +from .live_render import LiveRender, VerticalOverflowMethod +from .screen import Screen +from .text import Text + + +class _RefreshThread(Thread): + """A thread that calls refresh() at regular intervals.""" + + def __init__(self, live: "Live", refresh_per_second: float) -> None: + self.live = live + self.refresh_per_second = refresh_per_second + self.done = Event() + super().__init__(daemon=True) + + def stop(self) -> None: + self.done.set() + + def run(self) -> None: + while not self.done.wait(1 / self.refresh_per_second): + with self.live._lock: + if not self.done.is_set(): + self.live.refresh() + + +class Live(JupyterMixin, RenderHook): + """Renders an auto-updating live display of any given renderable. + + Args: + renderable (RenderableType, optional): The renderable to live display. Defaults to displaying nothing. + console (Console, optional): Optional Console instance. Default will an internal Console instance writing to stdout. + screen (bool, optional): Enable alternate screen mode. Defaults to False. + auto_refresh (bool, optional): Enable auto refresh. If disabled, you will need to call `refresh()` or `update()` with refresh flag. Defaults to True + refresh_per_second (float, optional): Number of times per second to refresh the live display. Defaults to 1. + transient (bool, optional): Clear the renderable on exit. Defaults to False. + redirect_stdout (bool, optional): Enable redirection of stdout, so ``print`` may be used. Defaults to True. + redirect_stderr (bool, optional): Enable redirection of stderr. Defaults to True. + vertical_overflow (VerticalOverflowMethod, optional): How to handle renderable when it is too tall for the console. Defaults to "ellipsis". + get_renderable (Callable[[], RenderableType], optional): Optional callable to get renderable. Defaults to None. + """ + + def __init__( + self, + renderable: RenderableType = None, + *, + console: Console = None, + screen: bool = False, + auto_refresh: bool = True, + refresh_per_second: float = 4, + transient: bool = False, + redirect_stdout: bool = True, + redirect_stderr: bool = True, + vertical_overflow: VerticalOverflowMethod = "ellipsis", + get_renderable: Callable[[], RenderableType] = None, + ) -> None: + assert refresh_per_second > 0, "refresh_per_second must be > 0" + self._renderable = renderable + self.console = console if console is not None else get_console() + self._screen = screen + self._alt_screen = False + + self._redirect_stdout = redirect_stdout + self._redirect_stderr = redirect_stderr + self._restore_stdout: Optional[IO[str]] = None + self._restore_stderr: Optional[IO[str]] = None + + self._lock = RLock() + self.ipy_widget: Optional[Any] = None + self.auto_refresh = auto_refresh + self._started: bool = False + self.transient = transient + + self._refresh_thread: Optional[_RefreshThread] = None + self.refresh_per_second = refresh_per_second + + self.vertical_overflow = vertical_overflow + self._get_renderable = get_renderable + self._live_render = LiveRender( + self.get_renderable(), vertical_overflow=vertical_overflow + ) + # cant store just clear_control as the live_render shape is lazily computed on render + + def get_renderable(self) -> RenderableType: + renderable = ( + self._get_renderable() + if self._get_renderable is not None + else self._renderable + ) + return renderable or "" + + def start(self, refresh=False) -> None: + """Start live rendering display. + + Args: + refresh (bool, optional): Also refresh. Defaults to False. + """ + with self._lock: + if self._started: + return + self.console.set_live(self) + self._started = True + if self._screen: + self._alt_screen = self.console.set_alt_screen(True) + self.console.show_cursor(False) + self._enable_redirect_io() + self.console.push_render_hook(self) + if refresh: + self.refresh() + if self.auto_refresh: + self._refresh_thread = _RefreshThread(self, self.refresh_per_second) + self._refresh_thread.start() + + def stop(self) -> None: + """Stop live rendering display.""" + with self._lock: + if not self._started: + return + self.console.clear_live() + self._started = False + try: + if self.auto_refresh and self._refresh_thread is not None: + self._refresh_thread.stop() + # allow it to fully render on the last even if overflow + self.vertical_overflow = "visible" + if not self._alt_screen: + if not self.console.is_jupyter: + self.refresh() + if self.console.is_terminal: + self.console.line() + finally: + self._disable_redirect_io() + self.console.pop_render_hook() + self.console.show_cursor(True) + if self._alt_screen: + self.console.set_alt_screen(False) + + if self._refresh_thread is not None: + self._refresh_thread.join() + self._refresh_thread = None + if self.transient and not self._screen: + self.console.control(self._live_render.restore_cursor()) + if self.ipy_widget is not None: # pragma: no cover + if self.transient: + self.ipy_widget.close() + else: + # jupyter last refresh must occur after console pop render hook + # i am not sure why this is needed + self.refresh() + + def __enter__(self) -> "Live": + self.start(refresh=self._renderable is not None) + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.stop() + + def _enable_redirect_io(self): + """Enable redirecting of stdout / stderr.""" + if self.console.is_terminal: + if self._redirect_stdout and not isinstance(sys.stdout, FileProxy): # type: ignore + self._restore_stdout = sys.stdout + sys.stdout = FileProxy(self.console, sys.stdout) + if self._redirect_stderr and not isinstance(sys.stderr, FileProxy): # type: ignore + self._restore_stderr = sys.stderr + sys.stderr = FileProxy(self.console, sys.stderr) + + def _disable_redirect_io(self): + """Disable redirecting of stdout / stderr.""" + if self._restore_stdout: + sys.stdout = self._restore_stdout + self._restore_stdout = None + if self._restore_stderr: + sys.stderr = self._restore_stderr + self._restore_stderr = None + + @property + def renderable(self) -> RenderableType: + """Get the renderable that is being displayed + + Returns: + RenderableType: Displayed renderable. + """ + renderable = self.get_renderable() + return Screen(renderable) if self._alt_screen else renderable + + def update(self, renderable: RenderableType, *, refresh: bool = False) -> None: + """Update the renderable that is being displayed + + Args: + renderable (RenderableType): New renderable to use. + refresh (bool, optional): Refresh the display. Defaults to False. + """ + with self._lock: + self._renderable = renderable + if refresh: + self.refresh() + + def refresh(self) -> None: + """Update the display of the Live Render.""" + self._live_render.set_renderable(self.renderable) + if self.console.is_jupyter: # pragma: no cover + try: + from IPython.display import display + from ipywidgets import Output + except ImportError: + import warnings + + warnings.warn('install "ipywidgets" for Jupyter support') + else: + with self._lock: + if self.ipy_widget is None: + self.ipy_widget = Output() + display(self.ipy_widget) + + with self.ipy_widget: + self.ipy_widget.clear_output(wait=True) + self.console.print(self._live_render.renderable) + elif self.console.is_terminal and not self.console.is_dumb_terminal: + with self._lock, self.console: + self.console.print(Control("")) + elif ( + not self._started and not self.transient + ): # if it is finished allow files or dumb-terminals to see final result + with self.console: + self.console.print(Control("")) + + def process_renderables( + self, renderables: List[ConsoleRenderable] + ) -> List[ConsoleRenderable]: + """Process renderables to restore cursor and display progress.""" + self._live_render.vertical_overflow = self.vertical_overflow + if self.console.is_interactive: + # lock needs acquiring as user can modify live_render renderable at any time unlike in Progress. + with self._lock: + # determine the control command needed to clear previous rendering + reset = ( + Control.home() + if self._alt_screen + else self._live_render.position_cursor() + ) + renderables = [ + reset, + *renderables, + self._live_render, + ] + elif ( + not self._started and not self.transient + ): # if it is finished render the final output for files or dumb_terminals + renderables = [*renderables, self._live_render] + + return renderables + + +if __name__ == "__main__": # pragma: no cover + import random + import time + from itertools import cycle + from typing import Dict, List, Tuple + + from .align import Align + from .console import Console + from .live import Live + from .panel import Panel + from .rule import Rule + from .syntax import Syntax + from .table import Table + + console = Console() + + syntax = Syntax( + '''def loop_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: + """Iterate and generate a tuple with a flag for last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + for value in iter_values: + yield False, previous_value + previous_value = value + yield True, previous_value''', + "python", + line_numbers=True, + ) + + table = Table("foo", "bar", "baz") + table.add_row("1", "2", "3") + + progress_renderables = [ + "You can make the terminal shorter and taller to see the live table hide" + "Text may be printed while the progress bars are rendering.", + Panel("In fact, [i]any[/i] renderable will work"), + "Such as [magenta]tables[/]...", + table, + "Pretty printed structures...", + {"type": "example", "text": "Pretty printed"}, + "Syntax...", + syntax, + Rule("Give it a try!"), + ] + + examples = cycle(progress_renderables) + + exchanges = [ + "SGD", + "MYR", + "EUR", + "USD", + "AUD", + "JPY", + "CNH", + "HKD", + "CAD", + "INR", + "DKK", + "GBP", + "RUB", + "NZD", + "MXN", + "IDR", + "TWD", + "THB", + "VND", + ] + with Live(console=console) as live_table: + exchange_rate_dict: Dict[Tuple[str, str], float] = {} + + for index in range(100): + select_exchange = exchanges[index % len(exchanges)] + + for exchange in exchanges: + if exchange == select_exchange: + continue + time.sleep(0.4) + if random.randint(0, 10) < 1: + console.log(next(examples)) + exchange_rate_dict[(select_exchange, exchange)] = 200 / ( + (random.random() * 320) + 1 + ) + if len(exchange_rate_dict) > len(exchanges) - 1: + exchange_rate_dict.pop(list(exchange_rate_dict.keys())[0]) + table = Table(title="Exchange Rates") + + table.add_column("Source Currency") + table.add_column("Destination Currency") + table.add_column("Exchange Rate") + + for ((source, dest), exchange_rate) in exchange_rate_dict.items(): + table.add_row( + source, + dest, + Text( + f"{exchange_rate:.4f}", + style="red" if exchange_rate < 1.0 else "green", + ), + ) + + live_table.update(Align.center(table)) diff --git a/rich/live_render.py b/rich/live_render.py new file mode 100644 index 0000000..8fea58b --- /dev/null +++ b/rich/live_render.py @@ -0,0 +1,93 @@ +from typing import Optional, Tuple + +from typing_extensions import Literal + +from ._loop import loop_last +from .console import Console, ConsoleOptions, RenderableType, RenderResult +from .control import Control +from .segment import Segment +from .style import StyleType +from .text import Text + +VerticalOverflowMethod = Literal["crop", "ellipsis", "visible"] + + +class LiveRender: + """Creates a renderable that may be updated. + + Args: + renderable (RenderableType): Any renderable object. + style (StyleType, optional): An optional style to apply to the renderable. Defaults to "". + """ + + def __init__( + self, + renderable: RenderableType, + style: StyleType = "", + vertical_overflow: VerticalOverflowMethod = "ellipsis", + ) -> None: + self.renderable = renderable + self.style = style + self.vertical_overflow = vertical_overflow + self._shape: Optional[Tuple[int, int]] = None + + def set_renderable(self, renderable: RenderableType) -> None: + """Set a new renderable. + + Args: + renderable (RenderableType): Any renderable object, including str. + """ + self.renderable = renderable + + def position_cursor(self) -> Control: + """Get control codes to move cursor to beginning of live render. + + Returns: + Control: A control instance that may be printed. + """ + if self._shape is not None: + _, height = self._shape + return Control("\r\x1b[2K" + "\x1b[1A\x1b[2K" * (height - 1)) + return Control("") + + def restore_cursor(self) -> Control: + """Get control codes to clear the render and restore the cursor to its previous position. + + Returns: + Control: A Control instance that may be printed. + """ + if self._shape is not None: + _, height = self._shape + return Control("\r" + "\x1b[1A\x1b[2K" * height) + return Control("") + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + _Segment = Segment + style = console.get_style(self.style) + lines = console.render_lines(self.renderable, options, style=style, pad=False) + shape = _Segment.get_shape(lines) + + _, height = shape + if height > options.size.height: + if self.vertical_overflow == "crop": + lines = lines[: options.size.height] + shape = _Segment.get_shape(lines) + elif self.vertical_overflow == "ellipsis": + lines = lines[: (options.size.height - 1)] + overflow_text = Text( + "...", + overflow="crop", + justify="center", + end="", + style="live.ellipsis", + ) + lines.append(list(console.render(overflow_text))) + shape = _Segment.get_shape(lines) + self._shape = shape + + for last, line in loop_last(lines): + yield from line + if not last: + yield _Segment.line() diff --git a/rich/logging.py b/rich/logging.py new file mode 100644 index 0000000..49c7498 --- /dev/null +++ b/rich/logging.py @@ -0,0 +1,256 @@ +import logging +from datetime import datetime +from logging import Handler, LogRecord +from pathlib import Path +from typing import ClassVar, List, Optional, Type, Union + +from . import get_console +from ._log_render import LogRender, FormatTimeCallable +from .console import Console, ConsoleRenderable +from .highlighter import Highlighter, ReprHighlighter +from .text import Text +from .traceback import Traceback + + +class RichHandler(Handler): + """A logging handler that renders output with Rich. The time / level / message and file are displayed in columns. + The level is color coded, and the message is syntax highlighted. + + Note: + Be careful when enabling console markup in log messages if you have configured logging for libraries not + under your control. If a dependency writes messages containing square brackets, it may not produce the intended output. + + Args: + level (Union[int, str], optional): Log level. Defaults to logging.NOTSET. + console (:class:`~rich.console.Console`, optional): Optional console instance to write logs. + Default will use a global console instance writing to stdout. + show_time (bool, optional): Show a column for the time. Defaults to True. + show_level (bool, optional): Show a column for the level. Defaults to True. + show_path (bool, optional): Show the path to the original log call. Defaults to True. + enable_link_path (bool, optional): Enable terminal link of path column to file. Defaults to True. + highlighter (Highlighter, optional): Highlighter to style log messages, or None to use ReprHighlighter. Defaults to None. + markup (bool, optional): Enable console markup in log messages. Defaults to False. + rich_tracebacks (bool, optional): Enable rich tracebacks with syntax highlighting and formatting. Defaults to False. + tracebacks_width (Optional[int], optional): Number of characters used to render tracebacks, or None for full width. Defaults to None. + tracebacks_extra_lines (int, optional): Additional lines of code to render tracebacks, or None for full width. Defaults to None. + tracebacks_theme (str, optional): Override pygments theme used in traceback. + tracebacks_word_wrap (bool, optional): Enable word wrapping of long tracebacks lines. Defaults to True. + tracebacks_show_locals (bool, optional): Enable display of locals in tracebacks. Defaults to False. + locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. + Defaults to 10. + locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80. + log_time_format (Union[str, TimeFormatterCallable], optional): If ``log_time`` is enabled, either string for strftime or callable that formats the time. Defaults to "[%x %X] ". + """ + + KEYWORDS: ClassVar[Optional[List[str]]] = [ + "GET", + "POST", + "HEAD", + "PUT", + "DELETE", + "OPTIONS", + "TRACE", + "PATCH", + ] + HIGHLIGHTER_CLASS: ClassVar[Type[Highlighter]] = ReprHighlighter + + def __init__( + self, + level: Union[int, str] = logging.NOTSET, + console: Console = None, + *, + show_time: bool = True, + show_level: bool = True, + show_path: bool = True, + enable_link_path: bool = True, + highlighter: Highlighter = None, + markup: bool = False, + rich_tracebacks: bool = False, + tracebacks_width: Optional[int] = None, + tracebacks_extra_lines: int = 3, + tracebacks_theme: Optional[str] = None, + tracebacks_word_wrap: bool = True, + tracebacks_show_locals: bool = False, + locals_max_length: int = 10, + locals_max_string: int = 80, + log_time_format: Union[str, FormatTimeCallable] = "[%x %X]", + ) -> None: + super().__init__(level=level) + self.console = console or get_console() + self.highlighter = highlighter or self.HIGHLIGHTER_CLASS() + self._log_render = LogRender( + show_time=show_time, + show_level=show_level, + show_path=show_path, + time_format=log_time_format, + level_width=None, + ) + self.enable_link_path = enable_link_path + self.markup = markup + self.rich_tracebacks = rich_tracebacks + self.tracebacks_width = tracebacks_width + self.tracebacks_extra_lines = tracebacks_extra_lines + self.tracebacks_theme = tracebacks_theme + self.tracebacks_word_wrap = tracebacks_word_wrap + self.tracebacks_show_locals = tracebacks_show_locals + self.locals_max_length = locals_max_length + self.locals_max_string = locals_max_string + + def get_level_text(self, record: LogRecord) -> Text: + """Get the level name from the record. + + Args: + record (LogRecord): LogRecord instance. + + Returns: + Text: A tuple of the style and level name. + """ + level_name = record.levelname + level_text = Text.styled( + level_name.ljust(8), f"logging.level.{level_name.lower()}" + ) + return level_text + + def emit(self, record: LogRecord) -> None: + """Invoked by logging.""" + message = self.format(record) + + traceback = None + if ( + self.rich_tracebacks + and record.exc_info + and record.exc_info != (None, None, None) + ): + exc_type, exc_value, exc_traceback = record.exc_info + assert exc_type is not None + assert exc_value is not None + traceback = Traceback.from_exception( + exc_type, + exc_value, + exc_traceback, + width=self.tracebacks_width, + extra_lines=self.tracebacks_extra_lines, + theme=self.tracebacks_theme, + word_wrap=self.tracebacks_word_wrap, + show_locals=self.tracebacks_show_locals, + locals_max_length=self.locals_max_length, + locals_max_string=self.locals_max_string, + ) + message = record.getMessage() + + message_renderable = self.render_message(record, message) + log_renderable = self.render( + record=record, traceback=traceback, message_renderable=message_renderable + ) + self.console.print(log_renderable) + + def render_message(self, record: LogRecord, message: str) -> "ConsoleRenderable": + """Render message text in to Text. + + record (LogRecord): logging Record. + message (str): String cotaining log message. + + Returns: + ConsoleRenderable: Renderable to display log message. + """ + use_markup = ( + getattr(record, "markup") if hasattr(record, "markup") else self.markup + ) + message_text = Text.from_markup(message) if use_markup else Text(message) + if self.highlighter: + message_text = self.highlighter(message_text) + if self.KEYWORDS: + message_text.highlight_words(self.KEYWORDS, "logging.keyword") + return message_text + + def render( + self, + *, + record: LogRecord, + traceback: Optional[Traceback], + message_renderable: "ConsoleRenderable", + ) -> "ConsoleRenderable": + """Render log for display. + + Args: + record (LogRecord): logging Record. + traceback (Optional[Traceback]): Traceback instance or None for no Traceback. + message_renderable (ConsoleRenderable): Renderable (typically Text) containing log message contents. + + Returns: + ConsoleRenderable: Renderable to display log. + """ + path = Path(record.pathname).name + level = self.get_level_text(record) + time_format = None if self.formatter is None else self.formatter.datefmt + log_time = datetime.fromtimestamp(record.created) + + log_renderable = self._log_render( + self.console, + [message_renderable] if not traceback else [message_renderable, traceback], + log_time=log_time, + time_format=time_format, + level=level, + path=path, + line_no=record.lineno, + link_path=record.pathname if self.enable_link_path else None, + ) + return log_renderable + + +if __name__ == "__main__": # pragma: no cover + from time import sleep + + FORMAT = "%(message)s" + # FORMAT = "%(asctime)-15s - %(level) - %(message)s" + logging.basicConfig( + level="NOTSET", + format=FORMAT, + datefmt="[%X]", + handlers=[RichHandler(rich_tracebacks=True, tracebacks_show_locals=True)], + ) + log = logging.getLogger("rich") + + log.info("Server starting...") + log.info("Listening on http://127.0.0.1:8080") + sleep(1) + + log.info("GET /index.html 200 1298") + log.info("GET /imgs/backgrounds/back1.jpg 200 54386") + log.info("GET /css/styles.css 200 54386") + log.warning("GET /favicon.ico 404 242") + sleep(1) + + log.debug( + "JSONRPC request\n--> %r\n<-- %r", + { + "version": "1.1", + "method": "confirmFruitPurchase", + "params": [["apple", "orange", "mangoes", "pomelo"], 1.123], + "id": "194521489", + }, + {"version": "1.1", "result": True, "error": None, "id": "194521489"}, + ) + log.debug( + "Loading configuration file /adasd/asdasd/qeqwe/qwrqwrqwr/sdgsdgsdg/werwerwer/dfgerert/ertertert/ertetert/werwerwer" + ) + log.error("Unable to find 'pomelo' in database!") + log.info("POST /jsonrpc/ 200 65532") + log.info("POST /admin/ 401 42234") + log.warning("password was rejected for admin site.") + + def divide(): + number = 1 + divisor = 0 + foos = ["foo"] * 100 + log.debug("in divide") + try: + number / divisor + except: + log.exception("An error of some kind occurred!") + + divide() + sleep(1) + log.critical("Out of memory!") + log.info("Server exited with code=-1") + log.info("[bold]EXITING...[/bold]", extra=dict(markup=True)) diff --git a/rich/markdown.py b/rich/markdown.py new file mode 100644 index 0000000..761215d --- /dev/null +++ b/rich/markdown.py @@ -0,0 +1,620 @@ +from typing import Any, ClassVar, Dict, List, Optional, Type, Union + +from commonmark.blocks import Parser + +from . import box +from ._loop import loop_first +from ._stack import Stack +from .console import Console, ConsoleOptions, JustifyMethod, RenderResult, Segment +from .containers import Renderables +from .jupyter import JupyterMixin +from .panel import Panel +from .rule import Rule +from .style import Style, StyleStack +from .syntax import Syntax +from .text import Text, TextType + + +class MarkdownElement: + + new_line: ClassVar[bool] = True + + @classmethod + def create(cls, markdown: "Markdown", node: Any) -> "MarkdownElement": + """Factory to create markdown element, + + Args: + markdown (Markdown): THe parent Markdown object. + node (Any): A node from Pygments. + + Returns: + MarkdownElement: A new markdown element + """ + return cls() + + def on_enter(self, context: "MarkdownContext"): + """Called when the node is entered. + + Args: + context (MarkdownContext): The markdown context. + """ + + def on_text(self, context: "MarkdownContext", text: TextType) -> None: + """Called when text is parsed. + + Args: + context (MarkdownContext): The markdown context. + """ + + def on_leave(self, context: "MarkdownContext") -> None: + """Called when the parser leaves the element. + + Args: + context (MarkdownContext): [description] + """ + + def on_child_close( + self, context: "MarkdownContext", child: "MarkdownElement" + ) -> bool: + """Called when a child element is closed. + + This method allows a parent element to take over rendering of its children. + + Args: + context (MarkdownContext): The markdown context. + child (MarkdownElement): The child markdown element. + + Returns: + bool: Return True to render the element, or False to not render the element. + """ + return True + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + return () + + +class UnknownElement(MarkdownElement): + """An unknown element. + + Hopefully there will be no unknown elements, and we will have a MarkdownElement for + everything in the document. + + """ + + +class TextElement(MarkdownElement): + """Base class for elements that render text.""" + + style_name = "none" + + def on_enter(self, context: "MarkdownContext") -> None: + self.style = context.enter_style(self.style_name) + self.text = Text(justify="left") + + def on_text(self, context: "MarkdownContext", text: TextType) -> None: + self.text.append(text, context.current_style if isinstance(text, str) else None) + + def on_leave(self, context: "MarkdownContext") -> None: + context.leave_style() + + +class Paragraph(TextElement): + """A Paragraph.""" + + style_name = "markdown.paragraph" + justify: JustifyMethod + + @classmethod + def create(cls, markdown: "Markdown", node) -> "Paragraph": + return cls(justify=markdown.justify or "left") + + def __init__(self, justify: JustifyMethod) -> None: + self.justify = justify + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + self.text.justify = self.justify + yield self.text + + +class Heading(TextElement): + """A heading.""" + + @classmethod + def create(cls, markdown: "Markdown", node: Any) -> "Heading": + heading = cls(node.level) + return heading + + def on_enter(self, context: "MarkdownContext") -> None: + self.text = Text() + context.enter_style(self.style_name) + + def __init__(self, level: int) -> None: + self.level = level + self.style_name = f"markdown.h{level}" + super().__init__() + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + text = self.text + text.justify = "center" + if self.level == 1: + # Draw a border around h1s + yield Panel( + text, + box=box.DOUBLE, + style="markdown.h1.border", + ) + else: + # Styled text for h2 and beyond + if self.level == 2: + yield Text("") + yield text + + +class CodeBlock(TextElement): + """A code block with syntax highlighting.""" + + style_name = "markdown.code_block" + + @classmethod + def create(cls, markdown: "Markdown", node: Any) -> "CodeBlock": + node_info = node.info or "" + lexer_name = node_info.partition(" ")[0] + return cls(lexer_name or "default", markdown.code_theme) + + def __init__(self, lexer_name: str, theme: str) -> None: + self.lexer_name = lexer_name + self.theme = theme + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + code = str(self.text).rstrip() + syntax = Panel( + Syntax(code, self.lexer_name, theme=self.theme), + border_style="dim", + box=box.SQUARE, + ) + yield syntax + + +class BlockQuote(TextElement): + """A block quote.""" + + style_name = "markdown.block_quote" + + def __init__(self) -> None: + self.elements: Renderables = Renderables() + + def on_child_close( + self, context: "MarkdownContext", child: "MarkdownElement" + ) -> bool: + self.elements.append(child) + return False + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + render_options = options.update(width=options.max_width - 4) + lines = console.render_lines(self.elements, render_options, style=self.style) + style = self.style + new_line = Segment("\n") + padding = Segment("▌ ", style) + for line in lines: + yield padding + yield from line + yield new_line + + +class HorizontalRule(MarkdownElement): + """A horizontal rule to divide sections.""" + + new_line = False + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + style = console.get_style("markdown.hr", default="none") + yield Rule(style=style) + + +class ListElement(MarkdownElement): + """A list element.""" + + @classmethod + def create(cls, markdown: "Markdown", node: Any) -> "ListElement": + list_data = node.list_data + return cls(list_data["type"], list_data["start"]) + + def __init__(self, list_type: str, list_start: Optional[int]) -> None: + self.items: List[ListItem] = [] + self.list_type = list_type + self.list_start = list_start + + def on_child_close( + self, context: "MarkdownContext", child: "MarkdownElement" + ) -> bool: + assert isinstance(child, ListItem) + self.items.append(child) + return False + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + if self.list_type == "bullet": + for item in self.items: + yield from item.render_bullet(console, options) + else: + number = 1 if self.list_start is None else self.list_start + last_number = number + len(self.items) + for item in self.items: + yield from item.render_number(console, options, number, last_number) + number += 1 + + +class ListItem(TextElement): + """An item in a list.""" + + style_name = "markdown.item" + + def __init__(self) -> None: + self.elements: Renderables = Renderables() + + def on_child_close( + self, context: "MarkdownContext", child: "MarkdownElement" + ) -> bool: + self.elements.append(child) + return False + + def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult: + render_options = options.update(width=options.max_width - 3) + lines = console.render_lines(self.elements, render_options, style=self.style) + bullet_style = console.get_style("markdown.item.bullet", default="none") + + bullet = Segment(" • ", bullet_style) + padding = Segment(" " * 3, bullet_style) + new_line = Segment("\n") + for first, line in loop_first(lines): + yield bullet if first else padding + yield from line + yield new_line + + def render_number( + self, console: Console, options: ConsoleOptions, number: int, last_number: int + ) -> RenderResult: + number_width = len(str(last_number)) + 2 + render_options = options.update(width=options.max_width - number_width) + lines = console.render_lines(self.elements, render_options, style=self.style) + number_style = console.get_style("markdown.item.number", default="none") + + new_line = Segment("\n") + padding = Segment(" " * number_width, number_style) + numeral = Segment(f"{number}".rjust(number_width - 1) + " ", number_style) + for first, line in loop_first(lines): + yield numeral if first else padding + yield from line + yield new_line + + +class ImageItem(TextElement): + """Renders a placeholder for an image.""" + + new_line = False + + @classmethod + def create(cls, markdown: "Markdown", node: Any) -> "MarkdownElement": + """Factory to create markdown element, + + Args: + markdown (Markdown): THe parent Markdown object. + node (Any): A node from Pygments. + + Returns: + MarkdownElement: A new markdown element + """ + return cls(node.destination, markdown.hyperlinks) + + def __init__(self, destination: str, hyperlinks: bool) -> None: + self.destination = destination + self.hyperlinks = hyperlinks + self.link: Optional[str] = None + super().__init__() + + def on_enter(self, context: "MarkdownContext") -> None: + self.link = context.current_style.link + self.text = Text(justify="left") + super().on_enter(context) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + link_style = Style(link=self.link or self.destination or None) + title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1]) + if self.hyperlinks: + title.stylize(link_style) + yield Text.assemble("🌆 ", title, " ", end="") + + +class MarkdownContext: + """Manages the console render state.""" + + def __init__( + self, + console: Console, + options: ConsoleOptions, + style: Style, + inline_code_lexer: str = None, + inline_code_theme: str = "monokai", + ) -> None: + self.console = console + self.options = options + self.style_stack: StyleStack = StyleStack(style) + self.stack: Stack[MarkdownElement] = Stack() + + self._syntax: Optional[Syntax] = None + if inline_code_lexer is not None: + self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme) + + @property + def current_style(self) -> Style: + """Current style which is the product of all styles on the stack.""" + return self.style_stack.current + + def on_text(self, text: str, node_type: str) -> None: + """Called when the parser visits text.""" + if node_type == "code" and self._syntax is not None: + highlight_text = self._syntax.highlight(text) + highlight_text.rstrip() + self.stack.top.on_text( + self, Text.assemble(highlight_text, style=self.style_stack.current) + ) + else: + self.stack.top.on_text(self, text) + + def enter_style(self, style_name: Union[str, Style]) -> Style: + """Enter a style context.""" + style = self.console.get_style(style_name, default="none") + self.style_stack.push(style) + return self.current_style + + def leave_style(self) -> Style: + """Leave a style context.""" + style = self.style_stack.pop() + return style + + +class Markdown(JupyterMixin): + """A Markdown renderable. + + Args: + markup (str): A string containing markdown. + code_theme (str, optional): Pygments theme for code blocks. Defaults to "monokai". + justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None. + style (Union[str, Style], optional): Optional style to apply to markdown. + hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``. + inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is + enabled. Defaults to "python". + inline_code_theme: (Optional[str], optional): Pygments theme for inline code + highlighting, or None for no highlighting. Defaults to None. + """ + + elements: ClassVar[Dict[str, Type[MarkdownElement]]] = { + "paragraph": Paragraph, + "heading": Heading, + "code_block": CodeBlock, + "block_quote": BlockQuote, + "thematic_break": HorizontalRule, + "list": ListElement, + "item": ListItem, + "image": ImageItem, + } + inlines = {"emph", "strong", "code", "strike"} + + def __init__( + self, + markup: str, + code_theme: str = "monokai", + justify: JustifyMethod = None, + style: Union[str, Style] = "none", + hyperlinks: bool = True, + inline_code_lexer: str = None, + inline_code_theme: str = None, + ) -> None: + self.markup = markup + parser = Parser() + self.parsed = parser.parse(markup) + self.code_theme = code_theme + self.justify = justify + self.style = style + self.hyperlinks = hyperlinks + self.inline_code_lexer = inline_code_lexer + self.inline_code_theme = inline_code_theme or code_theme + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + """Render markdown to the console.""" + style = console.get_style(self.style, default="none") + context = MarkdownContext( + console, + options, + style, + inline_code_lexer=self.inline_code_lexer, + inline_code_theme=self.inline_code_theme, + ) + nodes = self.parsed.walker() + inlines = self.inlines + new_line = False + for current, entering in nodes: + node_type = current.t + if node_type in ("html_inline", "html_block", "text"): + context.on_text(current.literal.replace("\n", " "), node_type) + elif node_type == "linebreak": + if entering: + context.on_text("\n", node_type) + elif node_type == "softbreak": + if entering: + context.on_text(" ", node_type) + elif node_type == "link": + if entering: + link_style = console.get_style("markdown.link", default="none") + if self.hyperlinks: + link_style += Style(link=current.destination) + context.enter_style(link_style) + else: + context.leave_style() + if not self.hyperlinks: + context.on_text(" (", node_type) + style = Style(underline=True) + console.get_style( + "markdown.link_url", default="none" + ) + context.enter_style(style) + context.on_text(current.destination, node_type) + context.leave_style() + context.on_text(")", node_type) + elif node_type in inlines: + if current.is_container(): + if entering: + context.enter_style(f"markdown.{node_type}") + else: + context.leave_style() + else: + context.enter_style(f"markdown.{node_type}") + if current.literal: + context.on_text(current.literal, node_type) + context.leave_style() + else: + element_class = self.elements.get(node_type) or UnknownElement + if current.is_container(): + if entering: + element = element_class.create(self, current) + context.stack.push(element) + element.on_enter(context) + else: + element = context.stack.pop() + if context.stack: + if context.stack.top.on_child_close(context, element): + if new_line: + yield Segment("\n") + yield from console.render(element, context.options) + element.on_leave(context) + else: + element.on_leave(context) + else: + element.on_leave(context) + yield from console.render(element, context.options) + new_line = element.new_line + else: + element = element_class.create(self, current) + + context.stack.push(element) + element.on_enter(context) + if current.literal: + element.on_text(context, current.literal.rstrip()) + context.stack.pop() + if new_line: + yield Segment("\n") + yield from console.render(element, context.options) + element.on_leave(context) + new_line = element.new_line + + +if __name__ == "__main__": # pragma: no cover + + import argparse + import sys + + parser = argparse.ArgumentParser( + description="Render Markdown to the console with Rich" + ) + parser.add_argument( + "path", + metavar="PATH", + nargs="?", + help="path to markdown file", + ) + parser.add_argument( + "-c", + "--force-color", + dest="force_color", + action="store_true", + default=None, + help="force color for non-terminals", + ) + parser.add_argument( + "-t", + "--code-theme", + dest="code_theme", + default="monokai", + help="pygments code theme", + ) + parser.add_argument( + "-i", + "--inline-code-lexer", + dest="inline_code_lexer", + default=None, + help="inline_code_lexer", + ) + parser.add_argument( + "-y", + "--hyperlinks", + dest="hyperlinks", + action="store_true", + help="enable hyperlinks", + ) + parser.add_argument( + "-w", + "--width", + type=int, + dest="width", + default=None, + help="width of output (default will auto-detect)", + ) + parser.add_argument( + "-j", + "--justify", + dest="justify", + action="store_true", + help="enable full text justify", + ) + parser.add_argument( + "-p", + "--page", + dest="page", + action="store_true", + help="use pager to scroll output", + ) + args = parser.parse_args() + + from rich.console import Console + + if not args.path or args.path == "-": + markdown_body = sys.stdin.read() + else: + with open(args.path, "rt", encoding="utf-8") as markdown_file: + markdown_body = markdown_file.read() + markdown = Markdown( + markdown_body, + justify="full" if args.justify else "left", + code_theme=args.code_theme, + hyperlinks=args.hyperlinks, + inline_code_lexer=args.inline_code_lexer, + ) + if args.page: + import pydoc + import io + + console = Console( + file=io.StringIO(), force_terminal=args.force_color, width=args.width + ) + console.print(markdown) + pydoc.pager(console.file.getvalue()) # type: ignore + + else: + console = Console(force_terminal=args.force_color, width=args.width) + console.print(markdown) diff --git a/rich/markup.py b/rich/markup.py new file mode 100644 index 0000000..99d5663 --- /dev/null +++ b/rich/markup.py @@ -0,0 +1,179 @@ +import re +from typing import Iterable, List, Match, NamedTuple, Optional, Tuple, Union + +from .errors import MarkupError +from .style import Style +from .text import Span, Text +from ._emoji_replace import _emoji_replace + + +RE_TAGS = re.compile( + r"""((\\*)\[([a-z#\/].*?)\])""", + re.VERBOSE, +) + + +class Tag(NamedTuple): + """A tag in console markup.""" + + name: str + """The tag name. e.g. 'bold'.""" + parameters: Optional[str] + """Any additional parameters after the name.""" + + def __str__(self) -> str: + return ( + self.name if self.parameters is None else f"{self.name} {self.parameters}" + ) + + @property + def markup(self) -> str: + """Get the string representation of this tag.""" + return ( + f"[{self.name}]" + if self.parameters is None + else f"[{self.name}={self.parameters}]" + ) + + +def escape(markup: str, _escape=re.compile(r"(\\*)(\[[a-z#\/].*?\])").sub) -> str: + """Escapes text so that it won't be interpreted as markup. + + Args: + markup (str): Content to be inserted in to markup. + + Returns: + str: Markup with square brackets escaped. + """ + + def escape_backslashes(match: Match[str]) -> str: + """Called by re.sub replace matches.""" + backslashes, text = match.groups() + return f"{backslashes}{backslashes}\\{text}" + + markup = _escape(escape_backslashes, markup) + return markup + + +def _parse(markup: str) -> Iterable[Tuple[int, Optional[str], Optional[Tag]]]: + """Parse markup in to an iterable of tuples of (position, text, tag). + + Args: + markup (str): A string containing console markup + + """ + position = 0 + _divmod = divmod + _Tag = Tag + for match in RE_TAGS.finditer(markup): + full_text, escapes, tag_text = match.groups() + start, end = match.span() + if start > position: + yield start, markup[position:start], None + if escapes: + backslashes, escaped = _divmod(len(escapes), 2) + if backslashes: + # Literal backslashes + yield start, "\\" * backslashes, None + start += backslashes * 2 + if escaped: + # Escape of tag + yield start, full_text[len(escapes) :], None + position = end + continue + text, equals, parameters = tag_text.partition("=") + yield start, None, _Tag(text, parameters if equals else None) + position = end + if position < len(markup): + yield position, markup[position:], None + + +def render(markup: str, style: Union[str, Style] = "", emoji: bool = True) -> Text: + """Render console markup in to a Text instance. + + Args: + markup (str): A string containing console markup. + emoji (bool, optional): Also render emoji code. Defaults to True. + + Raises: + MarkupError: If there is a syntax error in the markup. + + Returns: + Text: A test instance. + """ + emoji_replace = _emoji_replace + if "[" not in markup: + return Text(emoji_replace(markup) if emoji else markup, style=style) + text = Text(style=style) + append = text.append + normalize = Style.normalize + + style_stack: List[Tuple[int, Tag]] = [] + pop = style_stack.pop + + spans: List[Span] = [] + append_span = spans.append + + _Span = Span + _Tag = Tag + + def pop_style(style_name: str) -> Tuple[int, Tag]: + """Pop tag matching given style name.""" + for index, (_, tag) in enumerate(reversed(style_stack), 1): + if tag.name == style_name: + return pop(-index) + raise KeyError(style_name) + + for position, plain_text, tag in _parse(markup): + if plain_text is not None: + append(emoji_replace(plain_text) if emoji else plain_text) + elif tag is not None: + if tag.name.startswith("/"): # Closing tag + style_name = tag.name[1:].strip() + if style_name: # explicit close + style_name = normalize(style_name) + try: + start, open_tag = pop_style(style_name) + except KeyError: + raise MarkupError( + f"closing tag '{tag.markup}' at position {position} doesn't match any open tag" + ) from None + else: # implicit close + try: + start, open_tag = pop() + except IndexError: + raise MarkupError( + f"closing tag '[/]' at position {position} has nothing to close" + ) from None + + append_span(_Span(start, len(text), str(open_tag))) + else: # Opening tag + normalized_tag = _Tag(normalize(tag.name), tag.parameters) + style_stack.append((len(text), normalized_tag)) + + text_length = len(text) + while style_stack: + start, tag = style_stack.pop() + append_span(_Span(start, text_length, str(tag))) + + text.spans = sorted(spans) + return text + + +if __name__ == "__main__": # pragma: no cover + + from rich.console import Console + from rich.text import Text + + console = Console(highlight=False) + + console.print("Hello [1], [1,2,3] ['hello']") + console.print("foo") + console.print("Hello [link=https://www.willmcgugan.com]W[b red]o[/]rld[/]!") + + from rich import print + + print(escape("[red]")) + print(escape(r"\[red]")) + print(escape(r"\\[red]")) + print(escape(r"\\\[red]")) diff --git a/rich/measure.py b/rich/measure.py new file mode 100644 index 0000000..8089bf0 --- /dev/null +++ b/rich/measure.py @@ -0,0 +1,149 @@ +from operator import itemgetter +from typing import Iterable, NamedTuple, TYPE_CHECKING + +from . import errors +from .protocol import is_renderable + +if TYPE_CHECKING: + from .console import Console, RenderableType + + +class Measurement(NamedTuple): + """Stores the minimum and maximum widths (in characters) required to render an object.""" + + minimum: int + """Minimum number of cells required to render.""" + maximum: int + """Maximum number of cells required to render.""" + + @property + def span(self) -> int: + """Get difference between maximum and minimum.""" + return self.maximum - self.minimum + + def normalize(self) -> "Measurement": + """Get measurement that ensures that minimum <= maximum and minimum >= 0 + + Returns: + Measurement: A normalized measurement. + """ + minimum, maximum = self + minimum = min(max(0, minimum), maximum) + return Measurement(max(0, minimum), max(0, max(minimum, maximum))) + + def with_maximum(self, width: int) -> "Measurement": + """Get a RenderableWith where the widths are <= width. + + Args: + width (int): Maximum desired width. + + Returns: + Measurement: New Measurement object. + """ + minimum, maximum = self + return Measurement(min(minimum, width), min(maximum, width)) + + def with_minimum(self, width: int) -> "Measurement": + """Get a RenderableWith where the widths are >= width. + + Args: + width (int): Minimum desired width. + + Returns: + Measurement: New Measurement object. + """ + minimum, maximum = self + width = max(0, width) + return Measurement(max(minimum, width), max(maximum, width)) + + def clamp(self, min_width: int = None, max_width: int = None) -> "Measurement": + """Clamp a measurement within the specified range. + + Args: + min_width (int): Minimum desired width, or ``None`` for no minimum. Defaults to None. + max_width (int): Maximum desired width, or ``None`` for no maximum. Defaults to None. + + Returns: + Measurement: New Measurement object. + """ + measurement = self + if min_width is not None: + measurement = measurement.with_minimum(min_width) + if max_width is not None: + measurement = measurement.with_maximum(max_width) + return measurement + + @classmethod + def get( + cls, console: "Console", renderable: "RenderableType", max_width: int = None + ) -> "Measurement": + """Get a measurement for a renderable. + + Args: + console (~rich.console.Console): Console instance. + renderable (RenderableType): An object that may be rendered with Rich. + max_width (int, optional): The maximum width available, or None to use console.width. + Defaults to None. + + Raises: + errors.NotRenderableError: If the object is not renderable. + + Returns: + Measurement: Measurement object containing range of character widths required to render the object. + """ + from rich.console import RichCast + + _max_width = console.width if max_width is None else max_width + if _max_width < 1: + return Measurement(0, 0) + if isinstance(renderable, str): + renderable = console.render_str(renderable) + + if isinstance(renderable, RichCast): + renderable = renderable.__rich__() + + if is_renderable(renderable): + get_console_width = getattr(renderable, "__rich_measure__", None) + if get_console_width is not None: + render_width = ( + get_console_width(console, _max_width) + .normalize() + .with_maximum(_max_width) + ) + if render_width.maximum < 1: + return Measurement(0, 0) + return render_width.normalize() + else: + return Measurement(0, _max_width) + else: + raise errors.NotRenderableError( + f"Unable to get render width for {renderable!r}; " + "a str, Segment, or object with __rich_console__ method is required" + ) + + +def measure_renderables( + console: "Console", renderables: Iterable["RenderableType"], max_width: int +) -> "Measurement": + """Get a measurement that would fit a number of renderables. + + Args: + console (~rich.console.Console): Console instance. + renderables (Iterable[RenderableType]): One or more renderable objects. + max_width (int): The maximum width available. + + Returns: + Measurement: Measurement object containing range of character widths required to + contain all given renderables. + """ + if not renderables: + return Measurement(0, 0) + get_measurement = Measurement.get + measurements = [ + get_measurement(console, renderable, max_width) for renderable in renderables + ] + measured_width = Measurement( + max(measurements, key=itemgetter(0)).minimum, + max(measurements, key=itemgetter(1)).maximum, + ) + return measured_width diff --git a/rich/padding.py b/rich/padding.py new file mode 100644 index 0000000..da30816 --- /dev/null +++ b/rich/padding.py @@ -0,0 +1,124 @@ +from typing import cast, Tuple, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from .console import ( + Console, + ConsoleOptions, + RenderableType, + RenderResult, + ) +from .jupyter import JupyterMixin +from .measure import Measurement +from .style import Style +from .segment import Segment + + +PaddingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]] + + +class Padding(JupyterMixin): + """Draw space around content. + + Example: + >>> print(Padding("Hello", (2, 4), style="on blue")) + + Args: + renderable (RenderableType): String or other renderable. + pad (Union[int, Tuple[int]]): Padding for top, right, bottom, and left borders. + May be specified with 1, 2, or 4 integers (CSS style). + style (Union[str, Style], optional): Style for padding characters. Defaults to "none". + expand (bool, optional): Expand padding to fit available width. Defaults to True. + """ + + def __init__( + self, + renderable: "RenderableType", + pad: "PaddingDimensions" = (0, 0, 0, 0), + *, + style: Union[str, Style] = "none", + expand: bool = True, + ): + self.renderable = renderable + self.top, self.right, self.bottom, self.left = self.unpack(pad) + self.style = style + self.expand = expand + + @classmethod + def indent(cls, renderable: "RenderableType", level: int) -> "Padding": + """Make padding instance to render an indent. + + Args: + renderable (RenderableType): String or other renderable. + level (int): Number of characters to indent. + + Returns: + Padding: A Padding instance. + """ + + return Padding(renderable, pad=(0, 0, 0, level), expand=False) + + @staticmethod + def unpack(pad: "PaddingDimensions") -> Tuple[int, int, int, int]: + """Unpack padding specified in CSS style.""" + if isinstance(pad, int): + return (pad, pad, pad, pad) + if len(pad) == 1: + _pad = pad[0] + return (_pad, _pad, _pad, _pad) + if len(pad) == 2: + pad_top, pad_right = cast(Tuple[int, int], pad) + return (pad_top, pad_right, pad_top, pad_right) + if len(pad) == 4: + top, right, bottom, left = cast(Tuple[int, int, int, int], pad) + return (top, right, bottom, left) + raise ValueError(f"1, 2 or 4 integers required for padding; {len(pad)} given") + + def __repr__(self) -> str: + return f"Padding({self.renderable!r}, ({self.top},{self.right},{self.bottom},{self.left}))" + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + + style = console.get_style(self.style) + if self.expand: + width = options.max_width + else: + width = min( + Measurement.get(console, self.renderable, options.max_width).maximum + + self.left + + self.right, + options.max_width, + ) + child_options = options.update_width(width - self.left - self.right) + lines = console.render_lines( + self.renderable, child_options, style=style, pad=False + ) + lines = Segment.set_shape(lines, child_options.max_width, style=style) + + blank_line = Segment(" " * width + "\n", style) + top = [blank_line] * self.top + bottom = [blank_line] * self.bottom + left = Segment(" " * self.left, style) if self.left else None + right = Segment(" " * self.right, style) if self.right else None + new_line = Segment.line() + yield from top + for line in lines: + if left is not None: + yield left + yield from line + if right is not None: + yield right + yield new_line + yield from bottom + + def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement": + extra_width = self.left + self.right + if max_width - extra_width < 1: + return Measurement(max_width, max_width) + measure_min, measure_max = Measurement.get( + console, self.renderable, max(0, max_width - extra_width) + ) + measurement = Measurement(measure_min + extra_width, measure_max + extra_width) + measurement = measurement.with_maximum(max_width) + return measurement diff --git a/rich/pager.py b/rich/pager.py new file mode 100644 index 0000000..52e77ef --- /dev/null +++ b/rich/pager.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod +import pydoc + + +class Pager(ABC): + """Base class for a pager.""" + + @abstractmethod + def show(self, content: str) -> None: + """Show content in pager. + + Args: + content (str): Content to be displayed. + """ + + +class SystemPager(Pager): + """Uses the pager installed on the system.""" + + _pager = lambda self, content: pydoc.pager(content) + + def show(self, content: str) -> None: + """Use the same pager used by pydoc.""" + self._pager(content) + + +if __name__ == "__main__": # pragma: no cover + from .__main__ import make_test_card + from .console import Console + + console = Console() + with console.pager(styles=True): + console.print(make_test_card()) diff --git a/rich/palette.py b/rich/palette.py new file mode 100644 index 0000000..f295879 --- /dev/null +++ b/rich/palette.py @@ -0,0 +1,100 @@ +from math import sqrt +from functools import lru_cache +from typing import Sequence, Tuple, TYPE_CHECKING + +from .color_triplet import ColorTriplet + +if TYPE_CHECKING: + from rich.table import Table + + +class Palette: + """A palette of available colors.""" + + def __init__(self, colors: Sequence[Tuple[int, int, int]]): + self._colors = colors + + def __getitem__(self, number: int) -> ColorTriplet: + return ColorTriplet(*self._colors[number]) + + def __rich__(self) -> "Table": + from rich.color import Color + from rich.style import Style + from rich.text import Text + from rich.table import Table + + table = Table( + "index", + "RGB", + "Color", + title="Palette", + caption=f"{len(self._colors)} colors", + highlight=True, + caption_justify="right", + ) + for index, color in enumerate(self._colors): + table.add_row( + str(index), + repr(color), + Text(" " * 16, style=Style(bgcolor=Color.from_rgb(*color))), + ) + return table + + # This is somewhat inefficient and needs caching + @lru_cache(maxsize=1024) + def match(self, color: Tuple[int, int, int]) -> int: + """Find a color from a palette that most closely matches a given color. + + Args: + color (Tuple[int, int, int]): RGB components in range 0 > 255. + + Returns: + int: Index of closes matching color. + """ + red1, green1, blue1 = color + _sqrt = sqrt + get_color = self._colors.__getitem__ + + def get_color_distance(index: int) -> float: + """Get the distance to a color.""" + red2, green2, blue2 = get_color(index) + red_mean = (red1 + red2) // 2 + red = red1 - red2 + green = green1 - green2 + blue = blue1 - blue2 + return _sqrt( + (((512 + red_mean) * red * red) >> 8) + + 4 * green * green + + (((767 - red_mean) * blue * blue) >> 8) + ) + + min_index = min(range(len(self._colors)), key=get_color_distance) + return min_index + + +if __name__ == "__main__": # pragma: no cover + import colorsys + from typing import Iterable + from rich.color import Color + from rich.console import Console, ConsoleOptions + from rich.segment import Segment + from rich.style import Style + + class ColorBox: + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> Iterable[Segment]: + height = console.size.height - 3 + for y in range(0, height): + for x in range(options.max_width): + h = x / options.max_width + l = y / (height + 1) + r1, g1, b1 = colorsys.hls_to_rgb(h, l, 1.0) + r2, g2, b2 = colorsys.hls_to_rgb(h, l + (1 / height / 2), 1.0) + bgcolor = Color.from_rgb(r1 * 255, g1 * 255, b1 * 255) + color = Color.from_rgb(r2 * 255, g2 * 255, b2 * 255) + yield Segment("▄", Style(color=color, bgcolor=bgcolor)) + yield Segment.line() + + console = Console() + console.print(ColorBox()) diff --git a/rich/panel.py b/rich/panel.py new file mode 100644 index 0000000..2e54d9e --- /dev/null +++ b/rich/panel.py @@ -0,0 +1,206 @@ +from typing import Optional, TYPE_CHECKING + +from .box import Box, ROUNDED + +from .align import AlignMethod +from .jupyter import JupyterMixin +from .measure import Measurement, measure_renderables +from .padding import Padding, PaddingDimensions +from .style import StyleType +from .text import Text, TextType +from .segment import Segment + +if TYPE_CHECKING: + from .console import Console, ConsoleOptions, RenderableType, RenderResult + + +class Panel(JupyterMixin): + """A console renderable that draws a border around its contents. + + Example: + >>> console.print(Panel("Hello, World!")) + + Args: + renderable (RenderableType): A console renderable object. + box (Box, optional): A Box instance that defines the look of the border (see :ref:`appendix_box`. + Defaults to box.ROUNDED. + safe_box (bool, optional): Disable box characters that don't display on windows legacy terminal with *raster* fonts. Defaults to True. + expand (bool, optional): If True the panel will stretch to fill the console + width, otherwise it will be sized to fit the contents. Defaults to True. + style (str, optional): The style of the panel (border and contents). Defaults to "none". + border_style (str, optional): The style of the border. Defaults to "none". + width (Optional[int], optional): Optional width of panel. Defaults to None to auto-detect. + padding (Optional[PaddingDimensions]): Optional padding around renderable. Defaults to 0. + highlight (bool, optional): Enable automatic highlighting of panel title (if str). Defaults to False. + """ + + def __init__( + self, + renderable: "RenderableType", + box: Box = ROUNDED, + *, + title: TextType = None, + title_align: AlignMethod = "center", + safe_box: Optional[bool] = None, + expand: bool = True, + style: StyleType = "none", + border_style: StyleType = "none", + width: Optional[int] = None, + padding: PaddingDimensions = (0, 1), + highlight: bool = False, + ) -> None: + self.renderable = renderable + self.box = box + self.title = title + self.title_align = title_align + self.safe_box = safe_box + self.expand = expand + self.style = style + self.border_style = border_style + self.width = width + self.padding = padding + self.highlight = highlight + + @classmethod + def fit( + cls, + renderable: "RenderableType", + box: Box = ROUNDED, + *, + title: TextType = None, + title_align: AlignMethod = "center", + safe_box: Optional[bool] = None, + style: StyleType = "none", + border_style: StyleType = "none", + width: Optional[int] = None, + padding: PaddingDimensions = (0, 1), + ): + """An alternative constructor that sets expand=False.""" + return cls( + renderable, + box, + title=title, + title_align=title_align, + safe_box=safe_box, + style=style, + border_style=border_style, + width=width, + padding=padding, + expand=False, + ) + + @property + def _title(self) -> Optional[Text]: + if self.title: + title_text = ( + Text.from_markup(self.title) + if isinstance(self.title, str) + else self.title.copy() + ) + title_text.end = "" + title_text.plain = title_text.plain.replace("\n", " ") + title_text.no_wrap = True + title_text.expand_tabs() + title_text.pad(1) + return title_text + return None + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + _padding = Padding.unpack(self.padding) + renderable = ( + Padding(self.renderable, _padding) if any(_padding) else self.renderable + ) + style = console.get_style(self.style) + border_style = style + console.get_style(self.border_style) + width = ( + options.max_width + if self.width is None + else min(options.max_width, self.width) + ) + + safe_box: bool = console.safe_box if self.safe_box is None else self.safe_box # type: ignore + box = self.box.substitute(options, safe=safe_box) + + title_text = self._title + if title_text is not None: + title_text.style = border_style + + child_width = ( + width - 2 + if self.expand + else Measurement.get(console, renderable, width - 2).maximum + ) + child_height = None if options.height is None else options.height - 2 + if title_text is not None: + child_width = min( + options.max_width - 2, max(child_width, title_text.cell_len + 2) + ) + + width = child_width + 2 + child_options = options.update( + width=child_width, height=child_height, highlight=self.highlight + ) + lines = console.render_lines(renderable, child_options, style=style) + + line_start = Segment(box.mid_left, border_style) + line_end = Segment(f"{box.mid_right}", border_style) + new_line = Segment.line() + if title_text is None or width <= 4: + yield Segment(box.get_top([width - 2]), border_style) + else: + title_text.align(self.title_align, width - 4, character=box.top) + yield Segment(box.top_left + box.top, border_style) + yield from console.render(title_text) + yield Segment(box.top + box.top_right, border_style) + + yield new_line + for line in lines: + yield line_start + yield from line + yield line_end + yield new_line + yield Segment(box.get_bottom([width - 2]), border_style) + yield new_line + + def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement": + _title = self._title + _, right, _, left = Padding.unpack(self.padding) + padding = left + right + renderables = [self.renderable, _title] if _title else [self.renderable] + + if self.width is None: + width = ( + measure_renderables( + console, renderables, max_width - padding - 2 + ).maximum + + padding + + 2 + ) + else: + width = self.width + return Measurement(width, width) + + +if __name__ == "__main__": # pragma: no cover + from .console import Console + + c = Console() + + from .padding import Padding + from .box import ROUNDED, DOUBLE + + p = Panel( + Panel.fit( + Text.from_markup("[bold magenta]Hello World!"), + box=ROUNDED, + safe_box=True, + style="on red", + ), + title="[b]Hello, World", + box=DOUBLE, + ) + + print(p) + c.print(p) diff --git a/rich/pretty.py b/rich/pretty.py new file mode 100644 index 0000000..8a05992 --- /dev/null +++ b/rich/pretty.py @@ -0,0 +1,585 @@ +import builtins +import os +import sys +from array import array +from collections import Counter, abc, defaultdict, deque +from dataclasses import dataclass +from itertools import islice +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Iterable, + List, + Optional, + Set, + Tuple, +) + +from rich.highlighter import ReprHighlighter + +from .abc import RichRenderable +from . import get_console +from ._pick import pick_bool +from .cells import cell_len +from .highlighter import ReprHighlighter +from .jupyter import JupyterRenderable +from .measure import Measurement +from .text import Text + +if TYPE_CHECKING: + from .console import ( + Console, + ConsoleOptions, + HighlighterType, + JustifyMethod, + OverflowMethod, + RenderResult, + ) + + +def install( + console: "Console" = None, + overflow: "OverflowMethod" = "ignore", + crop: bool = False, + indent_guides: bool = False, + max_length: int = None, + max_string: int = None, + expand_all: bool = False, +) -> None: + """Install automatic pretty printing in the Python REPL. + + Args: + console (Console, optional): Console instance or ``None`` to use global console. Defaults to None. + overflow (Optional[OverflowMethod], optional): Overflow method. Defaults to "ignore". + crop (Optional[bool], optional): Enable cropping of long lines. Defaults to False. + indent_guides (bool, optional): Enable indentation guides. Defaults to False. + max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. + Defaults to None. + max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to None. + expand_all (bool, optional): Expand all containers. Defaults to False + """ + from rich import get_console + from .console import ConsoleRenderable # needed here to prevent circular import + + console = console or get_console() + assert console is not None + + def display_hook(value: Any) -> None: + """Replacement sys.displayhook which prettifies objects with Rich.""" + if value is not None: + assert console is not None + builtins._ = None # type: ignore + console.print( + value + if isinstance(value, RichRenderable) + else Pretty( + value, + overflow=overflow, + indent_guides=indent_guides, + max_length=max_length, + max_string=max_string, + expand_all=expand_all, + ), + crop=crop, + ) + builtins._ = value # type: ignore + + def ipy_display_hook(value: Any) -> None: # pragma: no cover + assert console is not None + # always skip rich generated jupyter renderables or None values + if isinstance(value, JupyterRenderable) or value is None: + return + # on jupyter rich display, if using one of the special representations dont use rich + if console.is_jupyter and any(attr.startswith("_repr_") for attr in dir(value)): + return + + # certain renderables should start on a new line + if isinstance(value, ConsoleRenderable): + console.line() + + console.print( + value + if isinstance(value, RichRenderable) + else Pretty( + value, + overflow=overflow, + indent_guides=indent_guides, + max_length=max_length, + max_string=max_string, + expand_all=expand_all, + margin=12, + ), + crop=crop, + ) + + try: # pragma: no cover + ip = get_ipython() # type: ignore + from IPython.core.formatters import BaseFormatter + + # replace plain text formatter with rich formatter + rich_formatter = BaseFormatter() + rich_formatter.for_type(object, func=ipy_display_hook) + ip.display_formatter.formatters["text/plain"] = rich_formatter + except Exception: + sys.displayhook = display_hook + + +class Pretty: + """A rich renderable that pretty prints an object. + + Args: + _object (Any): An object to pretty print. + highlighter (HighlighterType, optional): Highlighter object to apply to result, or None for ReprHighlighter. Defaults to None. + indent_size (int, optional): Number of spaces in indent. Defaults to 4. + justify (JustifyMethod, optional): Justify method, or None for default. Defaults to None. + overflow (OverflowMethod, optional): Overflow method, or None for default. Defaults to None. + no_wrap (Optional[bool], optional): Disable word wrapping. Defaults to False. + indent_guides (bool, optional): Enable indentation guides. Defaults to False. + max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. + Defaults to None. + max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to None. + expand_all (bool, optional): Expand all containers. Defaults to False. + margin (int, optional): Subtrace a margin from width to force containers to expand earlier. Defaults to 0. + insert_line (bool, optional): Insert a new line if the output has multiple new lines. Defaults to False. + """ + + def __init__( + self, + _object: Any, + highlighter: "HighlighterType" = None, + *, + indent_size: int = 4, + justify: "JustifyMethod" = None, + overflow: Optional["OverflowMethod"] = None, + no_wrap: Optional[bool] = False, + indent_guides: bool = False, + max_length: int = None, + max_string: int = None, + expand_all: bool = False, + margin: int = 0, + insert_line: bool = False, + ) -> None: + self._object = _object + self.highlighter = highlighter or ReprHighlighter() + self.indent_size = indent_size + self.justify = justify + self.overflow = overflow + self.no_wrap = no_wrap + self.indent_guides = indent_guides + self.max_length = max_length + self.max_string = max_string + self.expand_all = expand_all + self.margin = margin + self.insert_line = insert_line + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + pretty_str = pretty_repr( + self._object, + max_width=options.max_width - self.margin, + indent_size=self.indent_size, + max_length=self.max_length, + max_string=self.max_string, + expand_all=self.expand_all, + ) + pretty_text = Text( + pretty_str, + justify=self.justify or options.justify, + overflow=self.overflow or options.overflow, + no_wrap=pick_bool(self.no_wrap, options.no_wrap), + style="pretty", + ) + pretty_text = self.highlighter(pretty_text) + if self.indent_guides and not options.ascii_only: + pretty_text = pretty_text.with_indent_guides( + self.indent_size, style="repr.indent" + ) + if self.insert_line and "\n" in pretty_text: + yield "" + yield pretty_text + + def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement": + pretty_str = pretty_repr( + self._object, + max_width=max_width, + indent_size=self.indent_size, + max_length=self.max_length, + max_string=self.max_string, + ) + text_width = max(cell_len(line) for line in pretty_str.splitlines()) + return Measurement(text_width, text_width) + + +def _get_braces_for_defaultdict(_object: defaultdict) -> Tuple[str, str, str]: + return ( + f"defaultdict({_object.default_factory!r}, {{", + "})", + f"defaultdict({_object.default_factory!r}, {{}})", + ) + + +def _get_braces_for_array(_object: array) -> Tuple[str, str, str]: + return (f"array({_object.typecode!r}, [", "])", "array({_object.typecode!r})") + + +_BRACES: Dict[type, Callable[[Any], Tuple[str, str, str]]] = { + os._Environ: lambda _object: ("environ({", "})", "environ({})"), + array: _get_braces_for_array, + defaultdict: _get_braces_for_defaultdict, + Counter: lambda _object: ("Counter({", "})", "Counter()"), + deque: lambda _object: ("deque([", "])", "deque()"), + dict: lambda _object: ("{", "}", "{}"), + frozenset: lambda _object: ("frozenset({", "})", "frozenset()"), + list: lambda _object: ("[", "]", "[]"), + set: lambda _object: ("{", "}", "set()"), + tuple: lambda _object: ("(", ")", "()"), +} +_CONTAINERS = tuple(_BRACES.keys()) +_MAPPING_CONTAINERS = (dict, os._Environ) + + +@dataclass +class Node: + """A node in a repr tree. May be atomic or a container.""" + + key_repr: str = "" + value_repr: str = "" + open_brace: str = "" + close_brace: str = "" + empty: str = "" + last: bool = False + is_tuple: bool = False + children: Optional[List["Node"]] = None + + @property + def separator(self) -> str: + """Get separator between items.""" + return "" if self.last else "," + + def iter_tokens(self) -> Iterable[str]: + """Generate tokens for this node.""" + if self.key_repr: + yield self.key_repr + yield ": " + if self.value_repr: + yield self.value_repr + elif self.children is not None: + if self.children: + yield self.open_brace + if self.is_tuple and len(self.children) == 1: + yield from self.children[0].iter_tokens() + yield "," + else: + for child in self.children: + yield from child.iter_tokens() + if not child.last: + yield ", " + yield self.close_brace + else: + yield self.empty + + def check_length(self, start_length: int, max_length: int) -> bool: + """Check the length fits within a limit. + + Args: + start_length (int): Starting length of the line (indent, prefix, suffix). + max_length (int): Maximum length. + + Returns: + bool: True if the node can be rendered within max length, otherwise False. + """ + total_length = start_length + for token in self.iter_tokens(): + total_length += cell_len(token) + if total_length > max_length: + return False + return True + + def __str__(self) -> str: + repr_text = "".join(self.iter_tokens()) + return repr_text + + def render( + self, max_width: int = 80, indent_size: int = 4, expand_all: bool = False + ) -> str: + """Render the node to a pretty repr. + + Args: + max_width (int, optional): Maximum width of the repr. Defaults to 80. + indent_size (int, optional): Size of indents. Defaults to 4. + expand_all (bool, optional): Expand all levels. Defaults to False. + + Returns: + str: A repr string of the original object. + """ + lines = [_Line(node=self, is_root=True)] + line_no = 0 + while line_no < len(lines): + line = lines[line_no] + if line.expandable and not line.expanded: + if expand_all or not line.check_length(max_width): + lines[line_no : line_no + 1] = line.expand(indent_size) + line_no += 1 + + repr_str = "\n".join(str(line) for line in lines) + return repr_str + + +@dataclass +class _Line: + """A line in repr output.""" + + is_root: bool = False + node: Optional[Node] = None + text: str = "" + suffix: str = "" + whitespace: str = "" + expanded: bool = False + + @property + def expandable(self) -> bool: + """Check if the line may be expanded.""" + return bool(self.node is not None and self.node.children) + + def check_length(self, max_length: int) -> bool: + """Check this line fits within a given number of cells.""" + start_length = ( + len(self.whitespace) + cell_len(self.text) + cell_len(self.suffix) + ) + assert self.node is not None + return self.node.check_length(start_length, max_length) + + def expand(self, indent_size: int) -> Iterable["_Line"]: + """Expand this line by adding children on their own line.""" + node = self.node + assert node is not None + whitespace = self.whitespace + assert node.children + if node.key_repr: + yield _Line( + text=f"{node.key_repr}: {node.open_brace}", whitespace=whitespace + ) + else: + yield _Line(text=node.open_brace, whitespace=whitespace) + child_whitespace = self.whitespace + " " * indent_size + tuple_of_one = node.is_tuple and len(node.children) == 1 + for child in node.children: + separator = "," if tuple_of_one else child.separator + line = _Line( + node=child, + whitespace=child_whitespace, + suffix=separator, + ) + yield line + + yield _Line( + text=node.close_brace, + whitespace=whitespace, + suffix="," if (tuple_of_one and not self.is_root) else node.separator, + ) + + def __str__(self) -> str: + return f"{self.whitespace}{self.text}{self.node or ''}{self.suffix}" + + +def traverse(_object: Any, max_length: int = None, max_string: int = None) -> Node: + """Traverse object and generate a tree. + + Args: + _object (Any): Object to be traversed. + max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. + Defaults to None. + max_string (int, optional): Maximum length of string before truncating, or None to disable truncating. + Defaults to None. + + Returns: + Node: The root of a tree structure which can be used to render a pretty repr. + """ + + def to_repr(obj: Any) -> str: + """Get repr string for an object, but catch errors.""" + if ( + max_string is not None + and isinstance(obj, (bytes, str)) + and len(obj) > max_string + ): + truncated = len(obj) - max_string + obj_repr = f"{obj[:max_string]!r}+{truncated}" + else: + try: + obj_repr = repr(obj) + except Exception as error: + obj_repr = f"<repr-error '{error}'>" + return obj_repr + + visited_ids: Set[int] = set() + push_visited = visited_ids.add + pop_visited = visited_ids.remove + + def _traverse(obj: Any, root: bool = False) -> Node: + """Walk the object depth first.""" + obj_type = type(obj) + if obj_type in _CONTAINERS: + obj_id = id(obj) + + if obj_id in visited_ids: + # Recursion detected + return Node(value_repr="...") + push_visited(obj_id) + open_brace, close_brace, empty = _BRACES[obj_type](obj) + + if obj: + children: List[Node] = [] + node = Node( + open_brace=open_brace, + close_brace=close_brace, + children=children, + last=root, + ) + append = children.append + num_items = len(obj) + last_item_index = num_items - 1 + + if isinstance(obj, _MAPPING_CONTAINERS): + iter_items = iter(obj.items()) + if max_length is not None: + iter_items = islice(iter_items, max_length) + for index, (key, child) in enumerate(iter_items): + child_node = _traverse(child) + child_node.key_repr = to_repr(key) + child_node.last = index == last_item_index + append(child_node) + else: + iter_values = iter(obj) + if max_length is not None: + iter_values = islice(iter_values, max_length) + for index, child in enumerate(iter_values): + child_node = _traverse(child) + child_node.last = index == last_item_index + append(child_node) + if max_length is not None and num_items > max_length: + append(Node(value_repr=f"... +{num_items-max_length}", last=True)) + else: + node = Node(empty=empty, children=[], last=root) + + pop_visited(obj_id) + else: + node = Node(value_repr=to_repr(obj), last=root) + node.is_tuple = isinstance(obj, tuple) + return node + + node = _traverse(_object, root=True) + return node + + +def pretty_repr( + _object: Any, + *, + max_width: int = 80, + indent_size: int = 4, + max_length: int = None, + max_string: int = None, + expand_all: bool = False, +) -> str: + """Prettify repr string by expanding on to new lines to fit within a given width. + + Args: + _object (Any): Object to repr. + max_width (int, optional): Desired maximum width of repr string. Defaults to 80. + indent_size (int, optional): Number of spaces to indent. Defaults to 4. + max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. + Defaults to None. + max_string (int, optional): Maximum length of string before truncating, or None to disable truncating. + Defaults to None. + expand_all (bool, optional): Expand all containers regardless of available width. Defaults to False. + + Returns: + str: A possibly multi-line representation of the object. + """ + + if isinstance(_object, Node): + node = _object + else: + node = traverse(_object, max_length=max_length, max_string=max_string) + repr_str = node.render( + max_width=max_width, indent_size=indent_size, expand_all=expand_all + ) + return repr_str + + +def pprint( + _object: Any, + *, + console: "Console" = None, + indent_guides: bool = True, + max_length: int = None, + max_string: int = None, + expand_all: bool = False, +): + """A convenience function for pretty printing. + + Args: + _object (Any): Object to pretty print. + console (Console, optional): Console instance, or None to use default. Defaults to None. + max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. + Defaults to None. + max_string (int, optional): Maximum length of strings before truncating, or None to disable. Defaults to None. + indent_guides (bool, optional): Enable indentation guides. Defaults to True. + expand_all (bool, optional): Expand all containers. Defaults to False. + """ + _console = get_console() if console is None else console + _console.print( + Pretty( + _object, + max_length=max_length, + max_string=max_string, + indent_guides=indent_guides, + expand_all=expand_all, + overflow="ignore", + ), + soft_wrap=True, + ) + + +if __name__ == "__main__": # pragma: no cover + + class BrokenRepr: + def __repr__(self): + 1 / 0 + + d = defaultdict(int) + d["foo"] = 5 + data = { + "foo": [ + 1, + "Hello World!", + 100.123, + 323.232, + 432324.0, + {5, 6, 7, (1, 2, 3, 4), 8}, + ], + "bar": frozenset({1, 2, 3}), + "defaultdict": defaultdict( + list, {"crumble": ["apple", "rhubarb", "butter", "sugar", "flour"]} + ), + "counter": Counter( + [ + "apple", + "orange", + "pear", + "kumquat", + "kumquat", + "durian" * 100, + ] + ), + "atomic": (False, True, None), + "Broken": BrokenRepr(), + } + data["foo"].append(data) # type: ignore + + from rich import print + + print(Pretty(data, indent_guides=True, max_string=20)) diff --git a/rich/progress.py b/rich/progress.py new file mode 100644 index 0000000..2fd1f3d --- /dev/null +++ b/rich/progress.py @@ -0,0 +1,1019 @@ +import sys +from abc import ABC, abstractmethod +from collections import deque +from collections.abc import Sized +from dataclasses import dataclass, field +from datetime import timedelta +from math import ceil +from threading import Event, RLock, Thread +from typing import ( + Any, + Callable, + Deque, + Dict, + Iterable, + List, + NamedTuple, + NewType, + Optional, + Sequence, + Tuple, + TypeVar, + Union, +) + +from . import filesize, get_console +from .console import ( + Console, + JustifyMethod, + RenderableType, + RenderGroup, +) +from .jupyter import JupyterMixin +from .highlighter import Highlighter +from .live import Live +from .progress_bar import ProgressBar +from .spinner import Spinner +from .style import StyleType +from .table import Column, Table +from .text import Text, TextType + +TaskID = NewType("TaskID", int) + +ProgressType = TypeVar("ProgressType") + +GetTimeCallable = Callable[[], float] + + +class _TrackThread(Thread): + """A thread to periodically update progress.""" + + def __init__(self, progress: "Progress", task_id: "TaskID", update_period: float): + self.progress = progress + self.task_id = task_id + self.update_period = update_period + self.done = Event() + + self.completed = 0 + super().__init__() + + def run(self) -> None: + task_id = self.task_id + advance = self.progress.advance + update_period = self.update_period + last_completed = 0 + wait = self.done.wait + while not wait(update_period): + completed = self.completed + 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": + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.done.set() + self.join() + + +def track( + sequence: Union[Sequence[ProgressType], Iterable[ProgressType]], + description="Working...", + total: int = None, + auto_refresh=True, + console: Optional[Console] = None, + transient: bool = False, + get_time: Callable[[], float] = None, + refresh_per_second: float = 10, + style: StyleType = "bar.back", + complete_style: StyleType = "bar.complete", + finished_style: StyleType = "bar.finished", + pulse_style: StyleType = "bar.pulse", + update_period: float = 0.1, + disable: bool = False, +) -> Iterable[ProgressType]: + """Track progress by iterating over a sequence. + + Args: + sequence (Iterable[ProgressType]): A sequence (must support "len") you wish to iterate over. + description (str, optional): Description of task show next to progress bar. Defaults to "Working". + total: (int, optional): Total number of steps. Default is len(sequence). + auto_refresh (bool, optional): Automatic refresh, disable to force a refresh after each iteration. Default is True. + transient: (bool, optional): Clear the progress on exit. Defaults to False. + console (Console, optional): Console to write to. Default creates internal Console instance. + refresh_per_second (float): Number of times per second to refresh the progress information. Defaults to 10. + style (StyleType, optional): Style for the bar background. Defaults to "bar.back". + complete_style (StyleType, optional): Style for the completed bar. Defaults to "bar.complete". + finished_style (StyleType, optional): Style for a finished bar. Defaults to "bar.done". + pulse_style (StyleType, optional): Style for pulsing bars. Defaults to "bar.pulse". + update_period (float, optional): Minimum time (in seconds) between calls to update(). Defaults to 0.1. + disable (bool, optional): Disable display of progress. + Returns: + Iterable[ProgressType]: An iterable of the values in the sequence. + + """ + + columns: List["ProgressColumn"] = ( + [TextColumn("[progress.description]{task.description}")] if description else [] + ) + columns.extend( + ( + BarColumn( + style=style, + complete_style=complete_style, + finished_style=finished_style, + pulse_style=pulse_style, + ), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TimeRemainingColumn(), + ) + ) + progress = Progress( + *columns, + auto_refresh=auto_refresh, + console=console, + transient=transient, + get_time=get_time, + refresh_per_second=refresh_per_second or 10, + disable=disable, + ) + + with progress: + yield from progress.track( + sequence, total=total, description=description, update_period=update_period + ) + + +class ProgressColumn(ABC): + """Base class for a widget to use in progress display.""" + + max_refresh: Optional[float] = None + + def __init__(self, table_column: Column = None) -> None: + self._table_column = table_column + self._renderable_cache: Dict[TaskID, Tuple[float, RenderableType]] = {} + self._update_time: Optional[float] = None + + def get_table_column(self) -> Column: + """Get a table column, used to build tasks table.""" + return self._table_column or Column() + + def __call__(self, task: "Task") -> RenderableType: + """Called by the Progress object to return a renderable for the given task. + + Args: + task (Task): An object containing information regarding the task. + + Returns: + RenderableType: Anything renderable (including str). + """ + current_time = task.get_time() # type: ignore + if self.max_refresh is not None and not task.completed: + try: + timestamp, renderable = self._renderable_cache[task.id] + except KeyError: + pass + else: + if timestamp + self.max_refresh > current_time: + return renderable + + renderable = self.render(task) + self._renderable_cache[task.id] = (current_time, renderable) + return renderable + + @abstractmethod + def render(self, task: "Task") -> RenderableType: + """Should return a renderable object.""" + + +class RenderableColumn(ProgressColumn): + """A column to insert an arbitrary column. + + Args: + renderable (RenderableType, optional): Any renderable. Defaults to empty string. + """ + + def __init__(self, renderable: RenderableType = "", *, table_column: Column = None): + self.renderable = renderable + super().__init__(table_column=table_column) + + def render(self, task: "Task") -> RenderableType: + return self.renderable + + +class SpinnerColumn(ProgressColumn): + """A column with a 'spinner' animation. + + Args: + spinner_name (str, optional): Name of spinner animation. Defaults to "dots". + style (StyleType, optional): Style of spinner. Defaults to "progress.spinner". + speed (float, optional): Speed factor of spinner. Defaults to 1.0. + finished_text (TextType, optional): Text used when task is finished. Defaults to " ". + """ + + def __init__( + self, + spinner_name: str = "dots", + style: Optional[StyleType] = "progress.spinner", + speed: float = 1.0, + finished_text: TextType = " ", + table_column: Column = None, + ): + self.spinner = Spinner(spinner_name, style=style, speed=speed) + self.finished_text = ( + Text.from_markup(finished_text) + if isinstance(finished_text, str) + else finished_text + ) + super().__init__(table_column=table_column) + + def set_spinner( + self, + spinner_name: str, + spinner_style: Optional[StyleType] = "progress.spinner", + speed: float = 1.0, + ): + """Set a new spinner. + + Args: + spinner_name (str): Spinner name, see python -m rich.spinner. + spinner_style (Optional[StyleType], optional): Spinner style. Defaults to "progress.spinner". + speed (float, optional): Speed factor of spinner. Defaults to 1.0. + """ + self.spinner = Spinner(spinner_name, style=spinner_style, speed=speed) + + def render(self, task: "Task") -> Text: + text = ( + self.finished_text + if task.finished + else self.spinner.render(task.get_time()) + ) + return text + + +class TextColumn(ProgressColumn): + """A column containing text.""" + + def __init__( + self, + text_format: str, + style: StyleType = "none", + justify: JustifyMethod = "left", + markup: bool = True, + highlighter: Highlighter = None, + table_column: Column = None, + ) -> None: + self.text_format = text_format + self.justify = justify + self.style = style + self.markup = markup + self.highlighter = highlighter + super().__init__(table_column=table_column or Column(no_wrap=True)) + + def render(self, task: "Task") -> Text: + _text = self.text_format.format(task=task) + if self.markup: + text = Text.from_markup(_text, style=self.style, justify=self.justify) + else: + text = Text(_text, style=self.style, justify=self.justify) + if self.highlighter: + self.highlighter.highlight(text) + return text + + +class BarColumn(ProgressColumn): + """Renders a visual progress bar. + + Args: + bar_width (Optional[int], optional): Width of bar or None for full width. Defaults to 40. + style (StyleType, optional): Style for the bar background. Defaults to "bar.back". + complete_style (StyleType, optional): Style for the completed bar. Defaults to "bar.complete". + finished_style (StyleType, optional): Style for a finished bar. Defaults to "bar.done". + pulse_style (StyleType, optional): Style for pulsing bars. Defaults to "bar.pulse". + """ + + def __init__( + self, + bar_width: Optional[int] = 40, + style: StyleType = "bar.back", + complete_style: StyleType = "bar.complete", + finished_style: StyleType = "bar.finished", + pulse_style: StyleType = "bar.pulse", + table_column: Column = None, + ) -> None: + self.bar_width = bar_width + self.style = style + self.complete_style = complete_style + self.finished_style = finished_style + self.pulse_style = pulse_style + super().__init__(table_column=table_column) + + def render(self, task: "Task") -> ProgressBar: + """Gets a progress bar widget for a task.""" + return ProgressBar( + total=max(0, task.total), + completed=max(0, task.completed), + width=None if self.bar_width is None else max(1, self.bar_width), + pulse=not task.started, + animation_time=task.get_time(), + style=self.style, + complete_style=self.complete_style, + finished_style=self.finished_style, + pulse_style=self.pulse_style, + ) + + +class TimeElapsedColumn(ProgressColumn): + """Renders time elapsed.""" + + def render(self, task: "Task") -> Text: + """Show time remaining.""" + elapsed = task.finished_time if task.finished else task.elapsed + if elapsed is None: + return Text("-:--:--", style="progress.elapsed") + delta = timedelta(seconds=int(elapsed)) + return Text(str(delta), style="progress.elapsed") + + +class TimeRemainingColumn(ProgressColumn): + """Renders estimated time remaining.""" + + # Only refresh twice a second to prevent jitter + max_refresh = 0.5 + + def render(self, task: "Task") -> Text: + """Show time remaining.""" + remaining = task.time_remaining + if remaining is None: + return Text("-:--:--", style="progress.remaining") + remaining_delta = timedelta(seconds=int(remaining)) + return Text(str(remaining_delta), style="progress.remaining") + + +class FileSizeColumn(ProgressColumn): + """Renders completed filesize.""" + + def render(self, task: "Task") -> Text: + """Show data completed.""" + data_size = filesize.decimal(int(task.completed)) + return Text(data_size, style="progress.filesize") + + +class TotalFileSizeColumn(ProgressColumn): + """Renders total filesize.""" + + def render(self, task: "Task") -> Text: + """Show data completed.""" + data_size = filesize.decimal(int(task.total)) + return Text(data_size, style="progress.filesize.total") + + +class DownloadColumn(ProgressColumn): + """Renders file size downloaded and total, e.g. '0.5/2.3 GB'. + + Args: + binary_units (bool, optional): Use binary units, KiB, MiB etc. Defaults to False. + """ + + def __init__(self, binary_units: bool = False, table_column: Column = None) -> None: + self.binary_units = binary_units + super().__init__(table_column=table_column) + + def render(self, task: "Task") -> Text: + """Calculate common unit for completed and total.""" + completed = int(task.completed) + total = int(task.total) + if self.binary_units: + unit, suffix = filesize.pick_unit_and_suffix( + total, + ["bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"], + 1024, + ) + else: + unit, suffix = filesize.pick_unit_and_suffix( + total, ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"], 1000 + ) + completed_ratio = completed / unit + total_ratio = total / unit + precision = 0 if unit == 1 else 1 + completed_str = f"{completed_ratio:,.{precision}f}" + total_str = f"{total_ratio:,.{precision}f}" + download_status = f"{completed_str}/{total_str} {suffix}" + download_text = Text(download_status, style="progress.download") + return download_text + + +class TransferSpeedColumn(ProgressColumn): + """Renders human readable transfer speed.""" + + def render(self, task: "Task") -> Text: + """Show data transfer speed.""" + speed = task.speed + if speed is None: + return Text("?", style="progress.data.speed") + data_speed = filesize.decimal(int(speed)) + return Text(f"{data_speed}/s", style="progress.data.speed") + + +class ProgressSample(NamedTuple): + """Sample of progress for a given time.""" + + timestamp: float + """Timestamp of sample.""" + completed: float + """Number of steps completed.""" + + +@dataclass +class Task: + """Information regarding a progress task. + + This object should be considered read-only outside of the :class:`~Progress` class. + + """ + + id: TaskID + """Task ID associated with this task (used in Progress methods).""" + + description: str + """str: Description of the task.""" + + total: float + """str: Total number of steps in this task.""" + + completed: float + """float: Number of steps completed""" + + _get_time: GetTimeCallable + """Callable to get the current time.""" + + finished_time: Optional[float] = None + """float: Time task was finished.""" + + visible: bool = True + """bool: Indicates if this task is visible in the progress display.""" + + fields: Dict[str, Any] = field(default_factory=dict) + """dict: Arbitrary fields passed in via Progress.update.""" + + start_time: Optional[float] = field(default=None, init=False, repr=False) + """Optional[float]: Time this task was started, or None if not started.""" + + stop_time: Optional[float] = field(default=None, init=False, repr=False) + """Optional[float]: Time this task was stopped, or None if not stopped.""" + + _progress: Deque[ProgressSample] = field( + default_factory=deque, init=False, repr=False + ) + + _lock: RLock = field(repr=False, default_factory=RLock) + """Thread lock.""" + + def get_time(self) -> float: + """float: Get the current time, in seconds.""" + return self._get_time() # type: ignore + + @property + def started(self) -> bool: + """bool: Check if the task as started.""" + return self.start_time is not None + + @property + def remaining(self) -> float: + """float: Get the number of steps remaining.""" + return self.total - self.completed + + @property + def elapsed(self) -> Optional[float]: + """Optional[float]: Time elapsed since task was started, or ``None`` if the task hasn't started.""" + if self.start_time is None: + return None + if self.stop_time is not None: + return self.stop_time - self.start_time + return self.get_time() - self.start_time + + @property + def finished(self) -> bool: + """Check if the task has finished.""" + return self.finished_time is not None + + @property + def percentage(self) -> float: + """float: Get progress of task as a percentage.""" + if not self.total: + return 0.0 + completed = (self.completed / self.total) * 100.0 + completed = min(100.0, max(0.0, completed)) + return completed + + @property + def speed(self) -> Optional[float]: + """Optional[float]: Get the estimated speed in steps per second.""" + if self.start_time is None: + return None + with self._lock: + progress = self._progress + if not progress: + return None + total_time = progress[-1].timestamp - progress[0].timestamp + if total_time == 0: + return None + iter_progress = iter(progress) + next(iter_progress) + total_completed = sum(sample.completed for sample in iter_progress) + speed = total_completed / total_time + return speed + + @property + def time_remaining(self) -> Optional[float]: + """Optional[float]: Get estimated time to completion, or ``None`` if no data.""" + if self.finished: + return 0.0 + speed = self.speed + if not speed: + return None + estimate = ceil(self.remaining / speed) + return estimate + + def _reset(self) -> None: + """Reset progress.""" + self._progress.clear() + self.finished_time = None + + +class Progress(JupyterMixin): + """Renders an auto-updating progress bar(s). + + Args: + console (Console, optional): Optional Console instance. Default will an internal Console instance writing to stdout. + auto_refresh (bool, optional): Enable auto refresh. If disabled, you will need to call `refresh()`. + refresh_per_second (Optional[float], optional): Number of times per second to refresh the progress information or None to use default (10). Defaults to None. + speed_estimate_period: (float, optional): Period (in seconds) used to calculate the speed estimate. Defaults to 30. + transient: (bool, optional): Clear the progress on exit. Defaults to False. + redirect_stdout: (bool, optional): Enable redirection of stdout, so ``print`` may be used. Defaults to True. + redirect_stderr: (bool, optional): Enable redirection of stderr. Defaults to True. + get_time: (Callable, optional): A callable that gets the current time, or None to use Console.get_time. Defaults to None. + disable (bool, optional): Disable progress display. Defaults to False + expand (bool, optional): Expand tasks table to fit width. Defaults to False. + """ + + def __init__( + self, + *columns: Union[str, ProgressColumn], + console: Console = None, + auto_refresh: bool = True, + refresh_per_second: float = 10, + speed_estimate_period: float = 30.0, + transient: bool = False, + redirect_stdout: bool = True, + redirect_stderr: bool = True, + get_time: GetTimeCallable = None, + disable: bool = False, + expand: bool = False, + ) -> None: + assert ( + refresh_per_second is None or refresh_per_second > 0 # type: ignore + ), "refresh_per_second must be > 0" + self._lock = RLock() + self.columns = columns or ( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TimeRemainingColumn(), + ) + self.speed_estimate_period = speed_estimate_period + + self.disable = disable + self.expand = expand + self._tasks: Dict[TaskID, Task] = {} + self._task_index: TaskID = TaskID(0) + self.live = Live( + console=console or get_console(), + auto_refresh=auto_refresh, + refresh_per_second=refresh_per_second, + transient=transient, + redirect_stdout=redirect_stdout, + redirect_stderr=redirect_stderr, + get_renderable=self.get_renderable, + ) + self.get_time = get_time or self.console.get_time + self.print = self.console.print + self.log = self.console.log + + @property + def console(self) -> Console: + return self.live.console + + @property + def tasks(self) -> List[Task]: + """Get a list of Task instances.""" + with self._lock: + return list(self._tasks.values()) + + @property + def task_ids(self) -> List[TaskID]: + """A list of task IDs.""" + with self._lock: + return list(self._tasks.keys()) + + @property + def finished(self) -> bool: + """Check if all tasks have been completed.""" + with self._lock: + if not self._tasks: + return True + return all(task.finished for task in self._tasks.values()) + + def start(self) -> None: + """Start the progress display.""" + self.live.start(refresh=True) + + def stop(self) -> None: + """Stop the progress display.""" + self.live.stop() + + def __enter__(self) -> "Progress": + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.stop() + + def track( + self, + sequence: Union[Iterable[ProgressType], Sequence[ProgressType]], + total: int = None, + task_id: Optional[TaskID] = None, + description="Working...", + update_period: float = 0.1, + ) -> Iterable[ProgressType]: + """Track progress by iterating over a sequence. + + Args: + sequence (Sequence[ProgressType]): A sequence of values you want to iterate over and track progress. + total: (int, optional): Total number of steps. Default is len(sequence). + task_id: (TaskID): Task to track. Default is new task. + description: (str, optional): Description of task, if new task is created. + update_period (float, optional): Minimum time (in seconds) between calls to update(). Defaults to 0.1. + + Returns: + Iterable[ProgressType]: An iterable of values taken from the provided sequence. + """ + + if total is None: + if isinstance(sequence, Sized): + task_total = len(sequence) + else: + raise ValueError( + f"unable to get size of {sequence!r}, please specify 'total'" + ) + else: + task_total = total + + if task_id is None: + task_id = self.add_task(description, total=task_total) + else: + self.update(task_id, total=task_total) + + if self.live.auto_refresh: + with _TrackThread(self, task_id, update_period) as track_thread: + for value in sequence: + yield value + track_thread.completed += 1 + else: + advance = self.advance + refresh = self.refresh + for value in sequence: + yield value + advance(task_id, 1) + refresh() + + def start_task(self, task_id: TaskID) -> None: + """Start a task. + + Starts a task (used when calculating elapsed time). You may need to call this manually, + if you called ``add_task`` with ``start=False``. + + Args: + task_id (TaskID): ID of task. + """ + with self._lock: + task = self._tasks[task_id] + if task.start_time is None: + task.start_time = self.get_time() + + def stop_task(self, task_id: TaskID) -> None: + """Stop a task. + + This will freeze the elapsed time on the task. + + Args: + task_id (TaskID): ID of task. + """ + with self._lock: + task = self._tasks[task_id] + current_time = self.get_time() + if task.start_time is None: + task.start_time = current_time + task.stop_time = current_time + + def update( + self, + task_id: TaskID, + *, + total: float = None, + completed: float = None, + advance: float = None, + description: str = None, + visible: bool = None, + refresh: bool = False, + **fields: Any, + ) -> None: + """Update information associated with a task. + + Args: + task_id (TaskID): Task id (returned by add_task). + total (float, optional): Updates task.total if not None. + completed (float, optional): Updates task.completed if not None. + advance (float, optional): Add a value to task.completed if not None. + description (str, optional): Change task description if not None. + visible (bool, optional): Set visible flag if not None. + refresh (bool): Force a refresh of progress information. Default is False. + **fields (Any): Additional data fields required for rendering. + """ + with self._lock: + task = self._tasks[task_id] + completed_start = task.completed + + if total is not None: + task.total = total + task._reset() + if advance is not None: + task.completed += advance + if completed is not None: + task.completed = completed + if description is not None: + task.description = description + if visible is not None: + task.visible = visible + task.fields.update(fields) + update_completed = task.completed - completed_start + + if refresh: + self.refresh() + + current_time = self.get_time() + old_sample_time = current_time - self.speed_estimate_period + _progress = task._progress + + popleft = _progress.popleft + while _progress and _progress[0].timestamp < old_sample_time: + popleft() + while len(_progress) > 1000: + popleft() + if update_completed > 0: + _progress.append(ProgressSample(current_time, update_completed)) + if task.completed >= task.total and task.finished_time is None: + task.finished_time = task.elapsed + + def reset( + self, + task_id: TaskID, + *, + start: bool = True, + total: Optional[int] = None, + completed: int = 0, + visible: Optional[bool] = None, + description: Optional[str] = None, + **fields: Any, + ) -> None: + """Reset a task so completed is 0 and the clock is reset. + + Args: + task_id (TaskID): ID of task. + start (bool, optional): Start the task after reset. Defaults to True. + total (int, optional): New total steps in task, or None to use current total. Defaults to None. + completed (int, optional): Number of steps completed. Defaults to 0. + **fields (str): Additional data fields required for rendering. + """ + current_time = self.get_time() + with self._lock: + task = self._tasks[task_id] + task._reset() + task.start_time = current_time if start else None + if total is not None: + task.total = total + task.completed = completed + if visible is not None: + task.visible = visible + if fields: + task.fields = fields + if description is not None: + task.description = description + task.finished_time = None + self.refresh() + + def advance(self, task_id: TaskID, advance: float = 1) -> None: + """Advance task by a number of steps. + + Args: + task_id (TaskID): ID of task. + advance (float): Number of steps to advance. Default is 1. + """ + current_time = self.get_time() + with self._lock: + task = self._tasks[task_id] + completed_start = task.completed + task.completed += advance + update_completed = task.completed - completed_start + old_sample_time = current_time - self.speed_estimate_period + _progress = task._progress + + popleft = _progress.popleft + while _progress and _progress[0].timestamp < old_sample_time: + popleft() + while len(_progress) > 1000: + popleft() + _progress.append(ProgressSample(current_time, update_completed)) + if task.completed >= task.total and task.finished_time is None: + task.finished_time = task.elapsed + + def refresh(self) -> None: + """Refresh (render) the progress information.""" + if not self.disable: + self.live.refresh() + + def get_renderable(self) -> RenderableType: + """Get a renderable for the progress display.""" + renderable = RenderGroup(*self.get_renderables()) + return renderable + + def get_renderables(self) -> Iterable[RenderableType]: + """Get a number of renderables for the progress display.""" + table = self.make_tasks_table(self.tasks) + yield table + + def make_tasks_table(self, tasks: Iterable[Task]) -> Table: + """Get a table to render the Progress display. + + Args: + tasks (Iterable[Task]): An iterable of Task instances, one per row of the table. + + Returns: + Table: A table instance. + """ + + table_columns = ( + ( + Column(no_wrap=True) + if isinstance(_column, str) + else _column.get_table_column().copy() + ) + for _column in self.columns + ) + table = Table.grid(*table_columns, padding=(0, 1), expand=self.expand) + + for task in tasks: + if task.visible: + table.add_row( + *( + ( + column.format(task=task) + if isinstance(column, str) + else column(task) + ) + for column in self.columns + ) + ) + return table + + def __rich__(self) -> RenderableType: + """Makes the Progress class itself renderable.""" + return self.get_renderable() + + def add_task( + self, + description: str, + start: bool = True, + total: int = 100, + completed: int = 0, + visible: bool = True, + **fields: Any, + ) -> TaskID: + """Add a new 'task' to the Progress display. + + Args: + description (str): A description of the task. + start (bool, optional): Start the task immediately (to calculate elapsed time). If set to False, + you will need to call `start` manually. Defaults to True. + total (int, optional): Number of total steps in the progress if know. Defaults to 100. + completed (int, optional): Number of steps completed so far.. Defaults to 0. + visible (bool, optional): Enable display of the task. Defaults to True. + **fields (str): Additional data fields required for rendering. + + Returns: + TaskID: An ID you can use when calling `update`. + """ + with self._lock: + task = Task( + self._task_index, + description, + total, + completed, + visible=visible, + fields=fields, + _get_time=self.get_time, + _lock=self._lock, + ) + self._tasks[self._task_index] = task + if start: + self.start_task(self._task_index) + self.refresh() + try: + return self._task_index + finally: + self._task_index = TaskID(int(self._task_index) + 1) + + def remove_task(self, task_id: TaskID) -> None: + """Delete a task if it exists. + + Args: + task_id (TaskID): A task ID. + + """ + with self._lock: + del self._tasks[task_id] + + +if __name__ == "__main__": # pragma: no coverage + + import random + import time + + from .panel import Panel + from .rule import Rule + from .syntax import Syntax + from .table import Table + + syntax = Syntax( + '''def loop_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: + """Iterate and generate a tuple with a flag for last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + for value in iter_values: + yield False, previous_value + previous_value = value + yield True, previous_value''', + "python", + line_numbers=True, + ) + + table = Table("foo", "bar", "baz") + table.add_row("1", "2", "3") + + progress_renderables = [ + "Text may be printed while the progress bars are rendering.", + Panel("In fact, [i]any[/i] renderable will work"), + "Such as [magenta]tables[/]...", + table, + "Pretty printed structures...", + {"type": "example", "text": "Pretty printed"}, + "Syntax...", + syntax, + Rule("Give it a try!"), + ] + + from itertools import cycle + + examples = cycle(progress_renderables) + + console = Console(record=True) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TimeRemainingColumn(), + TimeElapsedColumn(), + console=console, + transient=True, + ) as progress: + + task1 = progress.add_task("[red]Downloading", total=1000) + task2 = progress.add_task("[green]Processing", total=1000) + task3 = progress.add_task("[yellow]Thinking", total=1000, start=False) + + while not progress.finished: + progress.update(task1, advance=0.5) + progress.update(task2, advance=0.3) + time.sleep(0.01) + if random.randint(0, 100) < 1: + progress.log(next(examples)) diff --git a/rich/progress_bar.py b/rich/progress_bar.py new file mode 100644 index 0000000..63b0168 --- /dev/null +++ b/rich/progress_bar.py @@ -0,0 +1,214 @@ +import math +from functools import lru_cache +from time import monotonic +from typing import Iterable, List, Optional + +from .color import Color, blend_rgb +from .color_triplet import ColorTriplet +from .console import Console, ConsoleOptions, RenderResult +from .jupyter import JupyterMixin +from .measure import Measurement +from .segment import Segment +from .style import Style, StyleType + +# Number of characters before 'pulse' animation repeats +PULSE_SIZE = 20 + + +class ProgressBar(JupyterMixin): + """Renders a (progress) bar. Used by rich.progress. + + Args: + total (float, optional): Number of steps in the bar. Defaults to 100. + completed (float, optional): Number of steps completed. Defaults to 0. + width (int, optional): Width of the bar, or ``None`` for maximum width. Defaults to None. + pulse (bool, optional): Enable pulse effect. Defaults to False. + style (StyleType, optional): Style for the bar background. Defaults to "bar.back". + complete_style (StyleType, optional): Style for the completed bar. Defaults to "bar.complete". + finished_style (StyleType, optional): Style for a finished bar. Defaults to "bar.done". + pulse_style (StyleType, optional): Style for pulsing bars. Defaults to "bar.pulse". + animation_time (Optional[float], optional): Time in seconds to use for animation, or None to use system time. + """ + + def __init__( + self, + total: float = 100, + completed: float = 0, + width: int = None, + pulse: bool = False, + style: StyleType = "bar.back", + complete_style: StyleType = "bar.complete", + finished_style: StyleType = "bar.finished", + pulse_style: StyleType = "bar.pulse", + animation_time: float = None, + ): + self.total = total + self.completed = completed + self.width = width + self.pulse = pulse + self.style = style + self.complete_style = complete_style + self.finished_style = finished_style + self.pulse_style = pulse_style + self.animation_time = animation_time + + self._pulse_segments: Optional[List[Segment]] = None + + def __repr__(self) -> str: + return f"<Bar {self.completed!r} of {self.total!r}>" + + @property + def percentage_completed(self) -> float: + """Calculate percentage complete.""" + completed = (self.completed / self.total) * 100.0 + completed = min(100, max(0.0, completed)) + return completed + + @lru_cache(maxsize=16) + def _get_pulse_segments( + self, + fore_style: Style, + back_style: Style, + color_system: str, + no_color: 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 ascii else "━" + segments: List[Segment] = [] + if color_system not in ("standard", "eight_bit", "truecolor") or no_color: + segments += [Segment(bar, fore_style)] * (PULSE_SIZE // 2) + segments += [Segment(" " if no_color else bar, back_style)] * ( + PULSE_SIZE - (PULSE_SIZE // 2) + ) + return segments + + append = segments.append + fore_color = ( + fore_style.color.get_truecolor() + if fore_style.color + else ColorTriplet(255, 0, 255) + ) + back_color = ( + back_style.color.get_truecolor() + if back_style.color + else ColorTriplet(0, 0, 0) + ) + cos = math.cos + pi = math.pi + _Segment = Segment + _Style = Style + from_triplet = Color.from_triplet + + for index in range(PULSE_SIZE): + position = index / PULSE_SIZE + fade = 0.5 + cos((position * pi * 2)) / 2.0 + color = blend_rgb(fore_color, back_color, cross_fade=fade) + append(_Segment(bar, _Style(color=from_triplet(color)))) + return segments + + def update(self, completed: float, total: float = None) -> None: + """Update progress with new values. + + Args: + completed (float): Number of steps completed. + total (float, optional): Total number of steps, or ``None`` to not change. Defaults to None. + """ + self.completed = completed + self.total = total if total is not None else self.total + + def _render_pulse( + self, console: Console, width: int, ascii: bool = False + ) -> Iterable[Segment]: + """Renders the pulse animation. + + Args: + console (Console): Console instance. + width (int): Width in characters of pulse animation. + + Returns: + RenderResult: [description] + + Yields: + Iterator[Segment]: Segments to render pulse + """ + fore_style = console.get_style(self.pulse_style, default="white") + back_style = console.get_style(self.style, default="black") + + pulse_segments = self._get_pulse_segments( + fore_style, back_style, console.color_system, console.no_color, ascii=ascii + ) + segment_count = len(pulse_segments) + current_time = ( + monotonic() if self.animation_time is None else self.animation_time + ) + segments = pulse_segments * (int(width / segment_count) + 2) + offset = int(-current_time * 15) % segment_count + segments = segments[offset : offset + width] + yield from segments + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> 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, ascii=ascii) + return + + completed = min(self.total, max(0, self.completed)) + + 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 + ) + bar_count = complete_halves // 2 + half_bar_count = complete_halves % 2 + style = console.get_style(self.style) + complete_style = console.get_style( + self.complete_style if self.completed < self.total else self.finished_style + ) + _Segment = Segment + if bar_count: + yield _Segment(bar * bar_count, complete_style) + if half_bar_count: + yield _Segment(half_bar_right * half_bar_count, complete_style) + + if not console.no_color: + remaining_bars = width - bar_count - half_bar_count + if remaining_bars and console.color_system is not None: + if not half_bar_count and bar_count: + yield _Segment(half_bar_left, style) + remaining_bars -= 1 + if remaining_bars: + yield _Segment(bar * remaining_bars, style) + + def __rich_measure__(self, console: Console, max_width: int) -> Measurement: + return ( + Measurement(self.width, self.width) + if self.width is not None + else Measurement(4, max_width) + ) + + +if __name__ == "__main__": # pragma: no cover + console = Console() + bar = ProgressBar(width=50, total=100) + + import time + + console.show_cursor(False) + for n in range(0, 101, 1): + bar.update(n) + console.print(bar) + console.file.write("\r") + time.sleep(0.05) + console.show_cursor(True) + console.print() diff --git a/rich/prompt.py b/rich/prompt.py new file mode 100644 index 0000000..7db5d2d --- /dev/null +++ b/rich/prompt.py @@ -0,0 +1,378 @@ +from typing import Any, Generic, List, Optional, TextIO, TypeVar, Union, overload + +from . import get_console +from .console import Console +from .text import Text, TextType + +PromptType = TypeVar("PromptType") +DefaultType = TypeVar("DefaultType") + + +class PromptError(Exception): + """Exception base class for prompt related errors.""" + + +class InvalidResponse(PromptError): + """Exception to indicate a response was invalid. Raise this within process_response() to indicate an error + and provide an error message. + + Args: + message (Union[str, Text]): Error message. + """ + + def __init__(self, message: TextType) -> None: + self.message = message + + def __rich__(self) -> TextType: + return self.message + + +class PromptBase(Generic[PromptType]): + """Ask the user for input until a valid response is received. This is the base class, see one of + the concrete classes for examples. + + Args: + prompt (TextType, optional): Prompt text. Defaults to "". + console (Console, optional): A Console instance or None to use global console. Defaults to None. + password (bool, optional): Enable password input. Defaults to False. + choices (List[str], optional): A list of valid choices. Defaults to None. + show_default (bool, optional): Show default in prompt. Defaults to True. + show_choices (bool, optional): Show choices in prompt. Defaults to True. + """ + + response_type: type = str + + validate_error_message = "[prompt.invalid]Please enter a valid value" + illegal_choice_message = ( + "[prompt.invalid.choice]Please select one of the available options" + ) + prompt_suffix = ": " + + choices: Optional[List[str]] = None + + def __init__( + self, + prompt: TextType = "", + *, + console: Console = None, + password: bool = False, + choices: List[str] = None, + show_default: bool = True, + show_choices: bool = True, + ) -> None: + self.console = console or get_console() + self.prompt = ( + Text.from_markup(prompt, style="prompt") + if isinstance(prompt, str) + else prompt + ) + self.password = password + if choices is not None: + self.choices = choices + self.show_default = show_default + self.show_choices = show_choices + + @classmethod + @overload + def ask( + cls, + prompt: TextType = "", + *, + console: Console = None, + password: bool = False, + choices: List[str] = None, + show_default: bool = True, + show_choices: bool = True, + default: DefaultType, + stream: TextIO = None, + ) -> Union[DefaultType, PromptType]: + ... + + @classmethod + @overload + def ask( + cls, + prompt: TextType = "", + *, + console: Console = None, + password: bool = False, + choices: List[str] = None, + show_default: bool = True, + show_choices: bool = True, + stream: TextIO = None, + ) -> PromptType: + ... + + @classmethod + def ask( + cls, + prompt: TextType = "", + *, + console: Console = None, + password: bool = False, + choices: List[str] = None, + show_default: bool = True, + show_choices: bool = True, + default: Any = ..., + stream: TextIO = None, + ) -> Any: + """Shortcut to construct and run a prompt loop and return the result. + + Example: + >>> filename = Prompt.ask("Enter a filename") + + Args: + prompt (TextType, optional): Prompt text. Defaults to "". + console (Console, optional): A Console instance or None to use global console. Defaults to None. + password (bool, optional): Enable password input. Defaults to False. + choices (List[str], optional): A list of valid choices. Defaults to None. + show_default (bool, optional): Show default in prompt. Defaults to True. + show_choices (bool, optional): Show choices in prompt. Defaults to True. + stream (TextIO, optional): Optional text file open for reading to get input. Defaults to None. + """ + _prompt = cls( + prompt, + console=console, + password=password, + choices=choices, + show_default=show_default, + show_choices=show_choices, + ) + return _prompt(default=default, stream=stream) + + def render_default(self, default: DefaultType) -> Text: + """Turn the supplied default in to a Text instance. + + Args: + default (DefaultType): Default value. + + Returns: + Text: Text containing rendering of default value. + """ + return Text(f"({default})", "prompt.default") + + def make_prompt(self, default: DefaultType) -> Text: + """Make prompt text. + + Args: + default (DefaultType): Default value. + + Returns: + Text: Text to display in prompt. + """ + prompt = self.prompt.copy() + prompt.end = "" + + if self.show_choices and self.choices: + _choices = "/".join(self.choices) + choices = f"[{_choices}]" + prompt.append(" ") + prompt.append(choices, "prompt.choices") + + if ( + default != ... + and self.show_default + and isinstance(default, (str, self.response_type)) + ): + prompt.append(" ") + _default = self.render_default(default) + prompt.append(_default) + + prompt.append(self.prompt_suffix) + + return prompt + + @classmethod + def get_input( + cls, + console: Console, + prompt: TextType, + password: bool, + stream: TextIO = None, + ) -> str: + """Get input from user. + + Args: + console (Console): Console instance. + prompt (TextType): Prompt text. + password (bool): Enable password entry. + + Returns: + str: String from user. + """ + return console.input(prompt, password=password, stream=stream) + + def check_choice(self, value: str) -> bool: + """Check value is in the list of valid choices. + + Args: + value (str): Value entered by user. + + Returns: + bool: True if choice was valid, otherwise False. + """ + assert self.choices is not None + return value.strip() in self.choices + + def process_response(self, value: str) -> PromptType: + """Process response from user, convert to prompt type. + + Args: + value (str): String typed by user. + + Raises: + InvalidResponse: If ``value`` is invalid. + + Returns: + PromptType: The value to be returned from ask method. + """ + value = value.strip() + try: + return_value = self.response_type(value) + except ValueError: + raise InvalidResponse(self.validate_error_message) + + if self.choices is not None and not self.check_choice(value): + raise InvalidResponse(self.illegal_choice_message) + + return return_value + + def on_validate_error(self, value: str, error: InvalidResponse) -> None: + """Called to handle validation error. + + Args: + value (str): String entered by user. + error (InvalidResponse): Exception instance the initiated the error. + """ + self.console.print(error) + + def pre_prompt(self) -> None: + """Hook to display something before the prompt.""" + + @overload + def __call__(self, *, stream: TextIO = None) -> PromptType: + ... + + @overload + def __call__( + self, *, default: DefaultType, stream: TextIO = None + ) -> Union[PromptType, DefaultType]: + ... + + def __call__(self, *, default: Any = ..., stream: TextIO = None) -> Any: + """Run the prompt loop. + + Args: + default (Any, optional): Optional default value. + + Returns: + PromptType: Processed value. + """ + while True: + self.pre_prompt() + prompt = self.make_prompt(default) + value = self.get_input(self.console, prompt, self.password, stream=stream) + if value == "" and default != ...: + return default + try: + return_value = self.process_response(value) + except InvalidResponse as error: + self.on_validate_error(value, error) + continue + else: + return return_value + + +class Prompt(PromptBase[str]): + """A prompt that returns a str. + + Example: + >>> name = Prompt.ask("Enter your name") + + + """ + + response_type = str + + +class IntPrompt(PromptBase[int]): + """A prompt that returns an integer. + + Example: + >>> burrito_count = IntPrompt.ask("How many burritos do you want to order") + + """ + + response_type = int + validate_error_message = "[prompt.invalid]Please enter a valid integer number" + + +class FloatPrompt(PromptBase[int]): + """A prompt that returns a float. + + Example: + >>> temperature = FloatPrompt.ask("Enter desired temperature") + + """ + + response_type = float + validate_error_message = "[prompt.invalid]Please enter a number" + + +class Confirm(PromptBase[bool]): + """A yes / no confirmation prompt. + + Example: + >>> if Confirm.ask("Continue"): + run_job() + + """ + + response_type = bool + validate_error_message = "[prompt.invalid]Please enter Y or N" + choices = ["y", "n"] + + def render_default(self, default: DefaultType) -> Text: + """Render the default as (y) or (n) rather than True/False.""" + assert self.choices is not None + yes, no = self.choices + return Text(f"({yes})" if default else f"({no})", style="prompt.default") + + def process_response(self, value: str) -> bool: + """Convert choices to a bool.""" + value = value.strip().lower() + if value not in self.choices: + raise InvalidResponse(self.validate_error_message) + assert self.choices is not None + return value == self.choices[0] + + +if __name__ == "__main__": # pragma: no cover + + from rich import print + + if Confirm.ask("Run [i]prompt[/i] tests?", default=True): + while True: + result = IntPrompt.ask( + ":rocket: Enter a number between [b]1[/b] and [b]10[/b]", default=5 + ) + if result >= 1 and result <= 10: + break + print(":pile_of_poo: [prompt.invalid]Number must be between 1 and 10") + print(f"number={result}") + + while True: + password = Prompt.ask( + "Please enter a password [cyan](must be at least 5 characters)", + password=True, + ) + if len(password) >= 5: + break + print("[prompt.invalid]password too short") + print(f"password={password!r}") + + fruit = Prompt.ask("Enter a fruit", choices=["apple", "orange", "pear"]) + print(f"fruit={fruit!r}") + + else: + print("[b]OK :loudly_crying_face:") diff --git a/rich/protocol.py b/rich/protocol.py new file mode 100644 index 0000000..6468e53 --- /dev/null +++ b/rich/protocol.py @@ -0,0 +1,8 @@ +from typing import Any + +from .abc import RichRenderable + + +def is_renderable(check_object: Any) -> bool: + """Check if an object may be rendered by Rich.""" + return isinstance(check_object, str) or isinstance(check_object, RichRenderable) diff --git a/rich/py.typed b/rich/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/rich/py.typed diff --git a/rich/rule.py b/rich/rule.py new file mode 100644 index 0000000..d14394f --- /dev/null +++ b/rich/rule.py @@ -0,0 +1,115 @@ +from typing import Union + +from .align import AlignMethod +from .cells import cell_len, set_cell_size +from .console import Console, ConsoleOptions, RenderResult +from .jupyter import JupyterMixin +from .style import Style +from .text import Text + + +class Rule(JupyterMixin): + """A console renderable to draw a horizontal rule (line). + + Args: + title (Union[str, Text], optional): Text to render in the rule. Defaults to "". + characters (str, optional): Character(s) used to draw the line. Defaults to "─". + style (StyleType, optional): Style of Rule. Defaults to "rule.line". + end (str, optional): Character at end of Rule. defaults to "\\\\n" + align (str, optional): How to align the title, one of "left", "center", or "right". Defaults to "center". + """ + + def __init__( + self, + title: Union[str, Text] = "", + *, + characters: str = "─", + style: Union[str, Style] = "rule.line", + end: str = "\n", + align: AlignMethod = "center", + ) -> None: + if cell_len(characters) < 1: + raise ValueError( + "'characters' argument must have a cell width of at least 1" + ) + if align not in ("left", "center", "right"): + raise ValueError( + f'invalid value for align, expected "left", "center", "right" (not {align!r})' + ) + self.title = title + self.characters = characters + self.style = style + self.end = end + self.align = align + + def __repr__(self) -> str: + return f"Rule({self.title!r}, {self.characters!r})" + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + width = options.max_width + + # Python3.6 doesn't have an isascii method on str + isascii = getattr(str, "isascii", None) or ( + lambda s: all(ord(c) < 128 for c in s) + ) + characters = ( + "-" + if (options.ascii_only and not isascii(self.characters)) + else self.characters + ) + + chars_len = cell_len(characters) + if not self.title: + rule_text = Text(characters * ((width // chars_len) + 1), self.style) + rule_text.truncate(width) + rule_text.plain = set_cell_size(rule_text.plain, width) + yield rule_text + return + + if isinstance(self.title, Text): + title_text = self.title + else: + title_text = console.render_str(self.title, style="rule.text") + + title_text.plain = title_text.plain.replace("\n", " ") + title_text.expand_tabs() + rule_text = Text(end=self.end) + + if self.align == "center": + title_text.truncate(width - 4, overflow="ellipsis") + side_width = (width - cell_len(title_text.plain)) // 2 + left = Text(characters * (side_width // chars_len + 1)) + left.truncate(side_width - 1) + right_length = width - cell_len(left.plain) - cell_len(title_text.plain) + right = Text(characters * (side_width // chars_len + 1)) + right.truncate(right_length) + rule_text.append(left.plain + " ", self.style) + rule_text.append(title_text) + rule_text.append(" " + right.plain, self.style) + elif self.align == "left": + title_text.truncate(width - 2, overflow="ellipsis") + rule_text.append(title_text) + rule_text.append(" ") + rule_text.append(characters * (width - rule_text.cell_len), self.style) + elif self.align == "right": + title_text.truncate(width - 2, overflow="ellipsis") + rule_text.append(characters * (width - title_text.cell_len - 1), self.style) + rule_text.append(" ") + rule_text.append(title_text) + + rule_text.plain = set_cell_size(rule_text.plain, width) + yield rule_text + + +if __name__ == "__main__": # pragma: no cover + from rich.console import Console + import sys + + try: + text = sys.argv[1] + except IndexError: + text = "Hello, World" + console = Console() + console.print(Rule(title=text)) diff --git a/rich/scope.py b/rich/scope.py new file mode 100644 index 0000000..4ab9525 --- /dev/null +++ b/rich/scope.py @@ -0,0 +1,86 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Tuple + +from .highlighter import ReprHighlighter +from .panel import Panel +from .pretty import Pretty +from .table import Table +from .text import Text, TextType + +if TYPE_CHECKING: + from .console import ConsoleRenderable + + +def render_scope( + scope: Mapping, + *, + title: TextType = None, + sort_keys: bool = True, + indent_guides: bool = False, + max_length: int = None, + max_string: int = None, +) -> "ConsoleRenderable": + """Render python variables in a given scope. + + Args: + scope (Mapping): A mapping containing variable names and values. + title (str, optional): Optional title. Defaults to None. + sort_keys (bool, optional): Enable sorting of items. Defaults to True. + indent_guides (bool, optional): Enable indentaton guides. Defaults to False. + max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. + Defaults to None. + max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to None. + + Returns: + ConsoleRenderable: A renderable object. + """ + highlighter = ReprHighlighter() + items_table = Table.grid(padding=(0, 1), expand=False) + items_table.add_column(justify="right") + + def sort_items(item: Tuple[str, Any]) -> Tuple[bool, str]: + """Sort special variables first, then alphabetically.""" + key, _ = item + return (not key.startswith("__"), key.lower()) + + items = sorted(scope.items(), key=sort_items) if sort_keys else scope.items() + for key, value in items: + key_text = Text.assemble( + (key, "scope.key.special" if key.startswith("__") else "scope.key"), + (" =", "scope.equals"), + ) + items_table.add_row( + key_text, + Pretty( + value, + highlighter=highlighter, + indent_guides=indent_guides, + max_length=max_length, + max_string=max_string, + ), + ) + return Panel.fit( + items_table, + title=title, + border_style="scope.border", + padding=(0, 1), + ) + + +if __name__ == "__main__": # pragma: no cover + from rich import print + + print() + + def test(foo, bar): + list_of_things = [1, 2, 3, None, 4, True, False, "Hello World"] + dict_of_things = { + "version": "1.1", + "method": "confirmFruitPurchase", + "params": [["apple", "orange", "mangoes", "pomelo"], 1.123], + "id": "194521489", + } + print(render_scope(locals(), title="[i]locals", sort_keys=False)) + + test(20.3423, 3.1427) + print() diff --git a/rich/screen.py b/rich/screen.py new file mode 100644 index 0000000..3ca5355 --- /dev/null +++ b/rich/screen.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING + +from .segment import Segment +from .style import StyleType +from ._loop import loop_last + + +if TYPE_CHECKING: + from .console import Console, ConsoleOptions, RenderResult, RenderableType + + +class Screen: + """A renderable that fills the terminal screen and crops excess. + + Args: + renderable (RenderableType): Child renderable. + style (StyleType, optional): Optional background style. Defaults to None. + """ + + def __init__( + self, renderable: "RenderableType" = None, style: StyleType = None + ) -> None: + self.renderable = renderable + self.style = style + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + width, height = options.size + style = console.get_style(self.style) if self.style else None + render_options = options.update(width=width, height=height) + lines = console.render_lines( + self.renderable or "", render_options, style=style, pad=True + ) + lines = Segment.set_shape(lines, width, height, style=style) + new_line = Segment.line() + for last, line in loop_last(lines): + yield from line + if not last: + yield new_line diff --git a/rich/segment.py b/rich/segment.py new file mode 100644 index 0000000..ff4996a --- /dev/null +++ b/rich/segment.py @@ -0,0 +1,392 @@ +from typing import Dict, NamedTuple, Optional + +from .cells import cell_len, set_cell_size +from .style import Style + +from itertools import filterfalse, zip_longest +from operator import attrgetter +from typing import Iterable, List, Tuple + + +class Segment(NamedTuple): + """A piece of text with associated style. Segments are produced by the Console render process and + are ultimately converted in to strings to be written to the terminal. + + Args: + text (str): A piece of text. + style (:class:`~rich.style.Style`, optional): An optional style to apply to the text. + is_control (bool, optional): Boolean that marks segment as containing non-printable control codes. + """ + + text: str = "" + """Raw text.""" + style: Optional[Style] = None + """An optional style.""" + is_control: bool = False + """True if the segment contains control codes, otherwise False.""" + + def __repr__(self) -> str: + """Simplified repr.""" + if self.is_control: + return f"Segment.control({self.text!r}, {self.style!r})" + else: + return f"Segment({self.text!r}, {self.style!r})" + + def __bool__(self) -> bool: + """Check if the segment contains text.""" + return bool(self.text) + + @property + def cell_length(self) -> int: + """Get cell length of segment.""" + return 0 if self.is_control else cell_len(self.text) + + @classmethod + def control(cls, text: str, style: Optional[Style] = None) -> "Segment": + """Create a Segment with control codes. + + Args: + text (str): Text containing non-printable control codes. + style (Optional[style]): Optional style. + + Returns: + Segment: A Segment instance with ``is_control=True``. + """ + return cls(text, style, is_control=True) + + @classmethod + def make_control(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]: + """Convert all segments in to control segments. + + Returns: + Iterable[Segments]: Segments with is_control=True + """ + return [cls(text, style, True) for text, style, _ in segments] + + @classmethod + def line(cls, is_control: bool = False) -> "Segment": + """Make a new line segment.""" + return cls("\n", is_control=is_control) + + @classmethod + def apply_style( + cls, + segments: Iterable["Segment"], + style: Style = None, + post_style: Style = None, + ) -> Iterable["Segment"]: + """Apply style(s) to an iterable of segments. + + Returns an iterable of segments where the style is replaced by ``style + segment.style + post_style``. + + Args: + segments (Iterable[Segment]): Segments to process. + style (Style, optional): Base style. Defaults to None. + post_style (Style, optional): Style to apply on top of segment style. Defaults to None. + + Returns: + Iterable[Segments]: A new iterable of segments (possibly the same iterable). + """ + if style: + apply = style.__add__ + segments = ( + cls(text, None if is_control else apply(_style), is_control) + for text, _style, is_control in segments + ) + if post_style: + segments = ( + cls( + text, + None + if is_control + else (_style + post_style if _style else post_style), + is_control, + ) + for text, _style, is_control in segments + ) + return segments + + @classmethod + def filter_control( + cls, segments: Iterable["Segment"], is_control=False + ) -> Iterable["Segment"]: + """Filter segments by ``is_control`` attribute. + + Args: + segments (Iterable[Segment]): An iterable of Segment instances. + is_control (bool, optional): is_control flag to match in search. + + Returns: + Iterable[Segment]: And iterable of Segment instances. + + """ + if is_control: + return filter(attrgetter("is_control"), segments) + else: + return filterfalse(attrgetter("is_control"), segments) + + @classmethod + def split_lines(cls, segments: Iterable["Segment"]) -> Iterable[List["Segment"]]: + """Split a sequence of segments in to a list of lines. + + Args: + segments (Iterable[Segment]): Segments potentially containing line feeds. + + Yields: + Iterable[List[Segment]]: Iterable of segment lists, one per line. + """ + line: List[Segment] = [] + append = line.append + + for segment in segments: + if "\n" in segment.text and not segment.is_control: + text, style, _ = segment + while text: + _text, new_line, text = text.partition("\n") + if _text: + append(cls(_text, style)) + if new_line: + yield line + line = [] + append = line.append + else: + append(segment) + if line: + yield line + + @classmethod + def split_and_crop_lines( + cls, + segments: Iterable["Segment"], + length: int, + style: Style = None, + pad: bool = True, + include_new_lines: bool = True, + ) -> Iterable[List["Segment"]]: + """Split segments in to lines, and crop lines greater than a given length. + + Args: + segments (Iterable[Segment]): An iterable of segments, probably + generated from console.render. + length (int): Desired line length. + style (Style, optional): Style to use for any padding. + pad (bool): Enable padding of lines that are less than `length`. + + Returns: + Iterable[List[Segment]]: An iterable of lines of segments. + """ + line: List[Segment] = [] + append = line.append + + adjust_line_length = cls.adjust_line_length + new_line_segment = cls("\n") + + for segment in segments: + if "\n" in segment.text and not segment.is_control: + text, style, _ = segment + while text: + _text, new_line, text = text.partition("\n") + if _text: + append(cls(_text, style)) + if new_line: + cropped_line = adjust_line_length( + line, length, style=style, pad=pad + ) + if include_new_lines: + cropped_line.append(new_line_segment) + yield cropped_line + del line[:] + else: + append(segment) + if line: + yield adjust_line_length(line, length, style=style, pad=pad) + + @classmethod + def adjust_line_length( + cls, line: List["Segment"], length: int, style: Style = None, pad: bool = True + ) -> List["Segment"]: + """Adjust a line to a given width (cropping or padding as required). + + Args: + segments (Iterable[Segment]): A list of segments in a single line. + length (int): The desired width of the line. + style (Style, optional): The style of padding if used (space on the end). Defaults to None. + pad (bool, optional): Pad lines with spaces if they are shorter than `length`. Defaults to True. + + Returns: + List[Segment]: A line of segments with the desired length. + """ + line_length = sum(segment.cell_length for segment in line) + new_line: List[Segment] + + if line_length < length: + if pad: + new_line = line + [cls(" " * (length - line_length), style)] + else: + new_line = line[:] + elif line_length > length: + new_line = [] + append = new_line.append + line_length = 0 + for segment in line: + segment_length = segment.cell_length + if line_length + segment_length < length or segment.is_control: + append(segment) + line_length += segment_length + else: + text, segment_style, _ = segment + text = set_cell_size(text, length - line_length) + append(cls(text, segment_style)) + break + else: + new_line = line[:] + return new_line + + @classmethod + def get_line_length(cls, line: List["Segment"]) -> int: + """Get the length of list of segments. + + Args: + line (List[Segment]): A line encoded as a list of Segments (assumes no '\\\\n' characters), + + Returns: + int: The length of the line. + """ + return sum(segment.cell_length for segment in line) + + @classmethod + def get_shape(cls, lines: List[List["Segment"]]) -> Tuple[int, int]: + """Get the shape (enclosing rectangle) of a list of lines. + + Args: + lines (List[List[Segment]]): A list of lines (no '\\\\n' characters). + + Returns: + Tuple[int, int]: Width and height in characters. + """ + get_line_length = cls.get_line_length + max_width = max(get_line_length(line) for line in lines) if lines else 0 + return (max_width, len(lines)) + + @classmethod + def set_shape( + cls, + lines: List[List["Segment"]], + width: int, + height: int = None, + style: Style = None, + ) -> List[List["Segment"]]: + """Set the shape of a list of lines (enclosing rectangle). + + Args: + lines (List[List[Segment]]): A list of lines. + width (int): Desired width. + height (int, optional): Desired height or None for no change. + style (Style, optional): Style of any padding added. Defaults to None. + + Returns: + List[List[Segment]]: New list of lines that fits width x height. + """ + if height is None: + height = len(lines) + new_lines: List[List[Segment]] = [] + pad_line = [Segment(" " * width, style)] + append = new_lines.append + adjust_line_length = cls.adjust_line_length + line: Optional[List[Segment]] + iter_lines = iter(lines) + for _ in range(height): + line = next(iter_lines, None) + if line is None: + append(pad_line) + else: + append(adjust_line_length(line, width, style=style)) + return new_lines + + @classmethod + def simplify(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]: + """Simplify an iterable of segments by combining contiguous segments with the same style. + + Args: + segments (Iterable[Segment]): An iterable of segments. + + Returns: + Iterable[Segment]: A possibly smaller iterable of segments that will render the same way. + """ + iter_segments = iter(segments) + try: + last_segment = next(iter_segments) + except StopIteration: + return + + _Segment = Segment + for segment in iter_segments: + if last_segment.style == segment.style and not segment.is_control: + last_segment = _Segment( + last_segment.text + segment.text, last_segment.style + ) + else: + yield last_segment + last_segment = segment + yield last_segment + + @classmethod + def strip_links(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]: + """Remove all links from an iterable of styles. + + Args: + segments (Iterable[Segment]): An iterable segments. + + Yields: + Segment: Segments with link removed. + """ + for segment in segments: + if segment.is_control or segment.style is None: + yield segment + else: + text, style, _is_control = segment + yield cls(text, style.update_link(None) if style else None) + + @classmethod + def strip_styles(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]: + """Remove all styles from an iterable of segments. + + Args: + segments (Iterable[Segment]): An iterable segments. + + Yields: + Segment: Segments with styles replace with None + """ + for text, _style, is_control in segments: + yield cls(text, None, is_control) + + @classmethod + def remove_color(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]: + """Remove all color from an iterable of segments. + + Args: + segments (Iterable[Segment]): An iterable segments. + + Yields: + Segment: Segments with colorless style. + """ + + cache: Dict[Style, Style] = {} + for text, style, is_control in segments: + if style: + colorless_style = cache.get(style) + if colorless_style is None: + colorless_style = style.without_color + cache[style] = colorless_style + yield cls(text, colorless_style, is_control) + else: + yield cls(text, None, is_control) + + +if __name__ == "__main__": # pragma: no cover + lines = [[Segment("Hello")]] + lines = Segment.set_shape(lines, 50, 4, style=Style.parse("on blue")) + for line in lines: + print(line) + + print(Style.parse("on blue") + Style.parse("on red")) diff --git a/rich/spinner.py b/rich/spinner.py new file mode 100644 index 0000000..b236e0b --- /dev/null +++ b/rich/spinner.py @@ -0,0 +1,88 @@ +from typing import cast, List, Optional, TYPE_CHECKING + +from ._spinners import SPINNERS +from .console import Console +from .measure import Measurement +from .style import StyleType +from .text import Text, TextType + +if TYPE_CHECKING: + from .console import Console, ConsoleOptions, RenderResult + + +class Spinner: + def __init__( + self, name: str, text: TextType = "", *, style: StyleType = None, speed=1.0 + ) -> None: + """A spinner animation. + + Args: + name (str): Name of spinner (run python -m rich.spinner). + text (TextType, optional): Text to display at the right of the spinner. Defaults to "". + style (StyleType, optional): Style for sinner amimation. Defaults to None. + speed (float, optional): Speed factor for animation. Defaults to 1.0. + + Raises: + KeyError: If name isn't one of the supported spinner animations. + """ + try: + spinner = SPINNERS[name] + except KeyError: + raise KeyError(f"no spinner called {name!r}") + self.text = text + self.frames = cast(List[str], spinner["frames"])[:] + self.interval = cast(float, spinner["interval"]) + self.start_time: Optional[float] = None + self.style = style + self.speed = speed + self.time = 0.0 + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + time = console.get_time() + if self.start_time is None: + self.start_time = time + text = self.render(time - self.start_time) + yield text + + def __rich_measure__(self, console: "Console", max_width: int) -> Measurement: + text = self.render(0) + return Measurement.get(console, text, max_width) + + def render(self, time: float) -> Text: + """Render the spinner for a given time. + + Args: + time (float): Time in seconds. + + Returns: + Text: A Text instance containing animation frame. + """ + frame_no = int((time * self.speed) / (self.interval / 1000.0)) + frame = Text(self.frames[frame_no % len(self.frames)], style=self.style or "") + return Text.assemble(frame, " ", self.text) if self.text else frame + + +if __name__ == "__main__": # pragma: no cover + from time import sleep + + from .columns import Columns + from .panel import Panel + from .live import Live + + all_spinners = Columns( + [ + Spinner(spinner_name, text=Text(repr(spinner_name), style="green")) + for spinner_name in sorted(SPINNERS.keys()) + ], + column_first=True, + expand=True, + ) + + with Live( + Panel(all_spinners, title="Spinners", border_style="blue"), + refresh_per_second=20, + ) as live: + while True: + sleep(0.1) diff --git a/rich/status.py b/rich/status.py new file mode 100644 index 0000000..62f1ba9 --- /dev/null +++ b/rich/status.py @@ -0,0 +1,131 @@ +from typing import Optional + +from .console import Console, RenderableType +from .jupyter import JupyterMixin +from .live import Live +from .spinner import Spinner +from .style import StyleType +from .table import Table + + +class Status(JupyterMixin): + """Displays a status indicator with a 'spinner' animation. + + Args: + status (RenderableType): A status renderable (str or Text typically). + console (Console, optional): Console instance to use, or None for global console. Defaults to None. + spinner (str, optional): Name of spinner animation (see python -m rich.spinner). Defaults to "dots". + spinner_style (StyleType, optional): Style of spinner. Defaults to "status.spinner". + speed (float, optional): Speed factor for spinner animation. Defaults to 1.0. + refresh_per_second (float, optional): Number of refreshes per second. Defaults to 12.5. + """ + + def __init__( + self, + status: RenderableType, + *, + console: Console = None, + spinner: str = "dots", + spinner_style: StyleType = "status.spinner", + speed: float = 1.0, + refresh_per_second: float = 12.5, + ): + self.status = status + self.spinner = spinner + self.spinner_style = spinner_style + self.speed = speed + self._spinner = Spinner(spinner, style=spinner_style, speed=speed) + self._live = Live( + self.renderable, + console=console, + refresh_per_second=refresh_per_second, + transient=True, + ) + self.update( + status=status, spinner=spinner, spinner_style=spinner_style, speed=speed + ) + + @property + def renderable(self) -> Table: + """Get the renderable for the status (a table with spinner and status).""" + table = Table.grid(padding=1) + table.add_row(self._spinner, self.status) + return table + + @property + def console(self) -> "Console": + """Get the Console used by the Status objects.""" + return self._live.console + + def update( + self, + status: Optional[RenderableType] = None, + *, + spinner: Optional[str] = None, + spinner_style: Optional[StyleType] = None, + speed: Optional[float] = None, + ): + """Update status. + + Args: + status (Optional[RenderableType], optional): New status renderable or None for no change. Defaults to None. + spinner (Optional[str], optional): New spinner or None for no change. Defaults to None. + spinner_style (Optional[StyleType], optional): New spinner style or None for no change. Defaults to None. + speed (Optional[float], optional): Speed factor for spinner animation or None for no change. Defaults to None. + """ + if status is not None: + self.status = status + if spinner is not None: + self.spinner = spinner + if spinner_style is not None: + self.spinner_style = spinner_style + if speed is not None: + self.speed = speed + self._spinner = Spinner( + self.spinner, style=self.spinner_style, speed=self.speed + ) + self._live.update(self.renderable, refresh=True) + + def start(self) -> None: + """Start the status animation.""" + self._live.start() + + def stop(self) -> None: + """Stop the spinner animation.""" + self._live.stop() + + def __rich__(self) -> RenderableType: + return self.renderable + + def __enter__(self) -> "Status": + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.stop() + + +if __name__ == "__main__": # pragma: no cover + + from time import sleep + + from .console import Console + + console = Console() + with console.status("[magenta]Covid detector booting up") as status: + sleep(3) + console.log("Importing advanced AI") + sleep(3) + console.log("Advanced Covid AI Ready") + sleep(3) + status.update(status="[bold blue] Scanning for Covid", spinner="earth") + sleep(3) + console.log("Found 10,000,000,000 copies of Covid32.exe") + sleep(3) + status.update( + status="[bold red]Moving Covid32.exe to Trash", + spinner="bouncingBall", + spinner_style="yellow", + ) + sleep(5) + console.print("[bold green]Covid deleted successfully") diff --git a/rich/style.py b/rich/style.py new file mode 100644 index 0000000..a6b756b --- /dev/null +++ b/rich/style.py @@ -0,0 +1,694 @@ +import sys +from functools import lru_cache +from random import randint +from time import time +from typing import Any, Dict, Iterable, List, Optional, Type, Union + +from . import errors +from .color import Color, ColorParseError, ColorSystem, blend_rgb +from .terminal_theme import DEFAULT_TERMINAL_THEME, TerminalTheme + +# Style instances and style definitions are often interchangeable +StyleType = Union[str, "Style"] + + +class _Bit: + """A descriptor to get/set a style attribute bit.""" + + __slots__ = ["bit"] + + def __init__(self, bit_no: int) -> None: + self.bit = 1 << bit_no + + def __get__(self, obj: "Style", objtype: Type["Style"]) -> Optional[bool]: + if obj._set_attributes & self.bit: + return obj._attributes & self.bit != 0 + return None + + +class Style: + """A terminal style. + + A terminal style consists of a color (`color`), a background color (`bgcolor`), and a number of attributes, such + as bold, italic etc. The attributes have 3 states: they can either be on + (``True``), off (``False``), or not set (``None``). + + Args: + color (Union[Color, str], optional): Color of terminal text. Defaults to None. + bgcolor (Union[Color, str], optional): Color of terminal background. Defaults to None. + bold (bool, optional): Enable bold text. Defaults to None. + dim (bool, optional): Enable dim text. Defaults to None. + italic (bool, optional): Enable italic text. Defaults to None. + underline (bool, optional): Enable underlined text. Defaults to None. + blink (bool, optional): Enabled blinking text. Defaults to None. + blink2 (bool, optional): Enable fast blinking text. Defaults to None. + reverse (bool, optional): Enabled reverse text. Defaults to None. + conceal (bool, optional): Enable concealed text. Defaults to None. + strike (bool, optional): Enable strikethrough text. Defaults to None. + underline2 (bool, optional): Enable doubly underlined text. Defaults to None. + frame (bool, optional): Enable framed text. Defaults to None. + encircle (bool, optional): Enable encircled text. Defaults to None. + overline (bool, optional): Enable overlined text. Defaults to None. + link (str, link): Link URL. Defaults to None. + + """ + + _color: Optional[Color] + _bgcolor: Optional[Color] + _attributes: int + _set_attributes: int + _hash: int + _null: bool + + __slots__ = [ + "_color", + "_bgcolor", + "_attributes", + "_set_attributes", + "_link", + "_link_id", + "_ansi", + "_style_definition", + "_hash", + "_null", + ] + + # maps bits on to SGR parameter + _style_map = { + 0: "1", + 1: "2", + 2: "3", + 3: "4", + 4: "5", + 5: "6", + 6: "7", + 7: "8", + 8: "9", + 9: "21", + 10: "51", + 11: "52", + 12: "53", + } + + def __init__( + self, + *, + color: Union[Color, str] = None, + bgcolor: Union[Color, str] = None, + bold: bool = None, + dim: bool = None, + italic: bool = None, + underline: bool = None, + blink: bool = None, + blink2: bool = None, + reverse: bool = None, + conceal: bool = None, + strike: bool = None, + underline2: bool = None, + frame: bool = None, + encircle: bool = None, + overline: bool = None, + link: str = None, + ): + self._ansi: Optional[str] = None + self._style_definition: Optional[str] = None + + def _make_color(color: Union[Color, str]) -> Color: + return color if isinstance(color, Color) else Color.parse(color) + + self._color = None if color is None else _make_color(color) + self._bgcolor = None if bgcolor is None else _make_color(bgcolor) + self._set_attributes = sum( + ( + bold is not None, + dim is not None and 2, + italic is not None and 4, + underline is not None and 8, + blink is not None and 16, + blink2 is not None and 32, + reverse is not None and 64, + conceal is not None and 128, + strike is not None and 256, + underline2 is not None and 512, + frame is not None and 1024, + encircle is not None and 2048, + overline is not None and 4096, + ) + ) + self._attributes = ( + sum( + ( + bold and 1 or 0, + dim and 2 or 0, + italic and 4 or 0, + underline and 8 or 0, + blink and 16 or 0, + blink2 and 32 or 0, + reverse and 64 or 0, + conceal and 128 or 0, + strike and 256 or 0, + underline2 and 512 or 0, + frame and 1024 or 0, + encircle and 2048 or 0, + overline and 4096 or 0, + ) + ) + if self._set_attributes + else 0 + ) + + self._link = link + self._link_id = f"{time()}-{randint(0, 999999)}" if link else "" + self._hash = hash( + ( + self._color, + self._bgcolor, + self._attributes, + self._set_attributes, + link, + ) + ) + self._null = not (self._set_attributes or color or bgcolor or link) + + @classmethod + def null(cls) -> "Style": + """Create an 'null' style, equivalent to Style(), but more performant.""" + return NULL_STYLE + + @classmethod + def from_color(cls, color: Color = None, bgcolor: Color = None) -> "Style": + """Create a new style with colors and no attributes. + + Returns: + color (Optional[Color]): A (foreground) color, or None for no color. Defaults to None. + bgcolor (Optional[Color]): A (background) color, or None for no color. Defaults to None. + """ + style = cls.__new__(Style) + style._ansi = None + style._style_definition = None + style._color = color + style._bgcolor = bgcolor + style._set_attributes = 0 + style._attributes = 0 + style._link = None + style._link_id = "" + style._hash = hash( + ( + color, + bgcolor, + None, + None, + None, + ) + ) + style._null = not (color or bgcolor) + return style + + bold = _Bit(0) + dim = _Bit(1) + italic = _Bit(2) + underline = _Bit(3) + blink = _Bit(4) + blink2 = _Bit(5) + reverse = _Bit(6) + conceal = _Bit(7) + strike = _Bit(8) + underline2 = _Bit(9) + frame = _Bit(10) + encircle = _Bit(11) + overline = _Bit(12) + + @property + def link_id(self) -> str: + """Get a link id, used in ansi code for links.""" + return self._link_id + + def __str__(self) -> str: + """Re-generate style definition from attributes.""" + if self._style_definition is None: + attributes: List[str] = [] + append = attributes.append + bits = self._set_attributes + if bits & 0b0000000001111: + if bits & 1: + append("bold" if self.bold else "not bold") + if bits & (1 << 1): + append("dim" if self.dim else "not dim") + if bits & (1 << 2): + append("italic" if self.italic else "not italic") + if bits & (1 << 3): + append("underline" if self.underline else "not underline") + if bits & 0b0000111110000: + if bits & (1 << 4): + append("blink" if self.blink else "not blink") + if bits & (1 << 5): + append("blink2" if self.blink2 else "not blink2") + if bits & (1 << 6): + append("reverse" if self.reverse else "not reverse") + if bits & (1 << 7): + append("conceal" if self.conceal else "not conceal") + if bits & (1 << 8): + append("strike" if self.strike else "not strike") + if bits & 0b1111000000000: + if bits & (1 << 9): + append("underline2" if self.underline2 else "not underline2") + if bits & (1 << 10): + append("frame" if self.frame else "not frame") + if bits & (1 << 11): + append("encircle" if self.encircle else "not encircle") + if bits & (1 << 12): + append("overline" if self.overline else "not overline") + if self._color is not None: + append(self._color.name) + if self._bgcolor is not None: + append("on") + append(self._bgcolor.name) + if self._link: + append("link") + append(self._link) + self._style_definition = " ".join(attributes) or "none" + return self._style_definition + + def __bool__(self) -> bool: + """A Style is false if it has no attributes, colors, or links.""" + return not self._null + + def _make_ansi_codes(self, color_system: ColorSystem) -> str: + """Generate ANSI codes for this style. + + Args: + color_system (ColorSystem): Color system. + + Returns: + str: String containing codes. + """ + if self._ansi is None: + sgr: List[str] = [] + append = sgr.append + _style_map = self._style_map + attributes = self._attributes & self._set_attributes + if attributes: + if attributes & 1: + append(_style_map[0]) + if attributes & 2: + append(_style_map[1]) + if attributes & 4: + append(_style_map[2]) + if attributes & 8: + append(_style_map[3]) + if attributes & 0b0000111110000: + for bit in range(4, 9): + if attributes & (1 << bit): + append(_style_map[bit]) + if attributes & 0b1111000000000: + for bit in range(9, 13): + if attributes & (1 << bit): + append(_style_map[bit]) + if self._color is not None: + sgr.extend(self._color.downgrade(color_system).get_ansi_codes()) + if self._bgcolor is not None: + sgr.extend( + self._bgcolor.downgrade(color_system).get_ansi_codes( + foreground=False + ) + ) + self._ansi = ";".join(sgr) + return self._ansi + + @classmethod + @lru_cache(maxsize=1024) + def normalize(cls, style: str) -> str: + """Normalize a style definition so that styles with the same effect have the same string + representation. + + Args: + style (str): A style definition. + + Returns: + str: Normal form of style definition. + """ + try: + return str(cls.parse(style)) + except errors.StyleSyntaxError: + return style.strip().lower() + + @classmethod + def pick_first(cls, *values: Optional[StyleType]) -> StyleType: + """Pick first non-None style.""" + for value in values: + if value is not None: + return value + raise ValueError("expected at least one non-None style") + + def __repr__(self) -> str: + """Render a named style differently from an anonymous style.""" + return f'Style.parse("{self}")' + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Style): + return NotImplemented + return ( + self._color == other._color + and self._bgcolor == other._bgcolor + and self._set_attributes == other._set_attributes + and self._attributes == other._attributes + and self._link == other._link + ) + + def __hash__(self) -> int: + return self._hash + + @property + def color(self) -> Optional[Color]: + """The foreground color or None if it is not set.""" + return self._color + + @property + def bgcolor(self) -> Optional[Color]: + """The background color or None if it is not set.""" + return self._bgcolor + + @property + def link(self) -> Optional[str]: + """Link text, if set.""" + return self._link + + @property + def transparent_background(self) -> bool: + """Check if the style specified a transparent background.""" + return self.bgcolor is None or self.bgcolor.is_default + + @property + def background_style(self) -> "Style": + """A Style with background only.""" + return Style(bgcolor=self.bgcolor) + + @property + def without_color(self) -> "Style": + """Get a copy of the style with color removed.""" + if self._null: + return NULL_STYLE + style = self.__new__(Style) + style._ansi = None + style._style_definition = None + style._color = None + style._bgcolor = None + style._attributes = self._attributes + style._set_attributes = self._set_attributes + style._link = self._link + style._link_id = f"{time()}-{randint(0, 999999)}" if self._link else "" + style._hash = self._hash + style._null = False + return style + + @classmethod + @lru_cache(maxsize=4096) + def parse(cls, style_definition: str) -> "Style": + """Parse a style definition. + + Args: + style_definition (str): A string containing a style. + + Raises: + errors.StyleSyntaxError: If the style definition syntax is invalid. + + Returns: + `Style`: A Style instance. + """ + if style_definition.strip() == "none" or not style_definition: + return cls.null() + + style_attributes = { + "dim": "dim", + "d": "dim", + "bold": "bold", + "b": "bold", + "italic": "italic", + "i": "italic", + "underline": "underline", + "u": "underline", + "blink": "blink", + "blink2": "blink2", + "reverse": "reverse", + "r": "reverse", + "conceal": "conceal", + "c": "conceal", + "strike": "strike", + "s": "strike", + "underline2": "underline2", + "uu": "underline2", + "frame": "frame", + "encircle": "encircle", + "overline": "overline", + "o": "overline", + } + color: Optional[str] = None + bgcolor: Optional[str] = None + attributes: Dict[str, Optional[bool]] = {} + link: Optional[str] = None + + words = iter(style_definition.split()) + for original_word in words: + word = original_word.lower() + if word == "on": + word = next(words, "") + if not word: + raise errors.StyleSyntaxError("color expected after 'on'") + try: + Color.parse(word) is None + except ColorParseError as error: + raise errors.StyleSyntaxError( + f"unable to parse {word!r} as background color; {error}" + ) from None + bgcolor = word + + elif word == "not": + word = next(words, "") + attribute = style_attributes.get(word) + if attribute is None: + raise errors.StyleSyntaxError( + f"expected style attribute after 'not', found {word!r}" + ) + attributes[attribute] = False + + elif word == "link": + word = next(words, "") + if not word: + raise errors.StyleSyntaxError("URL expected after 'link'") + link = word + + elif word in style_attributes: + attributes[style_attributes[word]] = True + + else: + try: + Color.parse(word) + except ColorParseError as error: + raise errors.StyleSyntaxError( + f"unable to parse {word!r} as color; {error}" + ) from None + color = word + style = Style(color=color, bgcolor=bgcolor, link=link, **attributes) + return style + + @lru_cache(maxsize=1024) + def get_html_style(self, theme: TerminalTheme = None) -> str: + """Get a CSS style rule.""" + theme = theme or DEFAULT_TERMINAL_THEME + css: List[str] = [] + append = css.append + + color = self.color + bgcolor = self.bgcolor + if self.reverse: + color, bgcolor = bgcolor, color + if self.dim: + foreground_color = ( + theme.foreground_color if color is None else color.get_truecolor(theme) + ) + color = Color.from_triplet( + blend_rgb(foreground_color, theme.background_color, 0.5) + ) + if color is not None: + theme_color = color.get_truecolor(theme) + append(f"color: {theme_color.hex}") + if bgcolor is not None: + theme_color = bgcolor.get_truecolor(theme, foreground=False) + append(f"background-color: {theme_color.hex}") + if self.bold: + append("font-weight: bold") + if self.italic: + append("font-style: italic") + if self.underline: + append("text-decoration: underline") + if self.strike: + append("text-decoration: line-through") + if self.overline: + append("text-decoration: overline") + return "; ".join(css) + + @classmethod + def combine(cls, styles: Iterable["Style"]) -> "Style": + """Combine styles and get result. + + Args: + styles (Iterable[Style]): Styles to combine. + + Returns: + Style: A new style instance. + """ + iter_styles = iter(styles) + return sum(iter_styles, next(iter_styles)) + + @classmethod + def chain(cls, *styles: "Style") -> "Style": + """Combine styles from positional argument in to a single style. + + Args: + *styles (Iterable[Style]): Styles to combine. + + Returns: + Style: A new style instance. + """ + iter_styles = iter(styles) + return sum(iter_styles, next(iter_styles)) + + def copy(self) -> "Style": + """Get a copy of this style. + + Returns: + Style: A new Style instance with identical attributes. + """ + if self._null: + return NULL_STYLE + style = self.__new__(Style) + style._ansi = self._ansi + style._style_definition = self._style_definition + style._color = self._color + style._bgcolor = self._bgcolor + style._attributes = self._attributes + style._set_attributes = self._set_attributes + style._link = self._link + style._link_id = f"{time()}-{randint(0, 999999)}" if self._link else "" + style._hash = self._hash + style._null = False + return style + + def update_link(self, link: str = None) -> "Style": + """Get a copy with a different value for link. + + Args: + link (str, optional): New value for link. Defaults to None. + + Returns: + Style: A new Style instance. + """ + style = self.__new__(Style) + style._ansi = self._ansi + style._style_definition = self._style_definition + style._color = self._color + style._bgcolor = self._bgcolor + style._attributes = self._attributes + style._set_attributes = self._set_attributes + style._link = link + style._link_id = f"{time()}-{randint(0, 999999)}" if link else "" + style._hash = self._hash + style._null = False + return style + + def render( + self, + text: str = "", + *, + color_system: Optional[ColorSystem] = ColorSystem.TRUECOLOR, + legacy_windows: bool = False, + ) -> str: + """Render the ANSI codes for the style. + + Args: + text (str, optional): A string to style. Defaults to "". + color_system (Optional[ColorSystem], optional): Color system to render to. Defaults to ColorSystem.TRUECOLOR. + + Returns: + str: A string containing ANSI style codes. + """ + if not text or color_system is None: + return text + attrs = self._make_ansi_codes(color_system) + rendered = f"\x1b[{attrs}m{text}\x1b[0m" if attrs else text + if self._link and not legacy_windows: + rendered = ( + f"\x1b]8;id={self._link_id};{self._link}\x1b\\{rendered}\x1b]8;;\x1b\\" + ) + return rendered + + def test(self, text: Optional[str] = None) -> None: + """Write text with style directly to terminal. + + This method is for testing purposes only. + + Args: + text (Optional[str], optional): Text to style or None for style name. + + """ + text = text or str(self) + sys.stdout.write(f"{self.render(text)}\n") + + def __add__(self, style: Optional["Style"]) -> "Style": + if not (isinstance(style, Style) or style is None): + return NotImplemented # type: ignore + if style is None or style._null: + return self + if self._null: + return style + new_style = self.__new__(Style) + new_style._ansi = None + new_style._style_definition = None + new_style._color = style._color or self._color + new_style._bgcolor = style._bgcolor or self._bgcolor + new_style._attributes = (self._attributes & ~style._set_attributes) | ( + style._attributes & style._set_attributes + ) + new_style._set_attributes = self._set_attributes | style._set_attributes + new_style._link = style._link or self._link + new_style._link_id = style._link_id or self._link_id + new_style._hash = style._hash + new_style._null = self._null or style._null + return new_style + + +NULL_STYLE = Style() + + +class StyleStack: + """A stack of styles.""" + + __slots__ = ["_stack"] + + def __init__(self, default_style: "Style") -> None: + self._stack: List[Style] = [default_style] + + def __repr__(self) -> str: + return f"<stylestack {self._stack!r}>" + + @property + def current(self) -> Style: + """Get the Style at the top of the stack.""" + return self._stack[-1] + + def push(self, style: Style) -> None: + """Push a new style on to the stack. + + Args: + style (Style): New style to combine with current style. + """ + self._stack.append(self._stack[-1] + style) + + def pop(self) -> Style: + """Pop last style and discard. + + Returns: + Style: New current style (also available as stack.current) + """ + self._stack.pop() + return self._stack[-1] diff --git a/rich/styled.py b/rich/styled.py new file mode 100644 index 0000000..f163122 --- /dev/null +++ b/rich/styled.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING + +from .measure import Measurement +from .segment import Segment +from .style import StyleType + +if TYPE_CHECKING: + from .console import Console, ConsoleOptions, RenderResult, RenderableType + + +class Styled: + """Apply a style to a renderable. + + Args: + renderable (RenderableType): Any renderable. + style (StyleType): A style to apply across the entire renderable. + """ + + def __init__(self, renderable: "RenderableType", style: "StyleType") -> None: + self.renderable = renderable + self.style = style + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + style = console.get_style(self.style) + rendered_segments = console.render(self.renderable, options) + segments = Segment.apply_style(rendered_segments, style) + return segments + + def __rich_measure__(self, console: "Console", max_width: int) -> Measurement: + return Measurement.get(console, self.renderable, max_width) + + +if __name__ == "__main__": # pragma: no cover + from rich import print + from rich.panel import Panel + + panel = Styled(Panel("hello"), "on blue") + print(panel) diff --git a/rich/syntax.py b/rich/syntax.py new file mode 100644 index 0000000..7bb749f --- /dev/null +++ b/rich/syntax.py @@ -0,0 +1,669 @@ +import os.path +import platform +import textwrap +from abc import ABC, abstractmethod +from typing import Any, Dict, Iterable, Optional, Set, Tuple, Type, Union + +from pygments.lexers import get_lexer_by_name, guess_lexer_for_filename +from pygments.style import Style as PygmentsStyle +from pygments.styles import get_style_by_name +from pygments.token import ( + Comment, + Error, + Generic, + Keyword, + Name, + Number, + Operator, + String, + Token, + Whitespace, +) +from pygments.util import ClassNotFound + +from ._loop import loop_first +from .color import Color, blend_rgb +from .console import Console, ConsoleOptions, JustifyMethod, RenderResult, Segment +from .jupyter import JupyterMixin +from .measure import Measurement +from .style import Style +from .text import Text + +TokenType = Tuple[str, ...] + +WINDOWS = platform.system() == "Windows" +DEFAULT_THEME = "monokai" + +# The following styles are based on https://github.com/pygments/pygments/blob/master/pygments/formatters/terminal.py +# A few modifications were made + +ANSI_LIGHT: Dict[TokenType, Style] = { + Token: Style(), + Whitespace: Style(color="white"), + Comment: Style(dim=True), + Comment.Preproc: Style(color="cyan"), + Keyword: Style(color="blue"), + Keyword.Type: Style(color="cyan"), + Operator.Word: Style(color="magenta"), + Name.Builtin: Style(color="cyan"), + Name.Function: Style(color="green"), + Name.Namespace: Style(color="cyan", underline=True), + Name.Class: Style(color="green", underline=True), + Name.Exception: Style(color="cyan"), + Name.Decorator: Style(color="magenta", bold=True), + Name.Variable: Style(color="red"), + Name.Constant: Style(color="red"), + Name.Attribute: Style(color="cyan"), + Name.Tag: Style(color="bright_blue"), + String: Style(color="yellow"), + Number: Style(color="blue"), + Generic.Deleted: Style(color="bright_red"), + Generic.Inserted: Style(color="green"), + Generic.Heading: Style(bold=True), + Generic.Subheading: Style(color="magenta", bold=True), + Generic.Prompt: Style(bold=True), + Generic.Error: Style(color="bright_red"), + Error: Style(color="red", underline=True), +} + +ANSI_DARK: Dict[TokenType, Style] = { + Token: Style(), + Whitespace: Style(color="bright_black"), + Comment: Style(dim=True), + Comment.Preproc: Style(color="bright_cyan"), + Keyword: Style(color="bright_blue"), + Keyword.Type: Style(color="bright_cyan"), + Operator.Word: Style(color="bright_magenta"), + Name.Builtin: Style(color="bright_cyan"), + Name.Function: Style(color="bright_green"), + Name.Namespace: Style(color="bright_cyan", underline=True), + Name.Class: Style(color="bright_green", underline=True), + Name.Exception: Style(color="bright_cyan"), + Name.Decorator: Style(color="bright_magenta", bold=True), + Name.Variable: Style(color="bright_red"), + Name.Constant: Style(color="bright_red"), + Name.Attribute: Style(color="bright_cyan"), + Name.Tag: Style(color="bright_blue"), + String: Style(color="yellow"), + Number: Style(color="bright_blue"), + Generic.Deleted: Style(color="bright_red"), + Generic.Inserted: Style(color="bright_green"), + Generic.Heading: Style(bold=True), + Generic.Subheading: Style(color="bright_magenta", bold=True), + Generic.Prompt: Style(bold=True), + Generic.Error: Style(color="bright_red"), + Error: Style(color="red", underline=True), +} + +RICH_SYNTAX_THEMES = {"ansi_light": ANSI_LIGHT, "ansi_dark": ANSI_DARK} + + +class SyntaxTheme(ABC): + """Base class for a syntax theme.""" + + @abstractmethod + def get_style_for_token(self, token_type: TokenType) -> Style: + """Get a style for a given Pygments token.""" + raise NotImplementedError # pragma: no cover + + @abstractmethod + def get_background_style(self) -> Style: + """Get the background color.""" + raise NotImplementedError # pragma: no cover + + +class PygmentsSyntaxTheme(SyntaxTheme): + """Syntax theme that delagates to Pygments theme.""" + + def __init__(self, theme: Union[str, Type[PygmentsStyle]]) -> None: + self._style_cache: Dict[TokenType, Style] = {} + if isinstance(theme, str): + try: + self._pygments_style_class = get_style_by_name(theme) + except ClassNotFound: + self._pygments_style_class = get_style_by_name("default") + else: + self._pygments_style_class = theme + + self._background_color = self._pygments_style_class.background_color + self._background_style = Style(bgcolor=self._background_color) + + def get_style_for_token(self, token_type: TokenType) -> Style: + """Get a style from a Pygments class.""" + try: + return self._style_cache[token_type] + except KeyError: + try: + pygments_style = self._pygments_style_class.style_for_token(token_type) + except KeyError: + style = Style.null() + else: + color = pygments_style["color"] + bgcolor = pygments_style["bgcolor"] + style = Style( + color="#" + color if color else "#000000", + bgcolor="#" + bgcolor if bgcolor else self._background_color, + bold=pygments_style["bold"], + italic=pygments_style["italic"], + underline=pygments_style["underline"], + ) + self._style_cache[token_type] = style + return style + + def get_background_style(self) -> Style: + return self._background_style + + +class ANSISyntaxTheme(SyntaxTheme): + """Syntax theme to use standard colors.""" + + def __init__(self, style_map: Dict[TokenType, Style]) -> None: + self.style_map = style_map + self._missing_style = Style.null() + self._background_style = Style.null() + self._style_cache: Dict[TokenType, Style] = {} + + def get_style_for_token(self, token_type: TokenType) -> Style: + """Look up style in the style map.""" + try: + return self._style_cache[token_type] + except KeyError: + # Styles form a hierarchy + # We need to go from most to least specific + # e.g. ("foo", "bar", "baz") to ("foo", "bar") to ("foo",) + get_style = self.style_map.get + token = tuple(token_type) + style = self._missing_style + while token: + _style = get_style(token) + if _style is not None: + style = _style + break + token = token[:-1] + self._style_cache[token_type] = style + return style + + def get_background_style(self) -> Style: + return self._background_style + + +class Syntax(JupyterMixin): + """Construct a Syntax object to render syntax highlighted code. + + Args: + code (str): Code to highlight. + lexer_name (str): Lexer to use (see https://pygments.org/docs/lexers/) + theme (str, optional): Color theme, aka Pygments style (see https://pygments.org/docs/styles/#getting-a-list-of-available-styles). Defaults to "monokai". + dedent (bool, optional): Enable stripping of initial whitespace. Defaults to False. + line_numbers (bool, optional): Enable rendering of line numbers. Defaults to False. + start_line (int, optional): Starting number for line numbers. Defaults to 1. + line_range (Tuple[int, int], optional): If given should be a tuple of the start and end line to render. + highlight_lines (Set[int]): A set of line numbers to highlight. + code_width: Width of code to render (not including line numbers), or ``None`` to use all available width. + tab_size (int, optional): Size of tabs. Defaults to 4. + word_wrap (bool, optional): Enable word wrapping. + background_color (str, optional): Optional background color, or None to use theme color. Defaults to None. + indent_guides (bool, optional): Show indent guides. Defaults to False. + """ + + _pygments_style_class: Type[PygmentsStyle] + _theme: SyntaxTheme + + @classmethod + def get_theme(cls, name: Union[str, SyntaxTheme]) -> SyntaxTheme: + """Get a syntax theme instance.""" + if isinstance(name, SyntaxTheme): + return name + theme: SyntaxTheme + if name in RICH_SYNTAX_THEMES: + theme = ANSISyntaxTheme(RICH_SYNTAX_THEMES[name]) + else: + theme = PygmentsSyntaxTheme(name) + return theme + + def __init__( + self, + code: str, + lexer_name: str, + *, + theme: Union[str, SyntaxTheme] = DEFAULT_THEME, + dedent: bool = False, + line_numbers: bool = False, + start_line: int = 1, + line_range: Tuple[int, int] = None, + highlight_lines: Set[int] = None, + code_width: Optional[int] = None, + tab_size: int = 4, + word_wrap: bool = False, + background_color: str = None, + indent_guides: bool = False, + ) -> None: + self.code = code + self.lexer_name = lexer_name + self.dedent = dedent + self.line_numbers = line_numbers + self.start_line = start_line + self.line_range = line_range + self.highlight_lines = highlight_lines or set() + self.code_width = code_width + self.tab_size = tab_size + self.word_wrap = word_wrap + self.background_color = background_color + self.indent_guides = indent_guides + + self._theme = self.get_theme(theme) + + @classmethod + def from_path( + cls, + path: str, + encoding: str = "utf-8", + theme: Union[str, SyntaxTheme] = DEFAULT_THEME, + dedent: bool = False, + line_numbers: bool = False, + line_range: Tuple[int, int] = None, + start_line: int = 1, + highlight_lines: Set[int] = None, + code_width: Optional[int] = None, + tab_size: int = 4, + word_wrap: bool = False, + background_color: str = None, + indent_guides: bool = False, + ) -> "Syntax": + """Construct a Syntax object from a file. + + Args: + path (str): Path to file to highlight. + encoding (str): Encoding of file. + theme (str, optional): Color theme, aka Pygments style (see https://pygments.org/docs/styles/#getting-a-list-of-available-styles). Defaults to "emacs". + dedent (bool, optional): Enable stripping of initial whitespace. Defaults to True. + line_numbers (bool, optional): Enable rendering of line numbers. Defaults to False. + start_line (int, optional): Starting number for line numbers. Defaults to 1. + line_range (Tuple[int, int], optional): If given should be a tuple of the start and end line to render. + highlight_lines (Set[int]): A set of line numbers to highlight. + code_width: Width of code to render (not including line numbers), or ``None`` to use all available width. + tab_size (int, optional): Size of tabs. Defaults to 4. + word_wrap (bool, optional): Enable word wrapping of code. + background_color (str, optional): Optional background color, or None to use theme color. Defaults to None. + indent_guides (bool, optional): Show indent guides. Defaults to False. + + Returns: + [Syntax]: A Syntax object that may be printed to the console + """ + with open(path, "rt", encoding=encoding) as code_file: + code = code_file.read() + + lexer = None + lexer_name = "default" + try: + _, ext = os.path.splitext(path) + if ext: + extension = ext.lstrip(".").lower() + lexer = get_lexer_by_name(extension) + lexer_name = lexer.name + except ClassNotFound: + pass + + if lexer is None: + try: + lexer_name = guess_lexer_for_filename(path, code).name + except ClassNotFound: + pass + + return cls( + code, + lexer_name, + theme=theme, + dedent=dedent, + line_numbers=line_numbers, + line_range=line_range, + start_line=start_line, + highlight_lines=highlight_lines, + code_width=code_width, + tab_size=tab_size, + word_wrap=word_wrap, + background_color=background_color, + indent_guides=indent_guides, + ) + + def _get_base_style(self) -> Style: + """Get the base style.""" + default_style = ( + Style(bgcolor=self.background_color) + if self.background_color is not None + else self._theme.get_background_style() + ) + return default_style + + def _get_token_color(self, token_type: TokenType) -> Optional[Color]: + """Get a color (if any) for the given token. + + Args: + token_type (TokenType): A token type tuple from Pygments. + + Returns: + Optional[Color]: Color from theme, or None for no color. + """ + style = self._theme.get_style_for_token(token_type) + return style.color + + def highlight(self, code: str, line_range: Tuple[int, int] = None) -> Text: + """Highlight code and return a Text instance. + + Args: + code (str): Code to highlight. + line_range(Tuple[int, int], optional): Optional line range to highlight. + + Returns: + Text: A text instance containing highlighted syntax. + """ + + base_style = self._get_base_style() + justify: JustifyMethod = ( + "default" if base_style.transparent_background else "left" + ) + + text = Text( + justify=justify, + style=base_style, + tab_size=self.tab_size, + no_wrap=not self.word_wrap, + ) + _get_theme_style = self._theme.get_style_for_token + try: + lexer = get_lexer_by_name(self.lexer_name) + except ClassNotFound: + text.append(code) + else: + if line_range: + # More complicated path to only stylize a portion of the code + # This speeds up further operations as there are less spans to process + line_start, line_end = line_range + + def line_tokenize() -> Iterable[Tuple[Any, str]]: + """Split tokens to one per line.""" + for token_type, token in lexer.get_tokens(code): + while token: + line_token, new_line, token = token.partition("\n") + yield token_type, line_token + new_line + + def tokens_to_spans() -> Iterable[Tuple[str, Optional[Style]]]: + """Convert tokens to spans.""" + tokens = iter(line_tokenize()) + line_no = 0 + _line_start = line_start - 1 + + # Skip over tokens until line start + while line_no < _line_start: + _token_type, token = next(tokens) + yield (token, None) + if token.endswith("\n"): + line_no += 1 + # Generate spans until line end + for token_type, token in tokens: + yield (token, _get_theme_style(token_type)) + if token.endswith("\n"): + line_no += 1 + if line_no >= line_end: + break + + text.append_tokens(tokens_to_spans()) + + else: + text.append_tokens( + (token, _get_theme_style(token_type)) + for token_type, token in lexer.get_tokens(code) + ) + if self.background_color is not None: + text.stylize(f"on {self.background_color}") + return text + + def _get_line_numbers_color(self, blend: float = 0.3) -> Color: + background_color = self._theme.get_background_style().bgcolor + if background_color is None or background_color.is_system_defined: + return background_color or Color.default() + foreground_color = self._get_token_color(Token.Text) + if foreground_color is None or foreground_color.is_system_defined: + return foreground_color or Color.default() + new_color = blend_rgb( + background_color.get_truecolor(), + foreground_color.get_truecolor(), + cross_fade=blend, + ) + return Color.from_triplet(new_color) + + @property + def _numbers_column_width(self) -> int: + """Get the number of characters used to render the numbers column.""" + column_width = 0 + if self.line_numbers: + column_width = len(str(self.start_line + self.code.count("\n"))) + 2 + return column_width + + def _get_number_styles(self, console: Console) -> Tuple[Style, Style, Style]: + """Get background, number, and highlight styles for line numbers.""" + background_style = self._get_base_style() + if background_style.transparent_background: + return Style.null(), Style(dim=True), Style.null() + if console.color_system in ("256", "truecolor"): + number_style = Style.chain( + background_style, + self._theme.get_style_for_token(Token.Text), + Style(color=self._get_line_numbers_color()), + ) + highlight_number_style = Style.chain( + background_style, + self._theme.get_style_for_token(Token.Text), + Style(bold=True, color=self._get_line_numbers_color(0.9)), + ) + else: + number_style = background_style + Style(dim=True) + highlight_number_style = background_style + Style(dim=False) + return background_style, number_style, highlight_number_style + + def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement": + if self.code_width is not None: + width = self.code_width + self._numbers_column_width + return Measurement(self._numbers_column_width, width) + return Measurement(self._numbers_column_width, max_width) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + + transparent_background = self._get_base_style().transparent_background + code_width = ( + (options.max_width - self._numbers_column_width - 1) + if self.code_width is None + else self.code_width + ) + + line_offset = 0 + if self.line_range: + start_line, end_line = self.line_range + line_offset = max(0, start_line - 1) + + code = textwrap.dedent(self.code) if self.dedent else self.code + code = code.expandtabs(self.tab_size) + text = self.highlight(code, self.line_range) + text.remove_suffix("\n") + + ( + background_style, + number_style, + highlight_number_style, + ) = self._get_number_styles(console) + + if not self.line_numbers: + # Simple case of just rendering text + yield from console.render(text, options=options.update(width=code_width)) + return + + lines = text.split("\n") + if self.line_range: + lines = lines[line_offset:end_line] + + if self.indent_guides and not options.ascii_only: + style = ( + self._get_base_style() + + self._theme.get_style_for_token(Comment) + + Style(dim=True) + ) + lines = ( + Text("\n") + .join(lines) + .with_indent_guides(self.tab_size, style=style) + .split("\n") + ) + + numbers_column_width = self._numbers_column_width + render_options = options.update(width=code_width) + + highlight_line = self.highlight_lines.__contains__ + _Segment = Segment + padding = _Segment(" " * numbers_column_width + " ", background_style) + new_line = _Segment("\n") + + line_pointer = "> " if options.legacy_windows else "❱ " + + for line_no, line in enumerate(lines, self.start_line + line_offset): + if self.word_wrap: + wrapped_lines = console.render_lines( + line, + render_options, + style=background_style, + pad=not transparent_background, + ) + else: + segments = list(line.render(console, end="")) + if options.no_wrap: + wrapped_lines = [segments] + else: + wrapped_lines = [ + _Segment.adjust_line_length( + segments, + render_options.max_width, + style=background_style, + pad=not transparent_background, + ) + ] + for first, wrapped_line in loop_first(wrapped_lines): + if first: + line_column = str(line_no).rjust(numbers_column_width - 2) + " " + if highlight_line(line_no): + yield _Segment(line_pointer, Style(color="red")) + yield _Segment(line_column, highlight_number_style) + else: + yield _Segment(" ", highlight_number_style) + yield _Segment(line_column, number_style) + else: + yield padding + yield from wrapped_line + yield new_line + + +if __name__ == "__main__": # pragma: no cover + + import argparse + import sys + + parser = argparse.ArgumentParser( + description="Render syntax to the console with Rich" + ) + parser.add_argument( + "path", + metavar="PATH", + nargs="?", + help="path to file", + ) + parser.add_argument( + "-c", + "--force-color", + dest="force_color", + action="store_true", + default=None, + help="force color for non-terminals", + ) + parser.add_argument( + "-i", + "--indent-guides", + dest="indent_guides", + action="store_true", + default=False, + help="display indent guides", + ) + parser.add_argument( + "-l", + "--line-numbers", + dest="line_numbers", + action="store_true", + help="render line numbers", + ) + parser.add_argument( + "-w", + "--width", + type=int, + dest="width", + default=None, + help="width of output (default will auto-detect)", + ) + parser.add_argument( + "-r", + "--wrap", + dest="word_wrap", + action="store_true", + default=False, + help="word wrap long lines", + ) + parser.add_argument( + "-s", + "--soft-wrap", + action="store_true", + dest="soft_wrap", + default=False, + help="enable soft wrapping mode", + ) + parser.add_argument( + "-t", "--theme", dest="theme", default="monokai", help="pygments theme" + ) + parser.add_argument( + "-b", + "--background-color", + dest="background_color", + default=None, + help="Overide background color", + ) + parser.add_argument( + "-x", + "--lexer", + default="default", + dest="lexer_name", + help="Lexer name", + ) + args = parser.parse_args() + + from rich.console import Console + + console = Console(force_terminal=args.force_color, width=args.width) + + if not args.path or args.path == "-": + code = sys.stdin.read() + syntax = Syntax( + code=code, + lexer_name=args.lexer_name, + line_numbers=args.line_numbers, + word_wrap=args.word_wrap, + theme=args.theme, + background_color=args.background_color, + indent_guides=args.indent_guides, + ) + else: + syntax = Syntax.from_path( + args.path, + line_numbers=args.line_numbers, + word_wrap=args.word_wrap, + theme=args.theme, + background_color=args.background_color, + indent_guides=args.indent_guides, + ) + console.print(syntax, soft_wrap=args.soft_wrap) diff --git a/rich/table.py b/rich/table.py new file mode 100644 index 0000000..41d0116 --- /dev/null +++ b/rich/table.py @@ -0,0 +1,881 @@ +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Optional, Tuple, Union + +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 +from .padding import Padding, PaddingDimensions +from .protocol import is_renderable +from .segment import Segment +from .style import Style, StyleType +from .text import Text, TextType + +if TYPE_CHECKING: + from .console import ( + Console, + ConsoleOptions, + JustifyMethod, + OverflowMethod, + RenderableType, + RenderResult, + ) + + +@dataclass +class Column: + """Defines a column in a table.""" + + header: "RenderableType" = "" + """RenderableType: Renderable for the header (typically a string)""" + + footer: "RenderableType" = "" + """RenderableType: Renderable for the footer (typically a string)""" + + header_style: StyleType = "" + """StyleType: The style of the header.""" + + footer_style: StyleType = "" + """StyleType: The style of the footer.""" + + style: StyleType = "" + """StyleType: The style of the column.""" + + justify: "JustifyMethod" = "left" + """str: How to justify text within the column ("left", "center", "right", or "full")""" + + overflow: "OverflowMethod" = "ellipsis" + """str: Overflow method.""" + + width: Optional[int] = None + """Optional[int]: Width of the column, or ``None`` (default) to auto calculate width.""" + + min_width: Optional[int] = None + """Optional[int]: Minimum width of column, or ``None`` for no minimum. Defaults to None.""" + + max_width: Optional[int] = None + """Optional[int]: Maximum width of column, or ``None`` for no maximum. Defaults to None.""" + + ratio: Optional[int] = None + """Optional[int]: Ratio to use when calculating column width, or ``None`` (default) to adapt to column contents.""" + + no_wrap: bool = False + """bool: Prevent wrapping of text within the column. Defaults to ``False``.""" + + _index: int = 0 + """Index of column.""" + + _cells: List["RenderableType"] = field(default_factory=list) + + def copy(self) -> "Column": + """Return a copy of this Column.""" + return replace(self, _cells=[]) + + @property + def cells(self) -> Iterable["RenderableType"]: + """Get all cells in the column, not including header.""" + yield from self._cells + + @property + def flexible(self) -> bool: + """Check if this column is flexible.""" + return self.ratio is not None + + +@dataclass +class Row: + """Information regarding a row.""" + + style: Optional[StyleType] = None + """Style to apply to row.""" + + end_section: bool = False + """Indicated end of section, which will force a line beneath the row.""" + + +class _Cell(NamedTuple): + """A single cell in a table.""" + + style: StyleType + """Style to apply to cell.""" + renderable: "RenderableType" + """Cell renderable.""" + + +class Table(JupyterMixin): + """A console renderable to draw a table. + + Args: + *headers (Union[Column, str]): Column headers, either as a string, or :class:`~rich.table.Column` instance. + title (Union[str, Text], optional): The title of the table rendered at the top. Defaults to None. + caption (Union[str, Text], optional): The table caption rendered below. Defaults to None. + width (int, optional): The width in characters of the table, or ``None`` to automatically fit. Defaults to None. + min_width (Optional[int], optional): The minimum width of the table, or ``None`` for no minimum. Defaults to None. + box (box.Box, optional): One of the constants in box.py used to draw the edges (see :ref:`appendix_box`). Defaults to box.HEAVY_HEAD. + safe_box (Optional[bool], optional): Disable box characters that don't display on windows legacy terminal with *raster* fonts. Defaults to True. + padding (PaddingDimensions, optional): Padding for cells (top, right, bottom, left). Defaults to (0, 1). + collapse_padding (bool, optional): Enable collapsing of padding around cells. Defaults to False. + pad_edge (bool, optional): Enable padding of edge cells. Defaults to True. + expand (bool, optional): Expand the table to fit the available space if ``True``, otherwise the table width will be auto-calculated. Defaults to False. + show_header (bool, optional): Show a header row. Defaults to True. + show_footer (bool, optional): Show a footer row. Defaults to False. + show_edge (bool, optional): Draw a box around the outside of the table. Defaults to True. + show_lines (bool, optional): Draw lines between every row. Defaults to False. + leading (bool, optional): Number of blank lines between rows (precludes ``show_lines``). Defaults to 0. + style (Union[str, Style], optional): Default style for the table. Defaults to "none". + row_styles (List[Union, str], optional): Optional list of row styles, if more that one style is give then the styles will alternate. Defaults to None. + header_style (Union[str, Style], optional): Style of the header. Defaults to "table.header". + footer_style (Union[str, Style], optional): Style of the footer. Defaults to "table.footer". + border_style (Union[str, Style], optional): Style of the border. Defaults to None. + title_style (Union[str, Style], optional): Style of the title. Defaults to None. + caption_style (Union[str, Style], optional): Style of the caption. Defaults to None. + title_justify (str, optional): Justify method for title. Defaults to "center". + caption_justify (str, optional): Justify method for caption. Defaults to "center". + highlight (bool, optional): Highlight cell contents (if str). Defaults to False. + """ + + columns: List[Column] + rows: List[Row] + + def __init__( + self, + *headers: Union[Column, str], + title: TextType = None, + caption: TextType = None, + width: int = None, + min_width: int = None, + box: Optional[box.Box] = box.HEAVY_HEAD, + safe_box: Optional[bool] = None, + padding: PaddingDimensions = (0, 1), + collapse_padding: bool = False, + pad_edge: bool = True, + expand: bool = False, + show_header: bool = True, + show_footer: bool = False, + show_edge: bool = True, + show_lines: bool = False, + leading: int = 0, + style: StyleType = "none", + row_styles: Iterable[StyleType] = None, + header_style: Optional[StyleType] = "table.header", + footer_style: Optional[StyleType] = "table.footer", + border_style: StyleType = None, + title_style: StyleType = None, + caption_style: StyleType = None, + title_justify: "JustifyMethod" = "center", + caption_justify: "JustifyMethod" = "center", + highlight: bool = False, + ) -> None: + + self.columns: List[Column] = [] + self.rows: List[Row] = [] + self.title = title + self.caption = caption + self.width = width + self.min_width = min_width + self.box = box + self.safe_box = safe_box + self._padding = Padding.unpack(padding) + self.pad_edge = pad_edge + self._expand = expand + self.show_header = show_header + self.show_footer = show_footer + self.show_edge = show_edge + self.show_lines = show_lines + self.leading = leading + self.collapse_padding = collapse_padding + self.style = style + self.header_style = header_style or "" + self.footer_style = footer_style or "" + self.border_style = border_style + self.title_style = title_style + self.caption_style = caption_style + self.title_justify = title_justify + self.caption_justify = caption_justify + self.highlight = highlight + self.row_styles = list(row_styles or []) + append_column = self.columns.append + for header in headers: + if isinstance(header, str): + self.add_column(header=header) + else: + header._index = len(self.columns) + append_column(header) + + @classmethod + def grid( + cls, + *headers: Union[Column, str], + padding: PaddingDimensions = 0, + collapse_padding: bool = True, + pad_edge: bool = False, + expand: bool = False, + ) -> "Table": + """Get a table with no lines, headers, or footer. + + Args: + *headers (Union[Column, str]): Column headers, either as a string, or :class:`~rich.table.Column` instance. + padding (PaddingDimensions, optional): Get padding around cells. Defaults to 0. + collapse_padding (bool, optional): Enable collapsing of padding around cells. Defaults to True. + pad_edge (bool, optional): Enable padding around edges of table. Defaults to False. + expand (bool, optional): Expand the table to fit the available space if ``True``, otherwise the table width will be auto-calculated. Defaults to False. + + Returns: + Table: A table instance. + """ + return cls( + *headers, + box=None, + padding=padding, + collapse_padding=collapse_padding, + show_header=False, + show_footer=False, + show_edge=False, + pad_edge=pad_edge, + expand=expand, + ) + + @property + def expand(self) -> int: + """Setting a non-None self.width implies expand.""" + return self._expand or self.width is not None + + @expand.setter + def expand(self, expand: bool) -> None: + """Set expand.""" + self._expand = expand + + @property + def _extra_width(self) -> int: + """Get extra width to add to cell content.""" + width = 0 + if self.box and self.show_edge: + width += 2 + if self.box: + width += len(self.columns) - 1 + return width + + @property + def row_count(self) -> int: + """Get the current number of rows.""" + return len(self.rows) + + def get_row_style(self, console: "Console", index: int) -> StyleType: + """Get the current row style.""" + style = Style.null() + if self.row_styles: + style += console.get_style(self.row_styles[index % len(self.row_styles)]) + row_style = self.rows[index].style + if row_style is not None: + style += console.get_style(row_style) + return style + + def __rich_measure__(self, console: "Console", max_width: int) -> Measurement: + if self.width is not None: + max_width = self.width + if max_width < 0: + return Measurement(0, 0) + + extra_width = self._extra_width + max_width = sum(self._calculate_column_widths(console, max_width - extra_width)) + _measure_column = self._measure_column + + measurements = [ + _measure_column(console, column, max_width) for column in self.columns + ] + minimum_width = ( + sum(measurement.minimum for measurement in measurements) + extra_width + ) + maximum_width = ( + sum(measurement.maximum for measurement in measurements) + extra_width + if (self.width is None) + else self.width + ) + measurement = Measurement(minimum_width, maximum_width) + measurement = measurement.clamp(self.min_width) + return measurement + + @property + def padding(self) -> Tuple[int, int, int, int]: + """Get cell padding.""" + return self._padding + + @padding.setter + def padding(self, padding: PaddingDimensions) -> "Table": + """Set cell padding.""" + self._padding = Padding.unpack(padding) + return self + + def add_column( + self, + header: "RenderableType" = "", + footer: "RenderableType" = "", + *, + header_style: StyleType = None, + footer_style: StyleType = None, + style: StyleType = None, + justify: "JustifyMethod" = "left", + overflow: "OverflowMethod" = "ellipsis", + width: int = None, + min_width: int = None, + max_width: int = None, + ratio: int = None, + no_wrap: bool = False, + ) -> None: + """Add a column to the table. + + Args: + header (RenderableType, optional): Text or renderable for the header. + Defaults to "". + footer (RenderableType, optional): Text or renderable for the footer. + Defaults to "". + header_style (Union[str, Style], optional): Style for the header, or None for default. Defaults to None. + footer_style (Union[str, Style], optional): Style for the footer, or None for default. Defaults to None. + style (Union[str, Style], optional): Style for the column cells, or None for default. Defaults to None. + justify (JustifyMethod, optional): Alignment for cells. Defaults to "left". + width (int, optional): Desired width of column in characters, or None to fit to contents. Defaults to None. + min_width (Optional[int], optional): Minimum width of column, or ``None`` for no minimum. Defaults to None. + max_width (Optional[int], optional): Maximum width of column, or ``None`` for no maximum. Defaults to None. + ratio (int, optional): Flexible ratio for the column (requires ``Table.expand`` or ``Table.width``). Defaults to None. + no_wrap (bool, optional): Set to ``True`` to disable wrapping of this column. + """ + + column = Column( + _index=len(self.columns), + header=header, + footer=footer, + header_style=header_style or "", + footer_style=footer_style or "", + style=style or "", + justify=justify, + overflow=overflow, + width=width, + min_width=min_width, + max_width=max_width, + ratio=ratio, + no_wrap=no_wrap, + ) + self.columns.append(column) + + def add_row( + self, + *renderables: Optional["RenderableType"], + style: StyleType = None, + end_section: bool = False, + ) -> None: + """Add a row of renderables. + + Args: + *renderables (None or renderable): Each cell in a row must be a renderable object (including str), + or ``None`` for a blank cell. + style (StyleType, optional): An optional style to apply to the entire row. Defaults to None. + end_section (bool, optional): End a section and draw a line. Defaults to False. + + Raises: + errors.NotRenderableError: If you add something that can't be rendered. + """ + + def add_cell(column: Column, renderable: "RenderableType") -> None: + column._cells.append(renderable) + + cell_renderables: List[Optional["RenderableType"]] = list(renderables) + + columns = self.columns + if len(cell_renderables) < len(columns): + cell_renderables = [ + *cell_renderables, + *[None] * (len(columns) - len(cell_renderables)), + ] + for index, renderable in enumerate(cell_renderables): + if index == len(columns): + column = Column(_index=index) + for _ in self.rows: + add_cell(column, Text("")) + self.columns.append(column) + else: + column = columns[index] + if renderable is None: + add_cell(column, "") + elif is_renderable(renderable): + add_cell(column, renderable) + else: + raise errors.NotRenderableError( + f"unable to render {type(renderable).__name__}; a string or other renderable object is required" + ) + self.rows.append(Row(style=style, end_section=end_section)) + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + + max_width = options.max_width + if self.width is not None: + max_width = self.width + + extra_width = self._extra_width + widths = self._calculate_column_widths(console, max_width - extra_width) + table_width = sum(widths) + extra_width + + render_options = options.update( + width=table_width, highlight=self.highlight, height=None + ) + + def render_annotation( + text: TextType, style: StyleType, justify: "JustifyMethod" = "center" + ) -> "RenderResult": + render_text = ( + console.render_str(text, style=style, highlight=False) + if isinstance(text, str) + else text + ) + return console.render( + render_text, options=render_options.update(justify=justify) + ) + + if self.title: + yield from render_annotation( + self.title, + style=Style.pick_first(self.title_style, "table.title"), + justify=self.title_justify, + ) + yield from self._render(console, render_options, widths) + if self.caption: + yield from render_annotation( + self.caption, + style=Style.pick_first(self.caption_style, "table.caption"), + justify=self.caption_justify, + ) + + def _calculate_column_widths(self, console: "Console", max_width: int) -> List[int]: + """Calculate the widths of each column, including padding, not including borders.""" + columns = self.columns + width_ranges = [ + self._measure_column(console, column, max_width) for column in columns + ] + widths = [_range.maximum or 1 for _range in width_ranges] + get_padding_width = self._get_padding_width + extra_width = self._extra_width + + if self.expand: + ratios = [col.ratio or 0 for col in columns if col.flexible] + if any(ratios): + fixed_widths = [ + 0 if column.flexible else _range.maximum + for _range, column in zip(width_ranges, columns) + ] + flex_minimum = [ + (column.width or 1) + get_padding_width(column._index) + for column in columns + if column.flexible + ] + flexible_width = max_width - sum(fixed_widths) + flex_widths = ratio_distribute(flexible_width, ratios, flex_minimum) + iter_flex_widths = iter(flex_widths) + for index, column in enumerate(columns): + if column.flexible: + widths[index] = fixed_widths[index] + next(iter_flex_widths) + table_width = sum(widths) + + if table_width > max_width: + widths = self._collapse_widths( + widths, + [(column.width is None and not column.no_wrap) for column in columns], + max_width, + ) + table_width = sum(widths) + + # last resort, reduce columns evenly + if table_width > max_width: + excess_width = table_width - max_width + widths = ratio_reduce(excess_width, [1] * len(widths), widths, widths) + table_width = sum(widths) + + width_ranges = [ + self._measure_column(console, column, width) + for width, column in zip(widths, columns) + ] + widths = [_range.maximum or 1 for _range in width_ranges] + + if (table_width < max_width and self.expand) or ( + self.min_width is not None and table_width < (self.min_width - extra_width) + ): + _max_width = ( + max_width + if self.min_width is None + else min(self.min_width - extra_width, max_width) + ) + pad_widths = ratio_distribute(_max_width - table_width, widths) + widths = [_width + pad for _width, pad in zip(widths, pad_widths)] + + return widths + + @classmethod + def _collapse_widths( + cls, widths: List[int], wrapable: List[bool], max_width: int + ) -> List[int]: + """Reduce widths so that the total is under max_width. + + Args: + widths (List[int]): List of widths. + wrapable (List[bool]): List of booleans that indicate if a column may shrink. + max_width (int): Maximum width to reduce to. + + Returns: + List[int]: A new list of widths. + """ + total_width = sum(widths) + excess_width = total_width - max_width + if any(wrapable): + while total_width and excess_width > 0: + max_column = max( + width for width, allow_wrap in zip(widths, wrapable) if allow_wrap + ) + second_max_column = max( + width if allow_wrap and width != max_column else 0 + for width, allow_wrap in zip(widths, wrapable) + ) + column_difference = max_column - second_max_column + ratios = [ + (1 if (width == max_column and allow_wrap) else 0) + for width, allow_wrap in zip(widths, wrapable) + ] + if not any(ratios) or not column_difference: + break + max_reduce = [min(excess_width, column_difference)] * len(widths) + widths = ratio_reduce(excess_width, ratios, max_reduce, widths) + + total_width = sum(widths) + excess_width = total_width - max_width + return widths + + def _get_cells( + self, console: "Console", column_index: int, column: Column + ) -> Iterable[_Cell]: + """Get all the cells with padding and optional header.""" + + collapse_padding = self.collapse_padding + pad_edge = self.pad_edge + padding = self.padding + any_padding = any(padding) + + first_column = column_index == 0 + last_column = column_index == len(self.columns) - 1 + + def add_padding( + renderable: "RenderableType", first_row: bool, last_row: bool + ) -> "RenderableType": + if not any_padding: + return renderable + top, right, bottom, left = padding + + if collapse_padding: + if not first_column: + left = max(0, left - right) + if not last_row: + bottom = max(0, top - bottom) + + if not pad_edge: + if first_column: + left = 0 + if last_column: + right = 0 + if first_row: + top = 0 + if last_row: + bottom = 0 + _padding = Padding(renderable, (top, right, bottom, left)) + return _padding + + raw_cells: List[Tuple[StyleType, "RenderableType"]] = [] + _append = raw_cells.append + get_style = console.get_style + if self.show_header: + header_style = get_style(self.header_style or "") + get_style( + column.header_style + ) + _append((header_style, column.header)) + cell_style = get_style(self.style or "") + get_style(column.style) + for cell in column.cells: + _append((cell_style, cell)) + if self.show_footer: + footer_style = get_style(self.footer_style or "") + get_style( + column.footer_style + ) + _append((footer_style, column.footer)) + for first, last, (style, renderable) in loop_first_last(raw_cells): + yield _Cell(style, add_padding(renderable, first, last)) + + def _get_padding_width(self, column_index: int) -> int: + """Get extra width from padding.""" + _, pad_right, _, pad_left = self.padding + if self.collapse_padding: + if column_index > 0: + pad_left = max(0, pad_left - pad_right) + return pad_left + pad_right + + def _measure_column( + self, console: "Console", column: Column, max_width: int + ) -> Measurement: + """Get the minimum and maximum width of the column.""" + + if max_width < 1: + return Measurement(0, 0) + + padding_width = self._get_padding_width(column._index) + + if column.width is not None: + # Fixed width column + return Measurement( + column.width + padding_width, column.width + padding_width + ).with_maximum(max_width) + # Flexible column, we need to measure contents + min_widths: List[int] = [] + max_widths: List[int] = [] + append_min = min_widths.append + append_max = max_widths.append + get_render_width = Measurement.get + for cell in self._get_cells(console, column._index, column): + _min, _max = get_render_width(console, cell.renderable, max_width) + append_min(_min) + append_max(_max) + + measurement = Measurement( + max(min_widths) if min_widths else 1, + max(max_widths) if max_widths else max_width, + ).with_maximum(max_width) + measurement = measurement.clamp( + None if column.min_width is None else column.min_width + padding_width, + None if column.max_width is None else column.max_width + padding_width, + ) + return measurement + + def _render( + self, console: "Console", options: "ConsoleOptions", widths: List[int] + ) -> "RenderResult": + table_style = console.get_style(self.style or "") + + border_style = table_style + console.get_style(self.border_style or "") + _column_cells = ( + self._get_cells(console, column_index, column) + for column_index, column in enumerate(self.columns) + ) + row_cells: List[Tuple[_Cell, ...]] = list(zip(*_column_cells)) + _box = ( + self.box.substitute( + options, safe=pick_bool(self.safe_box, console.safe_box) + ) + if self.box + else None + ) + + # _box = self.box + new_line = Segment.line() + + columns = self.columns + show_header = self.show_header + show_footer = self.show_footer + show_edge = self.show_edge + show_lines = self.show_lines + leading = self.leading + + _Segment = Segment + if _box: + box_segments = [ + ( + _Segment(_box.head_left, border_style), + _Segment(_box.head_right, border_style), + _Segment(_box.head_vertical, border_style), + ), + ( + _Segment(_box.foot_left, border_style), + _Segment(_box.foot_right, border_style), + _Segment(_box.foot_vertical, border_style), + ), + ( + _Segment(_box.mid_left, border_style), + _Segment(_box.mid_right, border_style), + _Segment(_box.mid_vertical, border_style), + ), + ] + if show_edge: + yield _Segment(_box.get_top(widths), border_style) + yield new_line + else: + box_segments = [] + + get_row_style = self.get_row_style + get_style = console.get_style + + for index, (first, last, row_cell) in enumerate(loop_first_last(row_cells)): + header_row = first and show_header + footer_row = last and show_footer + row = ( + self.rows[index - show_header] + if (not header_row and not footer_row) + else None + ) + max_height = 1 + cells: List[List[List[Segment]]] = [] + if header_row or footer_row: + row_style = Style.null() + else: + row_style = get_style( + get_row_style(console, index - 1 if show_header else index) + ) + for width, cell, column in zip(widths, row_cell, columns): + render_options = options.update( + width=width, + justify=column.justify, + no_wrap=column.no_wrap, + overflow=column.overflow, + height=None, + ) + cell_style = table_style + row_style + get_style(cell.style) + lines = console.render_lines( + cell.renderable, render_options, style=cell_style + ) + max_height = max(max_height, len(lines)) + cells.append(lines) + + cells[:] = [ + _Segment.set_shape( + _cell, width, max_height, style=table_style + row_style + ) + for width, _cell in zip(widths, cells) + ] + + if _box: + if last and show_footer: + yield _Segment( + _box.get_row(widths, "foot", edge=show_edge), border_style + ) + yield new_line + left, right, _divider = box_segments[0 if first else (2 if last else 1)] + + # If the column divider is whitespace also style it with the row background + divider = ( + _divider + if _divider.text.strip() + else _Segment( + _divider.text, row_style.background_style + _divider.style + ) + ) + for line_no in range(max_height): + if show_edge: + yield left + for last_cell, rendered_cell in loop_last(cells): + yield from rendered_cell[line_no] + if not last_cell: + yield divider + if show_edge: + yield right + yield new_line + else: + for line_no in range(max_height): + for rendered_cell in cells: + yield from rendered_cell[line_no] + yield new_line + if _box and first and show_header: + yield _Segment( + _box.get_row(widths, "head", edge=show_edge), border_style + ) + yield new_line + end_section = row and row.end_section + if _box and (show_lines or leading or end_section): + if ( + not last + and not (show_footer and index >= len(row_cells) - 2) + and not (show_header and header_row) + ): + if leading: + yield _Segment( + _box.get_row(widths, "mid", edge=show_edge) * leading, + border_style, + ) + else: + yield _Segment( + _box.get_row(widths, "row", edge=show_edge), border_style + ) + yield new_line + + if _box and show_edge: + yield _Segment(_box.get_bottom(widths), border_style) + yield new_line + + +if __name__ == "__main__": # pragma: no cover + from rich.console import Console + from rich.highlighter import ReprHighlighter + from rich.table import Table + + table = Table( + title="Star Wars Movies", + caption="Rich example table", + caption_justify="right", + ) + + table.add_column("Released", header_style="bright_cyan", style="cyan", no_wrap=True) + table.add_column("Title", style="magenta") + table.add_column("Box Office", justify="right", style="green") + + table.add_row( + "Dec 20, 2019", + "Star Wars: The Rise of Skywalker", + "$952,110,690", + ) + table.add_row("May 25, 2018", "Solo: A Star Wars Story", "$393,151,347") + table.add_row( + "Dec 15, 2017", + "Star Wars Ep. V111: The Last Jedi", + "$1,332,539,889", + style="on black", + end_section=True, + ) + table.add_row( + "Dec 16, 2016", + "Rogue One: A Star Wars Story", + "$1,332,439,889", + ) + + def header(text: str) -> None: + console.print() + console.rule(highlight(text)) + console.print() + + console = Console() + highlight = ReprHighlighter() + header("Example Table") + console.print(table, justify="center") + + table.expand = True + header("expand=True") + console.print(table, justify="center") + + table.width = 50 + header("width=50") + + console.print(table, justify="center") + + table.width = None + table.expand = False + table.row_styles = ["dim", "none"] + header("row_styles=['dim', 'none']") + + console.print(table, justify="center") + + table.width = None + table.expand = False + table.row_styles = ["dim", "none"] + table.leading = 1 + header("leading=1, row_styles=['dim', 'none']") + console.print(table, justify="center") + + table.width = None + table.expand = False + table.row_styles = ["dim", "none"] + table.show_lines = True + table.leading = 0 + header("show_lines=True, row_styles=['dim', 'none']") + console.print(table, justify="center") diff --git a/rich/tabulate.py b/rich/tabulate.py new file mode 100644 index 0000000..2966948 --- /dev/null +++ b/rich/tabulate.py @@ -0,0 +1,75 @@ +from collections.abc import Mapping +from typing import Optional + +from rich.console import JustifyMethod + +from . import box +from .highlighter import ReprHighlighter +from .pretty import Pretty +from .table import Table + + +def tabulate_mapping( + mapping: Mapping, + title: str = None, + caption: str = None, + title_justify: Optional[JustifyMethod] = None, + caption_justify: Optional[JustifyMethod] = None, +) -> Table: + """Generate a simple table from a mapping. + + Args: + mapping (Mapping): A mapping object (e.g. a dict); + title (str, optional): Optional title to be displayed over the table. + caption (str, optional): Optional caption to be displayed below the table. + title_justify (str, optional): Justify method for title. Defaults to None. + caption_justify (str, optional): Justify method for caption. Defaults to None. + + Returns: + Table: A table instance which may be rendered by the Console. + """ + table = Table( + show_header=False, + title=title, + caption=caption, + box=box.ROUNDED, + border_style="blue", + ) + table.title = title + table.caption = caption + if title_justify is not None: + table.title_justify = title_justify + if caption_justify is not None: + table.caption_justify = caption_justify + highlighter = ReprHighlighter() + for key, value in mapping.items(): + table.add_row( + Pretty(key, highlighter=highlighter), Pretty(value, highlighter=highlighter) + ) + return table + + +if __name__ == "__main__": # pragma: no cover + from rich import print + + def test(foo, bar, tjustify=None, cjustify=None): + list_of_things = [1, 2, 3, None, 4, True, False, "Hello World"] + dict_of_things = { + "version": "1.1", + "method": "confirmFruitPurchase", + "params": [["apple", "orange", "mangoes", "pomelo"], 1.123], + "id": "194521489", + } + print( + tabulate_mapping( + locals(), + title="locals()", + title_justify=tjustify, + caption="__main__.test", + caption_justify=cjustify, + ) + ) + + print() + test(20.3423, 3.1427, cjustify="right") + print() diff --git a/rich/terminal_theme.py b/rich/terminal_theme.py new file mode 100644 index 0000000..a5ca1c0 --- /dev/null +++ b/rich/terminal_theme.py @@ -0,0 +1,55 @@ +from typing import List, Tuple + +from .color_triplet import ColorTriplet +from .palette import Palette + +_ColorTuple = Tuple[int, int, int] + + +class TerminalTheme: + """A color theme used when exporting console content. + + Args: + background (Tuple[int, int, int]): The background color. + foreground (Tuple[int, int, int]): The foreground (text) color. + normal (List[Tuple[int, int, int]]): A list of 8 normal intensity colors. + bright (List[Tuple[int, int, int]], optional): A list of 8 bright colors, or None + to repeat normal intensity. Defaults to None. + """ + + def __init__( + self, + background: _ColorTuple, + foreground: _ColorTuple, + normal: List[_ColorTuple], + bright: List[_ColorTuple] = None, + ) -> None: + self.background_color = ColorTriplet(*background) + self.foreground_color = ColorTriplet(*foreground) + self.ansi_colors = Palette(normal + (bright or normal)) + + +DEFAULT_TERMINAL_THEME = TerminalTheme( + (255, 255, 255), + (0, 0, 0), + [ + (0, 0, 0), + (128, 0, 0), + (0, 128, 0), + (128, 128, 0), + (0, 0, 128), + (128, 0, 128), + (0, 128, 128), + (192, 192, 192), + ], + [ + (128, 128, 128), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), + ], +) diff --git a/rich/text.py b/rich/text.py new file mode 100644 index 0000000..6a4a332 --- /dev/null +++ b/rich/text.py @@ -0,0 +1,1133 @@ +import re +from functools import partial, reduce +from math import gcd +from operator import attrgetter, itemgetter +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Iterable, + List, + NamedTuple, + Optional, + Tuple, + Union, + cast, +) + +from ._loop import loop_last +from ._pick import pick_bool +from ._wrap import divide_line +from .align import AlignMethod +from .cells import cell_len, set_cell_size +from .containers import Lines +from .control import strip_control_codes +from .jupyter import JupyterMixin +from .measure import Measurement +from .segment import Segment +from .style import Style, StyleType + +if TYPE_CHECKING: # pragma: no cover + from .console import Console, ConsoleOptions, JustifyMethod, OverflowMethod + +DEFAULT_JUSTIFY: "JustifyMethod" = "default" +DEFAULT_OVERFLOW: "OverflowMethod" = "fold" + + +_re_whitespace = re.compile(r"\s+$") + +TextType = Union[str, "Text"] + +GetStyleCallable = Callable[[str], Optional[StyleType]] + + +class Span(NamedTuple): + """A marked up region in some text.""" + + start: int + """Span start index.""" + end: int + """Span end index.""" + style: Union[str, Style] + """Style associated with the span.""" + + def __repr__(self) -> str: + return f"Span({self.start}, {self.end}, {str(self.style)!r})" + + def __bool__(self) -> bool: + return self.end > self.start + + def split(self, offset: int) -> Tuple["Span", Optional["Span"]]: + """Split a span in to 2 from a given offset.""" + + if offset < self.start: + return self, None + if offset >= self.end: + return self, None + + start, end, style = self + span1 = Span(start, min(end, offset), style) + span2 = Span(span1.end, end, style) + return span1, span2 + + def move(self, offset: int) -> "Span": + """Move start and end by a given offset. + + Args: + offset (int): Number of characters to add to start and end. + + Returns: + TextSpan: A new TextSpan with adjusted position. + """ + start, end, style = self + return Span(start + offset, end + offset, style) + + def right_crop(self, offset: int) -> "Span": + """Crop the span at the given offset. + + Args: + offset (int): A value between start and end. + + Returns: + Span: A new (possibly smaller) span. + """ + start, end, style = self + if offset >= end: + return self + return Span(start, min(offset, end), style) + + +class Text(JupyterMixin): + """Text with color / style. + + Args: + text (str, optional): Default unstyled text. Defaults to "". + style (Union[str, Style], optional): Base style for text. Defaults to "". + justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None. + overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. + no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None. + end (str, optional): Character to end text with. Defaults to "\\\\n". + tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to 8. + spans (List[Span], optional). A list of predefined style spans. Defaults to None. + """ + + __slots__ = [ + "_text", + "style", + "justify", + "overflow", + "no_wrap", + "end", + "tab_size", + "_spans", + "_length", + ] + + def __init__( + self, + text: str = "", + style: Union[str, Style] = "", + *, + justify: "JustifyMethod" = None, + overflow: "OverflowMethod" = None, + no_wrap: bool = None, + end: str = "\n", + tab_size: Optional[int] = 8, + spans: List[Span] = None, + ) -> None: + self._text = [strip_control_codes(text)] + self.style = style + self.justify = justify + self.overflow = overflow + self.no_wrap = no_wrap + self.end = end + self.tab_size = tab_size + self._spans: List[Span] = spans or [] + self._length: int = len(text) + + def __len__(self) -> int: + return self._length + + def __bool__(self) -> bool: + return bool(self._length) + + def __str__(self) -> str: + return self.plain + + def __repr__(self) -> str: + return f"<text {self.plain!r} {self._spans!r}>" + + def __add__(self, other: Any) -> "Text": + if isinstance(other, (str, Text)): + result = self.copy() + result.append(other) + return result + return NotImplemented + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Text): + return NotImplemented + return self.plain == other.plain and self._spans == other._spans + + def __contains__(self, other: object) -> bool: + if isinstance(other, str): + return other in self.plain + elif isinstance(other, Text): + return other.plain in self.plain + return False + + def __getitem__(self, slice: Union[int, slice]) -> "Text": + def get_text_at(offset) -> "Text": + _Span = Span + text = Text( + self.plain[offset], + spans=[ + _Span(0, 1, style) + for start, end, style in self._spans + if end > offset >= start + ], + end="", + ) + return text + + if isinstance(slice, int): + return get_text_at(slice) + else: + start, stop, step = slice.indices(len(self.plain)) + if step == 1: + lines = self.divide([start, stop]) + return lines[1] + else: + # This would be a bit of work to implement efficiently + # For now, its not required + raise TypeError("slices with step!=1 are not supported") + + @property + def cell_len(self) -> int: + """Get the number of cells required to render this text.""" + return cell_len(self.plain) + + @classmethod + def from_markup( + cls, + text: str, + *, + style: Union[str, Style] = "", + emoji: bool = True, + justify: "JustifyMethod" = None, + overflow: "OverflowMethod" = None, + ) -> "Text": + """Create Text instance from markup. + + Args: + text (str): A string containing console markup. + emoji (bool, optional): Also render emoji code. Defaults to True. + justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None. + overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. + + Returns: + Text: A Text instance with markup rendered. + """ + from .markup import render + + rendered_text = render(text, style, emoji=emoji) + rendered_text.justify = justify + rendered_text.overflow = overflow + return rendered_text + + @classmethod + def styled( + cls, + text: str, + style: StyleType = "", + *, + justify: "JustifyMethod" = None, + overflow: "OverflowMethod" = None, + ) -> "Text": + """Construct a Text instance with a pre-applied styled. A style applied in this way won't be used + to pad the text when it is justified. + + Args: + text (str): A string containing console markup. + style (Union[str, Style]): Style to apply to the text. Defaults to "". + justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None. + overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. + + Returns: + Text: A text instance with a style applied to the entire string. + """ + styled_text = cls(text, justify=justify, overflow=overflow) + styled_text.stylize(style) + return styled_text + + @classmethod + def assemble( + cls, + *parts: Union[str, "Text", Tuple[str, StyleType]], + style: Union[str, Style] = "", + justify: "JustifyMethod" = None, + overflow: "OverflowMethod" = None, + no_wrap: bool = None, + end: str = "\n", + tab_size: int = 8, + ) -> "Text": + """Construct a text instance by combining a sequence of strings with optional styles. + The positional arguments should be either strings, or a tuple of string + style. + + Args: + style (Union[str, Style], optional): Base style for text. Defaults to "". + justify (str, optional): Justify method: "left", "center", "full", "right". Defaults to None. + overflow (str, optional): Overflow method: "crop", "fold", "ellipsis". Defaults to None. + end (str, optional): Character to end text with. Defaults to "\\\\n". + tab_size (int): Number of spaces per tab, or ``None`` to use ``console.tab_size``. Defaults to 8. + + Returns: + Text: A new text instance. + """ + text = cls( + style=style, + justify=justify, + overflow=overflow, + no_wrap=no_wrap, + end=end, + tab_size=tab_size, + ) + append = text.append + _Text = Text + for part in parts: + if isinstance(part, (_Text, str)): + append(part) + else: + append(*part) + return text + + @property + def plain(self) -> str: + """Get the text as a single string.""" + if len(self._text) != 1: + self._text[:] = ["".join(self._text)] + return self._text[0] + + @plain.setter + def plain(self, new_text: str) -> None: + """Set the text to a new value.""" + if new_text != self.plain: + self._text[:] = [new_text] + old_length = self._length + self._length = len(new_text) + if old_length > self._length: + self._trim_spans() + + @property + def spans(self) -> List[Span]: + """Get a reference to the internal list of spans.""" + return self._spans + + @spans.setter + def spans(self, spans: List[Span]) -> None: + """Set spans.""" + self._spans = spans[:] + + def blank_copy(self) -> "Text": + """Return a new Text instance with copied meta data (but not the string or spans).""" + copy_self = Text( + style=self.style, + justify=self.justify, + overflow=self.overflow, + no_wrap=self.no_wrap, + end=self.end, + tab_size=self.tab_size, + ) + return copy_self + + def copy(self) -> "Text": + """Return a copy of this instance.""" + copy_self = Text( + self.plain, + style=self.style, + justify=self.justify, + overflow=self.overflow, + no_wrap=self.no_wrap, + end=self.end, + tab_size=self.tab_size, + ) + copy_self._spans[:] = self._spans + return copy_self + + def stylize( + self, style: Union[str, Style], start: int = 0, end: Optional[int] = None + ) -> None: + """Apply a style to the text, or a portion of the text. + + Args: + style (Union[str, Style]): Style instance or style definition to apply. + start (int): Start offset (negative indexing is supported). Defaults to 0. + end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None. + + """ + length = len(self) + if start < 0: + start = length + start + if end is None: + end = length + if end < 0: + end = length + end + if start >= length or end <= start: + # Span not in text or not valid + return + self._spans.append(Span(start, min(length, end), style)) + + def remove_suffix(self, suffix: str) -> None: + """Remove a suffix if it exists. + + Args: + suffix (str): Suffix to remove. + """ + if self.plain.endswith(suffix): + self.right_crop(len(suffix)) + + def get_style_at_offset(self, console: "Console", offset: int) -> Style: + """Get the style of a character at give offset. + + Args: + console (~Console): Console where text will be rendered. + offset (int): Offset in to text (negative indexing supported) + + Returns: + Style: A Style instance. + """ + # TODO: This is a little inefficient, it is only used by full justify + if offset < 0: + offset = len(self) + offset + get_style = console.get_style + style = get_style(self.style).copy() + for start, end, span_style in self._spans: + if end > offset >= start: + style += get_style(span_style, default="") + return style + + def highlight_regex( + self, + re_highlight: str, + style: Union[GetStyleCallable, StyleType] = None, + *, + style_prefix: str = "", + ) -> int: + """Highlight text with a regular expression, where group names are + translated to styles. + + Args: + re_highlight (str): A regular expression. + style (Union[GetStyleCallable, StyleType]): Optional style to apply to whole match, or a callable + which accepts the matched text and returns a style. Defaults to None. + style_prefix (str, optional): Optional prefix to add to style group names. + + Returns: + int: Number of regex matches + """ + count = 0 + append_span = self._spans.append + _Span = Span + plain = self.plain + for match in re.finditer(re_highlight, plain): + get_span = match.span + if style: + start, end = get_span() + match_style = style(plain[start:end]) if callable(style) else style + if match_style is not None and end > start: + append_span(_Span(start, end, match_style)) + + count += 1 + for name in match.groupdict().keys(): + start, end = get_span(name) + if start != -1 and end > start: + append_span(_Span(start, end, f"{style_prefix}{name}")) + return count + + def highlight_words( + self, + words: Iterable[str], + style: Union[str, Style], + *, + case_sensitive: bool = True, + ) -> int: + """Highlight words with a style. + + Args: + words (Iterable[str]): Worlds to highlight. + style (Union[str, Style]): Style to apply. + case_sensitive (bool, optional): Enable case sensitive matchings. Defaults to True. + + Returns: + int: Number of words highlighted. + """ + re_words = "|".join(re.escape(word) for word in words) + add_span = self._spans.append + count = 0 + _Span = Span + for match in re.finditer( + re_words, self.plain, flags=0 if case_sensitive else re.IGNORECASE + ): + start, end = match.span(0) + add_span(_Span(start, end, style)) + count += 1 + return count + + def rstrip(self) -> None: + """Strip whitespace from end of text.""" + self.plain = self.plain.rstrip() + + def rstrip_end(self, size: int) -> None: + """Remove whitespace beyond a certain width at the end of the text. + + Args: + size (int): The desired size of the text. + """ + text_length = len(self) + if text_length > size: + excess = text_length - size + whitespace_match = _re_whitespace.search(self.plain) + if whitespace_match is not None: + whitespace_count = len(whitespace_match.group(0)) + self.right_crop(min(whitespace_count, excess)) + + def set_length(self, new_length: int) -> None: + """Set new length of the text, clipping or padding is required.""" + length = len(self) + if length != new_length: + if length < new_length: + self.pad_right(new_length - length) + else: + self.right_crop(length - new_length) + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> Iterable[Segment]: + tab_size: int = console.tab_size or self.tab_size or 8 # type: ignore + justify = cast( + "JustifyMethod", self.justify or options.justify or DEFAULT_OVERFLOW + ) + overflow = cast( + "OverflowMethod", self.overflow or options.overflow or DEFAULT_OVERFLOW + ) + + lines = self.wrap( + console, + options.max_width, + justify=justify, + overflow=overflow, + tab_size=tab_size or 8, + no_wrap=pick_bool(self.no_wrap, options.no_wrap, False), + ) + all_lines = Text("\n").join(lines) + yield from all_lines.render(console, end=self.end) + + def __rich_measure__(self, console: "Console", max_width: int) -> Measurement: + text = self.plain + if not text.strip(): + return Measurement(cell_len(text), cell_len(text)) + max_text_width = max(cell_len(line) for line in text.splitlines()) + min_text_width = max(cell_len(word) for word in text.split()) + return Measurement(min_text_width, max_text_width) + + def render(self, console: "Console", end: str = "") -> Iterable["Segment"]: + """Render the text as Segments. + + Args: + console (Console): Console instance. + end (Optional[str], optional): Optional end character. + + Returns: + Iterable[Segment]: Result of render that may be written to the console. + """ + + _Segment = Segment + text = self.plain + enumerated_spans = list(enumerate(self._spans, 1)) + get_style = partial(console.get_style, default=Style.null()) + style_map = {index: get_style(span.style) for index, span in enumerated_spans} + style_map[0] = get_style(self.style) + + spans = [ + (0, False, 0), + *((span.start, False, index) for index, span in enumerated_spans), + *((span.end, True, index) for index, span in enumerated_spans), + (len(text), True, 0), + ] + spans.sort(key=itemgetter(0, 1)) + + stack: List[int] = [] + stack_append = stack.append + stack_pop = stack.remove + + style_cache: Dict[Tuple[Style, ...], Style] = {} + style_cache_get = style_cache.get + combine = Style.combine + + def get_current_style() -> Style: + """Construct current style from stack.""" + styles = tuple(style_map[_style_id] for _style_id in sorted(stack)) + cached_style = style_cache_get(styles) + if cached_style is not None: + return cached_style + current_style = combine(styles) + style_cache[styles] = current_style + return current_style + + for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]): + if leaving: + stack_pop(style_id) + else: + stack_append(style_id) + if next_offset > offset: + yield _Segment(text[offset:next_offset], get_current_style()) + if end: + yield _Segment(end) + + def join(self, lines: Iterable["Text"]) -> "Text": + """Join text together with this instance as the separator. + + Args: + lines (Iterable[Text]): An iterable of Text instances to join. + + Returns: + Text: A new text instance containing join text. + """ + + new_text = self.blank_copy() + + def iter_text() -> Iterable["Text"]: + if self.plain: + for last, line in loop_last(lines): + yield line + if not last: + yield self + else: + yield from lines + + extend_text = new_text._text.extend + append_span = new_text._spans.append + extend_spans = new_text._spans.extend + offset = 0 + _Span = Span + + for text in iter_text(): + extend_text(text._text) + if text.style is not None: + append_span(_Span(offset, offset + len(text), text.style)) + extend_spans( + _Span(offset + start, offset + end, style) + for start, end, style in text._spans + ) + offset += len(text) + new_text._length = offset + return new_text + + def expand_tabs(self, tab_size: int = None) -> None: + """Converts tabs to spaces. + + Args: + tab_size (int, optional): Size of tabs. Defaults to 8. + + """ + if "\t" not in self.plain: + return + pos = 0 + if tab_size is None: + tab_size = self.tab_size + assert tab_size is not None + result = self.blank_copy() + append = result.append + + _style = self.style + for line in self.split("\n", include_separator=True): + parts = line.split("\t", include_separator=True) + for part in parts: + if part.plain.endswith("\t"): + part._text = [part.plain[:-1] + " "] + append(part) + pos += len(part) + spaces = tab_size - ((pos - 1) % tab_size) - 1 + if spaces: + append(" " * spaces, _style) + pos += spaces + else: + append(part) + self._text = [result.plain] + self._length = len(self.plain) + self._spans[:] = result._spans + + def truncate( + self, + max_width: int, + *, + overflow: Optional["OverflowMethod"] = None, + pad: bool = False, + ) -> None: + """Truncate text if it is longer that a given width. + + Args: + max_width (int): Maximum number of characters in text. + overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None, to use self.overflow. + pad (bool, optional): Pad with spaces if the length is less than max_width. Defaults to False. + """ + _overflow = overflow or self.overflow or DEFAULT_OVERFLOW + if _overflow != "ignore": + length = cell_len(self.plain) + if length > max_width: + if _overflow == "ellipsis": + self.plain = set_cell_size(self.plain, max_width - 1) + "…" + else: + self.plain = set_cell_size(self.plain, max_width) + if pad and length < max_width: + spaces = max_width - length + self._text = [f"{self.plain}{' ' * spaces}"] + self._length = len(self.plain) + + def _trim_spans(self) -> None: + """Remove or modify any spans that are over the end of the text.""" + max_offset = len(self.plain) + _Span = Span + self._spans[:] = [ + ( + span + if span.end < max_offset + else _Span(span.start, min(max_offset, span.end), span.style) + ) + for span in self._spans + if span.start < max_offset + ] + + def pad(self, count: int, character: str = " ") -> None: + """Pad left and right with a given number of characters. + + Args: + count (int): Width of padding. + """ + assert len(character) == 1, "Character must be a string of length 1" + if count: + pad_characters = character * count + self.plain = f"{pad_characters}{self.plain}{pad_characters}" + _Span = Span + self._spans[:] = [ + _Span(start + count, end + count, style) + for start, end, style in self._spans + ] + + def pad_left(self, count: int, character: str = " ") -> None: + """Pad the left with a given character. + + Args: + count (int): Number of characters to pad. + character (str, optional): Character to pad with. Defaults to " ". + """ + assert len(character) == 1, "Character must be a string of length 1" + if count: + self.plain = f"{character * count}{self.plain}" + _Span = Span + self._spans[:] = [ + _Span(start + count, end + count, style) + for start, end, style in self._spans + ] + + def pad_right(self, count: int, character: str = " ") -> None: + """Pad the right with a given character. + + Args: + count (int): Number of characters to pad. + character (str, optional): Character to pad with. Defaults to " ". + """ + assert len(character) == 1, "Character must be a string of length 1" + if count: + self.plain = f"{self.plain}{character * count}" + + def align(self, align: AlignMethod, width: int, character: str = " ") -> None: + """Align text to a given width. + + Args: + align (AlignMethod): One of "left", "center", or "right". + width (int): Desired width. + character (str, optional): Character to pad with. Defaults to " ". + """ + self.truncate(width) + excess_space = width - cell_len(self.plain) + if excess_space: + if align == "left": + self.pad_right(excess_space, character) + elif align == "center": + left = excess_space // 2 + self.pad_left(left, character) + self.pad_right(excess_space - left, character) + else: + self.pad_left(excess_space, character) + + def append( + self, text: Union["Text", str], style: Union[str, "Style"] = None + ) -> "Text": + """Add text with an optional style. + + Args: + text (Union[Text, str]): A str or Text to append. + style (str, optional): A style name. Defaults to None. + + Returns: + Text: Returns self for chaining. + """ + + if not isinstance(text, (str, Text)): + raise TypeError("Only str or Text can be appended to Text") + + if len(text): + if isinstance(text, str): + text = strip_control_codes(text) + self._text.append(text) + offset = len(self) + text_length = len(text) + if style is not None: + self._spans.append(Span(offset, offset + text_length, style)) + self._length += text_length + elif isinstance(text, Text): + _Span = Span + if style is not None: + raise ValueError( + "style must not be set when appending Text instance" + ) + text_length = self._length + if text.style is not None: + self._spans.append( + _Span(text_length, text_length + len(text), text.style) + ) + self._text.append(text.plain) + self._spans.extend( + _Span(start + text_length, end + text_length, style) + for start, end, style in text._spans + ) + self._length += len(text) + return self + + def append_text(self, text: "Text") -> "Text": + """Append another Text instance. This method is more performant that Text.append, but + only works for Text. + + Returns: + Text: Returns self for chaining. + """ + _Span = Span + text_length = self._length + if text.style is not None: + self._spans.append(_Span(text_length, text_length + len(text), text.style)) + self._text.append(text.plain) + self._spans.extend( + _Span(start + text_length, end + text_length, style) + for start, end, style in text._spans + ) + self._length += len(text) + return self + + def append_tokens(self, tokens: Iterable[Tuple[str, Optional[StyleType]]]): + """Append iterable of str and style. Style may be a Style instance or a str style definition. + + Args: + pairs (Iterable[Tuple[str, Optional[StyleType]]]): An iterable of tuples containing str content and style. + + Returns: + Text: Returns self for chaining. + """ + append_text = self._text.append + append_span = self._spans.append + _Span = Span + offset = len(self) + for content, style in tokens: + append_text(content) + if style is not None: + append_span(_Span(offset, offset + len(content), style)) + offset += len(content) + self._length = offset + return self + + def copy_styles(self, text: "Text") -> None: + """Copy styles from another Text instance. + + Args: + text (Text): A Text instance to copy styles from, must be the same length. + """ + self._spans.extend(text._spans) + + def split( + self, + separator="\n", + *, + include_separator: bool = False, + allow_blank: bool = False, + ) -> Lines: + """Split rich text in to lines, preserving styles. + + Args: + separator (str, optional): String to split on. Defaults to "\\\\n". + include_separator (bool, optional): Include the separator in the lines. Defaults to False. + allow_blank (bool, optional): Return a blank line if the text ends with a separator. Defaults to False. + + Returns: + List[RichText]: A list of rich text, one per line of the original. + """ + assert separator, "separator must not be empty" + + text = self.plain + if separator not in text: + return Lines([self.copy()]) + + if include_separator: + lines = self.divide( + match.end() for match in re.finditer(re.escape(separator), text) + ) + else: + + def flatten_spans() -> Iterable[int]: + for match in re.finditer(re.escape(separator), text): + start, end = match.span() + yield start + yield end + + lines = Lines( + line for line in self.divide(flatten_spans()) if line.plain != separator + ) + + if not allow_blank and text.endswith(separator): + lines.pop() + + return lines + + def divide(self, offsets: Iterable[int]) -> Lines: + """Divide text in to a number of lines at given offsets. + + Args: + offsets (Iterable[int]): Offsets used to divide text. + + Returns: + Lines: New RichText instances between offsets. + """ + _offsets = list(offsets) + if not _offsets: + return Lines([self.copy()]) + + text = self.plain + text_length = len(text) + divide_offsets = [0, *_offsets, text_length] + line_ranges = list(zip(divide_offsets, divide_offsets[1:])) + + style = self.style + justify = self.justify + overflow = self.overflow + _Text = Text + new_lines = Lines( + _Text( + text[start:end], + style=style, + justify=justify, + overflow=overflow, + ) + for start, end in line_ranges + ) + if not self._spans: + return new_lines + order = {span: span_index for span_index, span in enumerate(self._spans)} + span_stack = sorted(self._spans, key=attrgetter("start"), reverse=True) + + pop = span_stack.pop + push = span_stack.append + _Span = Span + get_order = order.__getitem__ + + for line, (start, end) in zip(new_lines, line_ranges): + if not span_stack: + break + append_span = line._spans.append + position = len(span_stack) - 1 + while span_stack[position].start < end: + span = pop(position) + add_span, remaining_span = span.split(end) + if remaining_span: + push(remaining_span) + order[remaining_span] = order[span] + span_start, span_end, span_style = add_span + line_span = _Span(span_start - start, span_end - start, span_style) + order[line_span] = order[span] + append_span(line_span) + position -= 1 + if position < 0 or not span_stack: + break # pragma: no cover + line._spans.sort(key=get_order) + + return new_lines + + def right_crop(self, amount: int = 1) -> None: + """Remove a number of characters from the end of the text.""" + max_offset = len(self.plain) - amount + _Span = Span + self._spans[:] = [ + ( + span + if span.end < max_offset + else _Span(span.start, min(max_offset, span.end), span.style) + ) + for span in self._spans + if span.start < max_offset + ] + self._text = [self.plain[:-amount]] + self._length -= amount + + def wrap( + self, + console: "Console", + width: int, + *, + justify: "JustifyMethod" = None, + overflow: "OverflowMethod" = None, + tab_size: int = 8, + no_wrap: bool = None, + ) -> Lines: + """Word wrap the text. + + Args: + console (Console): Console instance. + width (int): Number of characters per line. + emoji (bool, optional): Also render emoji code. Defaults to True. + justify (str, optional): Justify method: "default", "left", "center", "full", "right". Defaults to "default". + overflow (str, optional): Overflow method: "crop", "fold", or "ellipsis". Defaults to None. + tab_size (int, optional): Default tab size. Defaults to 8. + no_wrap (bool, optional): Disable wrapping, Defaults to False. + + Returns: + Lines: Number of lines. + """ + wrap_justify = cast("JustifyMethod", justify or self.justify or DEFAULT_JUSTIFY) + wrap_overflow = cast( + "OverflowMethod", overflow or self.overflow or DEFAULT_OVERFLOW + ) + no_wrap = pick_bool(no_wrap, self.no_wrap, False) or overflow == "ignore" + + lines = Lines() + for line in self.split(allow_blank=True): + if "\t" in line: + line.expand_tabs(tab_size) + if no_wrap: + new_lines = Lines([line]) + else: + offsets = divide_line(str(line), width, fold=wrap_overflow == "fold") + new_lines = line.divide(offsets) + for line in new_lines: + line.rstrip_end(width) + if wrap_justify: + new_lines.justify( + console, width, justify=wrap_justify, overflow=wrap_overflow + ) + for line in new_lines: + line.truncate(width, overflow=wrap_overflow) + lines.extend(new_lines) + return lines + + def fit(self, width: int) -> Lines: + """Fit the text in to given width by chopping in to lines. + + Args: + width (int): Maximum characters in a line. + + Returns: + Lines: List of lines. + """ + lines: Lines = Lines() + append = lines.append + for line in self.split(): + line.set_length(width) + append(line) + return lines + + def detect_indentation(self) -> int: + """Auto-detect indentation of code. + + Returns: + int: Number of spaces used to indent code. + """ + + _indentations = { + len(match.group(1)) + for match in re.finditer(r"^( *)(.*)$", self.plain, flags=re.MULTILINE) + } + + try: + indentation = ( + reduce(gcd, [indent for indent in _indentations if not indent % 2]) or 1 + ) + except TypeError: + indentation = 1 + + return indentation + + def with_indent_guides( + self, + indent_size: int = None, + *, + character: str = "│", + style: StyleType = "dim green", + ) -> "Text": + """Adds indent guide lines to text. + + Args: + indent_size (Optional[int]): Size of indentation, or None to auto detect. Defaults to None. + character (str, optional): Character to use for indentation. Defaults to "│". + style (Union[Style, str], optional): Style of indent guides. + + Returns: + Text: New text with indentation guides. + """ + + _indent_size = self.detect_indentation() if indent_size is None else indent_size + + text = self.copy() + text.expand_tabs() + indent_line = f"{character}{' ' * (_indent_size - 1)}" + + re_indent = re.compile(r"^( *)(.*)$") + new_lines: List[Text] = [] + add_line = new_lines.append + blank_lines = 0 + for line in text.split(): + match = re_indent.match(line.plain) + if not match or not match.group(2): + blank_lines += 1 + continue + indent = match.group(1) + full_indents, remaining_space = divmod(len(indent), _indent_size) + new_indent = f"{indent_line * full_indents}{' ' * remaining_space}" + line.plain = new_indent + line.plain[len(new_indent) :] + line.stylize(style, 0, len(new_indent)) + if blank_lines: + new_lines.extend([Text(new_indent, style=style)] * blank_lines) + blank_lines = 0 + add_line(line) + if blank_lines: + new_lines.extend([Text("", style=style)] * blank_lines) + + new_text = Text("\n").join(new_lines) + return new_text + + +if __name__ == "__main__": # pragma: no cover + from rich import print + + text = Text("<span>\n\tHello\n</span>") + text.expand_tabs(4) + print(text) + + code = """ +def __add__(self, other: Any) -> "Text": + if isinstance(other, (str, Text)): + result = self.copy() + result.append(other) + return result + return NotImplemented +""" + text = Text(code) + text = text.with_indent_guides() + print(text) diff --git a/rich/theme.py b/rich/theme.py new file mode 100644 index 0000000..7f161d9 --- /dev/null +++ b/rich/theme.py @@ -0,0 +1,110 @@ +import configparser +from typing import Dict, List, IO, Mapping, Optional + +from .default_styles import DEFAULT_STYLES +from .style import Style, StyleType + + +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 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: + self.styles.update( + { + name: style if isinstance(style, Style) else Style.parse(style) + for name, style in styles.items() + } + ) + + @property + def config(self) -> str: + """Get contents of a config file for this theme.""" + config = "[styles]\n" + "\n".join( + f"{name} = {style}" for name, style in sorted(self.styles.items()) + ) + return config + + @classmethod + def from_file( + cls, config_file: IO[str], source: str = None, inherit: bool = True + ) -> "Theme": + """Load a theme from a text mode file. + + Args: + config_file (IO[str]): An open conf file. + source (str, optional): The filename of the open file. Defaults to None. + inherit (bool, optional): Inherit default styles. Defaults to True. + + Returns: + Theme: A New theme instance. + """ + config = configparser.ConfigParser() + config.read_file(config_file, source=source) + styles = {name: Style.parse(value) for name, value in config.items("styles")} + theme = Theme(styles, inherit=inherit) + return theme + + @classmethod + def read(cls, path: str, inherit: bool = True) -> "Theme": + """Read a theme from a path. + + Args: + path (str): Path to a config file readable by Python configparser module. + inherit (bool, optional): Inherit default styles. Defaults to True. + + Returns: + Theme: A new theme instance. + """ + with open(path, "rt") as config_file: + 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] + styles = ( + {**self._entries[-1], **theme.styles} if inherit else 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/themes.py b/rich/themes.py new file mode 100644 index 0000000..bf6db10 --- /dev/null +++ b/rich/themes.py @@ -0,0 +1,5 @@ +from .default_styles import DEFAULT_STYLES +from .theme import Theme + + +DEFAULT = Theme(DEFAULT_STYLES) diff --git a/rich/traceback.py b/rich/traceback.py new file mode 100644 index 0000000..e97cc52 --- /dev/null +++ b/rich/traceback.py @@ -0,0 +1,621 @@ +from __future__ import absolute_import + +import os +import platform +import sys +from dataclasses import dataclass, field +from traceback import walk_tb +from types import TracebackType +from typing import Any, Callable, Dict, Iterable, List, Optional, Type + +from pygments.lexers import guess_lexer_for_filename +from pygments.token import Comment, Keyword, Name, Number, Operator, String +from pygments.token import Text as TextToken +from pygments.token import Token + +from . import pretty +from ._loop import loop_first, loop_last +from .columns import Columns +from .console import ( + Console, + ConsoleOptions, + ConsoleRenderable, + RenderResult, + render_group, +) +from .constrain import Constrain +from .highlighter import RegexHighlighter, ReprHighlighter +from .panel import Panel +from .scope import render_scope +from .style import Style +from .syntax import Syntax +from .text import Text +from .theme import Theme + +WINDOWS = platform.system() == "Windows" + +LOCALS_MAX_LENGTH = 10 +LOCALS_MAX_STRING = 80 + + +def install( + *, + console: Console = None, + width: Optional[int] = 100, + extra_lines: int = 3, + theme: Optional[str] = None, + word_wrap: bool = False, + show_locals: bool = False, + indent_guides: bool = True, +) -> Callable: + """Install a rich traceback handler. + + Once installed, any tracebacks will be printed with syntax highlighting and rich formatting. + + + Args: + console (Optional[Console], optional): Console to write exception to. Default uses internal Console instance. + width (Optional[int], optional): Width (in characters) of traceback. Defaults to 100. + extra_lines (int, optional): Extra lines of code. Defaults to 3. + theme (Optional[str], optional): Pygments theme to use in traceback. Defaults to ``None`` which will pick + a theme appropriate for the platform. + word_wrap (bool, optional): Enable word wrapping of long lines. Defaults to False. + show_locals (bool, optional): Enable display of local variables. Defaults to False. + indent_guides (bool, optional): Enable indent guides in code and locals. Defaults to True. + + Returns: + Callable: The previous exception handler that was replaced. + + """ + traceback_console = Console(file=sys.stderr) if console is None else console + + def excepthook( + type_: Type[BaseException], + value: BaseException, + traceback: Optional[TracebackType], + ) -> None: + traceback_console.print( + Traceback.from_exception( + type_, + value, + traceback, + width=width, + extra_lines=extra_lines, + theme=theme, + word_wrap=word_wrap, + show_locals=show_locals, + indent_guides=indent_guides, + ) + ) + + def ipy_excepthook_closure(ip) -> None: # pragma: no cover + tb_data = {} # store information about showtraceback call + default_showtraceback = ip.showtraceback # keep reference of default traceback + + def ipy_show_traceback(*args, **kwargs) -> None: + """wrap the default ip.showtraceback to store info for ip._showtraceback""" + nonlocal tb_data + tb_data = kwargs + default_showtraceback(*args, **kwargs) + + def ipy_display_traceback(*args, is_syntax: bool = False, **kwargs) -> None: + """Internally called traceback from ip._showtraceback""" + nonlocal tb_data + exc_tuple = ip._get_exc_info() + + # do not display trace on syntax error + tb: Optional[TracebackType] = None if is_syntax else exc_tuple[2] + + # determine correct tb_offset + compiled = tb_data.get("running_compiled_code", False) + tb_offset = tb_data.get("tb_offset", 1 if compiled else 0) + # remove ipython internal frames from trace with tb_offset + for _ in range(tb_offset): + if tb is None: + break + tb = tb.tb_next + + excepthook(exc_tuple[0], exc_tuple[1], tb) + tb_data = {} # clear data upon usage + + # replace _showtraceback instead of showtraceback to allow ipython features such as debugging to work + # this is also what the ipython docs recommends to modify when subclassing InteractiveShell + ip._showtraceback = ipy_display_traceback + # add wrapper to capture tb_data + ip.showtraceback = ipy_show_traceback + ip.showsyntaxerror = lambda *args, **kwargs: ipy_display_traceback( + *args, is_syntax=True, **kwargs + ) + + try: # pragma: no cover + # if wihin ipython, use customized traceback + ip = get_ipython() # type: ignore + ipy_excepthook_closure(ip) + return sys.excepthook + except Exception: + # otherwise use default system hook + old_excepthook = sys.excepthook + sys.excepthook = excepthook + return old_excepthook + + +@dataclass +class Frame: + filename: str + lineno: int + name: str + line: str = "" + locals: Optional[Dict[str, pretty.Node]] = None + + +@dataclass +class _SyntaxError: + offset: int + filename: str + line: str + lineno: int + msg: str + + +@dataclass +class Stack: + exc_type: str + exc_value: str + syntax_error: Optional[_SyntaxError] = None + is_cause: bool = False + frames: List[Frame] = field(default_factory=list) + + +@dataclass +class Trace: + stacks: List[Stack] + + +class PathHighlighter(RegexHighlighter): + highlights = [r"(?P<dim>.*/)(?P<bold>.+)"] + + +class Traceback: + """A Console renderable that renders a traceback. + + Args: + trace (Trace, optional): A `Trace` object produced from `extract`. Defaults to None, which uses + the last exception. + width (Optional[int], optional): Number of characters used to traceback. Defaults to 100. + extra_lines (int, optional): Additional lines of code to render. Defaults to 3. + theme (str, optional): Override pygments theme used in traceback. + word_wrap (bool, optional): Enable word wrapping of long lines. Defaults to False. + show_locals (bool, optional): Enable display of local variables. Defaults to False. + indent_guides (bool, optional): Enable indent guides in code and locals. Defaults to True. + locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. + Defaults to 10. + locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80. + """ + + LEXERS = { + "": "text", + ".py": "python", + ".pxd": "cython", + ".pyx": "cython", + ".pxi": "pyrex", + } + + def __init__( + self, + trace: Trace = None, + width: Optional[int] = 100, + extra_lines: int = 3, + theme: Optional[str] = None, + word_wrap: bool = False, + show_locals: bool = False, + indent_guides: bool = True, + locals_max_length: int = LOCALS_MAX_LENGTH, + locals_max_string: int = LOCALS_MAX_STRING, + ): + if trace is None: + exc_type, exc_value, traceback = sys.exc_info() + if exc_type is None or exc_value is None or traceback is None: + raise ValueError( + "Value for 'trace' required if not called in except: block" + ) + trace = self.extract( + exc_type, exc_value, traceback, show_locals=show_locals + ) + self.trace = trace + self.width = width + self.extra_lines = extra_lines + self.theme = Syntax.get_theme(theme or "ansi_dark") + self.word_wrap = word_wrap + self.show_locals = show_locals + self.indent_guides = indent_guides + self.locals_max_length = locals_max_length + self.locals_max_string = locals_max_string + + @classmethod + def from_exception( + cls, + exc_type: Type, + exc_value: BaseException, + traceback: Optional[TracebackType], + width: Optional[int] = 100, + extra_lines: int = 3, + theme: Optional[str] = None, + word_wrap: bool = False, + show_locals: bool = False, + indent_guides: bool = True, + locals_max_length: int = LOCALS_MAX_LENGTH, + locals_max_string: int = LOCALS_MAX_STRING, + ) -> "Traceback": + """Create a traceback from exception info + + Args: + exc_type (Type[BaseException]): Exception type. + exc_value (BaseException): Exception value. + traceback (TracebackType): Python Traceback object. + width (Optional[int], optional): Number of characters used to traceback. Defaults to 100. + extra_lines (int, optional): Additional lines of code to render. Defaults to 3. + theme (str, optional): Override pygments theme used in traceback. + word_wrap (bool, optional): Enable word wrapping of long lines. Defaults to False. + show_locals (bool, optional): Enable display of local variables. Defaults to False. + indent_guides (bool, optional): Enable indent guides in code and locals. Defaults to True. + locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. + Defaults to 10. + locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80. + + Returns: + Traceback: A Traceback instance that may be printed. + """ + rich_traceback = cls.extract( + exc_type, exc_value, traceback, show_locals=show_locals + ) + return cls( + rich_traceback, + width=width, + extra_lines=extra_lines, + theme=theme, + word_wrap=word_wrap, + show_locals=show_locals, + indent_guides=indent_guides, + locals_max_length=locals_max_length, + locals_max_string=locals_max_string, + ) + + @classmethod + def extract( + cls, + exc_type: Type[BaseException], + exc_value: BaseException, + traceback: Optional[TracebackType], + show_locals: bool = False, + locals_max_length: int = LOCALS_MAX_LENGTH, + locals_max_string: int = LOCALS_MAX_STRING, + ) -> Trace: + """Extract traceback information. + + Args: + exc_type (Type[BaseException]): Exception type. + exc_value (BaseException): Exception value. + traceback (TracebackType): Python Traceback object. + show_locals (bool, optional): Enable display of local variables. Defaults to False. + locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. + Defaults to 10. + locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80. + + Returns: + Trace: A Trace instance which you can use to construct a `Traceback`. + """ + + stacks: List[Stack] = [] + is_cause = False + + from rich import _IMPORT_CWD + + def safe_str(_object: Any) -> str: + """Don't allow exceptions from __str__ to propegate.""" + try: + return str(_object) + except Exception: + return "<exception str() failed>" + + while True: + stack = Stack( + exc_type=safe_str(exc_type.__name__), + exc_value=safe_str(exc_value), + is_cause=is_cause, + ) + + if isinstance(exc_value, SyntaxError): + stack.syntax_error = _SyntaxError( + offset=exc_value.offset or 0, + filename=exc_value.filename or "?", + lineno=exc_value.lineno or 0, + line=exc_value.text or "", + msg=exc_value.msg, + ) + + stacks.append(stack) + append = stack.frames.append + + for frame_summary, line_no in walk_tb(traceback): + filename = frame_summary.f_code.co_filename + if filename and not filename.startswith("<"): + if not os.path.isabs(filename): + filename = os.path.join(_IMPORT_CWD, filename) + frame = Frame( + filename=filename or "?", + lineno=line_no, + name=frame_summary.f_code.co_name, + locals={ + key: pretty.traverse( + value, + max_length=locals_max_length, + max_string=locals_max_string, + ) + for key, value in frame_summary.f_locals.items() + } + if show_locals + else None, + ) + append(frame) + + cause = getattr(exc_value, "__cause__", None) + if cause and cause.__traceback__: + exc_type = cause.__class__ + exc_value = cause + traceback = cause.__traceback__ + if traceback: + is_cause = True + continue + + cause = exc_value.__context__ + if ( + cause + and cause.__traceback__ + and not getattr(exc_value, "__suppress_context__", False) + ): + exc_type = cause.__class__ + exc_value = cause + traceback = cause.__traceback__ + if traceback: + is_cause = False + continue + # No cover, code is reached but coverage doesn't recognize it. + break # pragma: no cover + + trace = Trace(stacks=stacks) + return trace + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + theme = self.theme + background_style = theme.get_background_style() + token_style = theme.get_style_for_token + + traceback_theme = Theme( + { + "pretty": token_style(TextToken), + "pygments.text": token_style(Token), + "pygments.string": token_style(String), + "pygments.function": token_style(Name.Function), + "pygments.number": token_style(Number), + "repr.indent": token_style(Comment) + Style(dim=True), + "repr.str": token_style(String), + "repr.brace": token_style(TextToken) + Style(bold=True), + "repr.number": token_style(Number), + "repr.bool_true": token_style(Keyword.Constant), + "repr.bool_false": token_style(Keyword.Constant), + "repr.none": token_style(Keyword.Constant), + "scope.border": token_style(String.Delimiter), + "scope.equals": token_style(Operator), + "scope.key": token_style(Name), + "scope.key.special": token_style(Name.Constant) + Style(dim=True), + } + ) + + highlighter = ReprHighlighter() + for last, stack in loop_last(reversed(self.trace.stacks)): + if stack.frames: + stack_renderable: ConsoleRenderable = Panel( + self._render_stack(stack), + title="[traceback.title]Traceback [dim](most recent call last)", + style=background_style, + border_style="traceback.border.syntax_error", + expand=True, + padding=(0, 1), + ) + stack_renderable = Constrain(stack_renderable, self.width) + with console.use_theme(traceback_theme): + yield stack_renderable + if stack.syntax_error is not None: + with console.use_theme(traceback_theme): + yield Constrain( + Panel( + self._render_syntax_error(stack.syntax_error), + style=background_style, + border_style="traceback.border", + expand=True, + padding=(0, 1), + width=self.width, + ), + self.width, + ) + yield Text.assemble( + (f"{stack.exc_type}: ", "traceback.exc_type"), + highlighter(stack.syntax_error.msg), + ) + else: + yield Text.assemble( + (f"{stack.exc_type}: ", "traceback.exc_type"), + highlighter(stack.exc_value), + ) + + if not last: + if stack.is_cause: + yield Text.from_markup( + "\n[i]The above exception was the direct cause of the following exception:\n", + ) + else: + yield Text.from_markup( + "\n[i]During handling of the above exception, another exception occurred:\n", + ) + + @render_group() + def _render_syntax_error(self, syntax_error: _SyntaxError) -> RenderResult: + highlighter = ReprHighlighter() + path_highlighter = PathHighlighter() + if syntax_error.filename != "<stdin>": + text = Text.assemble( + (f" {syntax_error.filename}", "pygments.string"), + (":", "pygments.text"), + (str(syntax_error.lineno), "pygments.number"), + style="pygments.text", + ) + yield path_highlighter(text) + syntax_error_text = highlighter(syntax_error.line.rstrip()) + syntax_error_text.no_wrap = True + offset = min(syntax_error.offset - 1, len(syntax_error_text)) + syntax_error_text.stylize("bold underline", offset, offset + 1) + syntax_error_text += Text.from_markup( + "\n" + " " * offset + "[traceback.offset]▲[/]", + style="pygments.text", + ) + yield syntax_error_text + + @classmethod + def _guess_lexer(cls, filename: str, code: str) -> str: + ext = os.path.splitext(filename)[-1] + if not ext: + # No extension, look at first line to see if it is a hashbang + # Note, this is an educated guess and not a guarantee + # If it fails, the only downside is that the code is highlighted strangely + new_line_index = code.index("\n") + first_line = code[:new_line_index] if new_line_index != -1 else code + if first_line.startswith("#!") and "python" in first_line.lower(): + return "python" + lexer_name = ( + cls.LEXERS.get(ext) or guess_lexer_for_filename(filename, code).name + ) + return lexer_name + + @render_group() + def _render_stack(self, stack: Stack) -> 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", encoding="utf-8", errors="replace" + ) as code_file: + code = code_file.read() + code_cache[filename] = code + return code + + def render_locals(frame: Frame) -> Iterable[ConsoleRenderable]: + if frame.locals: + yield render_scope( + frame.locals, + title="locals", + indent_guides=self.indent_guides, + max_length=self.locals_max_length, + max_string=self.locals_max_string, + ) + + for first, frame in loop_first(stack.frames): + text = Text.assemble( + path_highlighter(Text(frame.filename, style="pygments.string")), + (":", "pygments.text"), + (str(frame.lineno), "pygments.number"), + " in ", + (frame.name, "pygments.function"), + style="pygments.text", + ) + if not frame.filename.startswith("<") and not first: + yield "" + yield text + if frame.filename.startswith("<"): + yield from render_locals(frame) + continue + try: + code = read_code(frame.filename) + lexer_name = self._guess_lexer(frame.filename, code) + syntax = Syntax( + code, + lexer_name, + theme=theme, + line_numbers=True, + line_range=( + frame.lineno - self.extra_lines, + frame.lineno + self.extra_lines, + ), + highlight_lines={frame.lineno}, + word_wrap=self.word_wrap, + code_width=88, + indent_guides=self.indent_guides, + dedent=False, + ) + yield "" + except Exception as error: + yield Text.assemble( + (f"\n{error}", "traceback.error"), + ) + else: + yield ( + Columns( + [ + syntax, + *render_locals(frame), + ], + padding=1, + ) + if frame.locals + else syntax + ) + + +if __name__ == "__main__": # pragma: no cover + + from .console import Console + + console = Console() + import sys + + def bar(a): # 这是对亚洲语言支持的测试。面对模棱两可的想法,拒绝猜测的诱惑 + one = 1 + print(one / a) + + def foo(a): + + zed = { + "characters": { + "Paul Atriedies", + "Vladimir Harkonnen", + "Thufir Haway", + "Duncan Idaho", + }, + "atomic_types": (None, False, True), + } + bar(a) + + def error(): + + try: + try: + foo(0) + except: + slfkjsldkfj # type: ignore + except: + console.print_exception(show_locals=True) + + error() diff --git a/rich/tree.py b/rich/tree.py new file mode 100644 index 0000000..15a3a93 --- /dev/null +++ b/rich/tree.py @@ -0,0 +1,240 @@ +from typing import Iterator, List, Tuple + +from ._loop import loop_first, loop_last +from .console import Console, ConsoleOptions, RenderableType, RenderResult +from .jupyter import JupyterMixin +from .measure import Measurement +from .segment import Segment +from .style import Style, StyleStack, StyleType +from .styled import Styled + + +class Tree(JupyterMixin): + """A renderable for a tree structure. + + Args: + label (RenderableType): The renderable or str for the tree label. + style (StyleType, optional): Style of this tree. Defaults to "tree". + guide_style (StyleType, optional): Style of the guide lines. Defaults to "tree.line". + expanded (bool, optional): Also display children. Defaults to True. + highlight (bool, optional): Highlight renderable (if str). Defaults to False. + """ + + def __init__( + self, + label: RenderableType, + *, + style: StyleType = "tree", + guide_style: StyleType = "tree.line", + expanded=True, + highlight=False, + ) -> None: + self.label = label + self.style = style + self.guide_style = guide_style + self.children: List[Tree] = [] + self.expanded = expanded + self.highlight = highlight + + def add( + self, + label: RenderableType, + *, + style: StyleType = None, + guide_style: StyleType = None, + expanded=True, + highlight=False, + ) -> "Tree": + """Add a child tree. + + Args: + label (RenderableType): The renderable or str for the tree label. + style (StyleType, optional): Style of this tree. Defaults to "tree". + guide_style (StyleType, optional): Style of the guide lines. Defaults to "tree.line". + expanded (bool, optional): Also display children. Defaults to True. + highlight (Optional[bool], optional): Highlight renderable (if str). Defaults to False. + + Returns: + Tree: A new child Tree, which may be further modified. + """ + node = Tree( + label, + style=self.style if style is None else style, + guide_style=self.guide_style if guide_style is None else guide_style, + expanded=expanded, + highlight=self.highlight if highlight is None else highlight, + ) + self.children.append(node) + return node + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + + stack: List[Iterator[Tuple[bool, Tree]]] = [] + pop = stack.pop + push = stack.append + new_line = Segment.line() + + get_style = console.get_style + null_style = Style.null() + guide_style = get_style(self.guide_style) or null_style + SPACE, CONTINUE, FORK, END = range(4) + + ASCII_GUIDES = (" ", "| ", "+-- ", "`-- ") + TREE_GUIDES = [ + (" ", "│ ", "├── ", "└── "), + (" ", "┃ ", "┣━━ ", "┗━━ "), + (" ", "║ ", "╠══ ", "╚══ "), + ] + _Segment = Segment + + def make_guide(index: int, style: Style) -> Segment: + """Make a Segment for a level of the guide lines.""" + if options.ascii_only: + line = ASCII_GUIDES[index] + else: + guide = 1 if style.bold else (2 if style.underline2 else 0) + line = TREE_GUIDES[0 if options.legacy_windows else guide][index] + return _Segment(line, style) + + levels: List[Segment] = [make_guide(CONTINUE, guide_style)] + push(iter(loop_last([self]))) + + guide_style_stack = StyleStack(get_style(self.guide_style)) + style_stack = StyleStack(get_style(self.style)) + remove_guide_styles = Style(bold=False, underline2=False) + + while stack: + stack_node = pop() + try: + last, node = next(stack_node) + except StopIteration: + levels.pop() + if levels: + guide_style = levels[-1].style or null_style + levels[-1] = make_guide(FORK, guide_style) + guide_style_stack.pop() + style_stack.pop() + continue + push(stack_node) + if last: + levels[-1] = make_guide(END, levels[-1].style or null_style) + + guide_style = guide_style_stack.current + get_style(node.guide_style) + style = style_stack.current + get_style(node.style) + prefix = levels[1:] + renderable_lines = console.render_lines( + Styled(node.label, style), + options.update( + width=options.max_width + - sum(level.cell_length for level in prefix), + highlight=self.highlight, + height=None, + ), + ) + for first, line in loop_first(renderable_lines): + if prefix: + yield from _Segment.apply_style( + prefix, + style.background_style, + post_style=remove_guide_styles, + ) + yield from line + yield new_line + if first and prefix: + prefix[-1] = make_guide( + SPACE if last else CONTINUE, prefix[-1].style or null_style + ) + + if node.expanded and node.children: + levels[-1] = make_guide( + SPACE if last else CONTINUE, levels[-1].style or null_style + ) + levels.append( + make_guide(END if len(node.children) == 1 else FORK, guide_style) + ) + style_stack.push(get_style(node.style)) + guide_style_stack.push(get_style(node.guide_style)) + push(iter(loop_last(node.children))) + + def __rich_measure__(self, console: "Console", max_width: int) -> "Measurement": + stack: List[Iterator[Tree]] = [iter([self])] + pop = stack.pop + push = stack.append + minimum = 0 + maximum = 0 + measure = Measurement.get + level = 0 + while stack: + iter_tree = pop() + try: + tree = next(iter_tree) + except StopIteration: + level -= 1 + continue + push(iter_tree) + min_measure, max_measure = measure(console, tree.label, max_width) + indent = level * 4 + minimum = max(min_measure + indent, minimum) + maximum = max(max_measure + indent, maximum) + if tree.expanded and tree.children: + push(iter(tree.children)) + level += 1 + return Measurement(minimum, maximum) + + +if __name__ == "__main__": # pragma: no cover + + from rich.console import RenderGroup + from rich.markdown import Markdown + from rich.panel import Panel + from rich.syntax import Syntax + from rich.table import Table + + table = Table(row_styles=["", "dim"]) + + table.add_column("Released", style="cyan", no_wrap=True) + table.add_column("Title", style="magenta") + table.add_column("Box Office", justify="right", style="green") + + table.add_row("Dec 20, 2019", "Star Wars: The Rise of Skywalker", "$952,110,690") + table.add_row("May 25, 2018", "Solo: A Star Wars Story", "$393,151,347") + table.add_row("Dec 15, 2017", "Star Wars Ep. V111: The Last Jedi", "$1,332,539,889") + table.add_row("Dec 16, 2016", "Rogue One: A Star Wars Story", "$1,332,439,889") + + code = """\ +class Segment(NamedTuple): + text: str = "" + style: Optional[Style] = None + is_control: bool = False +""" + syntax = Syntax(code, "python", theme="monokai", line_numbers=True) + + markdown = Markdown( + """\ +### example.md +> Hello, World! +> +> Markdown _all_ the things +""" + ) + + root = Tree("🌲 [b green]Rich Tree", highlight=True) + + node = root.add(":file_folder: Renderables", guide_style="red") + simple_node = node.add(":file_folder: [bold yellow]Atomic", guide_style="uu green") + simple_node.add(RenderGroup("📄 Syntax", syntax)) + simple_node.add(RenderGroup("📄 Markdown", Panel(markdown, border_style="green"))) + + containers_node = node.add( + ":file_folder: [bold magenta]Containers", guide_style="bold magenta" + ) + containers_node.expanded = True + panel = Panel.fit("Just a panel", border_style="red") + containers_node.add(RenderGroup("📄 Panels", panel)) + + containers_node.add(RenderGroup("📄 [b magenta]Table", table)) + + console = Console() + console.print(root) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..43a929c --- /dev/null +++ b/setup.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python + +# This is a shim to hopefully allow Github to detect the package, build is done with poetry + +import setuptools + +if __name__ == "__main__": + setuptools.setup(name="rich") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/_card_render.py b/tests/_card_render.py new file mode 100644 index 0000000..6b7fd17 --- /dev/null +++ b/tests/_card_render.py @@ -0,0 +1 @@ +expected = "\x1b[3m Rich features \x1b[0m\n\x1b[1;31m \x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m Colors \x1b[0m\x1b[1;31m \x1b[0m✓ \x1b[1;32m4-bit color\x1b[0m \x1b[38;2;86;0;0;48;2;51;0;0m▄\x1b[0m\x1b[38;2;86;9;0;48;2;51;5;0m▄\x1b[0m\x1b[38;2;86;18;0;48;2;51;11;0m▄\x1b[0m\x1b[38;2;86;28;0;48;2;51;16;0m▄\x1b[0m\x1b[38;2;86;37;0;48;2;51;22;0m▄\x1b[0m\x1b[38;2;86;47;0;48;2;51;27;0m▄\x1b[0m\x1b[38;2;86;56;0;48;2;51;33;0m▄\x1b[0m\x1b[38;2;86;66;0;48;2;51;38;0m▄\x1b[0m\x1b[38;2;86;75;0;48;2;51;44;0m▄\x1b[0m\x1b[38;2;86;85;0;48;2;51;50;0m▄\x1b[0m\x1b[38;2;78;86;0;48;2;46;51;0m▄\x1b[0m\x1b[38;2;69;86;0;48;2;40;51;0m▄\x1b[0m\x1b[38;2;59;86;0;48;2;35;51;0m▄\x1b[0m\x1b[38;2;50;86;0;48;2;29;51;0m▄\x1b[0m\x1b[38;2;40;86;0;48;2;24;51;0m▄\x1b[0m\x1b[38;2;31;86;0;48;2;18;51;0m▄\x1b[0m\x1b[38;2;22;86;0;48;2;12;51;0m▄\x1b[0m\x1b[38;2;12;86;0;48;2;7;51;0m▄\x1b[0m\x1b[38;2;3;86;0;48;2;1;51;0m▄\x1b[0m\x1b[38;2;0;86;6;48;2;0;51;3m▄\x1b[0m\x1b[38;2;0;86;15;48;2;0;51;9m▄\x1b[0m\x1b[38;2;0;86;25;48;2;0;51;14m▄\x1b[0m\x1b[38;2;0;86;34;48;2;0;51;20m▄\x1b[0m\x1b[38;2;0;86;44;48;2;0;51;25m▄\x1b[0m\x1b[38;2;0;86;53;48;2;0;51;31m▄\x1b[0m\x1b[38;2;0;86;63;48;2;0;51;37m▄\x1b[0m\x1b[38;2;0;86;72;48;2;0;51;42m▄\x1b[0m\x1b[38;2;0;86;81;48;2;0;51;48m▄\x1b[0m\x1b[38;2;0;81;86;48;2;0;48;51m▄\x1b[0m\x1b[38;2;0;72;86;48;2;0;42;51m▄\x1b[0m\x1b[38;2;0;63;86;48;2;0;37;51m▄\x1b[0m\x1b[38;2;0;53;86;48;2;0;31;51m▄\x1b[0m\x1b[38;2;0;44;86;48;2;0;25;51m▄\x1b[0m\x1b[38;2;0;34;86;48;2;0;20;51m▄\x1b[0m\x1b[38;2;0;25;86;48;2;0;14;51m▄\x1b[0m\x1b[38;2;0;15;86;48;2;0;9;51m▄\x1b[0m\x1b[38;2;0;6;86;48;2;0;3;51m▄\x1b[0m\x1b[38;2;3;0;86;48;2;1;0;51m▄\x1b[0m\x1b[38;2;12;0;86;48;2;7;0;51m▄\x1b[0m\x1b[38;2;22;0;86;48;2;12;0;51m▄\x1b[0m\x1b[38;2;31;0;86;48;2;18;0;51m▄\x1b[0m\x1b[38;2;40;0;86;48;2;24;0;51m▄\x1b[0m\x1b[38;2;50;0;86;48;2;29;0;51m▄\x1b[0m\x1b[38;2;59;0;86;48;2;35;0;51m▄\x1b[0m\x1b[38;2;69;0;86;48;2;40;0;51m▄\x1b[0m\x1b[38;2;78;0;86;48;2;46;0;51m▄\x1b[0m\x1b[38;2;86;0;85;48;2;51;0;50m▄\x1b[0m\x1b[38;2;86;0;75;48;2;51;0;44m▄\x1b[0m\x1b[38;2;86;0;66;48;2;51;0;38m▄\x1b[0m\x1b[38;2;86;0;56;48;2;51;0;33m▄\x1b[0m\x1b[38;2;86;0;47;48;2;51;0;27m▄\x1b[0m\x1b[38;2;86;0;37;48;2;51;0;22m▄\x1b[0m\x1b[38;2;86;0;28;48;2;51;0;16m▄\x1b[0m\x1b[38;2;86;0;18;48;2;51;0;11m▄\x1b[0m\x1b[38;2;86;0;9;48;2;51;0;5m▄\x1b[0m \n ✓ \x1b[1;34m8-bit color\x1b[0m \x1b[38;2;158;0;0;48;2;122;0;0m▄\x1b[0m\x1b[38;2;158;17;0;48;2;122;13;0m▄\x1b[0m\x1b[38;2;158;34;0;48;2;122;26;0m▄\x1b[0m\x1b[38;2;158;51;0;48;2;122;40;0m▄\x1b[0m\x1b[38;2;158;68;0;48;2;122;53;0m▄\x1b[0m\x1b[38;2;158;86;0;48;2;122;66;0m▄\x1b[0m\x1b[38;2;158;103;0;48;2;122;80;0m▄\x1b[0m\x1b[38;2;158;120;0;48;2;122;93;0m▄\x1b[0m\x1b[38;2;158;137;0;48;2;122;106;0m▄\x1b[0m\x1b[38;2;158;155;0;48;2;122;120;0m▄\x1b[0m\x1b[38;2;143;158;0;48;2;111;122;0m▄\x1b[0m\x1b[38;2;126;158;0;48;2;97;122;0m▄\x1b[0m\x1b[38;2;109;158;0;48;2;84;122;0m▄\x1b[0m\x1b[38;2;91;158;0;48;2;71;122;0m▄\x1b[0m\x1b[38;2;74;158;0;48;2;57;122;0m▄\x1b[0m\x1b[38;2;57;158;0;48;2;44;122;0m▄\x1b[0m\x1b[38;2;40;158;0;48;2;31;122;0m▄\x1b[0m\x1b[38;2;22;158;0;48;2;17;122;0m▄\x1b[0m\x1b[38;2;5;158;0;48;2;4;122;0m▄\x1b[0m\x1b[38;2;0;158;11;48;2;0;122;8m▄\x1b[0m\x1b[38;2;0;158;28;48;2;0;122;22m▄\x1b[0m\x1b[38;2;0;158;45;48;2;0;122;35m▄\x1b[0m\x1b[38;2;0;158;63;48;2;0;122;48m▄\x1b[0m\x1b[38;2;0;158;80;48;2;0;122;62m▄\x1b[0m\x1b[38;2;0;158;97;48;2;0;122;75m▄\x1b[0m\x1b[38;2;0;158;114;48;2;0;122;89m▄\x1b[0m\x1b[38;2;0;158;132;48;2;0;122;102m▄\x1b[0m\x1b[38;2;0;158;149;48;2;0;122;115m▄\x1b[0m\x1b[38;2;0;149;158;48;2;0;115;122m▄\x1b[0m\x1b[38;2;0;132;158;48;2;0;102;122m▄\x1b[0m\x1b[38;2;0;114;158;48;2;0;89;122m▄\x1b[0m\x1b[38;2;0;97;158;48;2;0;75;122m▄\x1b[0m\x1b[38;2;0;80;158;48;2;0;62;122m▄\x1b[0m\x1b[38;2;0;63;158;48;2;0;48;122m▄\x1b[0m\x1b[38;2;0;45;158;48;2;0;35;122m▄\x1b[0m\x1b[38;2;0;28;158;48;2;0;22;122m▄\x1b[0m\x1b[38;2;0;11;158;48;2;0;8;122m▄\x1b[0m\x1b[38;2;5;0;158;48;2;4;0;122m▄\x1b[0m\x1b[38;2;22;0;158;48;2;17;0;122m▄\x1b[0m\x1b[38;2;40;0;158;48;2;31;0;122m▄\x1b[0m\x1b[38;2;57;0;158;48;2;44;0;122m▄\x1b[0m\x1b[38;2;74;0;158;48;2;57;0;122m▄\x1b[0m\x1b[38;2;91;0;158;48;2;71;0;122m▄\x1b[0m\x1b[38;2;109;0;158;48;2;84;0;122m▄\x1b[0m\x1b[38;2;126;0;158;48;2;97;0;122m▄\x1b[0m\x1b[38;2;143;0;158;48;2;111;0;122m▄\x1b[0m\x1b[38;2;158;0;155;48;2;122;0;120m▄\x1b[0m\x1b[38;2;158;0;137;48;2;122;0;106m▄\x1b[0m\x1b[38;2;158;0;120;48;2;122;0;93m▄\x1b[0m\x1b[38;2;158;0;103;48;2;122;0;80m▄\x1b[0m\x1b[38;2;158;0;86;48;2;122;0;66m▄\x1b[0m\x1b[38;2;158;0;68;48;2;122;0;53m▄\x1b[0m\x1b[38;2;158;0;51;48;2;122;0;40m▄\x1b[0m\x1b[38;2;158;0;34;48;2;122;0;26m▄\x1b[0m\x1b[38;2;158;0;17;48;2;122;0;13m▄\x1b[0m \n ✓ \x1b[1;35mTruecolor (16.7 million)\x1b[0m \x1b[38;2;229;0;0;48;2;193;0;0m▄\x1b[0m\x1b[38;2;229;25;0;48;2;193;21;0m▄\x1b[0m\x1b[38;2;229;50;0;48;2;193;42;0m▄\x1b[0m\x1b[38;2;229;75;0;48;2;193;63;0m▄\x1b[0m\x1b[38;2;229;100;0;48;2;193;84;0m▄\x1b[0m\x1b[38;2;229;125;0;48;2;193;105;0m▄\x1b[0m\x1b[38;2;229;150;0;48;2;193;126;0m▄\x1b[0m\x1b[38;2;229;175;0;48;2;193;147;0m▄\x1b[0m\x1b[38;2;229;200;0;48;2;193;169;0m▄\x1b[0m\x1b[38;2;229;225;0;48;2;193;190;0m▄\x1b[0m\x1b[38;2;208;229;0;48;2;176;193;0m▄\x1b[0m\x1b[38;2;183;229;0;48;2;155;193;0m▄\x1b[0m\x1b[38;2;158;229;0;48;2;133;193;0m▄\x1b[0m\x1b[38;2;133;229;0;48;2;112;193;0m▄\x1b[0m\x1b[38;2;108;229;0;48;2;91;193;0m▄\x1b[0m\x1b[38;2;83;229;0;48;2;70;193;0m▄\x1b[0m\x1b[38;2;58;229;0;48;2;49;193;0m▄\x1b[0m\x1b[38;2;33;229;0;48;2;28;193;0m▄\x1b[0m\x1b[38;2;8;229;0;48;2;7;193;0m▄\x1b[0m\x1b[38;2;0;229;16;48;2;0;193;14m▄\x1b[0m\x1b[38;2;0;229;41;48;2;0;193;35m▄\x1b[0m\x1b[38;2;0;229;66;48;2;0;193;56m▄\x1b[0m\x1b[38;2;0;229;91;48;2;0;193;77m▄\x1b[0m\x1b[38;2;0;229;116;48;2;0;193;98m▄\x1b[0m\x1b[38;2;0;229;141;48;2;0;193;119m▄\x1b[0m\x1b[38;2;0;229;166;48;2;0;193;140m▄\x1b[0m\x1b[38;2;0;229;191;48;2;0;193;162m▄\x1b[0m\x1b[38;2;0;229;216;48;2;0;193;183m▄\x1b[0m\x1b[38;2;0;216;229;48;2;0;183;193m▄\x1b[0m\x1b[38;2;0;191;229;48;2;0;162;193m▄\x1b[0m\x1b[38;2;0;166;229;48;2;0;140;193m▄\x1b[0m\x1b[38;2;0;141;229;48;2;0;119;193m▄\x1b[0m\x1b[38;2;0;116;229;48;2;0;98;193m▄\x1b[0m\x1b[38;2;0;91;229;48;2;0;77;193m▄\x1b[0m\x1b[38;2;0;66;229;48;2;0;56;193m▄\x1b[0m\x1b[38;2;0;41;229;48;2;0;35;193m▄\x1b[0m\x1b[38;2;0;16;229;48;2;0;14;193m▄\x1b[0m\x1b[38;2;8;0;229;48;2;7;0;193m▄\x1b[0m\x1b[38;2;33;0;229;48;2;28;0;193m▄\x1b[0m\x1b[38;2;58;0;229;48;2;49;0;193m▄\x1b[0m\x1b[38;2;83;0;229;48;2;70;0;193m▄\x1b[0m\x1b[38;2;108;0;229;48;2;91;0;193m▄\x1b[0m\x1b[38;2;133;0;229;48;2;112;0;193m▄\x1b[0m\x1b[38;2;158;0;229;48;2;133;0;193m▄\x1b[0m\x1b[38;2;183;0;229;48;2;155;0;193m▄\x1b[0m\x1b[38;2;208;0;229;48;2;176;0;193m▄\x1b[0m\x1b[38;2;229;0;225;48;2;193;0;190m▄\x1b[0m\x1b[38;2;229;0;200;48;2;193;0;169m▄\x1b[0m\x1b[38;2;229;0;175;48;2;193;0;147m▄\x1b[0m\x1b[38;2;229;0;150;48;2;193;0;126m▄\x1b[0m\x1b[38;2;229;0;125;48;2;193;0;105m▄\x1b[0m\x1b[38;2;229;0;100;48;2;193;0;84m▄\x1b[0m\x1b[38;2;229;0;75;48;2;193;0;63m▄\x1b[0m\x1b[38;2;229;0;50;48;2;193;0;42m▄\x1b[0m\x1b[38;2;229;0;25;48;2;193;0;21m▄\x1b[0m \n ✓ \x1b[1;33mDumb terminals\x1b[0m \x1b[38;2;254;45;45;48;2;255;10;10m▄\x1b[0m\x1b[38;2;254;68;45;48;2;255;36;10m▄\x1b[0m\x1b[38;2;254;91;45;48;2;255;63;10m▄\x1b[0m\x1b[38;2;254;114;45;48;2;255;90;10m▄\x1b[0m\x1b[38;2;254;137;45;48;2;255;117;10m▄\x1b[0m\x1b[38;2;254;159;45;48;2;255;143;10m▄\x1b[0m\x1b[38;2;254;182;45;48;2;255;170;10m▄\x1b[0m\x1b[38;2;254;205;45;48;2;255;197;10m▄\x1b[0m\x1b[38;2;254;228;45;48;2;255;223;10m▄\x1b[0m\x1b[38;2;254;251;45;48;2;255;250;10m▄\x1b[0m\x1b[38;2;235;254;45;48;2;232;255;10m▄\x1b[0m\x1b[38;2;213;254;45;48;2;206;255;10m▄\x1b[0m\x1b[38;2;190;254;45;48;2;179;255;10m▄\x1b[0m\x1b[38;2;167;254;45;48;2;152;255;10m▄\x1b[0m\x1b[38;2;144;254;45;48;2;125;255;10m▄\x1b[0m\x1b[38;2;121;254;45;48;2;99;255;10m▄\x1b[0m\x1b[38;2;99;254;45;48;2;72;255;10m▄\x1b[0m\x1b[38;2;76;254;45;48;2;45;255;10m▄\x1b[0m\x1b[38;2;53;254;45;48;2;19;255;10m▄\x1b[0m\x1b[38;2;45;254;61;48;2;10;255;28m▄\x1b[0m\x1b[38;2;45;254;83;48;2;10;255;54m▄\x1b[0m\x1b[38;2;45;254;106;48;2;10;255;81m▄\x1b[0m\x1b[38;2;45;254;129;48;2;10;255;108m▄\x1b[0m\x1b[38;2;45;254;152;48;2;10;255;134m▄\x1b[0m\x1b[38;2;45;254;175;48;2;10;255;161m▄\x1b[0m\x1b[38;2;45;254;197;48;2;10;255;188m▄\x1b[0m\x1b[38;2;45;254;220;48;2;10;255;214m▄\x1b[0m\x1b[38;2;45;254;243;48;2;10;255;241m▄\x1b[0m\x1b[38;2;45;243;254;48;2;10;241;255m▄\x1b[0m\x1b[38;2;45;220;254;48;2;10;214;255m▄\x1b[0m\x1b[38;2;45;197;254;48;2;10;188;255m▄\x1b[0m\x1b[38;2;45;175;254;48;2;10;161;255m▄\x1b[0m\x1b[38;2;45;152;254;48;2;10;134;255m▄\x1b[0m\x1b[38;2;45;129;254;48;2;10;108;255m▄\x1b[0m\x1b[38;2;45;106;254;48;2;10;81;255m▄\x1b[0m\x1b[38;2;45;83;254;48;2;10;54;255m▄\x1b[0m\x1b[38;2;45;61;254;48;2;10;28;255m▄\x1b[0m\x1b[38;2;53;45;254;48;2;19;10;255m▄\x1b[0m\x1b[38;2;76;45;254;48;2;45;10;255m▄\x1b[0m\x1b[38;2;99;45;254;48;2;72;10;255m▄\x1b[0m\x1b[38;2;121;45;254;48;2;99;10;255m▄\x1b[0m\x1b[38;2;144;45;254;48;2;125;10;255m▄\x1b[0m\x1b[38;2;167;45;254;48;2;152;10;255m▄\x1b[0m\x1b[38;2;190;45;254;48;2;179;10;255m▄\x1b[0m\x1b[38;2;213;45;254;48;2;206;10;255m▄\x1b[0m\x1b[38;2;235;45;254;48;2;232;10;255m▄\x1b[0m\x1b[38;2;254;45;251;48;2;255;10;250m▄\x1b[0m\x1b[38;2;254;45;228;48;2;255;10;223m▄\x1b[0m\x1b[38;2;254;45;205;48;2;255;10;197m▄\x1b[0m\x1b[38;2;254;45;182;48;2;255;10;170m▄\x1b[0m\x1b[38;2;254;45;159;48;2;255;10;143m▄\x1b[0m\x1b[38;2;254;45;137;48;2;255;10;117m▄\x1b[0m\x1b[38;2;254;45;114;48;2;255;10;90m▄\x1b[0m\x1b[38;2;254;45;91;48;2;255;10;63m▄\x1b[0m\x1b[38;2;254;45;68;48;2;255;10;36m▄\x1b[0m \n ✓ \x1b[1;36mAutomatic color conversion\x1b[0m \x1b[38;2;255;117;117;48;2;255;81;81m▄\x1b[0m\x1b[38;2;255;132;117;48;2;255;100;81m▄\x1b[0m\x1b[38;2;255;147;117;48;2;255;119;81m▄\x1b[0m\x1b[38;2;255;162;117;48;2;255;138;81m▄\x1b[0m\x1b[38;2;255;177;117;48;2;255;157;81m▄\x1b[0m\x1b[38;2;255;192;117;48;2;255;176;81m▄\x1b[0m\x1b[38;2;255;207;117;48;2;255;195;81m▄\x1b[0m\x1b[38;2;255;222;117;48;2;255;214;81m▄\x1b[0m\x1b[38;2;255;237;117;48;2;255;232;81m▄\x1b[0m\x1b[38;2;255;252;117;48;2;255;251;81m▄\x1b[0m\x1b[38;2;242;255;117;48;2;239;255;81m▄\x1b[0m\x1b[38;2;227;255;117;48;2;220;255;81m▄\x1b[0m\x1b[38;2;212;255;117;48;2;201;255;81m▄\x1b[0m\x1b[38;2;197;255;117;48;2;182;255;81m▄\x1b[0m\x1b[38;2;182;255;117;48;2;163;255;81m▄\x1b[0m\x1b[38;2;167;255;117;48;2;144;255;81m▄\x1b[0m\x1b[38;2;152;255;117;48;2;125;255;81m▄\x1b[0m\x1b[38;2;137;255;117;48;2;106;255;81m▄\x1b[0m\x1b[38;2;122;255;117;48;2;87;255;81m▄\x1b[0m\x1b[38;2;117;255;127;48;2;81;255;94m▄\x1b[0m\x1b[38;2;117;255;142;48;2;81;255;113m▄\x1b[0m\x1b[38;2;117;255;157;48;2;81;255;132m▄\x1b[0m\x1b[38;2;117;255;172;48;2;81;255;150m▄\x1b[0m\x1b[38;2;117;255;187;48;2;81;255;169m▄\x1b[0m\x1b[38;2;117;255;202;48;2;81;255;188m▄\x1b[0m\x1b[38;2;117;255;217;48;2;81;255;207m▄\x1b[0m\x1b[38;2;117;255;232;48;2;81;255;226m▄\x1b[0m\x1b[38;2;117;255;247;48;2;81;255;245m▄\x1b[0m\x1b[38;2;117;247;255;48;2;81;245;255m▄\x1b[0m\x1b[38;2;117;232;255;48;2;81;226;255m▄\x1b[0m\x1b[38;2;117;217;255;48;2;81;207;255m▄\x1b[0m\x1b[38;2;117;202;255;48;2;81;188;255m▄\x1b[0m\x1b[38;2;117;187;255;48;2;81;169;255m▄\x1b[0m\x1b[38;2;117;172;255;48;2;81;150;255m▄\x1b[0m\x1b[38;2;117;157;255;48;2;81;132;255m▄\x1b[0m\x1b[38;2;117;142;255;48;2;81;113;255m▄\x1b[0m\x1b[38;2;117;127;255;48;2;81;94;255m▄\x1b[0m\x1b[38;2;122;117;255;48;2;87;81;255m▄\x1b[0m\x1b[38;2;137;117;255;48;2;106;81;255m▄\x1b[0m\x1b[38;2;152;117;255;48;2;125;81;255m▄\x1b[0m\x1b[38;2;167;117;255;48;2;144;81;255m▄\x1b[0m\x1b[38;2;182;117;255;48;2;163;81;255m▄\x1b[0m\x1b[38;2;197;117;255;48;2;182;81;255m▄\x1b[0m\x1b[38;2;212;117;255;48;2;201;81;255m▄\x1b[0m\x1b[38;2;227;117;255;48;2;220;81;255m▄\x1b[0m\x1b[38;2;242;117;255;48;2;239;81;255m▄\x1b[0m\x1b[38;2;255;117;252;48;2;255;81;251m▄\x1b[0m\x1b[38;2;255;117;237;48;2;255;81;232m▄\x1b[0m\x1b[38;2;255;117;222;48;2;255;81;214m▄\x1b[0m\x1b[38;2;255;117;207;48;2;255;81;195m▄\x1b[0m\x1b[38;2;255;117;192;48;2;255;81;176m▄\x1b[0m\x1b[38;2;255;117;177;48;2;255;81;157m▄\x1b[0m\x1b[38;2;255;117;162;48;2;255;81;138m▄\x1b[0m\x1b[38;2;255;117;147;48;2;255;81;119m▄\x1b[0m\x1b[38;2;255;117;132;48;2;255;81;100m▄\x1b[0m \n\x1b[1;31m \x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m Styles \x1b[0m\x1b[1;31m \x1b[0mAll ansi styles: \x1b[1mbold\x1b[0m, \x1b[2mdim\x1b[0m, \x1b[3mitalic\x1b[0m, \x1b[4munderline\x1b[0m, \x1b[9mstrikethrough\x1b[0m, \x1b[7mreverse\x1b[0m, and even \n \x1b[5mblink\x1b[0m. \n\x1b[1;31m \x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m Text \x1b[0m\x1b[1;31m \x1b[0mWord wrap text. Justify \x1b[32mleft\x1b[0m, \x1b[33mcenter\x1b[0m, \x1b[34mright\x1b[0m or \x1b[31mfull\x1b[0m. \n \n \x1b[32mLorem ipsum dolor \x1b[0m \x1b[33m Lorem ipsum dolor \x1b[0m \x1b[34m Lorem ipsum dolor\x1b[0m \x1b[31mLorem\x1b[0m\x1b[31m \x1b[0m\x1b[31mipsum\x1b[0m\x1b[31m \x1b[0m\x1b[31mdolor\x1b[0m\x1b[31m \x1b[0m\x1b[31msit\x1b[0m \n \x1b[32msit amet, \x1b[0m \x1b[33m sit amet, \x1b[0m \x1b[34m sit amet,\x1b[0m \x1b[31mamet,\x1b[0m\x1b[31m \x1b[0m\x1b[31mconsectetur\x1b[0m \n \x1b[32mconsectetur \x1b[0m \x1b[33m consectetur \x1b[0m \x1b[34m consectetur\x1b[0m \x1b[31madipiscing\x1b[0m\x1b[31m \x1b[0m\x1b[31melit.\x1b[0m \n \x1b[32madipiscing elit. \x1b[0m \x1b[33m adipiscing elit. \x1b[0m \x1b[34m adipiscing elit.\x1b[0m \x1b[31mQuisque\x1b[0m\x1b[31m \x1b[0m\x1b[31min\x1b[0m\x1b[31m \x1b[0m\x1b[31mmetus\x1b[0m\x1b[31m \x1b[0m\x1b[31msed\x1b[0m \n \x1b[32mQuisque in metus sed\x1b[0m \x1b[33mQuisque in metus sed\x1b[0m \x1b[34mQuisque in metus sed\x1b[0m \x1b[31msapien\x1b[0m\x1b[31m \x1b[0m\x1b[31multricies\x1b[0m \n \x1b[32msapien ultricies \x1b[0m \x1b[33m sapien ultricies \x1b[0m \x1b[34m sapien ultricies\x1b[0m \x1b[31mpretium\x1b[0m\x1b[31m \x1b[0m\x1b[31ma\x1b[0m\x1b[31m \x1b[0m\x1b[31mat\x1b[0m\x1b[31m \x1b[0m\x1b[31mjusto.\x1b[0m \n \x1b[32mpretium a at justo. \x1b[0m \x1b[33mpretium a at justo. \x1b[0m \x1b[34m pretium a at justo.\x1b[0m \x1b[31mMaecenas\x1b[0m\x1b[31m \x1b[0m\x1b[31mluctus\x1b[0m\x1b[31m \x1b[0m\x1b[31mvelit\x1b[0m \n \x1b[32mMaecenas luctus \x1b[0m \x1b[33m Maecenas luctus \x1b[0m \x1b[34m Maecenas luctus\x1b[0m \x1b[31met auctor maximus.\x1b[0m \n \x1b[32mvelit et auctor \x1b[0m \x1b[33m velit et auctor \x1b[0m \x1b[34m velit et auctor\x1b[0m \n \x1b[32mmaximus. \x1b[0m \x1b[33m maximus. \x1b[0m \x1b[34m maximus.\x1b[0m \n\x1b[1;31m \x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m Asian \x1b[0m\x1b[1;31m \x1b[0m🇨🇳 该库支持中文,日文和韩文文本! \n\x1b[1;31m \x1b[0m\x1b[1;31m language \x1b[0m\x1b[1;31m \x1b[0m🇯🇵 ライブラリは中国語、日本語、韓国語のテキストをサポートしています \n\x1b[1;31m \x1b[0m\x1b[1;31m support \x1b[0m\x1b[1;31m \x1b[0m🇰🇷 이 라이브러리는 중국어, 일본어 및 한국어 텍스트를 지원합니다 \n\x1b[1;31m \x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m Markup \x1b[0m\x1b[1;31m \x1b[0m\x1b[1;35mRich\x1b[0m supports a simple \x1b[3mbbcode\x1b[0m like \x1b[1mmarkup\x1b[0m for \x1b[33mcolor\x1b[0m, \x1b[4mstyle\x1b[0m, and emoji! 👍 🍎 🐜 🐻 … \n 🚌 \n\x1b[1;31m \x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m Tables \x1b[0m\x1b[1;31m \x1b[0m\x1b[1m \x1b[0m\x1b[1;32mDate\x1b[0m\x1b[1m \x1b[0m\x1b[1m \x1b[0m \x1b[1m \x1b[0m\x1b[1;34mTitle\x1b[0m\x1b[1m \x1b[0m\x1b[1m \x1b[0m \x1b[1m \x1b[0m\x1b[1;36mProduction Budget\x1b[0m\x1b[1m \x1b[0m \x1b[1m \x1b[0m\x1b[1m \x1b[0m\x1b[1;35mBox Office\x1b[0m\x1b[1m \x1b[0m \n ───────────────────────────────────────────────────────────────────────────────────── \n \x1b[32m \x1b[0m\x1b[32mDec 20, 2019\x1b[0m\x1b[32m \x1b[0m \x1b[34m \x1b[0m\x1b[34mStar Wars: The Rise of \x1b[0m\x1b[34m \x1b[0m \x1b[36m \x1b[0m\x1b[36m $275,000,000\x1b[0m\x1b[36m \x1b[0m \x1b[35m \x1b[0m\x1b[35m $375,126,118\x1b[0m\x1b[35m \x1b[0m \n \x1b[34m \x1b[0m\x1b[34mSkywalker \x1b[0m\x1b[34m \x1b[0m \n \x1b[2;32m \x1b[0m\x1b[2;32mMay 25, 2018\x1b[0m\x1b[2;32m \x1b[0m \x1b[2;34m \x1b[0m\x1b[1;2;34mSolo\x1b[0m\x1b[2;34m: A Star Wars Story \x1b[0m\x1b[2;34m \x1b[0m \x1b[2;36m \x1b[0m\x1b[2;36m $275,000,000\x1b[0m\x1b[2;36m \x1b[0m \x1b[2;35m \x1b[0m\x1b[2;35m $393,151,347\x1b[0m\x1b[2;35m \x1b[0m \n \x1b[32m \x1b[0m\x1b[32mDec 15, 2017\x1b[0m\x1b[32m \x1b[0m \x1b[34m \x1b[0m\x1b[34mStar Wars Ep. VIII: The Last \x1b[0m\x1b[34m \x1b[0m \x1b[36m \x1b[0m\x1b[36m $262,000,000\x1b[0m\x1b[36m \x1b[0m \x1b[35m \x1b[0m\x1b[1;35m$1,332,539,889\x1b[0m\x1b[35m \x1b[0m \n \x1b[34m \x1b[0m\x1b[34mJedi \x1b[0m\x1b[34m \x1b[0m \n \x1b[2;32m \x1b[0m\x1b[2;32mMay 19, 1999\x1b[0m\x1b[2;32m \x1b[0m \x1b[2;34m \x1b[0m\x1b[2;34mStar Wars Ep. \x1b[0m\x1b[1;2;34mI\x1b[0m\x1b[2;34m: \x1b[0m\x1b[2;3;34mThe phantom \x1b[0m\x1b[2;34m \x1b[0m\x1b[2;34m \x1b[0m \x1b[2;36m \x1b[0m\x1b[2;36m $115,000,000\x1b[0m\x1b[2;36m \x1b[0m \x1b[2;35m \x1b[0m\x1b[2;35m$1,027,044,677\x1b[0m\x1b[2;35m \x1b[0m \n \x1b[2m \x1b[0m \x1b[2;34m \x1b[0m\x1b[2;3;34mMenace\x1b[0m\x1b[2;34m \x1b[0m\x1b[2;34m \x1b[0m \x1b[2m \x1b[0m \x1b[2m \x1b[0m \n\x1b[1;31m \x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m Syntax \x1b[0m\x1b[1;31m \x1b[0m\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 1 \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mdef\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;166;226;46;48;2;39;40;34miter_last\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mvalues\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mIterable\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m[\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mT\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m]\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34m-\x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34m>\x1b[0m \x1b[1m{\x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31mhighlighting\x1b[0m\x1b[1;31m \x1b[0m\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 2 \x1b[0m\x1b[2;38;2;117;113;94;48;2;39;40;34m│ \x1b[0m\x1b[38;2;230;219;116;48;2;39;40;34m\"\"\"Iterate and generate a tuple w\x1b[0m \x1b[2;32m│ \x1b[0m\x1b[32m'foo'\x1b[0m: \x1b[1m[\x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m & \x1b[0m\x1b[1;31m \x1b[0m\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 3 \x1b[0m\x1b[2;38;2;117;113;94;48;2;39;40;34m│ \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34miter_values\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34m=\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34miter\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mvalues\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[48;2;39;40;34m \x1b[0m \x1b[2;32m│ │ \x1b[0m\x1b[1;34m3.1427\x1b[0m, \n\x1b[1;31m \x1b[0m\x1b[1;31m pretty \x1b[0m\x1b[1;31m \x1b[0m\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 4 \x1b[0m\x1b[2;38;2;117;113;94;48;2;39;40;34m│ \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mtry\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m \x1b[2;32m│ │ \x1b[0m\x1b[1m(\x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m printing \x1b[0m\x1b[1;31m \x1b[0m\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 5 \x1b[0m\x1b[2;38;2;117;113;94;48;2;39;40;34m│ │ \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mprevious_value\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34m=\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mnext\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34miter_va\x1b[0m \x1b[2;32m│ │ │ \x1b[0m\x1b[32m'Paul Atriedies'\x1b[0m, \n \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 6 \x1b[0m\x1b[2;38;2;117;113;94;48;2;39;40;34m│ \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mexcept\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;166;226;46;48;2;39;40;34mStopIteration\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m \x1b[2;32m│ │ │ \x1b[0m\x1b[32m'Vladimir Harkonnen'\x1b[0m, \n \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 7 \x1b[0m\x1b[2;38;2;117;113;94;48;2;39;40;34m│ │ \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mreturn\x1b[0m\x1b[48;2;39;40;34m \x1b[0m \x1b[2;32m│ │ │ \x1b[0m\x1b[32m'Thufir Haway'\x1b[0m \n \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 8 \x1b[0m\x1b[2;38;2;117;113;94;48;2;39;40;34m│ \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mfor\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mvalue\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34min\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34miter_values\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m \x1b[2;32m│ │ \x1b[0m\x1b[1m)\x1b[0m \n \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 9 \x1b[0m\x1b[2;38;2;117;113;94;48;2;39;40;34m│ │ \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34myield\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mFalse\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m,\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mprevious_value\x1b[0m\x1b[48;2;39;40;34m \x1b[0m \x1b[2;32m│ \x1b[0m\x1b[1m]\x1b[0m, \n \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m10 \x1b[0m\x1b[2;38;2;117;113;94;48;2;39;40;34m│ │ \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mprevious_value\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34m=\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mvalue\x1b[0m\x1b[48;2;39;40;34m \x1b[0m \x1b[2;32m│ \x1b[0m\x1b[32m'atomic'\x1b[0m: \x1b[1m(\x1b[0m\x1b[3;91mFalse\x1b[0m, \x1b[3;92mTrue\x1b[0m, \x1b[3;35mNone\x1b[0m\x1b[1m)\x1b[0m \n \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m11 \x1b[0m\x1b[2;38;2;117;113;94;48;2;39;40;34m│ \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34myield\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mTrue\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m,\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mprevious_value\x1b[0m\x1b[48;2;39;40;34m \x1b[0m \x1b[1m}\x1b[0m \n\x1b[1;31m \x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m Markdown \x1b[0m\x1b[1;31m \x1b[0m\x1b[36m# Markdown\x1b[0m ╔═══════════════════════════════════════╗ \n ║ \x1b[1mMarkdown\x1b[0m ║ \n \x1b[36mSupports much of the *markdown*, \x1b[0m ╚═══════════════════════════════════════╝ \n \x1b[36m__syntax__!\x1b[0m \n Supports much of the \x1b[3mmarkdown\x1b[0m, \x1b[1msyntax\x1b[0m! \n \x1b[36m- Headers\x1b[0m \n \x1b[36m- Basic formatting: **bold**, *italic*, \x1b[0m \x1b[1;33m • \x1b[0mHeaders \n \x1b[36m`code`\x1b[0m \x1b[1;33m • \x1b[0mBasic formatting: \x1b[1mbold\x1b[0m, \x1b[3mitalic\x1b[0m, \x1b[97;40mcode\x1b[0m \n \x1b[36m- Block quotes\x1b[0m \x1b[1;33m • \x1b[0mBlock quotes \n \x1b[36m- Lists, and more...\x1b[0m \x1b[1;33m • \x1b[0mLists, and more... \n \x1b[36m \x1b[0m \n\x1b[1;31m \x1b[0m \n\x1b[1;31m \x1b[0m\x1b[1;31m +more! \x1b[0m\x1b[1;31m \x1b[0mProgress bars, columns, styled logging handler, tracebacks, etc... \n\x1b[1;31m \x1b[0m \n" diff --git a/tests/_exception_render.py b/tests/_exception_render.py new file mode 100644 index 0000000..15fad3d --- /dev/null +++ b/tests/_exception_render.py @@ -0,0 +1 @@ +expected = '\x1b[1mTraceback\x1b[0m \x1b[2m(most recent call last):\x1b[0m\n\x1b[34m╭──────────────────────────────────────────────────────────────────────────────────────╮\x1b[0m\n\x1b[34m│\x1b[0m File \x1b[32m"test_traceback.py"\x1b[0m, line \x1b[1;36m24\x1b[0m, in \x1b[33mget_exception\x1b[0m \x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m21 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mtry\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m22 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mtry\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m23 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mfoo\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;174;129;255;48;2;39;40;34m0\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[38;2;101;102;96;48;2;39;40;34m❱ \x1b[0m\x1b[1;38;2;227;227;221;48;2;39;40;34m24 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mexcept\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m25 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mfoobarbaz\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m26 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mexcept\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m27 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mtb\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34m=\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mTraceback\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m File \x1b[32m"test_traceback.py"\x1b[0m, line \x1b[1;36m20\x1b[0m, in \x1b[33mfoo\x1b[0m \x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m17 \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m18 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mdef\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;166;226;46;48;2;39;40;34mfoo\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34ma\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m19 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mbar\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34ma\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[38;2;101;102;96;48;2;39;40;34m❱ \x1b[0m\x1b[1;38;2;227;227;221;48;2;39;40;34m20 \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m21 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mtry\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m22 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mtry\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m23 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mfoo\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;174;129;255;48;2;39;40;34m0\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m File \x1b[32m"test_traceback.py"\x1b[0m, line \x1b[1;36m17\x1b[0m, in \x1b[33mbar\x1b[0m \x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m14 \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mdef\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;166;226;46;48;2;39;40;34mget_exception\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34m-\x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34m>\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mTraceback\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m15 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mdef\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;166;226;46;48;2;39;40;34mbar\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34ma\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m16 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mprint\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;174;129;255;48;2;39;40;34m1\x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34m/\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34ma\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[38;2;101;102;96;48;2;39;40;34m❱ \x1b[0m\x1b[1;38;2;227;227;221;48;2;39;40;34m17 \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m18 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mdef\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;166;226;46;48;2;39;40;34mfoo\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34ma\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m19 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mbar\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34ma\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m20 \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m╰──────────────────────────────────────────────────────────────────────────────────────╯\x1b[0m\n\x1b[1;38;5;9mZeroDivisionError: \x1b[0mdivision by zero\n\n\x1b[3mDuring handling of the above exception, another exception occurred:\x1b[0m\n\n\x1b[1mTraceback\x1b[0m \x1b[2m(most recent call last):\x1b[0m\n\x1b[34m╭──────────────────────────────────────────────────────────────────────────────────────╮\x1b[0m\n\x1b[34m│\x1b[0m File \x1b[32m"test_traceback.py"\x1b[0m, line \x1b[1;36m26\x1b[0m, in \x1b[33mget_exception\x1b[0m \x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m23 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mfoo\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;174;129;255;48;2;39;40;34m0\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m24 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mexcept\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m25 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mfoobarbaz\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[38;2;101;102;96;48;2;39;40;34m❱ \x1b[0m\x1b[1;38;2;227;227;221;48;2;39;40;34m26 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mexcept\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m:\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m27 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mtb\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;249;38;114;48;2;39;40;34m=\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mTraceback\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m(\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m)\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m28 \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;102;217;239;48;2;39;40;34mreturn\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mtb\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m│\x1b[0m \x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m29 \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[34m│\x1b[0m\n\x1b[34m╰──────────────────────────────────────────────────────────────────────────────────────╯\x1b[0m\n\x1b[1;38;5;9mNameError: \x1b[0mname \x1b[32m\'foobarbaz\'\x1b[0m is not defined\n' diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..5496d46 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +junit_family=legacy diff --git a/tests/render.py b/tests/render.py new file mode 100644 index 0000000..a2435c5 --- /dev/null +++ b/tests/render.py @@ -0,0 +1,24 @@ +import io +import re + +from rich.console import Console, RenderableType + + +re_link_ids = re.compile(r"id=[\d\.\-]*?;.*?\x1b") + + +def replace_link_ids(render: str) -> str: + """Link IDs have a random ID and system path which is a problem for + reproducible tests. + + """ + return re_link_ids.sub("id=0;foo\x1b", render) + + +def render(renderable: RenderableType, no_wrap: bool = False) -> str: + console = Console( + width=100, file=io.StringIO(), color_system="truecolor", legacy_windows=False + ) + console.print(renderable, no_wrap=no_wrap) + output = replace_link_ids(console.file.getvalue()) + return output diff --git a/tests/test_align.py b/tests/test_align.py new file mode 100644 index 0000000..6473334 --- /dev/null +++ b/tests/test_align.py @@ -0,0 +1,146 @@ +import io + +import pytest + +from rich.console import Console +from rich.align import Align, VerticalCenter +from rich.measure import Measurement + + +def test_bad_align_legal(): + + # Legal + Align("foo", "left") + Align("foo", "center") + Align("foo", "right") + + # illegal + with pytest.raises(ValueError): + Align("foo", None) + with pytest.raises(ValueError): + Align("foo", "middle") + with pytest.raises(ValueError): + Align("foo", "") + with pytest.raises(ValueError): + Align("foo", "LEFT") + with pytest.raises(ValueError): + Align("foo", vertical="somewhere") + + +def test_repr(): + repr(Align("foo", "left")) + repr(Align("foo", "center")) + repr(Align("foo", "right")) + + +def test_align_left(): + console = Console(file=io.StringIO(), width=10) + console.print(Align("foo", "left")) + assert console.file.getvalue() == "foo \n" + + +def test_align_center(): + console = Console(file=io.StringIO(), width=10) + console.print(Align("foo", "center")) + assert console.file.getvalue() == " foo \n" + + +def test_align_right(): + console = Console(file=io.StringIO(), width=10) + console.print(Align("foo", "right")) + assert console.file.getvalue() == " foo\n" + + +def test_align_top(): + console = Console(file=io.StringIO(), width=10) + console.print(Align("foo", vertical="top"), height=5) + expected = "foo \n \n \n \n \n" + result = console.file.getvalue() + print(repr(result)) + assert result == expected + + +def test_align_middle(): + console = Console(file=io.StringIO(), width=10) + console.print(Align("foo", vertical="middle"), height=5) + expected = " \n \nfoo \n \n \n" + result = console.file.getvalue() + print(repr(result)) + assert result == expected + + +def test_align_bottom(): + console = Console(file=io.StringIO(), width=10) + console.print(Align("foo", vertical="bottom"), height=5) + expected = " \n \n \n \nfoo \n" + result = console.file.getvalue() + print(repr(result)) + assert result == expected + + +def test_align_center_middle(): + console = Console(file=io.StringIO(), width=10) + console.print(Align("foo\nbar", "center", vertical="middle"), height=5) + expected = " \n foo \n bar \n \n \n" + result = console.file.getvalue() + print(repr(result)) + assert result == expected + + +def test_align_fit(): + console = Console(file=io.StringIO(), width=10) + console.print(Align("foobarbaze", "center")) + assert console.file.getvalue() == "foobarbaze\n" + + +def test_align_right_style(): + console = Console( + file=io.StringIO(), width=10, color_system="truecolor", force_terminal=True + ) + console.print(Align("foo", "right", style="on blue")) + assert console.file.getvalue() == "\x1b[44m \x1b[0m\x1b[44mfoo\x1b[0m\n" + + +def test_measure(): + console = Console(file=io.StringIO(), width=20) + _min, _max = Measurement.get(console, Align("foo bar", "left"), 20) + assert _min == 3 + assert _max == 7 + + +def test_align_no_pad(): + console = Console(file=io.StringIO(), width=10) + console.print(Align("foo", "center", pad=False)) + console.print(Align("foo", "left", pad=False)) + assert console.file.getvalue() == " foo\nfoo\n" + + +def test_align_width(): + console = Console(file=io.StringIO(), width=40) + words = "Deep in the human unconscious is a pervasive need for a logical universe that makes sense. But the real universe is always one step beyond logic" + console.print(Align(words, "center", width=30)) + result = console.file.getvalue() + expected = " Deep in the human unconscious \n is a pervasive need for a \n logical universe that makes \n sense. But the real universe \n is always one step beyond \n logic \n" + assert result == expected + + +def test_shortcuts(): + assert Align.left("foo").align == "left" + assert Align.left("foo").renderable == "foo" + assert Align.right("foo").align == "right" + assert Align.right("foo").renderable == "foo" + assert Align.center("foo").align == "center" + assert Align.center("foo").renderable == "foo" + + +def test_vertical_center(): + console = Console(color_system=None, height=6) + console.begin_capture() + vertical_center = VerticalCenter("foo") + repr(vertical_center) + console.print(vertical_center) + result = console.end_capture() + print(repr(result)) + expected = " \n \nfoo\n \n \n \n" + assert result == expected + assert Measurement.get(console, vertical_center) == Measurement(3, 3) diff --git a/tests/test_ansi.py b/tests/test_ansi.py new file mode 100644 index 0000000..898286c --- /dev/null +++ b/tests/test_ansi.py @@ -0,0 +1,32 @@ +import io + +from rich.ansi import AnsiDecoder +from rich.console import Console +from rich.style import Style +from rich.text import Span, Text + + +def test_decode(): + console = Console( + force_terminal=True, legacy_windows=False, color_system="truecolor" + ) + console.begin_capture() + console.print("Hello") + console.print("[b]foo[/b]") + console.print("[link http://example.org]bar") + console.print("[#ff0000 on color(200)]red") + console.print("[color(200) on #ff0000]red") + terminal_codes = console.end_capture() + + decoder = AnsiDecoder() + lines = list(decoder.decode(terminal_codes)) + + expected = [ + Text("Hello"), + Text("foo", spans=[Span(0, 3, Style.parse("bold"))]), + Text("bar", spans=[Span(0, 3, Style.parse("link http://example.org"))]), + Text("red", spans=[Span(0, 3, Style.parse("#ff0000 on color(200)"))]), + Text("red", spans=[Span(0, 3, Style.parse("color(200) on #ff0000"))]), + ] + + assert lines == expected diff --git a/tests/test_bar.py b/tests/test_bar.py new file mode 100644 index 0000000..46f8e4e --- /dev/null +++ b/tests/test_bar.py @@ -0,0 +1,99 @@ +from rich.progress_bar import ProgressBar +from rich.segment import Segment +from rich.style import Style + +from .render import render + + +def test_init(): + bar = ProgressBar(completed=50) + repr(bar) + assert bar.percentage_completed == 50.0 + + +def test_update(): + bar = ProgressBar() + assert bar.completed == 0 + assert bar.total == 100 + bar.update(10, 20) + assert bar.completed == 10 + assert bar.total == 20 + assert bar.percentage_completed == 50 + bar.update(100) + assert bar.percentage_completed == 100 + + +expected = [ + "\x1b[38;2;249;38;114m━━━━━\x1b[0m\x1b[38;2;249;38;114m╸\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m", + "\x1b[38;2;249;38;114m━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m", +] + + +def test_render(): + bar = ProgressBar(completed=11, width=50) + bar_render = render(bar) + assert bar_render == expected[0] + bar.update(completed=12) + bar_render = render(bar) + assert bar_render == expected[1] + + +def test_measure(): + bar = ProgressBar() + measurement = bar.__rich_measure__(None, 120) + assert measurement.minimum == 4 + assert measurement.maximum == 120 + + +def test_zero_total(): + # Shouldn't throw zero division error + bar = ProgressBar(total=0) + render(bar) + + +def test_pulse(): + bar = ProgressBar(pulse=True, animation_time=10) + bar_render = render(bar) + print(repr(bar_render)) + expected = "\x1b[38;2;249;38;114m━\x1b[0m\x1b[38;2;244;38;112m━\x1b[0m\x1b[38;2;230;39;108m━\x1b[0m\x1b[38;2;209;42;102m━\x1b[0m\x1b[38;2;183;44;94m━\x1b[0m\x1b[38;2;153;48;86m━\x1b[0m\x1b[38;2;123;51;77m━\x1b[0m\x1b[38;2;97;53;69m━\x1b[0m\x1b[38;2;76;56;63m━\x1b[0m\x1b[38;2;62;57;59m━\x1b[0m\x1b[38;2;58;58;58m━\x1b[0m\x1b[38;2;62;57;59m━\x1b[0m\x1b[38;2;76;56;63m━\x1b[0m\x1b[38;2;97;53;69m━\x1b[0m\x1b[38;2;123;51;77m━\x1b[0m\x1b[38;2;153;48;86m━\x1b[0m\x1b[38;2;183;44;94m━\x1b[0m\x1b[38;2;209;42;102m━\x1b[0m\x1b[38;2;230;39;108m━\x1b[0m\x1b[38;2;244;38;112m━\x1b[0m\x1b[38;2;249;38;114m━\x1b[0m\x1b[38;2;244;38;112m━\x1b[0m\x1b[38;2;230;39;108m━\x1b[0m\x1b[38;2;209;42;102m━\x1b[0m\x1b[38;2;183;44;94m━\x1b[0m\x1b[38;2;153;48;86m━\x1b[0m\x1b[38;2;123;51;77m━\x1b[0m\x1b[38;2;97;53;69m━\x1b[0m\x1b[38;2;76;56;63m━\x1b[0m\x1b[38;2;62;57;59m━\x1b[0m\x1b[38;2;58;58;58m━\x1b[0m\x1b[38;2;62;57;59m━\x1b[0m\x1b[38;2;76;56;63m━\x1b[0m\x1b[38;2;97;53;69m━\x1b[0m\x1b[38;2;123;51;77m━\x1b[0m\x1b[38;2;153;48;86m━\x1b[0m\x1b[38;2;183;44;94m━\x1b[0m\x1b[38;2;209;42;102m━\x1b[0m\x1b[38;2;230;39;108m━\x1b[0m\x1b[38;2;244;38;112m━\x1b[0m\x1b[38;2;249;38;114m━\x1b[0m\x1b[38;2;244;38;112m━\x1b[0m\x1b[38;2;230;39;108m━\x1b[0m\x1b[38;2;209;42;102m━\x1b[0m\x1b[38;2;183;44;94m━\x1b[0m\x1b[38;2;153;48;86m━\x1b[0m\x1b[38;2;123;51;77m━\x1b[0m\x1b[38;2;97;53;69m━\x1b[0m\x1b[38;2;76;56;63m━\x1b[0m\x1b[38;2;62;57;59m━\x1b[0m\x1b[38;2;58;58;58m━\x1b[0m\x1b[38;2;62;57;59m━\x1b[0m\x1b[38;2;76;56;63m━\x1b[0m\x1b[38;2;97;53;69m━\x1b[0m\x1b[38;2;123;51;77m━\x1b[0m\x1b[38;2;153;48;86m━\x1b[0m\x1b[38;2;183;44;94m━\x1b[0m\x1b[38;2;209;42;102m━\x1b[0m\x1b[38;2;230;39;108m━\x1b[0m\x1b[38;2;244;38;112m━\x1b[0m\x1b[38;2;249;38;114m━\x1b[0m\x1b[38;2;244;38;112m━\x1b[0m\x1b[38;2;230;39;108m━\x1b[0m\x1b[38;2;209;42;102m━\x1b[0m\x1b[38;2;183;44;94m━\x1b[0m\x1b[38;2;153;48;86m━\x1b[0m\x1b[38;2;123;51;77m━\x1b[0m\x1b[38;2;97;53;69m━\x1b[0m\x1b[38;2;76;56;63m━\x1b[0m\x1b[38;2;62;57;59m━\x1b[0m\x1b[38;2;58;58;58m━\x1b[0m\x1b[38;2;62;57;59m━\x1b[0m\x1b[38;2;76;56;63m━\x1b[0m\x1b[38;2;97;53;69m━\x1b[0m\x1b[38;2;123;51;77m━\x1b[0m\x1b[38;2;153;48;86m━\x1b[0m\x1b[38;2;183;44;94m━\x1b[0m\x1b[38;2;209;42;102m━\x1b[0m\x1b[38;2;230;39;108m━\x1b[0m\x1b[38;2;244;38;112m━\x1b[0m\x1b[38;2;249;38;114m━\x1b[0m\x1b[38;2;244;38;112m━\x1b[0m\x1b[38;2;230;39;108m━\x1b[0m\x1b[38;2;209;42;102m━\x1b[0m\x1b[38;2;183;44;94m━\x1b[0m\x1b[38;2;153;48;86m━\x1b[0m\x1b[38;2;123;51;77m━\x1b[0m\x1b[38;2;97;53;69m━\x1b[0m\x1b[38;2;76;56;63m━\x1b[0m\x1b[38;2;62;57;59m━\x1b[0m\x1b[38;2;58;58;58m━\x1b[0m\x1b[38;2;62;57;59m━\x1b[0m\x1b[38;2;76;56;63m━\x1b[0m\x1b[38;2;97;53;69m━\x1b[0m\x1b[38;2;123;51;77m━\x1b[0m\x1b[38;2;153;48;86m━\x1b[0m\x1b[38;2;183;44;94m━\x1b[0m\x1b[38;2;209;42;102m━\x1b[0m\x1b[38;2;230;39;108m━\x1b[0m\x1b[38;2;244;38;112m━\x1b[0m" + assert bar_render == expected + + +def test_get_pulse_segments(): + bar = ProgressBar() + segments = bar._get_pulse_segments( + Style.parse("red"), Style.parse("yellow"), None, False, False + ) + print(repr(segments)) + expected = [ + Segment("━", Style.parse("red"), False), + Segment("━", Style.parse("red"), False), + Segment("━", Style.parse("red"), False), + Segment("━", Style.parse("red"), False), + Segment("━", Style.parse("red"), False), + Segment("━", Style.parse("red"), False), + Segment("━", Style.parse("red"), False), + Segment("━", Style.parse("red"), False), + Segment("━", Style.parse("red"), False), + Segment("━", Style.parse("red"), False), + Segment("━", Style.parse("yellow"), False), + Segment("━", Style.parse("yellow"), False), + Segment("━", Style.parse("yellow"), False), + Segment("━", Style.parse("yellow"), False), + Segment("━", Style.parse("yellow"), False), + Segment("━", Style.parse("yellow"), False), + Segment("━", Style.parse("yellow"), False), + Segment("━", Style.parse("yellow"), False), + Segment("━", Style.parse("yellow"), False), + Segment("━", Style.parse("yellow"), False), + ] + assert segments == expected + + +if __name__ == "__main__": + bar = ProgressBar(completed=11, width=50) + bar_render = render(bar) + print(repr(bar_render)) + bar.update(completed=12) + bar_render = render(bar) + print(repr(bar_render)) diff --git a/tests/test_block_bar.py b/tests/test_block_bar.py new file mode 100644 index 0000000..973b9e8 --- /dev/null +++ b/tests/test_block_bar.py @@ -0,0 +1,53 @@ +from rich.bar import Bar + +from .render import render + + +expected = [ + "\x1b[39;49m ▐█████████████████████████ \x1b[0m\n", + "\x1b[39;49m ██████████████████████▌ \x1b[0m\n", + "\x1b[39;49m \x1b[0m\n", +] + + +def test_repr(): + bar = Bar(size=100, begin=11, end=62, width=50) + assert repr(bar) == "Bar(100, 11, 62)" + + +def test_render(): + bar = Bar(size=100, begin=11, end=62, width=50) + bar_render = render(bar) + assert bar_render == expected[0] + bar = Bar(size=100, begin=12, end=57, width=50) + bar_render = render(bar) + assert bar_render == expected[1] + # begin after end + bar = Bar(size=100, begin=60, end=40, width=50) + bar_render = render(bar) + assert bar_render == expected[2] + + +def test_measure(): + bar = Bar(size=100, begin=11, end=62) + measurement = bar.__rich_measure__(None, 120) + assert measurement.minimum == 4 + assert measurement.maximum == 120 + + +def test_zero_total(): + # Shouldn't throw zero division error + bar = Bar(size=0, begin=0, end=0) + render(bar) + + +if __name__ == "__main__": + bar = Bar(size=100, begin=11, end=62, width=50) + bar_render = render(bar) + print(repr(bar_render)) + bar = Bar(size=100, begin=12, end=57, width=50) + bar_render = render(bar) + print(repr(bar_render)) + bar = Bar(size=100, begin=60, end=40, width=50) + bar_render = render(bar) + print(repr(bar_render)) diff --git a/tests/test_box.py b/tests/test_box.py new file mode 100644 index 0000000..f235a82 --- /dev/null +++ b/tests/test_box.py @@ -0,0 +1,54 @@ +import pytest + +from rich.console import ConsoleOptions, ConsoleDimensions +from rich.box import ASCII, DOUBLE, ROUNDED, HEAVY, SQUARE + + +def test_str(): + assert str(ASCII) == "+--+\n| ||\n|-+|\n| ||\n|-+|\n|-+|\n| ||\n+--+\n" + + +def test_repr(): + assert repr(ASCII) == "Box(...)" + + +def test_get_top(): + top = HEAVY.get_top(widths=[1, 2]) + assert top == "┏━┳━━┓" + + +def test_get_row(): + head_row = DOUBLE.get_row(widths=[3, 2, 1], level="head") + assert head_row == "╠═══╬══╬═╣" + + row = ASCII.get_row(widths=[1, 2, 3], level="row") + assert row == "|-+--+---|" + + foot_row = ROUNDED.get_row(widths=[2, 1, 3], level="foot") + assert foot_row == "├──┼─┼───┤" + + with pytest.raises(ValueError): + ROUNDED.get_row(widths=[1, 2, 3], level="FOO") + + +def test_get_bottom(): + bottom = HEAVY.get_bottom(widths=[1, 2, 3]) + assert bottom == "┗━┻━━┻━━━┛" + + +def test_box_substitute(): + options = ConsoleOptions( + ConsoleDimensions(80, 25), + 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 diff --git a/tests/test_card.py b/tests/test_card.py new file mode 100644 index 0000000..9f167b9 --- /dev/null +++ b/tests/test_card.py @@ -0,0 +1,40 @@ +import io +import re + +from rich.console import Console, RenderableType +from rich.__main__ import make_test_card + +from ._card_render import expected + +re_link_ids = re.compile(r"id=[\d\.\-]*?;.*?\x1b") + + +def replace_link_ids(render: str) -> str: + """Link IDs have a random ID and system path which is a problem for + reproducible tests. + + """ + return re_link_ids.sub("id=0;foo\x1b", render) + + +def render(renderable: RenderableType) -> str: + console = Console( + width=100, file=io.StringIO(), color_system="truecolor", legacy_windows=False + ) + console.print(renderable) + output = replace_link_ids(console.file.getvalue()) + return output + + +def test_card_render(): + card = make_test_card() + result = render(card) + assert result == expected + + +if __name__ == "__main__": + card = make_test_card() + with open("_card_render.py", "wt") as fh: + card_render = render(card) + print(card_render) + fh.write(f"expected={card_render!r}") diff --git a/tests/test_cells.py b/tests/test_cells.py new file mode 100644 index 0000000..06de437 --- /dev/null +++ b/tests/test_cells.py @@ -0,0 +1,11 @@ +from rich import cells + + +def test_set_cell_size(): + assert cells.set_cell_size("foo", 2) == "fo" + assert cells.set_cell_size("foo", 3) == "foo" + assert cells.set_cell_size("foo", 4) == "foo " + assert cells.set_cell_size("😽😽", 4) == "😽😽" + assert cells.set_cell_size("😽😽", 3) == "😽 " + assert cells.set_cell_size("😽😽", 2) == "😽" + assert cells.set_cell_size("😽😽", 1) == " " diff --git a/tests/test_color.py b/tests/test_color.py new file mode 100644 index 0000000..49f344e --- /dev/null +++ b/tests/test_color.py @@ -0,0 +1,184 @@ +from rich.color import ( + blend_rgb, + parse_rgb_hex, + Color, + ColorParseError, + ColorSystem, + ColorType, + ColorTriplet, +) +from rich.style import Style +from rich.text import Text, Span + +import pytest + + +def test_str() -> None: + assert str(Color.parse("red")) == "<color 'red' 1 (standard)>" + + +def test_repr() -> None: + assert repr(Color.parse("red")) == "<color 'red' 1 (standard)>" + + +def test_rich() -> None: + color = Color.parse("red") + as_text = color.__rich__() + print(repr(as_text)) + print(repr(as_text.spans)) + assert as_text == Text( + "<color 'red' (standard)⬤ >", spans=[Span(23, 24, Style(color=color))] + ) + + +def test_system() -> None: + assert Color.parse("default").system == ColorSystem.STANDARD + assert Color.parse("red").system == ColorSystem.STANDARD + assert Color.parse("#ff0000").system == ColorSystem.TRUECOLOR + + +def test_windows() -> None: + assert Color("red", ColorType.WINDOWS, number=1).get_ansi_codes() == ("31",) + + +def test_truecolor() -> None: + assert Color.parse("#ff0000").get_truecolor() == ColorTriplet(255, 0, 0) + assert Color.parse("red").get_truecolor() == ColorTriplet(128, 0, 0) + assert Color.parse("color(1)").get_truecolor() == ColorTriplet(128, 0, 0) + assert Color.parse("color(17)").get_truecolor() == ColorTriplet(0, 0, 95) + assert Color.parse("default").get_truecolor() == ColorTriplet(0, 0, 0) + assert Color.parse("default").get_truecolor(foreground=False) == ColorTriplet( + 255, 255, 255 + ) + assert Color("red", ColorType.WINDOWS, number=1).get_truecolor() == ColorTriplet( + 197, 15, 31 + ) + + +def test_parse_success() -> None: + assert Color.parse("default") == Color("default", ColorType.DEFAULT, None, None) + assert Color.parse("red") == Color("red", ColorType.STANDARD, 1, None) + assert Color.parse("bright_red") == Color("bright_red", ColorType.STANDARD, 9, None) + assert Color.parse("yellow4") == Color("yellow4", ColorType.EIGHT_BIT, 106, None) + assert Color.parse("color(100)") == Color( + "color(100)", ColorType.EIGHT_BIT, 100, None + ) + assert Color.parse("#112233") == Color( + "#112233", ColorType.TRUECOLOR, None, ColorTriplet(0x11, 0x22, 0x33) + ) + assert Color.parse("rgb(90,100,110)") == Color( + "rgb(90,100,110)", ColorType.TRUECOLOR, None, ColorTriplet(90, 100, 110) + ) + + +def test_from_triplet() -> None: + assert Color.from_triplet(ColorTriplet(0x10, 0x20, 0x30)) == Color( + "#102030", ColorType.TRUECOLOR, None, ColorTriplet(0x10, 0x20, 0x30) + ) + + +def test_from_rgb() -> None: + assert Color.from_rgb(0x10, 0x20, 0x30) == Color( + "#102030", ColorType.TRUECOLOR, None, ColorTriplet(0x10, 0x20, 0x30) + ) + + +def test_from_ansi() -> None: + assert Color.from_ansi(1) == Color("color(1)", ColorType.STANDARD, 1) + + +def test_default() -> None: + assert Color.default() == Color("default", ColorType.DEFAULT, None, None) + + +def test_parse_error() -> None: + with pytest.raises(ColorParseError): + Color.parse("256") + with pytest.raises(ColorParseError): + Color.parse("color(256)") + with pytest.raises(ColorParseError): + Color.parse("rgb(999,0,0)") + with pytest.raises(ColorParseError): + Color.parse("rgb(0,0)") + with pytest.raises(ColorParseError): + Color.parse("rgb(0,0,0,0)") + with pytest.raises(ColorParseError): + Color.parse("nosuchcolor") + with pytest.raises(ColorParseError): + Color.parse("#xxyyzz") + + +def test_get_ansi_codes() -> None: + assert Color.parse("default").get_ansi_codes() == ("39",) + assert Color.parse("default").get_ansi_codes(False) == ("49",) + assert Color.parse("red").get_ansi_codes() == ("31",) + assert Color.parse("red").get_ansi_codes(False) == ("41",) + assert Color.parse("color(1)").get_ansi_codes() == ("31",) + assert Color.parse("color(1)").get_ansi_codes(False) == ("41",) + assert Color.parse("#ff0000").get_ansi_codes() == ("38", "2", "255", "0", "0") + assert Color.parse("#ff0000").get_ansi_codes(False) == ("48", "2", "255", "0", "0") + + +def test_downgrade() -> None: + + assert Color.parse("color(9)").downgrade(0) == Color( + "color(9)", ColorType.STANDARD, 9, None + ) + + assert Color.parse("#000000").downgrade(ColorSystem.EIGHT_BIT) == Color( + "#000000", ColorType.EIGHT_BIT, 16, None + ) + + assert Color.parse("#ffffff").downgrade(ColorSystem.EIGHT_BIT) == Color( + "#ffffff", ColorType.EIGHT_BIT, 231, None + ) + + assert Color.parse("#404142").downgrade(ColorSystem.EIGHT_BIT) == Color( + "#404142", ColorType.EIGHT_BIT, 237, None + ) + + assert Color.parse("#ff0000").downgrade(ColorSystem.EIGHT_BIT) == Color( + "#ff0000", ColorType.EIGHT_BIT, 196, None + ) + + assert Color.parse("#ff0000").downgrade(ColorSystem.STANDARD) == Color( + "#ff0000", ColorType.STANDARD, 1, None + ) + + assert Color.parse("color(9)").downgrade(ColorSystem.STANDARD) == Color( + "color(9)", ColorType.STANDARD, 9, None + ) + + assert Color.parse("color(20)").downgrade(ColorSystem.STANDARD) == Color( + "color(20)", ColorType.STANDARD, 4, None + ) + + assert Color.parse("red").downgrade(ColorSystem.WINDOWS) == Color( + "red", ColorType.WINDOWS, 1, None + ) + + assert Color.parse("bright_red").downgrade(ColorSystem.WINDOWS) == Color( + "bright_red", ColorType.WINDOWS, 9, None + ) + + assert Color.parse("#ff0000").downgrade(ColorSystem.WINDOWS) == Color( + "#ff0000", ColorType.WINDOWS, 1, None + ) + + assert Color.parse("color(255)").downgrade(ColorSystem.WINDOWS) == Color( + "color(255)", ColorType.WINDOWS, 15, None + ) + + assert Color.parse("#00ff00").downgrade(ColorSystem.STANDARD) == Color( + "#00ff00", ColorType.STANDARD, 2, None + ) + + +def test_parse_rgb_hex() -> None: + assert parse_rgb_hex("aabbcc") == ColorTriplet(0xAA, 0xBB, 0xCC) + + +def test_blend_rgb() -> None: + assert blend_rgb( + ColorTriplet(10, 20, 30), ColorTriplet(30, 40, 50) + ) == ColorTriplet(20, 30, 40) diff --git a/tests/test_color_triplet.py b/tests/test_color_triplet.py new file mode 100644 index 0000000..4a592c8 --- /dev/null +++ b/tests/test_color_triplet.py @@ -0,0 +1,16 @@ +from rich.color_triplet import ColorTriplet + + +def test_hex(): + assert ColorTriplet(255, 255, 255).hex == "#ffffff" + assert ColorTriplet(0, 255, 0).hex == "#00ff00" + + +def test_rgb(): + assert ColorTriplet(255, 255, 255).rgb == "rgb(255,255,255)" + assert ColorTriplet(0, 255, 0).rgb == "rgb(0,255,0)" + + +def test_normalized(): + assert ColorTriplet(255, 255, 255).normalized == (1.0, 1.0, 1.0) + assert ColorTriplet(0, 255, 0).normalized == (0.0, 1.0, 0.0) diff --git a/tests/test_columns.py b/tests/test_columns.py new file mode 100644 index 0000000..927aba2 --- /dev/null +++ b/tests/test_columns.py @@ -0,0 +1,72 @@ +# encoding=utf-8 + +import io + +from rich.columns import Columns +from rich.console import Console + +COLUMN_DATA = [ + "Ursus americanus", + "American buffalo", + "Bison bison", + "American crow", + "Corvus brachyrhynchos", + "American marten", + "Martes americana", + "American racer", + "Coluber constrictor", + "American woodcock", + "Scolopax minor", + "Anaconda (unidentified)", + "Eunectes sp.", + "Andean goose", + "Chloephaga melanoptera", + "Ant", + "Anteater, australian spiny", + "Tachyglossus aculeatus", + "Anteater, giant", +] + + +def render(): + console = Console(file=io.StringIO(), width=100, legacy_windows=False) + + console.rule("empty") + empty_columns = Columns([]) + console.print(empty_columns) + columns = Columns(COLUMN_DATA) + columns.add_renderable("Myrmecophaga tridactyla") + console.rule("optimal") + console.print(columns) + console.rule("optimal, expand") + columns.expand = True + console.print(columns) + console.rule("columm first, optimal") + columns.column_first = True + columns.expand = False + console.print(columns) + console.rule("column first, right to left") + columns.right_to_left = True + console.print(columns) + console.rule("equal columns, expand") + columns.equal = True + columns.expand = True + console.print(columns) + console.rule("fixed width") + columns.width = 16 + columns.expand = False + console.print(columns) + console.print() + render_result = console.file.getvalue() + return render_result + + +def test_render(): + expected = "────────────────────────────────────────────── empty ───────────────────────────────────────────────\n───────────────────────────────────────────── optimal ──────────────────────────────────────────────\nUrsus americanus American buffalo Bison bison American crow \nCorvus brachyrhynchos American marten Martes americana American racer \nColuber constrictor American woodcock Scolopax minor Anaconda (unidentified)\nEunectes sp. Andean goose Chloephaga melanoptera Ant \nAnteater, australian spiny Tachyglossus aculeatus Anteater, giant Myrmecophaga tridactyla\n───────────────────────────────────────── optimal, expand ──────────────────────────────────────────\nUrsus americanus American buffalo Bison bison American crow \nCorvus brachyrhynchos American marten Martes americana American racer \nColuber constrictor American woodcock Scolopax minor Anaconda (unidentified)\nEunectes sp. Andean goose Chloephaga melanoptera Ant \nAnteater, australian spiny Tachyglossus aculeatus Anteater, giant Myrmecophaga tridactyla\n────────────────────────────────────── columm first, optimal ───────────────────────────────────────\nUrsus americanus American marten Scolopax minor Ant \nAmerican buffalo Martes americana Anaconda (unidentified) Anteater, australian spiny\nBison bison American racer Eunectes sp. Tachyglossus aculeatus \nAmerican crow Coluber constrictor Andean goose Anteater, giant \nCorvus brachyrhynchos American woodcock Chloephaga melanoptera Myrmecophaga tridactyla \n─────────────────────────────────── column first, right to left ────────────────────────────────────\nAnt Scolopax minor American marten Ursus americanus \nAnteater, australian spiny Anaconda (unidentified) Martes americana American buffalo \nTachyglossus aculeatus Eunectes sp. American racer Bison bison \nAnteater, giant Andean goose Coluber constrictor American crow \nMyrmecophaga tridactyla Chloephaga melanoptera American woodcock Corvus brachyrhynchos\n────────────────────────────────────── equal columns, expand ───────────────────────────────────────\nChloephaga melanoptera American racer Ursus americanus \nAnt Coluber constrictor American buffalo \nAnteater, australian spiny American woodcock Bison bison \nTachyglossus aculeatus Scolopax minor American crow \nAnteater, giant Anaconda (unidentified) Corvus brachyrhynchos \nMyrmecophaga tridactyla Eunectes sp. American marten \n Andean goose Martes americana \n─────────────────────────────────────────── fixed width ────────────────────────────────────────────\nAnteater, Eunectes sp. Coluber Corvus Ursus americanus \naustralian spiny constrictor brachyrhynchos \nTachyglossus Andean goose American American marten American buffalo \naculeatus woodcock \nAnteater, giant Chloephaga Scolopax minor Martes americana Bison bison \n melanoptera \nMyrmecophaga Ant Anaconda American racer American crow \ntridactyla (unidentified) \n\n" + assert render() == expected + + +if __name__ == "__main__": + result = render() + print(result) + print(repr(result)) diff --git a/tests/test_columns_align.py b/tests/test_columns_align.py new file mode 100644 index 0000000..456510a --- /dev/null +++ b/tests/test_columns_align.py @@ -0,0 +1,43 @@ +# encoding=utf-8 + +import io + +from rich import box +from rich.columns import Columns +from rich.console import Console +from rich.panel import Panel + + +def render(): + console = Console(file=io.StringIO(), width=100, legacy_windows=False) + panel = Panel.fit("foo", box=box.SQUARE, padding=0) + columns = Columns([panel] * 4) + columns.expand = True + console.rule("no align") + console.print(columns) + + columns.align = "left" + console.rule("left align") + console.print(columns) + + columns.align = "center" + console.rule("center align") + console.print(columns) + + columns.align = "right" + console.rule("right align") + console.print(columns) + + return console.file.getvalue() + + +def test_align(): + result = render() + expected = "───────────────────────────────────────────── no align ─────────────────────────────────────────────\n┌───┐ ┌───┐ ┌───┐ ┌───┐ \n│foo│ │foo│ │foo│ │foo│ \n└───┘ └───┘ └───┘ └───┘ \n──────────────────────────────────────────── left align ────────────────────────────────────────────\n┌───┐ ┌───┐ ┌───┐ ┌───┐ \n│foo│ │foo│ │foo│ │foo│ \n└───┘ └───┘ └───┘ └───┘ \n─────────────────────────────────────────── center align ───────────────────────────────────────────\n ┌───┐ ┌───┐ ┌───┐ ┌───┐ \n │foo│ │foo│ │foo│ │foo│ \n └───┘ └───┘ └───┘ └───┘ \n─────────────────────────────────────────── right align ────────────────────────────────────────────\n ┌───┐ ┌───┐ ┌───┐ ┌───┐\n │foo│ │foo│ │foo│ │foo│\n └───┘ └───┘ └───┘ └───┘\n" + assert result == expected + + +if __name__ == "__main__": + rendered = render() + print(rendered) + print(repr(rendered)) diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..ad1e0f1 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,545 @@ +import datetime +import io +import os +import sys +import tempfile +from typing import Optional + +import pytest + +from rich import errors +from rich.color import ColorSystem +from rich.console import ( + CaptureError, + Console, + ConsoleDimensions, + ConsoleOptions, + render_group, +) +from rich.measure import measure_renderables +from rich.pager import SystemPager +from rich.panel import Panel +from rich.status import Status +from rich.style import Style +from rich.text import Text + + +def test_dumb_terminal(): + console = Console(force_terminal=True) + assert console.color_system is not None + + console = Console(force_terminal=True, _environ={"TERM": "dumb"}) + assert console.color_system is None + width, height = console.size + assert width == 80 + assert height == 25 + + +def test_soft_wrap(): + console = Console(file=io.StringIO(), width=20, soft_wrap=True) + console.print("foo " * 10) + assert console.file.getvalue() == "foo " * 20 + + +@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") +def test_16color_terminal(): + console = Console( + force_terminal=True, _environ={"TERM": "xterm-16color"}, legacy_windows=False + ) + assert console.color_system == "standard" + + +@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") +def test_truecolor_terminal(): + console = Console( + force_terminal=True, + legacy_windows=False, + _environ={"COLORTERM": "truecolor", "TERM": "xterm-16color"}, + ) + assert console.color_system == "truecolor" + + +def test_console_options_update(): + options = ConsoleOptions( + ConsoleDimensions(80, 25), + 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 + + options2 = options.update(min_width=5, max_width=15, justify="right") + assert ( + options2.min_width == 5 + and options2.max_width == 15 + and options2.justify == "right" + ) + + options_copy = options.update() + assert options_copy == options and options_copy is not options + + +def test_init(): + console = Console(color_system=None) + assert console._color_system == None + console = Console(color_system="standard") + assert console._color_system == ColorSystem.STANDARD + console = Console(color_system="auto") + + +def test_size(): + console = Console() + w, h = console.size + assert console.width == w + + console = Console(width=99, height=101) + w, h = console.size + assert w == 99 and h == 101 + + +def test_repr(): + console = Console() + assert isinstance(repr(console), str) + assert isinstance(str(console), str) + + +def test_print(): + console = Console(file=io.StringIO(), color_system="truecolor") + console.print("foo") + assert console.file.getvalue() == "foo\n" + + +def test_log(): + console = Console( + file=io.StringIO(), + width=80, + color_system="truecolor", + log_time_format="TIME", + log_path=False, + ) + console.log("foo", style="red") + expected = "\x1b[2;36mTIME\x1b[0m\x1b[2;36m \x1b[0m\x1b[31mfoo\x1b[0m\x1b[31m \x1b[0m\n" + result = console.file.getvalue() + print(repr(result)) + assert result == expected + + +def test_log_milliseconds(): + def time_formatter(timestamp: datetime) -> Text: + return Text("TIME") + + console = Console( + file=io.StringIO(), width=40, log_time_format=time_formatter, log_path=False + ) + console.log("foo") + result = console.file.getvalue() + assert result == "TIME foo \n" + + +def test_print_empty(): + console = Console(file=io.StringIO(), color_system="truecolor") + console.print() + assert console.file.getvalue() == "\n" + + +def test_markup_highlight(): + console = Console(file=io.StringIO(), color_system="truecolor") + console.print("'[bold]foo[/bold]'") + assert ( + console.file.getvalue() + == "\x1b[32m'\x1b[0m\x1b[1;32mfoo\x1b[0m\x1b[32m'\x1b[0m\n" + ) + + +def test_print_style(): + console = Console(file=io.StringIO(), color_system="truecolor") + console.print("foo", style="bold") + assert console.file.getvalue() == "\x1b[1mfoo\x1b[0m\n" + + +def test_show_cursor(): + console = Console(file=io.StringIO(), force_terminal=True, legacy_windows=False) + console.show_cursor(False) + console.print("foo") + console.show_cursor(True) + assert console.file.getvalue() == "\x1b[?25lfoo\n\x1b[?25h" + + +def test_clear(): + console = Console(file=io.StringIO(), force_terminal=True) + console.clear() + console.clear(home=False) + assert console.file.getvalue() == "\033[2J\033[H" + "\033[2J" + + +def test_clear_no_terminal(): + console = Console(file=io.StringIO()) + console.clear() + console.clear(home=False) + assert console.file.getvalue() == "" + + +def test_get_style(): + console = Console() + console.get_style("repr.brace") == Style(bold=True) + + +def test_get_style_default(): + console = Console() + console.get_style("foobar", default="red") == Style(color="red") + + +def test_get_style_error(): + console = Console() + with pytest.raises(errors.MissingStyle): + console.get_style("nosuchstyle") + with pytest.raises(errors.MissingStyle): + console.get_style("foo bar") + + +def test_render_error(): + console = Console() + with pytest.raises(errors.NotRenderableError): + list(console.render([], console.options)) + + +def test_control(): + console = Console(file=io.StringIO(), force_terminal=True) + console.control("FOO") + console.print("BAR") + assert console.file.getvalue() == "FOOBAR\n" + + +def test_capture(): + console = Console() + with console.capture() as capture: + with pytest.raises(CaptureError): + capture.get() + console.print("Hello") + assert capture.get() == "Hello\n" + + +def test_input(monkeypatch, capsys): + def fake_input(prompt): + console.file.write(prompt) + return "bar" + + monkeypatch.setattr("builtins.input", fake_input) + console = Console() + user_input = console.input(prompt="foo:") + assert capsys.readouterr().out == "foo:" + assert user_input == "bar" + + +def test_input_legacy_windows(monkeypatch, capsys): + def fake_input(prompt): + console.file.write(prompt) + return "bar" + + monkeypatch.setattr("builtins.input", fake_input) + console = Console(legacy_windows=True) + user_input = console.input(prompt="foo:") + assert capsys.readouterr().out == "foo:" + assert user_input == "bar" + + +def test_input_password(monkeypatch, capsys): + def fake_input(prompt, stream=None): + console.file.write(prompt) + return "bar" + + import rich.console + + monkeypatch.setattr(rich.console, "getpass", fake_input) + console = Console() + user_input = console.input(prompt="foo:", password=True) + assert capsys.readouterr().out == "foo:" + assert user_input == "bar" + + +def test_status(): + console = Console(file=io.StringIO(), force_terminal=True, width=20) + status = console.status("foo") + assert isinstance(status, Status) + + +def test_justify_none(): + console = Console(file=io.StringIO(), force_terminal=True, width=20) + console.print("FOO", justify=None) + assert console.file.getvalue() == "FOO\n" + + +def test_justify_left(): + console = Console(file=io.StringIO(), force_terminal=True, width=20) + console.print("FOO", justify="left") + assert console.file.getvalue() == "FOO \n" + + +def test_justify_center(): + console = Console(file=io.StringIO(), force_terminal=True, width=20) + console.print("FOO", justify="center") + assert console.file.getvalue() == " FOO \n" + + +def test_justify_right(): + console = Console(file=io.StringIO(), force_terminal=True, width=20) + console.print("FOO", justify="right") + assert console.file.getvalue() == " FOO\n" + + +def test_justify_renderable_none(): + console = Console( + file=io.StringIO(), force_terminal=True, width=20, legacy_windows=False + ) + console.print(Panel("FOO", expand=False, padding=0), justify=None) + assert console.file.getvalue() == "╭───╮\n│FOO│\n╰───╯\n" + + +def test_justify_renderable_left(): + console = Console( + file=io.StringIO(), force_terminal=True, width=10, legacy_windows=False + ) + console.print(Panel("FOO", expand=False, padding=0), justify="left") + assert console.file.getvalue() == "╭───╮ \n│FOO│ \n╰───╯ \n" + + +def test_justify_renderable_center(): + console = Console( + file=io.StringIO(), force_terminal=True, width=10, legacy_windows=False + ) + console.print(Panel("FOO", expand=False, padding=0), justify="center") + assert console.file.getvalue() == " ╭───╮ \n │FOO│ \n ╰───╯ \n" + + +def test_justify_renderable_right(): + console = Console( + file=io.StringIO(), force_terminal=True, width=20, legacy_windows=False + ) + console.print(Panel("FOO", expand=False, padding=0), justify="right") + assert ( + console.file.getvalue() + == " ╭───╮\n │FOO│\n ╰───╯\n" + ) + + +class BrokenRenderable: + def __rich_console__(self, console, options): + pass + + +def test_render_broken_renderable(): + console = Console() + broken = BrokenRenderable() + with pytest.raises(errors.NotRenderableError): + list(console.render(broken, console.options)) + + +def test_export_text(): + console = Console(record=True, width=100) + console.print("[b]foo") + text = console.export_text() + expected = "foo\n" + assert text == expected + + +def test_export_html(): + console = Console(record=True, width=100) + console.print("[b]foo [link=https://example.org]Click[/link]") + html = console.export_html() + expected = '<!DOCTYPE html>\n<head>\n<meta charset="UTF-8">\n<style>\n.r1 {font-weight: bold}\nbody {\n color: #000000;\n background-color: #ffffff;\n}\n</style>\n</head>\n<html>\n<body>\n <code>\n <pre style="font-family:Menlo,\'DejaVu Sans Mono\',consolas,\'Courier New\',monospace"><span class="r1">foo </span><a href="https://example.org"><span class="r1">Click</span></a>\n</pre>\n </code>\n</body>\n</html>\n' + assert html == expected + + +def test_export_html_inline(): + console = Console(record=True, width=100) + console.print("[b]foo [link=https://example.org]Click[/link]") + html = console.export_html(inline_styles=True) + expected = '<!DOCTYPE html>\n<head>\n<meta charset="UTF-8">\n<style>\n\nbody {\n color: #000000;\n background-color: #ffffff;\n}\n</style>\n</head>\n<html>\n<body>\n <code>\n <pre style="font-family:Menlo,\'DejaVu Sans Mono\',consolas,\'Courier New\',monospace"><span style="font-weight: bold">foo </span><a href="https://example.org"><span style="font-weight: bold">Click</span></a>\n</pre>\n </code>\n</body>\n</html>\n' + assert html == expected + + +def test_save_text(): + console = Console(record=True, width=100) + console.print("foo") + with tempfile.TemporaryDirectory() as path: + export_path = os.path.join(path, "rich.txt") + console.save_text(export_path) + with open(export_path, "rt") as text_file: + assert text_file.read() == "foo\n" + + +def test_save_html(): + expected = "<!DOCTYPE html>\n<head>\n<meta charset=\"UTF-8\">\n<style>\n\nbody {\n color: #000000;\n background-color: #ffffff;\n}\n</style>\n</head>\n<html>\n<body>\n <code>\n <pre style=\"font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">foo\n</pre>\n </code>\n</body>\n</html>\n" + console = Console(record=True, width=100) + console.print("foo") + with tempfile.TemporaryDirectory() as path: + export_path = os.path.join(path, "example.html") + console.save_html(export_path) + with open(export_path, "rt") as html_file: + assert html_file.read() == expected + + +def test_no_wrap(): + console = Console(width=10, file=io.StringIO()) + console.print("foo bar baz egg", no_wrap=True) + assert console.file.getvalue() == "foo bar ba\n" + + +def test_soft_wrap(): + console = Console(width=10, file=io.StringIO()) + console.print("foo bar baz egg", soft_wrap=True) + assert console.file.getvalue() == "foo bar baz egg\n" + + +def test_unicode_error() -> None: + try: + with tempfile.TemporaryFile("wt", encoding="ascii") as tmpfile: + console = Console(file=tmpfile) + console.print(":vampire:") + except UnicodeEncodeError as error: + assert "PYTHONIOENCODING" in str(error) + else: + assert False, "didn't raise UnicodeEncodeError" + + +def test_bell() -> None: + console = Console(force_terminal=True) + console.begin_capture() + console.bell() + assert console.end_capture() == "\x07" + + +def test_pager() -> None: + console = Console() + + pager_content: Optional[str] = None + + def mock_pager(content: str) -> None: + nonlocal pager_content + pager_content = content + + pager = SystemPager() + pager._pager = mock_pager + + with console.pager(pager): + console.print("[bold]Hello World") + assert pager_content == "Hello World\n" + + with console.pager(pager, styles=True, links=False): + console.print("[bold link https:/example.org]Hello World") + + assert pager_content == "Hello World\n" + + +def test_out() -> None: + console = Console(width=10) + console.begin_capture() + console.out(*(["foo bar"] * 5), sep=".", end="X") + assert console.end_capture() == "foo bar.foo bar.foo bar.foo bar.foo barX" + + +def test_render_group() -> None: + @render_group(fit=False) + def renderable(): + yield "one" + yield "two" + yield "three" # <- largest width of 5 + yield "four" + + renderables = [renderable() for _ in range(4)] + console = Console(width=42) + min_width, _ = measure_renderables(console, renderables, 42) + assert min_width == 42 + + +def test_render_group_fit() -> None: + @render_group() + def renderable(): + yield "one" + yield "two" + yield "three" # <- largest width of 5 + yield "four" + + renderables = [renderable() for _ in range(4)] + + console = Console(width=42) + + min_width, _ = measure_renderables(console, renderables, 42) + assert min_width == 5 + + +def test_get_time() -> None: + console = Console( + get_time=lambda: 99, get_datetime=lambda: datetime.datetime(1974, 7, 5) + ) + assert console.get_time() == 99 + assert console.get_datetime() == datetime.datetime(1974, 7, 5) + + +def test_console_style() -> None: + console = Console( + file=io.StringIO(), color_system="truecolor", force_terminal=True, style="red" + ) + console.print("foo") + expected = "\x1b[31mfoo\x1b[0m\n" + result = console.file.getvalue() + assert result == expected + + +def test_no_color(): + console = Console( + file=io.StringIO(), color_system="truecolor", force_terminal=True, no_color=True + ) + console.print("[bold magenta on red]FOO") + expected = "\x1b[1mFOO\x1b[0m\n" + result = console.file.getvalue() + print(repr(result)) + assert result == expected + + +def test_quiet(): + console = Console(file=io.StringIO(), quiet=True) + console.print("Hello, World!") + assert console.file.getvalue() == "" + + +def test_no_nested_live(): + console = Console() + with pytest.raises(errors.LiveError): + with console.status("foo"): + with console.status("bar"): + pass + + +@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") +def test_screen(): + console = Console(color_system=None, force_terminal=True, force_interactive=True) + with console.capture() as capture: + with console.screen(): + console.print("Don't panic") + expected = "\x1b[?1049h\x1b[H\x1b[?25lDon't panic\n\x1b[?1049l\x1b[?25h" + result = capture.get() + print(repr(result)) + assert result == expected + + +@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") +def test_screen_update(): + console = Console(width=20, height=4, color_system="truecolor", force_terminal=True) + with console.capture() as capture: + with console.screen() as screen: + screen.update("foo", style="blue") + screen.update("bar") + screen.update() + result = capture.get() + print(repr(result)) + expected = "\x1b[?1049h\x1b[H\x1b[?25l\x1b[34mfoo\x1b[0m\x1b[34m \x1b[0m\n\x1b[34m \x1b[0m\n\x1b[34m \x1b[0m\n\x1b[34m \x1b[0m\x1b[34mbar\x1b[0m\x1b[34m \x1b[0m\n\x1b[34m \x1b[0m\n\x1b[34m \x1b[0m\n\x1b[34m \x1b[0m\x1b[34mbar\x1b[0m\x1b[34m \x1b[0m\n\x1b[34m \x1b[0m\n\x1b[34m \x1b[0m\n\x1b[34m \x1b[0m\x1b[?1049l\x1b[?25h" + assert result == expected + + +def test_height(): + console = Console(width=80, height=46) + assert console.height == 46 diff --git a/tests/test_constrain.py b/tests/test_constrain.py new file mode 100644 index 0000000..6fc9636 --- /dev/null +++ b/tests/test_constrain.py @@ -0,0 +1,11 @@ +from rich.console import Console +from rich.constrain import Constrain +from rich.text import Text + + +def test_width_of_none(): + console = Console() + constrain = Constrain(Text("foo"), width=None) + min_width, max_width = constrain.__rich_measure__(console, 80) + assert min_width == 3 + assert max_width == 3 diff --git a/tests/test_containers.py b/tests/test_containers.py new file mode 100644 index 0000000..8009898 --- /dev/null +++ b/tests/test_containers.py @@ -0,0 +1,56 @@ +from rich.console import Console +from rich.containers import Lines, Renderables +from rich.text import Span, Text +from rich.style import Style + + +def test_renderables_measure(): + console = Console() + text = Text("foo") + renderables = Renderables([text]) + + result = renderables.__rich_measure__(console, console.width) + _min, _max = result + assert _min == 3 + assert _max == 3 + + assert list(renderables) == [text] + + +def test_renderables_empty(): + console = Console() + renderables = Renderables() + + result = renderables.__rich_measure__(console, console.width) + _min, _max = result + assert _min == 1 + assert _max == 1 + + +def test_lines_rich_console(): + console = Console() + lines = Lines([Text("foo")]) + + result = list(lines.__rich_console__(console, console.options)) + assert result == [Text("foo")] + + +def test_lines_justify(): + console = Console() + lines1 = Lines([Text("foo"), Text("test")]) + lines1.justify(console, 10, justify="left") + assert lines1._lines == [Text("foo "), Text("test ")] + lines1.justify(console, 10, justify="center") + assert lines1._lines == [Text(" foo "), Text(" test ")] + lines1.justify(console, 10, justify="right") + assert lines1._lines == [Text(" foo"), Text(" test")] + + lines2 = Lines([Text("foo bar"), Text("test")]) + lines2.justify(console, 7, justify="full") + assert lines2._lines == [ + Text( + "foo bar", + spans=[Span(0, 3, ""), Span(3, 4, Style.parse("none")), Span(4, 7, "")], + ), + Text("test"), + ] diff --git a/tests/test_control.py b/tests/test_control.py new file mode 100644 index 0000000..d568355 --- /dev/null +++ b/tests/test_control.py @@ -0,0 +1,12 @@ +from rich.control import Control, strip_control_codes + + +def test_control(): + control = Control("FOO") + assert str(control) == "FOO" + + +def test_strip_control_codes(): + assert strip_control_codes("") == "" + assert strip_control_codes("foo\rbar") == "foobar" + assert strip_control_codes("Fear is the mind killer") == "Fear is the mind killer" diff --git a/tests/test_emoji.py b/tests/test_emoji.py new file mode 100644 index 0000000..cc519da --- /dev/null +++ b/tests/test_emoji.py @@ -0,0 +1,24 @@ +import pytest + +from rich.emoji import Emoji, NoEmoji + +from .render import render + + +def test_no_emoji(): + with pytest.raises(NoEmoji): + Emoji("ambivalent_bunny") + + +def test_str_repr(): + assert str(Emoji("pile_of_poo")) == "💩" + assert repr(Emoji("pile_of_poo")) == "<emoji 'pile_of_poo'>" + + +def test_replace(): + assert Emoji.replace("my code is :pile_of_poo:") == "my code is 💩" + + +def test_render(): + render_result = render(Emoji("pile_of_poo")) + assert render_result == "💩" diff --git a/tests/test_file_proxy.py b/tests/test_file_proxy.py new file mode 100644 index 0000000..2218d03 --- /dev/null +++ b/tests/test_file_proxy.py @@ -0,0 +1,27 @@ +import io +import sys + +import pytest + +from rich.console import Console +from rich.file_proxy import FileProxy + + +def test_empty_bytes(): + console = Console() + file_proxy = FileProxy(console, sys.stdout) + # File should raise TypeError when writing bytes + with pytest.raises(TypeError): + file_proxy.write(b"") # type: ignore + with pytest.raises(TypeError): + file_proxy.write(b"foo") # type: ignore + + +def test_flush(): + file = io.StringIO() + console = Console(file=file) + file_proxy = FileProxy(console, file) + file_proxy.write("foo") + assert file.getvalue() == "" + file_proxy.flush() + assert file.getvalue() == "foo\n" diff --git a/tests/test_filesize.py b/tests/test_filesize.py new file mode 100644 index 0000000..937ef73 --- /dev/null +++ b/tests/test_filesize.py @@ -0,0 +1,15 @@ +from rich import filesize + + +def test_traditional(): + assert filesize.decimal(0) == "0 bytes" + assert filesize.decimal(1) == "1 byte" + assert filesize.decimal(2) == "2 bytes" + assert filesize.decimal(1000) == "1.0 kB" + assert filesize.decimal(1.5 * 1000 * 1000) == "1.5 MB" + + +def test_pick_unit_and_suffix(): + units = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] + assert filesize.pick_unit_and_suffix(50, units, 1024) == (1, "bytes") + assert filesize.pick_unit_and_suffix(2048, units, 1024) == (1024, "KB") diff --git a/tests/test_highlighter.py b/tests/test_highlighter.py new file mode 100644 index 0000000..da5fc9b --- /dev/null +++ b/tests/test_highlighter.py @@ -0,0 +1,83 @@ +"""Tests for the higlighter classes.""" +import pytest +from typing import List + +from rich.highlighter import NullHighlighter, ReprHighlighter +from rich.text import Span, Text + + +def test_wrong_type(): + highlighter = NullHighlighter() + with pytest.raises(TypeError): + highlighter([]) + + +highlight_tests = [ + ("", []), + (" ", []), + ( + "<foo>", + [ + Span(0, 1, "repr.tag_start"), + Span(1, 4, "repr.tag_name"), + Span(4, 5, "repr.tag_end"), + ], + ), + ( + "False True None", + [ + Span(0, 5, "repr.bool_false"), + Span(6, 10, "repr.bool_true"), + Span(11, 15, "repr.none"), + ], + ), + ("foo=bar", [Span(0, 3, "repr.attrib_name"), Span(4, 7, "repr.attrib_value")]), + ( + 'foo="bar"', + [ + Span(0, 3, "repr.attrib_name"), + Span(4, 9, "repr.attrib_value"), + Span(4, 9, "repr.str"), + ], + ), + ("( )", [Span(0, 1, "repr.brace"), Span(2, 3, "repr.brace")]), + ("[ ]", [Span(0, 1, "repr.brace"), Span(2, 3, "repr.brace")]), + ("{ }", [Span(0, 1, "repr.brace"), Span(2, 3, "repr.brace")]), + (" 1 ", [Span(1, 2, "repr.number")]), + (" 1.2 ", [Span(1, 4, "repr.number")]), + (" 0xff ", [Span(1, 5, "repr.number")]), + (" 1e10 ", [Span(1, 5, "repr.number")]), + (" /foo ", [Span(1, 2, "repr.path"), Span(2, 5, "repr.filename")]), + (" /foo/bar.html ", [Span(1, 6, "repr.path"), Span(6, 14, "repr.filename")]), + ("01-23-45-67-89-AB", [Span(0, 17, "repr.eui48")]), # 6x2 hyphen + ("01-23-45-FF-FE-67-89-AB", [Span(0, 23, "repr.eui64")]), # 8x2 hyphen + ("01:23:45:67:89:AB", [Span(0, 17, "repr.ipv6")]), # 6x2 colon + ("01:23:45:FF:FE:67:89:AB", [Span(0, 23, "repr.ipv6")]), # 8x2 colon + ("0123.4567.89AB", [Span(0, 14, "repr.eui48")]), # 3x4 dot + ("0123.45FF.FE67.89AB", [Span(0, 19, "repr.eui64")]), # 4x4 dot + ("ed-ed-ed-ed-ed-ed", [Span(0, 17, "repr.eui48")]), # lowercase + ("ED-ED-ED-ED-ED-ED", [Span(0, 17, "repr.eui48")]), # uppercase + ("Ed-Ed-Ed-Ed-Ed-Ed", [Span(0, 17, "repr.eui48")]), # mixed case + ("0-00-1-01-2-02", [Span(0, 14, "repr.eui48")]), # dropped zero + (" https://example.org ", [Span(1, 20, "repr.url")]), + (" http://example.org ", [Span(1, 19, "repr.url")]), + (" http://example.org/index.html ", [Span(1, 30, "repr.url")]), + ("No place like 127.0.0.1", [Span(14, 23, "repr.ipv4")]), + ("''", [Span(0, 2, "repr.str")]), + ("'hello'", [Span(0, 7, "repr.str")]), + ("'''hello'''", [Span(0, 11, "repr.str")]), + ('""', [Span(0, 2, "repr.str")]), + ('"hello"', [Span(0, 7, "repr.str")]), + ('"""hello"""', [Span(0, 11, "repr.str")]), + ("\\'foo'", []), +] + + +@pytest.mark.parametrize("test, spans", highlight_tests) +def test_highlight_regex(test: str, spans: List[Span]): + """Tests for the regular expressions used in ReprHighlighter.""" + text = Text(test) + highlighter = ReprHighlighter() + highlighter.highlight(text) + print(text.spans) + assert text.spans == spans diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 0000000..0ffc313 --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,205 @@ +import io +import sys + +import pytest + +from rich import inspect +from rich.console import Console + + +skip_py36 = pytest.mark.skipif( + sys.version_info.minor == 6 and sys.version_info.major == 3, + reason="rendered differently on py3.6", +) + + +skip_py37 = pytest.mark.skipif( + sys.version_info.minor == 7 and sys.version_info.major == 3, + reason="rendered differently on py3.7", +) + + +def render(obj, methods=False, value=False, width=50) -> str: + console = Console(file=io.StringIO(), width=width, legacy_windows=False) + inspect(obj, console=console, methods=methods, value=value) + return console.file.getvalue() + + +class InspectError(Exception): + def __str__(self) -> str: + return "INSPECT ERROR" + + +class Foo: + """Foo test + + Second line + """ + + def __init__(self, foo: int) -> None: + """constructor docs.""" + self.foo = foo + + @property + def broken(self): + raise InspectError() + + def method(self, a, b) -> str: + """Multi line + + docs. + """ + return "test" + + def __dir__(self): + return ["__init__", "broken", "method"] + + +@skip_py36 +def test_render(): + console = Console(width=100, file=io.StringIO(), legacy_windows=False) + + foo = Foo("hello") + inspect(foo, console=console, all=True, value=False) + result = console.file.getvalue() + print(repr(result)) + expected = "╭────────────── <class 'tests.test_inspect.Foo'> ──────────────╮\n│ Foo test │\n│ │\n│ broken = InspectError() │\n│ __init__ = def __init__(foo: int) -> None: constructor docs. │\n│ method = def method(a, b) -> str: Multi line │\n╰──────────────────────────────────────────────────────────────╯\n" + assert expected == result + + +def test_inspect_text(): + + expected = ( + "╭──────────────── <class 'str'> ─────────────────╮\n" + "│ str(object='') -> str │\n" + "│ str(bytes_or_buffer[, encoding[, errors]]) -> │\n" + "│ str │\n" + "│ │\n" + "│ 33 attribute(s) not shown. Run │\n" + "│ inspect(inspect) for options. │\n" + "╰────────────────────────────────────────────────╯\n" + ) + print(repr(expected)) + assert expected == render("Hello") + + +@skip_py36 +@skip_py37 +def test_inspect_empty_dict(): + + expected = ( + "╭──────────────── <class 'dict'> ────────────────╮\n" + "│ dict() -> new empty dictionary │\n" + "│ dict(mapping) -> new dictionary initialized │\n" + "│ from a mapping object's │\n" + "│ (key, value) pairs │\n" + "│ dict(iterable) -> new dictionary initialized │\n" + "│ as if via: │\n" + "│ d = {} │\n" + "│ for k, v in iterable: │\n" + "│ d[k] = v │\n" + "│ dict(**kwargs) -> new dictionary initialized │\n" + "│ with the name=value pairs │\n" + "│ in the keyword argument list. For │\n" + "│ example: dict(one=1, two=2) │\n" + "│ │\n" + ) + assert render({}).startswith(expected) + + +def test_inspect_builtin_function(): + + expected = ( + "╭────────── <built-in function print> ───────────╮\n" + "│ def print(...) │\n" + "│ │\n" + "│ print(value, ..., sep=' ', end='\\n', │\n" + "│ file=sys.stdout, flush=False) │\n" + "│ │\n" + "│ 29 attribute(s) not shown. Run │\n" + "│ inspect(inspect) for options. │\n" + "╰────────────────────────────────────────────────╯\n" + ) + assert expected == render(print) + + +@skip_py36 +def test_inspect_integer(): + + expected = ( + "╭────── <class 'int'> ───────╮\n" + "│ int([x]) -> integer │\n" + "│ int(x, base=10) -> integer │\n" + "│ │\n" + "│ denominator = 1 │\n" + "│ imag = 0 │\n" + "│ numerator = 1 │\n" + "│ real = 1 │\n" + "╰────────────────────────────╯\n" + ) + assert expected == render(1) + + +@skip_py36 +def test_inspect_integer_with_value(): + + expected = "╭────── <class 'int'> ───────╮\n│ int([x]) -> integer │\n│ int(x, base=10) -> integer │\n│ │\n│ ╭────────────────────────╮ │\n│ │ 1 │ │\n│ ╰────────────────────────╯ │\n│ │\n│ denominator = 1 │\n│ imag = 0 │\n│ numerator = 1 │\n│ real = 1 │\n╰────────────────────────────╯\n" + value = render(1, value=True) + print(repr(value)) + assert expected == value + + +@skip_py36 +@skip_py37 +def test_inspect_integer_with_methods(): + + expected = ( + "╭──────────────── <class 'int'> ─────────────────╮\n" + "│ int([x]) -> integer │\n" + "│ int(x, base=10) -> integer │\n" + "│ │\n" + "│ denominator = 1 │\n" + "│ imag = 0 │\n" + "│ numerator = 1 │\n" + "│ real = 1 │\n" + "│ as_integer_ratio = def as_integer_ratio(): │\n" + "│ Return integer ratio. │\n" + "│ bit_length = def bit_length(): Number of │\n" + "│ bits necessary to represent │\n" + "│ self in binary. │\n" + "│ conjugate = def conjugate(...) Returns │\n" + "│ self, the complex conjugate │\n" + "│ of any int. │\n" + "│ from_bytes = def from_bytes(bytes, │\n" + "│ byteorder, *, │\n" + "│ signed=False): Return the │\n" + "│ integer represented by the │\n" + "│ given array of bytes. │\n" + "│ to_bytes = def to_bytes(length, │\n" + "│ byteorder, *, │\n" + "│ signed=False): Return an │\n" + "│ array of bytes representing │\n" + "│ an integer. │\n" + "╰────────────────────────────────────────────────╯\n" + ) + assert expected == render(1, methods=True) + + +@skip_py36 +@skip_py37 +def test_broken_call_attr(): + class NotCallable: + __call__ = 5 # Passes callable() but isn't really callable + + def __repr__(self): + return "NotCallable()" + + class Foo: + foo = NotCallable() + + foo = Foo() + assert callable(foo.foo) + expected = "╭─ <class 'tests.test_inspect.test_broken_call_attr.<locals>.Foo'> ─╮\n│ foo = NotCallable() │\n╰───────────────────────────────────────────────────────────────────╯\n" + result = render(foo, methods=True, width=100) + print(repr(result)) + assert expected == result diff --git a/tests/test_jupyter.py b/tests/test_jupyter.py new file mode 100644 index 0000000..a69f211 --- /dev/null +++ b/tests/test_jupyter.py @@ -0,0 +1,7 @@ +from rich.console import Console + + +def test_jupyter(): + console = Console(force_jupyter=True) + assert console.width == 93 + assert console.color_system == "truecolor" diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..08beccc --- /dev/null +++ b/tests/test_layout.py @@ -0,0 +1,54 @@ +import sys +import pytest + +from rich.console import Console +from rich.layout import Layout +from rich.panel import Panel + + +@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") +def test_render(): + layout = Layout(name="root") + repr(layout) + + layout.split(Layout(name="top"), Layout(name="bottom")) + top = layout["top"] + top.update(Panel("foo")) + + print(type(top._renderable)) + assert isinstance(top.renderable, Panel) + layout["bottom"].split( + Layout(name="left"), Layout(name="right"), direction="horizontal" + ) + + assert layout["root"].name == "root" + assert layout["left"].name == "left" + with pytest.raises(KeyError): + top["asdasd"] + + layout["left"].update("foobar") + print(layout["left"].children) + + console = Console(width=60, color_system=None) + + with console.capture() as capture: + console.print(layout, height=10) + + result = capture.get() + expected = "╭──────────────────────────────────────────────────────────╮\n│ foo │\n│ │\n│ │\n╰──────────────────────────────────────────────────────────╯\nfoobar ╭───── 'right' (30 x 5) ─────╮\n │ { │\n │ 'size': None, │\n │ 'minimum_size': 1, │\n ╰────────────────────────────╯\n" + assert result == expected + + +def test_tree(): + layout = Layout(name="root") + layout.split(Layout("foo", size=2), Layout("bar")) + + console = Console(width=60, color_system=None) + + with console.capture() as capture: + console.print(layout.tree, height=10) + result = capture.get() + print(repr(result)) + expected = "⬇ 'root' (ratio=1) \n├── ■ (size=2) \n└── ■ (ratio=1) \n" + + assert result == expected diff --git a/tests/test_live.py b/tests/test_live.py new file mode 100644 index 0000000..bc370a0 --- /dev/null +++ b/tests/test_live.py @@ -0,0 +1,172 @@ +# encoding=utf-8 +import io +import time +from typing import Optional + +# import pytest +from rich.console import Console +from rich.text import Text +from rich.live import Live + + +def create_capture_console( + *, width: int = 60, height: int = 80, force_terminal: Optional[bool] = True +) -> Console: + return Console( + width=width, + height=height, + force_terminal=force_terminal, + legacy_windows=False, + color_system=None, # use no color system to reduce complexity of output + ) + + +def test_live_state() -> None: + + with Live("") as live: + assert live._started + live.start() + + assert live.renderable == "" + + assert live._started + live.stop() + assert not live._started + + assert not live._started + + +def test_growing_display() -> None: + console = create_capture_console() + console.begin_capture() + with Live(console=console, auto_refresh=False) as live: + display = "" + for step in range(10): + display += f"Step {step}\n" + live.update(display, refresh=True) + output = console.end_capture() + print(repr(output)) + assert ( + output + == "\x1b[?25lStep 0\n\r\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n\n\x1b[?25h" + ) + + +def test_growing_display_transient() -> None: + console = create_capture_console() + console.begin_capture() + with Live(console=console, auto_refresh=False, transient=True) as live: + display = "" + for step in range(10): + display += f"Step {step}\n" + live.update(display, refresh=True) + output = console.end_capture() + assert ( + output + == "\x1b[?25lStep 0\n\r\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n\n\x1b[?25h\r\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K" + ) + + +def test_growing_display_overflow_ellipsis() -> None: + console = create_capture_console(height=5) + console.begin_capture() + with Live( + console=console, auto_refresh=False, vertical_overflow="ellipsis" + ) as live: + display = "" + for step in range(10): + display += f"Step {step}\n" + live.update(display, refresh=True) + output = console.end_capture() + assert ( + output + == "\x1b[?25lStep 0\n\r\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n ... \r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n ... \r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n ... \r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n ... \r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n ... \r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n ... \r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n\n\x1b[?25h" + ) + + +def test_growing_display_overflow_crop() -> None: + console = create_capture_console(height=5) + console.begin_capture() + with Live(console=console, auto_refresh=False, vertical_overflow="crop") as live: + display = "" + for step in range(10): + display += f"Step {step}\n" + live.update(display, refresh=True) + output = console.end_capture() + assert ( + output + == "\x1b[?25lStep 0\n\r\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n\n\x1b[?25h" + ) + + +def test_growing_display_overflow_visible() -> None: + console = create_capture_console(height=5) + console.begin_capture() + with Live(console=console, auto_refresh=False, vertical_overflow="visible") as live: + display = "" + for step in range(10): + display += f"Step {step}\n" + live.update(display, refresh=True) + output = console.end_capture() + assert ( + output + == "\x1b[?25lStep 0\n\r\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n\n\x1b[?25h" + ) + + +def test_growing_display_autorefresh() -> None: + """Test generating a table but using auto-refresh from threading""" + console = create_capture_console() + + console = create_capture_console(height=5) + console.begin_capture() + with Live(console=console, auto_refresh=True, vertical_overflow="visible") as live: + display = "" + for step in range(10): + display += f"Step {step}\n" + live.update(display) + time.sleep(0.2) + + # no way to truly test w/ multithreading, just make sure it doesn't crash + + +def test_growing_display_console_redirect() -> None: + console = create_capture_console() + console.begin_capture() + with Live(console=console, auto_refresh=False) as live: + display = "" + for step in range(10): + console.print(f"Running step {step}") + display += f"Step {step}\n" + live.update(display, refresh=True) + output = console.end_capture() + assert ( + output + == "\x1b[?25lRunning step 0\n\r\x1b[2KStep 0\n\r\x1b[2K\x1b[1A\x1b[2KRunning step 1\nStep 0\n\r\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KRunning step 2\nStep 0\nStep 1\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KRunning step 3\nStep 0\nStep 1\nStep 2\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KRunning step 4\nStep 0\nStep 1\nStep 2\nStep 3\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KRunning step 5\nStep 0\nStep 1\nStep 2\nStep 3\nStep 4\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KRunning step 6\nStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KRunning step 7\nStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KRunning step 8\nStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KRunning step 9\nStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2KStep 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n\n\x1b[?25h" + ) + + +def test_growing_display_file_console() -> None: + console = create_capture_console(force_terminal=False) + console.begin_capture() + with Live(console=console, auto_refresh=False) as live: + display = "" + for step in range(10): + display += f"Step {step}\n" + live.update(display, refresh=True) + output = console.end_capture() + assert ( + output + == "Step 0\nStep 1\nStep 2\nStep 3\nStep 4\nStep 5\nStep 6\nStep 7\nStep 8\nStep 9\n" + ) + + +def test_live_screen() -> None: + console = create_capture_console(width=20, height=5) + console.begin_capture() + with Live(Text("foo"), screen=True, console=console, auto_refresh=False) as live: + live.refresh() + result = console.end_capture() + print(repr(result)) + expected = "\x1b[?1049h\x1b[H\x1b[?25l\x1b[Hfoo \n \n \n \n \x1b[Hfoo \n \n \n \n \x1b[?25h\x1b[?1049l" + assert result == expected diff --git a/tests/test_live_render.py b/tests/test_live_render.py new file mode 100644 index 0000000..17b5632 --- /dev/null +++ b/tests/test_live_render.py @@ -0,0 +1,44 @@ +import pytest +from rich.live_render import LiveRender +from rich.console import Console, ConsoleDimensions, ConsoleOptions +from rich.style import Style +from rich.segment import Segment + + +@pytest.fixture +def live_render(): + return LiveRender(renderable="my string") + + +def test_renderable(live_render): + assert live_render.renderable == "my string" + live_render.set_renderable("another string") + assert live_render.renderable == "another string" + + +def test_position_cursor(live_render): + assert str(live_render.position_cursor()) == "" + live_render._shape = (80, 2) + assert str(live_render.position_cursor()) == "\r\x1b[2K\x1b[1A\x1b[2K" + + +def test_restore_cursor(live_render): + assert str(live_render.restore_cursor()) == "" + live_render._shape = (80, 2) + assert str(live_render.restore_cursor()) == "\r\x1b[1A\x1b[2K\x1b[1A\x1b[2K" + + +def test_rich_console(live_render): + options = ConsoleOptions( + ConsoleDimensions(80, 25), + legacy_windows=False, + min_width=10, + max_width=20, + is_terminal=False, + encoding="utf-8", + ) + rich_console = live_render.__rich_console__(Console(), options) + assert [Segment("my string", Style.parse("none"))] == list(rich_console) + live_render.style = "red" + rich_console = live_render.__rich_console__(Console(), options) + assert [Segment("my string", Style.parse("red"))] == list(rich_console) diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000..82dbfe2 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,59 @@ +# encoding=utf-8 + + +import io +import re + +from rich.console import Console + + +re_link_ids = re.compile(r"id=[\d\.\-]*?;.*?\x1b") + + +def replace_link_ids(render: str) -> str: + """Link IDs have a random ID and system path which is a problem for + reproducible tests. + + """ + return re_link_ids.sub("id=0;foo\x1b", render) + + +test_data = [1, 2, 3] + + +def render_log(): + console = Console( + file=io.StringIO(), + width=80, + force_terminal=True, + log_time_format="[TIME]", + color_system="truecolor", + legacy_windows=False, + ) + console.log() + console.log("Hello from", console, "!") + console.log(test_data, log_locals=True) + return replace_link_ids(console.file.getvalue()) + + +def test_log(): + expected = replace_link_ids( + "\n\x1b[2;36m[TIME]\x1b[0m\x1b[2;36m \x1b[0mHello from \x1b[1m<\x1b[0m\x1b[1;95mconsole\x1b[0m\x1b[39m \x1b[0m\x1b[33mwidth\x1b[0m\x1b[39m=\x1b[0m\x1b[1;34m80\x1b[0m\x1b[39m ColorSystem.TRUECOLOR\x1b[0m\x1b[1m>\x1b[0m ! \x1b]8;id=0;foo\x1b\\\x1b[2mtest_log.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:34\x1b[0m\n\x1b[2;36m \x1b[0m\x1b[2;36m \x1b[0m\x1b[1m[\x1b[0m\x1b[1;34m1\x1b[0m, \x1b[1;34m2\x1b[0m, \x1b[1;34m3\x1b[0m\x1b[1m]\x1b[0m \x1b]8;id=0;foo\x1b\\\x1b[2mtest_log.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:35\x1b[0m\n \x1b[34m╭─\x1b[0m\x1b[34m───────────────────── \x1b[0m\x1b[3;34mlocals\x1b[0m\x1b[34m ─────────────────────\x1b[0m\x1b[34m─╮\x1b[0m \n \x1b[34m│\x1b[0m \x1b[3;33mconsole\x1b[0m\x1b[31m =\x1b[0m \x1b[1m<\x1b[0m\x1b[1;95mconsole\x1b[0m\x1b[39m \x1b[0m\x1b[33mwidth\x1b[0m\x1b[39m=\x1b[0m\x1b[1;34m80\x1b[0m\x1b[39m ColorSystem.TRUECOLOR\x1b[0m\x1b[1m>\x1b[0m \x1b[34m│\x1b[0m \n \x1b[34m╰────────────────────────────────────────────────────╯\x1b[0m \n" + ) + rendered = render_log() + print(repr(rendered)) + assert rendered == expected + + +def test_justify(): + console = Console(width=20, log_path=False, log_time=False, color_system=None) + console.begin_capture() + console.log("foo", justify="right") + result = console.end_capture() + assert result == " foo\n" + + +if __name__ == "__main__": + render = render_log() + print(render) + print(repr(render)) diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..566c685 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,80 @@ +import io +import sys +import os +import logging +import pytest + +from rich.console import Console +from rich.logging import RichHandler + +handler = RichHandler( + console=Console( + file=io.StringIO(), force_terminal=True, width=80, color_system="truecolor" + ), + enable_link_path=False, +) +logging.basicConfig( + level="NOTSET", format="%(message)s", datefmt="[DATE]", handlers=[handler] +) +log = logging.getLogger("rich") + + +skip_win = pytest.mark.skipif( + os.name == "nt", + reason="rendered differently on windows", +) + + +@skip_win +def test_exception(): + console = Console( + file=io.StringIO(), force_terminal=True, width=140, color_system="truecolor" + ) + handler_with_tracebacks = RichHandler( + console=console, enable_link_path=False, rich_tracebacks=True + ) + log.addHandler(handler_with_tracebacks) + + try: + 1 / 0 + except ZeroDivisionError: + log.exception("message") + + render = handler_with_tracebacks.console.file.getvalue() + print(render) + + assert "ZeroDivisionError" in render + assert "message" in render + assert "division by zero" in render + + +def test_exception_with_extra_lines(): + console = Console( + file=io.StringIO(), force_terminal=True, width=140, color_system="truecolor" + ) + handler_extra_lines = RichHandler( + console=console, + enable_link_path=False, + markup=True, + rich_tracebacks=True, + tracebacks_extra_lines=5, + ) + log.addHandler(handler_extra_lines) + + try: + 1 / 0 + except ZeroDivisionError: + log.exception("message") + + render = handler_extra_lines.console.file.getvalue() + print(render) + + assert "ZeroDivisionError" in render + assert "message" in render + assert "division by zero" in render + + +if __name__ == "__main__": + render = make_log() + print(render) + print(repr(render)) diff --git a/tests/test_lrucache.py b/tests/test_lrucache.py new file mode 100644 index 0000000..9a1d7d1 --- /dev/null +++ b/tests/test_lrucache.py @@ -0,0 +1,30 @@ +from __future__ import unicode_literals + +import unittest + +from rich._lru_cache import LRUCache + + +def test_lru_cache(): + cache = LRUCache(3) + + # insert some values + cache["foo"] = 1 + cache["bar"] = 2 + cache["baz"] = 3 + assert "foo" in cache + + # Cache size is 3, so the following should kick oldest one out + cache["egg"] = 4 + assert "foo" not in cache + assert "egg" in "egg" in cache + + # cache is now full + # look up two keys + cache["bar"] + cache["baz"] + + # Insert a new value + cache["eggegg"] = 5 + # Check it kicked out the 'oldest' key + assert "egg" not in cache diff --git a/tests/test_markdown.py b/tests/test_markdown.py new file mode 100644 index 0000000..fdd56b7 --- /dev/null +++ b/tests/test_markdown.py @@ -0,0 +1,117 @@ +# coding=utf-8 + +MARKDOWN = """Heading +======= + +Sub-heading +----------- + +### Heading + +#### H4 Heading + +##### H5 Heading + +###### H6 Heading + + +Paragraphs are separated +by a blank line. + +Two spaces at the end of a line +produces a line break. + +Text attributes _italic_, +**bold**, `monospace`. + +Horizontal rule: + +--- + +Bullet list: + + * apples + * oranges + * pears + +Numbered list: + + 1. lather + 2. rinse + 3. repeat + +An [example](http://example.com). + +> Markdown uses email-style > characters for blockquoting. +> +> Lorem ipsum + +![progress](https://github.com/willmcgugan/rich/raw/master/imgs/progress.gif) + + +``` +a=1 +``` + +```python +import this +``` + +```somelang +foobar +``` + +""" + +import io +import re + +from rich.console import Console, RenderableType +from rich.markdown import Markdown + + +re_link_ids = re.compile(r"id=[\d\.\-]*?;.*?\x1b") + + +def replace_link_ids(render: str) -> str: + """Link IDs have a random ID and system path which is a problem for + reproducible tests. + + """ + return re_link_ids.sub("id=0;foo\x1b", render) + + +def render(renderable: RenderableType) -> str: + console = Console( + width=100, file=io.StringIO(), color_system="truecolor", legacy_windows=False + ) + console.print(renderable) + output = replace_link_ids(console.file.getvalue()) + return output + + +def test_markdown_render(): + markdown = Markdown(MARKDOWN) + rendered_markdown = render(markdown) + expected = "╔══════════════════════════════════════════════════════════════════════════════════════════════════╗\n║ \x1b[1mHeading\x1b[0m ║\n╚══════════════════════════════════════════════════════════════════════════════════════════════════╝\n\n\n \x1b[1;4mSub-heading\x1b[0m \n\n \x1b[1mHeading\x1b[0m \n\n \x1b[1;2mH4 Heading\x1b[0m \n\n \x1b[4mH5 Heading\x1b[0m \n\n \x1b[3mH6 Heading\x1b[0m \n\nParagraphs are separated by a blank line. \n\nTwo spaces at the end of a line \nproduces a line break. \n\nText attributes \x1b[3mitalic\x1b[0m, \x1b[1mbold\x1b[0m, \x1b[97;40mmonospace\x1b[0m. \n\nHorizontal rule: \n\n\x1b[33m────────────────────────────────────────────────────────────────────────────────────────────────────\x1b[0m\nBullet list: \n\n\x1b[1;33m • \x1b[0mapples \n\x1b[1;33m • \x1b[0moranges \n\x1b[1;33m • \x1b[0mpears \n\nNumbered list: \n\n\x1b[1;33m 1 \x1b[0mlather \n\x1b[1;33m 2 \x1b[0mrinse \n\x1b[1;33m 3 \x1b[0mrepeat \n\nAn \x1b]8;id=0;foo\x1b\\\x1b[94mexample\x1b[0m\x1b]8;;\x1b\\. \n\n\x1b[35m▌ \x1b[0m\x1b[35mMarkdown uses email-style > characters for blockquoting.\x1b[0m\x1b[35m \x1b[0m\n\x1b[35m▌ \x1b[0m\x1b[35mLorem ipsum\x1b[0m\x1b[35m \x1b[0m\n\n🌆 \x1b]8;id=0;foo\x1b\\progress\x1b]8;;\x1b\\ \n\n\x1b[2m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐\x1b[0m\n\x1b[2m│\x1b[0m \x1b[48;2;39;40;34ma=1 \x1b[0m \x1b[2m│\x1b[0m\n\x1b[2m└──────────────────────────────────────────────────────────────────────────────────────────────────┘\x1b[0m\n\n\x1b[2m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐\x1b[0m\n\x1b[2m│\x1b[0m \x1b[38;2;249;38;114;48;2;39;40;34mimport\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mthis\x1b[0m\x1b[48;2;39;40;34m \x1b[0m \x1b[2m│\x1b[0m\n\x1b[2m└──────────────────────────────────────────────────────────────────────────────────────────────────┘\x1b[0m\n\n\x1b[2m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐\x1b[0m\n\x1b[2m│\x1b[0m \x1b[48;2;39;40;34mfoobar \x1b[0m \x1b[2m│\x1b[0m\n\x1b[2m└──────────────────────────────────────────────────────────────────────────────────────────────────┘\x1b[0m\n" + assert rendered_markdown == expected + + +def test_inline_code(): + markdown = Markdown( + "inline `import this` code", + inline_code_lexer="python", + inline_code_theme="emacs", + ) + result = render(markdown) + expected = "inline \x1b[1;38;2;170;34;255;48;2;248;248;248mimport\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;0;255;48;2;248;248;248mthis\x1b[0m code \n" + print(result) + print(repr(result)) + assert result == expected + + +if __name__ == "__main__": + markdown = Markdown(MARKDOWN) + rendered = render(markdown) + print(rendered) + print(repr(rendered)) diff --git a/tests/test_markdown_no_hyperlinks.py b/tests/test_markdown_no_hyperlinks.py new file mode 100644 index 0000000..99be518 --- /dev/null +++ b/tests/test_markdown_no_hyperlinks.py @@ -0,0 +1,104 @@ +# coding=utf-8 + +MARKDOWN = """Heading +======= + +Sub-heading +----------- + +### Heading + +#### H4 Heading + +##### H5 Heading + +###### H6 Heading + + +Paragraphs are separated +by a blank line. + +Two spaces at the end of a line +produces a line break. + +Text attributes _italic_, +**bold**, `monospace`. + +Horizontal rule: + +--- + +Bullet list: + + * apples + * oranges + * pears + +Numbered list: + + 1. lather + 2. rinse + 3. repeat + +An [example](http://example.com). + +> Markdown uses email-style > characters for blockquoting. +> +> Lorem ipsum + +![progress](https://github.com/willmcgugan/rich/raw/master/imgs/progress.gif) + + +``` +a=1 +``` + +```python +import this +``` + +```somelang +foobar +``` + +""" + +import io +import re + +from rich.console import Console, RenderableType +from rich.markdown import Markdown + + +re_link_ids = re.compile(r"id=[\d\.\-]*?;.*?\x1b") + + +def replace_link_ids(render: str) -> str: + """Link IDs have a random ID and system path which is a problem for + reproducible tests. + + """ + return re_link_ids.sub("id=0;foo\x1b", render) + + +def render(renderable: RenderableType) -> str: + console = Console( + width=100, file=io.StringIO(), color_system="truecolor", legacy_windows=False + ) + console.print(renderable) + output = replace_link_ids(console.file.getvalue()) + return output + + +def test_markdown_render(): + markdown = Markdown(MARKDOWN, hyperlinks=False) + rendered_markdown = render(markdown) + expected = "╔══════════════════════════════════════════════════════════════════════════════════════════════════╗\n║ \x1b[1mHeading\x1b[0m ║\n╚══════════════════════════════════════════════════════════════════════════════════════════════════╝\n\n\n \x1b[1;4mSub-heading\x1b[0m \n\n \x1b[1mHeading\x1b[0m \n\n \x1b[1;2mH4 Heading\x1b[0m \n\n \x1b[4mH5 Heading\x1b[0m \n\n \x1b[3mH6 Heading\x1b[0m \n\nParagraphs are separated by a blank line. \n\nTwo spaces at the end of a line \nproduces a line break. \n\nText attributes \x1b[3mitalic\x1b[0m, \x1b[1mbold\x1b[0m, \x1b[97;40mmonospace\x1b[0m. \n\nHorizontal rule: \n\n\x1b[33m────────────────────────────────────────────────────────────────────────────────────────────────────\x1b[0m\nBullet list: \n\n\x1b[1;33m • \x1b[0mapples \n\x1b[1;33m • \x1b[0moranges \n\x1b[1;33m • \x1b[0mpears \n\nNumbered list: \n\n\x1b[1;33m 1 \x1b[0mlather \n\x1b[1;33m 2 \x1b[0mrinse \n\x1b[1;33m 3 \x1b[0mrepeat \n\nAn \x1b[94mexample\x1b[0m (\x1b[4;34mhttp://example.com\x1b[0m). \n\n\x1b[35m▌ \x1b[0m\x1b[35mMarkdown uses email-style > characters for blockquoting.\x1b[0m\x1b[35m \x1b[0m\n\x1b[35m▌ \x1b[0m\x1b[35mLorem ipsum\x1b[0m\x1b[35m \x1b[0m\n\n🌆 progress \n\n\x1b[2m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐\x1b[0m\n\x1b[2m│\x1b[0m \x1b[48;2;39;40;34ma=1 \x1b[0m \x1b[2m│\x1b[0m\n\x1b[2m└──────────────────────────────────────────────────────────────────────────────────────────────────┘\x1b[0m\n\n\x1b[2m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐\x1b[0m\n\x1b[2m│\x1b[0m \x1b[38;2;249;38;114;48;2;39;40;34mimport\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mthis\x1b[0m\x1b[48;2;39;40;34m \x1b[0m \x1b[2m│\x1b[0m\n\x1b[2m└──────────────────────────────────────────────────────────────────────────────────────────────────┘\x1b[0m\n\n\x1b[2m┌──────────────────────────────────────────────────────────────────────────────────────────────────┐\x1b[0m\n\x1b[2m│\x1b[0m \x1b[48;2;39;40;34mfoobar \x1b[0m \x1b[2m│\x1b[0m\n\x1b[2m└──────────────────────────────────────────────────────────────────────────────────────────────────┘\x1b[0m\n" + assert rendered_markdown == expected + + +if __name__ == "__main__": + markdown = Markdown(MARKDOWN, hyperlinks=False) + rendered = render(markdown) + print(rendered) + print(repr(rendered)) diff --git a/tests/test_markup.py b/tests/test_markup.py new file mode 100644 index 0000000..07c1642 --- /dev/null +++ b/tests/test_markup.py @@ -0,0 +1,151 @@ +import pytest + +from rich.console import Console +from rich.markup import escape, MarkupError, _parse, render, Tag, RE_TAGS +from rich.text import Span + + +def test_re_no_match(): + assert RE_TAGS.match("[True]") == None + assert RE_TAGS.match("[False]") == None + assert RE_TAGS.match("[None]") == None + assert RE_TAGS.match("[1]") == None + assert RE_TAGS.match("[2]") == None + assert RE_TAGS.match("[]") == None + + +def test_re_match(): + assert RE_TAGS.match("[true]") + assert RE_TAGS.match("[false]") + assert RE_TAGS.match("[none]") + assert RE_TAGS.match("[color(1)]") + assert RE_TAGS.match("[#ff00ff]") + assert RE_TAGS.match("[/]") + + +def test_escape(): + # Potential tags + assert escape("foo[bar]") == r"foo\[bar]" + assert escape(r"foo\[bar]") == r"foo\\\[bar]" + + # Not tags (escape not required) + assert escape("[5]") == "[5]" + assert escape("\\[5]") == "\\[5]" + + +def test_render_escape(): + console = Console(width=80, color_system=None) + console.begin_capture() + console.print( + escape(r"[red]"), escape(r"\[red]"), escape(r"\\[red]"), escape(r"\\\[red]") + ) + result = console.end_capture() + expected = r"[red] \[red] \\[red] \\\[red]" + "\n" + assert result == expected + + +def test_parse(): + result = list(_parse(r"[foo]hello[/foo][bar]world[/]\[escaped]")) + expected = [ + (0, None, Tag(name="foo", parameters=None)), + (10, "hello", None), + (10, None, Tag(name="/foo", parameters=None)), + (16, None, Tag(name="bar", parameters=None)), + (26, "world", None), + (26, None, Tag(name="/", parameters=None)), + (29, "[escaped]", None), + ] + print(repr(result)) + assert result == expected + + +def test_parse_link(): + result = list(_parse("[link=foo]bar[/link]")) + expected = [ + (0, None, Tag(name="link", parameters="foo")), + (13, "bar", None), + (13, None, Tag(name="/link", parameters=None)), + ] + assert result == expected + + +def test_render(): + result = render("[bold]FOO[/bold]") + assert str(result) == "FOO" + assert result.spans == [Span(0, 3, "bold")] + + +def test_render_not_tags(): + result = render('[[1], [1,2,3,4], ["hello"], [None], [False], [True]] []') + assert str(result) == '[[1], [1,2,3,4], ["hello"], [None], [False], [True]] []' + assert result.spans == [] + + +def test_render_link(): + result = render("[link=foo]FOO[/link]") + assert str(result) == "FOO" + assert result.spans == [Span(0, 3, "link foo")] + + +def test_render_combine(): + result = render("[green]X[blue]Y[/blue]Z[/green]") + assert str(result) == "XYZ" + assert result.spans == [ + Span(0, 3, "green"), + Span(1, 2, "blue"), + ] + + +def test_render_overlap(): + result = render("[green]X[bold]Y[/green]Z[/bold]") + assert str(result) == "XYZ" + assert result.spans == [ + Span(0, 2, "green"), + Span(1, 3, "bold"), + ] + + +def test_render_close(): + result = render("[bold]X[/]Y") + assert str(result) == "XY" + assert result.spans == [Span(0, 1, "bold")] + + +def test_render_close_ambiguous(): + result = render("[green]X[bold]Y[/]Z[/]") + assert str(result) == "XYZ" + assert result.spans == [Span(0, 3, "green"), Span(1, 2, "bold")] + + +def test_markup_error(): + with pytest.raises(MarkupError): + assert render("foo[/]") + with pytest.raises(MarkupError): + assert render("foo[/bar]") + with pytest.raises(MarkupError): + assert render("[foo]hello[/bar]") + + +def test_escape_escape(): + # Escaped escapes (i.e. double backslash)should be treated as literal + result = render(r"\\[bold]FOO") + assert str(result) == r"\FOO" + + # Single backslash makes the tag literal + result = render(r"\[bold]FOO") + assert str(result) == "[bold]FOO" + + # Double backslash produces a backslash + result = render(r"\\[bold]some text[/]") + assert str(result) == r"\some text" + + # Triple backslash parsed as literal backslash plus escaped tag + result = render(r"\\\[bold]some text\[/]") + assert str(result) == r"\[bold]some text[/]" + + # Backslash escaping only happens when preceding a tag + result = render(r"\\") + assert str(result) == r"\\" + + result = render(r"\\\\") + assert str(result) == r"\\\\" diff --git a/tests/test_measure.py b/tests/test_measure.py new file mode 100644 index 0000000..664701f --- /dev/null +++ b/tests/test_measure.py @@ -0,0 +1,42 @@ +from rich.text import Text +import pytest + +from rich.errors import NotRenderableError +from rich.console import Console +from rich.measure import Measurement, measure_renderables + + +def test_span(): + measurement = Measurement(10, 100) + assert measurement.span == 90 + + +def test_no_renderable(): + console = Console() + text = Text() + + with pytest.raises(NotRenderableError): + Measurement.get(console, None, console.width) + + +def test_null_get(): + # Test negative console.width passed into get method + assert Measurement.get(Console(width=-1), None) == Measurement(0, 0) + # Test negative max_width passed into get method + assert Measurement.get(Console(), None, -1) == Measurement(0, 0) + + +def test_measure_renderables(): + # Test measure_renderables returning a null Measurement object + assert measure_renderables(Console(), None, None) == Measurement(0, 0) + # Test measure_renderables returning a valid Measurement object + assert measure_renderables(Console(width=1), ["test"], 1) == Measurement(1, 1) + + +def test_clamp(): + measurement = Measurement(20, 100) + assert measurement.clamp(10, 50) == Measurement(20, 50) + assert measurement.clamp(30, 50) == Measurement(30, 50) + assert measurement.clamp(None, 50) == Measurement(20, 50) + assert measurement.clamp(30, None) == Measurement(30, 100) + assert measurement.clamp(None, None) == Measurement(20, 100) diff --git a/tests/test_padding.py b/tests/test_padding.py new file mode 100644 index 0000000..d4508e9 --- /dev/null +++ b/tests/test_padding.py @@ -0,0 +1,59 @@ +import pytest + +from rich.padding import Padding +from rich.console import Console, ConsoleDimensions, ConsoleOptions +from rich.style import Style +from rich.segment import Segment + + +def test_repr(): + padding = Padding("foo", (1, 2)) + assert isinstance(repr(padding), str) + + +def test_indent(): + indent_result = Padding.indent("test", 4) + assert indent_result.top == 0 + assert indent_result.right == 0 + assert indent_result.bottom == 0 + assert indent_result.left == 4 + + +def test_unpack(): + assert Padding.unpack(3) == (3, 3, 3, 3) + assert Padding.unpack((3,)) == (3, 3, 3, 3) + assert Padding.unpack((3, 4)) == (3, 4, 3, 4) + assert Padding.unpack((3, 4, 5, 6)) == (3, 4, 5, 6) + with pytest.raises(ValueError): + Padding.unpack((1, 2, 3)) + + +def test_expand_false(): + console = Console(width=100, color_system=None) + console.begin_capture() + console.print(Padding("foo", 1, expand=False)) + assert console.end_capture() == " \n foo \n \n" + + +def test_rich_console(): + renderable = "test renderable" + style = Style(color="red") + options = ConsoleOptions( + ConsoleDimensions(80, 25), + legacy_windows=False, + min_width=10, + max_width=20, + is_terminal=False, + encoding="utf-8", + ) + + expected_outputs = [ + Segment(renderable, style=style), + Segment(" " * (20 - len(renderable)), style=style), + Segment("\n", style=None), + ] + padding_generator = Padding(renderable, style=style).__rich_console__( + Console(), options + ) + for output, expected in zip(padding_generator, expected_outputs): + assert output == expected diff --git a/tests/test_palette.py b/tests/test_palette.py new file mode 100644 index 0000000..8ddd7cc --- /dev/null +++ b/tests/test_palette.py @@ -0,0 +1,8 @@ +from rich._palettes import STANDARD_PALETTE +from rich.table import Table + + +def test_rich_cast(): + table = STANDARD_PALETTE.__rich__() + assert isinstance(table, Table) + assert table.row_count == 16 diff --git a/tests/test_panel.py b/tests/test_panel.py new file mode 100644 index 0000000..da6ad78 --- /dev/null +++ b/tests/test_panel.py @@ -0,0 +1,62 @@ +import io +from rich.console import Console +from rich.measure import Measurement +from rich.panel import Panel + +import pytest + +tests = [ + Panel("Hello, World", padding=0), + Panel("Hello, World", expand=False, padding=0), + Panel.fit("Hello, World", padding=0), + Panel("Hello, World", width=8, padding=0), + Panel(Panel("Hello, World", padding=0), padding=0), + Panel("Hello, World", title="FOO", padding=0), +] + +expected = [ + "╭────────────────────────────────────────────────╮\n│Hello, World │\n╰────────────────────────────────────────────────╯\n", + "╭────────────╮\n│Hello, World│\n╰────────────╯\n", + "╭────────────╮\n│Hello, World│\n╰────────────╯\n", + "╭──────╮\n│Hello,│\n│World │\n╰──────╯\n", + "╭────────────────────────────────────────────────╮\n│╭──────────────────────────────────────────────╮│\n││Hello, World ││\n│╰──────────────────────────────────────────────╯│\n╰────────────────────────────────────────────────╯\n", + "╭───────────────────── FOO ──────────────────────╮\n│Hello, World │\n╰────────────────────────────────────────────────╯\n", +] + + +def render(panel, width=50) -> str: + console = Console(file=io.StringIO(), width=50, legacy_windows=False) + console.print(panel) + return console.file.getvalue() + + +@pytest.mark.parametrize("panel,expected", zip(tests, expected)) +def test_render_panel(panel, expected): + assert render(panel) == expected + + +def test_console_width(): + console = Console(file=io.StringIO(), width=50, legacy_windows=False) + panel = Panel("Hello, World", expand=False) + min_width, max_width = panel.__rich_measure__(console, 50) + assert min_width == 16 + assert max_width == 16 + + +def test_fixed_width(): + console = Console(file=io.StringIO(), width=50, legacy_windows=False) + panel = Panel("Hello World", width=20) + min_width, max_width = panel.__rich_measure__(console, 100) + assert min_width == 20 + assert max_width == 20 + + +if __name__ == "__main__": + expected = [] + for panel in tests: + result = render(panel) + print(result) + expected.append(result) + print("--") + print() + print(f"expected={repr(expected)}") diff --git a/tests/test_pick.py b/tests/test_pick.py new file mode 100644 index 0000000..9261d6f --- /dev/null +++ b/tests/test_pick.py @@ -0,0 +1,11 @@ +from rich._pick import pick_bool + + +def test_pick_bool(): + assert pick_bool(False, True) == False + assert pick_bool(None, True) == True + assert pick_bool(True, None) == True + assert pick_bool(False, None) == False + assert pick_bool(None, None) == False + assert pick_bool(None, None, False, True) == False + assert pick_bool(None, None, True, False) == True diff --git a/tests/test_pretty.py b/tests/test_pretty.py new file mode 100644 index 0000000..4ba8c27 --- /dev/null +++ b/tests/test_pretty.py @@ -0,0 +1,145 @@ +from array import array +from collections import defaultdict +import io +import sys + +from rich.console import Console +from rich.pretty import install, Pretty, pprint, pretty_repr, Node + + +def test_install(): + console = Console(file=io.StringIO()) + dh = sys.displayhook + install(console) + sys.displayhook("foo") + assert console.file.getvalue() == "'foo'\n" + assert sys.displayhook is not dh + + +def test_pretty(): + test = { + "foo": [1, 2, 3, (4, 5, {6}, 7, 8, {9}), {}], + "bar": {"egg": "baz", "words": ["Hello World"] * 10}, + False: "foo", + True: "", + "text": ("Hello World", "foo bar baz egg"), + } + + result = pretty_repr(test, max_width=80) + print(result) + print(repr(result)) + expected = "{\n 'foo': [1, 2, 3, (4, 5, {6}, 7, 8, {9}), {}],\n 'bar': {\n 'egg': 'baz',\n 'words': [\n 'Hello World',\n 'Hello World',\n 'Hello World',\n 'Hello World',\n 'Hello World',\n 'Hello World',\n 'Hello World',\n 'Hello World',\n 'Hello World',\n 'Hello World'\n ]\n },\n False: 'foo',\n True: '',\n 'text': ('Hello World', 'foo bar baz egg')\n}" + print(expected) + assert result == expected + + +def test_small_width(): + test = ["Hello world! 12345"] + result = pretty_repr(test, max_width=10) + expected = "[\n 'Hello world! 12345'\n]" + assert result == expected + + +def test_broken_repr(): + class BrokenRepr: + def __repr__(self): + 1 / 0 + + test = [BrokenRepr()] + result = pretty_repr(test) + expected = "[<repr-error 'division by zero'>]" + assert result == expected + + +def test_recursive(): + test = [] + test.append(test) + result = pretty_repr(test) + expected = "[...]" + assert result == expected + + +def test_defaultdict(): + test_dict = defaultdict(int, {"foo": 2}) + result = pretty_repr(test_dict) + assert result == "defaultdict(<class 'int'>, {'foo': 2})" + + +def test_array(): + test_array = array("I", [1, 2, 3]) + result = pretty_repr(test_array) + assert result == "array('I', [1, 2, 3])" + + +def test_tuple_of_one(): + assert pretty_repr((1,)) == "(1,)" + + +def test_node(): + node = Node("abc") + assert pretty_repr(node) == "abc: " + + +def test_indent_lines(): + console = Console(width=100, color_system=None) + console.begin_capture() + console.print(Pretty([100, 200], indent_guides=True), width=8) + expected = """\ +[ +│ 100, +│ 200 +] +""" + result = console.end_capture() + print(repr(result)) + print(result) + assert result == expected + + +def test_pprint(): + console = Console(color_system=None) + console.begin_capture() + pprint(1, console=console) + assert console.end_capture() == "1\n" + + +def test_pprint_max_values(): + console = Console(color_system=None) + console.begin_capture() + pprint([1, 2, 3, 4, 5, 6, 7, 8, 9, 0], console=console, max_length=2) + assert console.end_capture() == "[1, 2, ... +8]\n" + + +def test_pprint_max_items(): + console = Console(color_system=None) + console.begin_capture() + pprint({"foo": 1, "bar": 2, "egg": 3}, console=console, max_length=2) + assert console.end_capture() == """{'foo': 1, 'bar': 2, ... +1}\n""" + + +def test_pprint_max_string(): + console = Console(color_system=None) + console.begin_capture() + pprint(["Hello" * 20], console=console, max_string=8) + assert console.end_capture() == """['HelloHel'+92]\n""" + + +def test_tuples(): + console = Console(color_system=None) + console.begin_capture() + pprint((1,), console=console) + pprint((1,), expand_all=True, console=console) + pprint(((1,),), expand_all=True, console=console) + result = console.end_capture() + print(repr(result)) + expected = "(1,)\n(\n│ 1,\n)\n(\n│ (\n│ │ 1,\n│ ),\n)\n" + assert result == expected + + +def test_newline(): + console = Console(color_system=None) + console.begin_capture() + console.print(Pretty((1,), insert_line=True, expand_all=True)) + result = console.end_capture() + expected = "\n(\n 1,\n)\n" + assert result == expected diff --git a/tests/test_progress.py b/tests/test_progress.py new file mode 100644 index 0000000..565e971 --- /dev/null +++ b/tests/test_progress.py @@ -0,0 +1,430 @@ +# encoding=utf-8 + +import io +from time import sleep + +import pytest + +from rich.progress_bar import ProgressBar +from rich.console import Console +from rich.highlighter import NullHighlighter +from rich.progress import ( + BarColumn, + FileSizeColumn, + TotalFileSizeColumn, + DownloadColumn, + TransferSpeedColumn, + RenderableColumn, + SpinnerColumn, + Progress, + Task, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, + track, + _TrackThread, + TaskID, +) +from rich.text import Text + + +class MockClock: + """A clock that is manually advanced.""" + + def __init__(self, time=0.0, auto=True) -> None: + self.time = time + self.auto = auto + + def __call__(self) -> float: + try: + return self.time + finally: + if self.auto: + self.time += 1 + + def tick(self, advance: float = 1) -> None: + self.time += advance + + +def test_bar_columns(): + bar_column = BarColumn(100) + assert bar_column.bar_width == 100 + task = Task(1, "test", 100, 20, _get_time=lambda: 1.0) + bar = bar_column(task) + assert isinstance(bar, ProgressBar) + assert bar.completed == 20 + assert bar.total == 100 + + +def test_text_column(): + text_column = TextColumn("[b]foo", highlighter=NullHighlighter()) + task = Task(1, "test", 100, 20, _get_time=lambda: 1.0) + text = text_column.render(task) + assert str(text) == "foo" + + text_column = TextColumn("[b]bar", markup=False) + task = Task(1, "test", 100, 20, _get_time=lambda: 1.0) + text = text_column.render(task) + assert text == Text("[b]bar") + + +def test_time_remaining_column(): + class FakeTask(Task): + time_remaining = 60 + + column = TimeRemainingColumn() + task = Task(1, "test", 100, 20, _get_time=lambda: 1.0) + text = column(task) + assert str(text) == "-:--:--" + + text = column(FakeTask(1, "test", 100, 20, _get_time=lambda: 1.0)) + assert str(text) == "0:01:00" + + +def test_renderable_column(): + column = RenderableColumn("foo") + task = Task(1, "test", 100, 20, _get_time=lambda: 1.0) + assert column.render(task) == "foo" + + +def test_spinner_column(): + column = SpinnerColumn() + column.set_spinner("dots2") + task = Task(1, "test", 100, 20, _get_time=lambda: 1.0) + result = column.render(task) + print(repr(result)) + expected = "⡿" + assert str(result) == expected + + +def test_download_progress_uses_decimal_units() -> None: + + column = DownloadColumn() + test_task = Task(1, "test", 1000, 500, _get_time=lambda: 1.0) + rendered_progress = str(column.render(test_task)) + expected = "0.5/1.0 KB" + assert rendered_progress == expected + + +def test_download_progress_uses_binary_units() -> None: + + column = DownloadColumn(binary_units=True) + test_task = Task(1, "test", 1024, 512, _get_time=lambda: 1.0) + rendered_progress = str(column.render(test_task)) + expected = "0.5/1.0 KiB" + assert rendered_progress == expected + + +def test_task_ids(): + progress = make_progress() + assert progress.task_ids == [0, 1, 2, 4] + + +def test_finished(): + progress = make_progress() + assert not progress.finished + + +def make_progress() -> Progress: + _time = 0.0 + + def fake_time(): + nonlocal _time + try: + return _time + finally: + _time += 1 + + console = Console( + file=io.StringIO(), + force_terminal=True, + color_system="truecolor", + width=80, + legacy_windows=False, + ) + progress = Progress(console=console, get_time=fake_time, auto_refresh=False) + task1 = progress.add_task("foo") + task2 = progress.add_task("bar", total=30) + progress.advance(task2, 16) + task3 = progress.add_task("baz", visible=False) + task4 = progress.add_task("egg") + progress.remove_task(task4) + task4 = progress.add_task("foo2", completed=50, start=False) + progress.stop_task(task4) + progress.start_task(task4) + progress.update( + task4, total=200, advance=50, completed=200, visible=True, refresh=True + ) + progress.stop_task(task4) + return progress + + +def render_progress() -> str: + progress = make_progress() + progress.start() # superfluous noop + with progress: + pass + progress.stop() # superfluous noop + progress_render = progress.console.file.getvalue() + return progress_render + + +def test_expand_bar() -> None: + console = Console( + file=io.StringIO(), + force_terminal=True, + width=10, + color_system="truecolor", + legacy_windows=False, + ) + progress = Progress( + BarColumn(bar_width=None), + console=console, + get_time=lambda: 1.0, + auto_refresh=False, + ) + progress.add_task("foo") + with progress: + pass + expected = "\x1b[?25l\x1b[38;5;237m━━━━━━━━━━\x1b[0m\r\x1b[2K\x1b[38;5;237m━━━━━━━━━━\x1b[0m\n\x1b[?25h" + render_result = console.file.getvalue() + print("RESULT\n", repr(render_result)) + print("EXPECTED\n", repr(expected)) + assert render_result == expected + + +def test_render() -> None: + expected = "\x1b[?25lfoo \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 0%\x1b[0m \x1b[36m-:--:--\x1b[0m\nbar \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 53%\x1b[0m \x1b[36m-:--:--\x1b[0m\nfoo2 \x1b[38;2;114;156;31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m100%\x1b[0m \x1b[36m0:00:00\x1b[0m\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2Kfoo \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 0%\x1b[0m \x1b[36m-:--:--\x1b[0m\nbar \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 53%\x1b[0m \x1b[36m-:--:--\x1b[0m\nfoo2 \x1b[38;2;114;156;31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m100%\x1b[0m \x1b[36m0:00:00\x1b[0m\n\x1b[?25h" + render_result = render_progress() + print(repr(render_result)) + assert render_result == expected + + +def test_track() -> None: + + console = Console( + file=io.StringIO(), + force_terminal=True, + width=60, + color_system="truecolor", + legacy_windows=False, + ) + test = ["foo", "bar", "baz"] + expected_values = iter(test) + for value in track( + test, "test", console=console, auto_refresh=False, get_time=MockClock(auto=True) + ): + assert value == next(expected_values) + result = console.file.getvalue() + print(repr(result)) + expected = "\x1b[?25l\r\x1b[2Ktest \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 0%\x1b[0m \x1b[36m-:--:--\x1b[0m\r\x1b[2Ktest \x1b[38;2;249;38;114m━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 33%\x1b[0m \x1b[36m-:--:--\x1b[0m\r\x1b[2Ktest \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\x1b[38;2;249;38;114m╸\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━\x1b[0m \x1b[35m 67%\x1b[0m \x1b[36m0:00:06\x1b[0m\r\x1b[2Ktest \x1b[38;2;114;156;31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m100%\x1b[0m \x1b[36m0:00:00\x1b[0m\r\x1b[2Ktest \x1b[38;2;114;156;31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m100%\x1b[0m \x1b[36m0:00:00\x1b[0m\n\x1b[?25h" + print("--") + print("RESULT:") + print(result) + print(repr(result)) + print("EXPECTED:") + print(expected) + print(repr(expected)) + + assert result == expected + + with pytest.raises(ValueError): + for n in track(5): + pass + + +def test_progress_track() -> None: + console = Console( + file=io.StringIO(), + force_terminal=True, + width=60, + color_system="truecolor", + legacy_windows=False, + ) + progress = Progress( + console=console, auto_refresh=False, get_time=MockClock(auto=True) + ) + test = ["foo", "bar", "baz"] + expected_values = iter(test) + with progress: + for value in progress.track(test, description="test"): + assert value == next(expected_values) + result = console.file.getvalue() + print(repr(result)) + expected = "\x1b[?25l\r\x1b[2Ktest \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 0%\x1b[0m \x1b[36m-:--:--\x1b[0m\r\x1b[2Ktest \x1b[38;2;249;38;114m━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 33%\x1b[0m \x1b[36m-:--:--\x1b[0m\r\x1b[2Ktest \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\x1b[38;2;249;38;114m╸\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━\x1b[0m \x1b[35m 67%\x1b[0m \x1b[36m0:00:06\x1b[0m\r\x1b[2Ktest \x1b[38;2;114;156;31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m100%\x1b[0m \x1b[36m0:00:00\x1b[0m\r\x1b[2Ktest \x1b[38;2;114;156;31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m100%\x1b[0m \x1b[36m0:00:00\x1b[0m\n\x1b[?25h" + + print(expected) + print(repr(expected)) + print(result) + print(repr(result)) + + assert result == expected + + with pytest.raises(ValueError): + for n in progress.track(5): + pass + + +def test_columns() -> None: + + console = Console( + file=io.StringIO(), + force_terminal=True, + width=80, + log_time_format="[TIME]", + color_system="truecolor", + legacy_windows=False, + log_path=False, + ) + progress = Progress( + "test", + TextColumn("{task.description}"), + BarColumn(bar_width=None), + TimeRemainingColumn(), + TimeElapsedColumn(), + FileSizeColumn(), + TotalFileSizeColumn(), + DownloadColumn(), + TransferSpeedColumn(), + transient=True, + console=console, + auto_refresh=False, + get_time=MockClock(), + ) + task1 = progress.add_task("foo", total=10) + task2 = progress.add_task("bar", total=7) + with progress: + for n in range(4): + progress.advance(task1, 3) + progress.advance(task2, 4) + print("foo") + console.log("hello") + console.print("world") + progress.refresh() + from .render import replace_link_ids + + result = replace_link_ids(console.file.getvalue()) + print(repr(result)) + expected = "\x1b[?25ltest foo \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:37\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 bytes\x1b[0m \x1b[31m?\x1b[0m\ntest bar \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:36\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes \x1b[0m \x1b[31m?\x1b[0m\r\x1b[2K\x1b[1A\x1b[2Kfoo\ntest foo \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:37\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 bytes\x1b[0m \x1b[31m?\x1b[0m\ntest bar \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:36\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes \x1b[0m \x1b[31m?\x1b[0m\r\x1b[2K\x1b[1A\x1b[2K\x1b[2;36m[TIME]\x1b[0m\x1b[2;36m \x1b[0mhello \ntest foo \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:37\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 bytes\x1b[0m \x1b[31m?\x1b[0m\ntest bar \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:36\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes \x1b[0m \x1b[31m?\x1b[0m\r\x1b[2K\x1b[1A\x1b[2Kworld\ntest foo \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:37\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 bytes\x1b[0m \x1b[31m?\x1b[0m\ntest bar \x1b[38;5;237m━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:36\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes \x1b[0m \x1b[31m?\x1b[0m\r\x1b[2K\x1b[1A\x1b[2Ktest foo \x1b[38;2;114;156;31m━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m0:00:00\x1b[0m \x1b[33m0:01:00\x1b[0m \x1b[32m12 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m12/10 bytes\x1b[0m \x1b[31m1 byte/s\x1b[0m\ntest bar \x1b[38;2;114;156;31m━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m0:00:00\x1b[0m \x1b[33m0:00:45\x1b[0m \x1b[32m16 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m16/7 bytes \x1b[0m \x1b[31m1 byte/s\x1b[0m\r\x1b[2K\x1b[1A\x1b[2Ktest foo \x1b[38;2;114;156;31m━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m0:00:00\x1b[0m \x1b[33m0:01:00\x1b[0m \x1b[32m12 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m12/10 bytes\x1b[0m \x1b[31m1 byte/s\x1b[0m\ntest bar \x1b[38;2;114;156;31m━━━━━━━━━━━━━━━━\x1b[0m \x1b[36m0:00:00\x1b[0m \x1b[33m0:00:45\x1b[0m \x1b[32m16 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m16/7 bytes \x1b[0m \x1b[31m1 byte/s\x1b[0m\n\x1b[?25h\r\x1b[1A\x1b[2K\x1b[1A\x1b[2K" + assert result == expected + + +def test_task_create() -> None: + task = Task(TaskID(1), "foo", 100, 0, _get_time=lambda: 1) + assert task.elapsed is None + assert not task.finished + assert task.percentage == 0.0 + assert task.speed is None + assert task.time_remaining is None + + +def test_task_start() -> None: + current_time = 1 + + def get_time(): + nonlocal current_time + return current_time + + task = Task(TaskID(1), "foo", 100, 0, _get_time=get_time) + task.start_time = get_time() + assert task.started == True + assert task.elapsed == 0 + current_time += 1 + assert task.elapsed == 1 + current_time += 1 + task.stop_time = get_time() + current_time += 1 + assert task.elapsed == 2 + + +def test_task_zero_total() -> None: + task = Task(TaskID(1), "foo", 0, 0, _get_time=lambda: 1) + assert task.percentage == 0 + + +def test_progress_create() -> None: + progress = Progress() + assert progress.finished + assert progress.tasks == [] + assert progress.task_ids == [] + + +def test_track_thread() -> None: + progress = Progress() + task_id = progress.add_task("foo") + track_thread = _TrackThread(progress, task_id, 0.1) + assert track_thread.completed == 0 + from time import sleep + + with track_thread: + track_thread.completed = 1 + sleep(0.3) + assert progress.tasks[task_id].completed >= 1 + track_thread.completed += 1 + + +def test_reset() -> None: + progress = Progress() + task_id = progress.add_task("foo") + progress.advance(task_id, 1) + progress.advance(task_id, 1) + progress.advance(task_id, 1) + progress.advance(task_id, 7) + task = progress.tasks[task_id] + assert task.completed == 10 + progress.reset( + task_id, + total=200, + completed=20, + visible=False, + description="bar", + example="egg", + ) + assert task.total == 200 + assert task.completed == 20 + assert task.visible == False + assert task.description == "bar" + assert task.fields == {"example": "egg"} + assert not task._progress + + +def test_progress_max_refresh() -> None: + """Test max_refresh argment.""" + time = 0.0 + + def get_time() -> float: + nonlocal time + try: + return time + finally: + time = time + 1.0 + + console = Console( + color_system=None, width=80, legacy_windows=False, force_terminal=True + ) + column = TextColumn("{task.description}") + column.max_refresh = 3 + progress = Progress( + column, + get_time=get_time, + auto_refresh=False, + console=console, + ) + console.begin_capture() + with progress: + task_id = progress.add_task("start") + for tick in range(6): + progress.update(task_id, description=f"tick {tick}") + progress.refresh() + result = console.end_capture() + print(repr(result)) + assert ( + result + == "\x1b[?25l\r\x1b[2Kstart\r\x1b[2Kstart\r\x1b[2Ktick 1\r\x1b[2Ktick 1\r\x1b[2Ktick 3\r\x1b[2Ktick 3\r\x1b[2Ktick 5\r\x1b[2Ktick 5\n\x1b[?25h" + ) + + +if __name__ == "__main__": + _render = render_progress() + print(_render) + print(repr(_render)) diff --git a/tests/test_prompt.py b/tests/test_prompt.py new file mode 100644 index 0000000..9a41cc3 --- /dev/null +++ b/tests/test_prompt.py @@ -0,0 +1,95 @@ +import io + +from rich.console import Console +from rich.prompt import Prompt, IntPrompt, Confirm + + +def test_prompt_str(): + INPUT = "egg\nfoo" + console = Console(file=io.StringIO()) + name = Prompt.ask( + "what is your name", + console=console, + choices=["foo", "bar"], + default="baz", + stream=io.StringIO(INPUT), + ) + assert name == "foo" + expected = "what is your name [foo/bar] (baz): Please select one of the available options\nwhat is your name [foo/bar] (baz): " + output = console.file.getvalue() + print(repr(output)) + assert output == expected + + +def test_prompt_str_default(): + INPUT = "" + console = Console(file=io.StringIO()) + name = Prompt.ask( + "what is your name", + console=console, + default="Will", + stream=io.StringIO(INPUT), + ) + assert name == "Will" + expected = "what is your name (Will): " + output = console.file.getvalue() + print(repr(output)) + assert output == expected + + +def test_prompt_int(): + INPUT = "foo\n100" + console = Console(file=io.StringIO()) + number = IntPrompt.ask( + "Enter a number", + console=console, + stream=io.StringIO(INPUT), + ) + assert number == 100 + expected = "Enter a number: Please enter a valid integer number\nEnter a number: " + output = console.file.getvalue() + print(repr(output)) + assert output == expected + + +def test_prompt_confirm_no(): + INPUT = "foo\nNO\nn" + console = Console(file=io.StringIO()) + answer = Confirm.ask( + "continue", + console=console, + stream=io.StringIO(INPUT), + ) + assert answer is False + expected = "continue [y/n]: Please enter Y or N\ncontinue [y/n]: Please enter Y or N\ncontinue [y/n]: " + output = console.file.getvalue() + print(repr(output)) + assert output == expected + + +def test_prompt_confirm_yes(): + INPUT = "foo\nNO\ny" + console = Console(file=io.StringIO()) + answer = Confirm.ask( + "continue", + console=console, + stream=io.StringIO(INPUT), + ) + assert answer is True + expected = "continue [y/n]: Please enter Y or N\ncontinue [y/n]: Please enter Y or N\ncontinue [y/n]: " + output = console.file.getvalue() + print(repr(output)) + assert output == expected + + +def test_prompt_confirm_default(): + INPUT = "foo\nNO\ny" + console = Console(file=io.StringIO()) + answer = Confirm.ask( + "continue", console=console, stream=io.StringIO(INPUT), default=True + ) + assert answer is True + expected = "continue [y/n] (y): Please enter Y or N\ncontinue [y/n] (y): Please enter Y or N\ncontinue [y/n] (y): " + output = console.file.getvalue() + print(repr(output)) + assert output == expected diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..310b994 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,35 @@ +import io + +from rich.abc import RichRenderable +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + + +class Foo: + def __rich__(self) -> Text: + return Text("Foo") + + +def test_rich_cast(): + foo = Foo() + console = Console(file=io.StringIO()) + console.print(foo) + assert console.file.getvalue() == "Foo\n" + + +def test_rich_cast_container(): + foo = Foo() + console = Console(file=io.StringIO(), legacy_windows=False) + console.print(Panel.fit(foo, padding=0)) + assert console.file.getvalue() == "╭───╮\n│Foo│\n╰───╯\n" + + +def test_abc(): + foo = Foo() + assert isinstance(foo, RichRenderable) + assert isinstance(Text("hello"), RichRenderable) + assert isinstance(Panel("hello"), RichRenderable) + assert not isinstance(foo, str) + assert not isinstance("foo", RichRenderable) + assert not isinstance([], RichRenderable) diff --git a/tests/test_ratio.py b/tests/test_ratio.py new file mode 100644 index 0000000..8a44e8c --- /dev/null +++ b/tests/test_ratio.py @@ -0,0 +1,50 @@ +import pytest +from typing import NamedTuple, Optional + +from rich._ratio import ratio_reduce, ratio_resolve + + +class Edge(NamedTuple): + size: Optional[int] = None + ratio: int = 1 + minimum_size: int = 1 + + +@pytest.mark.parametrize( + "total,ratios,maximums,values,result", + [ + (20, [2, 4], [20, 20], [5, 5], [-2, -8]), + (20, [2, 4], [1, 1], [5, 5], [4, 4]), + (20, [2, 4], [1, 1], [2, 2], [1, 1]), + (3, [2, 4], [3, 3], [2, 2], [1, 0]), + (3, [2, 4], [3, 3], [0, 0], [-1, -2]), + (3, [0, 0], [3, 3], [4, 4], [4, 4]), + ], +) +def test_ratio_reduce(total, ratios, maximums, values, result): + assert ratio_reduce(total, ratios, maximums, values) == result + + +def test_ratio_resolve(): + assert ratio_resolve(100, []) == [] + assert ratio_resolve(100, [Edge(size=100), Edge(ratio=1)]) == [100, 1] + assert ratio_resolve(100, [Edge(ratio=1)]) == [100] + assert ratio_resolve(100, [Edge(ratio=1), Edge(ratio=1)]) == [50, 50] + assert ratio_resolve(100, [Edge(size=20), Edge(ratio=1), Edge(ratio=1)]) == [ + 20, + 40, + 40, + ] + assert ratio_resolve(100, [Edge(size=40), Edge(ratio=2), Edge(ratio=1)]) == [ + 40, + 40, + 20, + ] + assert ratio_resolve( + 100, [Edge(size=40), Edge(ratio=2), Edge(ratio=1, minimum_size=25)] + ) == [40, 35, 25] + assert ratio_resolve(100, [Edge(ratio=1), Edge(ratio=1), Edge(ratio=1)]) == [ + 33, + 33, + 34, + ] diff --git a/tests/test_rich_print.py b/tests/test_rich_print.py new file mode 100644 index 0000000..18467c6 --- /dev/null +++ b/tests/test_rich_print.py @@ -0,0 +1,42 @@ +import io + +import rich +from rich.console import Console + + +def test_get_console(): + console = rich.get_console() + assert isinstance(console, Console) + + +def test_reconfigure_console(): + rich.reconfigure(width=100) + assert rich.get_console().width == 100 + + +def test_rich_print(): + console = rich.get_console() + output = io.StringIO() + backup_file = console.file + try: + console.file = output + rich.print("foo", "bar") + rich.print("foo\n") + rich.print("foo\n\n") + assert output.getvalue() == "foo bar\nfoo\n\nfoo\n\n\n" + finally: + console.file = backup_file + + +def test_rich_print_X(): + console = rich.get_console() + output = io.StringIO() + backup_file = console.file + try: + console.file = output + rich.print("foo") + rich.print("fooX") + rich.print("fooXX") + assert output.getvalue() == "foo\nfooX\nfooXX\n" + finally: + console.file = backup_file diff --git a/tests/test_rule.py b/tests/test_rule.py new file mode 100644 index 0000000..d5edcf4 --- /dev/null +++ b/tests/test_rule.py @@ -0,0 +1,83 @@ +import io + +import pytest + +from rich.console import Console +from rich.rule import Rule +from rich.text import Text + + +def test_rule(): + console = Console( + width=16, file=io.StringIO(), force_terminal=True, legacy_windows=False + ) + console.print(Rule()) + console.print(Rule("foo")) + console.rule(Text("foo", style="bold")) + console.rule("foobarbazeggfoobarbazegg") + expected = "\x1b[92m────────────────\x1b[0m\n" + expected += "\x1b[92m───── \x1b[0mfoo\x1b[92m ──────\x1b[0m\n" + expected += "\x1b[92m───── \x1b[0m\x1b[1mfoo\x1b[0m\x1b[92m ──────\x1b[0m\n" + expected += "\x1b[92m─ \x1b[0mfoobarbazeg…\x1b[92m ─\x1b[0m\n" + + result = console.file.getvalue() + assert result == expected + + +def test_rule_error(): + console = Console(width=16, file=io.StringIO(), legacy_windows=False) + with pytest.raises(ValueError): + console.rule("foo", align="foo") + + +def test_rule_align(): + console = Console(width=16, file=io.StringIO(), legacy_windows=False) + console.rule("foo") + console.rule("foo", align="left") + console.rule("foo", align="center") + console.rule("foo", align="right") + console.rule() + result = console.file.getvalue() + print(repr(result)) + expected = "───── foo ──────\nfoo ────────────\n───── foo ──────\n──────────── foo\n────────────────\n" + assert result == expected + + +def test_rule_cjk(): + console = Console( + width=16, + file=io.StringIO(), + force_terminal=True, + color_system=None, + legacy_windows=False, + ) + console.rule("欢迎!") + expected = "──── 欢迎! ────\n" + assert console.file.getvalue() == expected + + +def test_characters(): + console = Console( + width=16, + file=io.StringIO(), + force_terminal=True, + color_system=None, + legacy_windows=False, + ) + console.rule(characters="+*") + console.rule("foo", characters="+*") + console.print(Rule(characters=".,")) + expected = "+*+*+*+*+*+*+*+*\n" + expected += "+*+*+ foo +*+*+*\n" + expected += ".,.,.,.,.,.,.,.,\n" + assert console.file.getvalue() == expected + + +def test_repr(): + rule = Rule("foo") + assert isinstance(repr(rule), str) + + +def test_error(): + with pytest.raises(ValueError): + Rule(characters="") diff --git a/tests/test_screen.py b/tests/test_screen.py new file mode 100644 index 0000000..7596c3a --- /dev/null +++ b/tests/test_screen.py @@ -0,0 +1,12 @@ +from rich.console import Console +from rich.screen import Screen + + +def test_screen(): + console = Console(color_system=None, width=20, height=5) + with console.capture() as capture: + console.print(Screen("foo\nbar\nbaz\nfoo\nbar\nbaz\foo")) + result = capture.get() + print(repr(result)) + expected = "foo \nbar \nbaz \nfoo \nbar " + assert result == expected diff --git a/tests/test_segment.py b/tests/test_segment.py new file mode 100644 index 0000000..7e7a3b3 --- /dev/null +++ b/tests/test_segment.py @@ -0,0 +1,117 @@ +from rich.segment import Segment +from rich.style import Style + + +def test_repr(): + assert repr(Segment("foo")) == "Segment('foo', None)" + assert repr(Segment.control("foo")) == "Segment.control('foo', None)" + + +def test_line(): + assert Segment.line() == Segment("\n") + + +def test_apply_style(): + segments = [Segment("foo"), Segment("bar", Style(bold=True))] + assert Segment.apply_style(segments, None) is segments + assert list(Segment.apply_style(segments, Style(italic=True))) == [ + Segment("foo", Style(italic=True)), + Segment("bar", Style(italic=True, bold=True)), + ] + + +def test_split_lines(): + lines = [Segment("Hello\nWorld")] + assert list(Segment.split_lines(lines)) == [[Segment("Hello")], [Segment("World")]] + + +def test_split_and_crop_lines(): + assert list( + Segment.split_and_crop_lines([Segment("Hello\nWorld!\n"), Segment("foo")], 4) + ) == [ + [Segment("Hell"), Segment("\n", None)], + [Segment("Worl"), Segment("\n", None)], + [Segment("foo"), Segment(" ")], + ] + + +def test_adjust_line_length(): + line = [Segment("Hello", "foo")] + assert Segment.adjust_line_length(line, 10, style="bar") == [ + Segment("Hello", "foo"), + Segment(" ", "bar"), + ] + + line = [Segment("H"), Segment("ello, World!")] + assert Segment.adjust_line_length(line, 5) == [Segment("H"), Segment("ello")] + + line = [Segment("Hello")] + assert Segment.adjust_line_length(line, 5) == line + + +def test_get_line_length(): + assert Segment.get_line_length([Segment("foo"), Segment("bar")]) == 6 + + +def test_get_shape(): + assert Segment.get_shape([[Segment("Hello")]]) == (5, 1) + assert Segment.get_shape([[Segment("Hello")], [Segment("World!")]]) == (6, 2) + + +def test_set_shape(): + assert Segment.set_shape([[Segment("Hello")]], 10) == [ + [Segment("Hello"), Segment(" ")] + ] + assert Segment.set_shape([[Segment("Hello")]], 10, 2) == [ + [Segment("Hello"), Segment(" ")], + [Segment(" " * 10)], + ] + + +def test_simplify(): + assert list( + Segment.simplify([Segment("Hello"), Segment(" "), Segment("World!")]) + ) == [Segment("Hello World!")] + assert list( + Segment.simplify( + [Segment("Hello", "red"), Segment(" ", "red"), Segment("World!", "blue")] + ) + ) == [Segment("Hello ", "red"), Segment("World!", "blue")] + assert list(Segment.simplify([])) == [] + + +def test_filter_control(): + segments = [Segment("foo"), Segment("bar", is_control=True)] + assert list(Segment.filter_control(segments)) == [Segment("foo")] + assert list(Segment.filter_control(segments, is_control=True)) == [ + Segment("bar", is_control=True) + ] + + +def test_strip_styles(): + segments = [Segment("foo", Style(bold=True))] + assert list(Segment.strip_styles(segments)) == [Segment("foo", None)] + + +def test_strip_links(): + segments = [Segment("foo", Style(bold=True, link="https://www.example.org"))] + assert list(Segment.strip_links(segments)) == [Segment("foo", Style(bold=True))] + + +def test_remove_color(): + segments = [ + Segment("foo", Style(bold=True, color="red")), + Segment("bar", None), + ] + assert list(Segment.remove_color(segments)) == [ + Segment("foo", Style(bold=True)), + Segment("bar", None), + ] + + +def test_make_control(): + segments = [Segment("foo"), Segment("bar")] + assert Segment.make_control(segments) == [ + Segment.control("foo"), + Segment.control("bar"), + ] diff --git a/tests/test_spinner.py b/tests/test_spinner.py new file mode 100644 index 0000000..7f2b0a1 --- /dev/null +++ b/tests/test_spinner.py @@ -0,0 +1,42 @@ +from time import time +import pytest + +from rich.console import Console +from rich.measure import Measurement +from rich.spinner import Spinner + + +def test_spinner_create(): + spinner = Spinner("dots") + assert spinner.time == 0.0 + with pytest.raises(KeyError): + Spinner("foobar") + + +def test_spinner_render(): + time = 0.0 + + def get_time(): + nonlocal time + return time + + console = Console( + width=80, color_system=None, force_terminal=True, get_time=get_time + ) + console.begin_capture() + spinner = Spinner("dots", "Foo") + console.print(spinner) + time += 80 / 1000 + console.print(spinner) + result = console.end_capture() + print(repr(result)) + expected = "⠋ Foo\n⠙ Foo\n" + assert result == expected + + +def test_rich_measure(): + console = Console(width=80, color_system=None, force_terminal=True) + spinner = Spinner("dots", "Foo") + min_width, max_width = Measurement.get(console, spinner, 80) + assert min_width == 3 + assert max_width == 5 diff --git a/tests/test_stack.py b/tests/test_stack.py new file mode 100644 index 0000000..5bbb885 --- /dev/null +++ b/tests/test_stack.py @@ -0,0 +1,11 @@ +from rich._stack import Stack + + +def test_stack(): + + stack = Stack() + stack.push("foo") + stack.push("bar") + assert stack.top == "bar" + assert stack.pop() == "bar" + assert stack.top == "foo" diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..3abaa33 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,31 @@ +from time import sleep + +from rich.console import Console +from rich.status import Status +from rich.table import Table + + +def test_status(): + + console = Console( + color_system=None, width=80, legacy_windows=False, get_time=lambda: 0.0 + ) + status = Status("foo", console=console) + assert status.console == console + status.update(status="bar", spinner="dots2", spinner_style="red", speed=2.0) + + assert isinstance(status.renderable, Table) + + # TODO: Testing output is tricky with threads + with status: + sleep(0.2) + + +def test_renderable(): + console = Console( + color_system=None, width=80, legacy_windows=False, get_time=lambda: 0.0 + ) + status = Status("foo", console=console) + console.begin_capture() + console.print(status) + assert console.end_capture() == "⠋ foo\n" diff --git a/tests/test_style.py b/tests/test_style.py new file mode 100644 index 0000000..8282331 --- /dev/null +++ b/tests/test_style.py @@ -0,0 +1,208 @@ +import pytest + +from rich.color import Color, ColorSystem, ColorType +from rich import errors +from rich.style import Style, StyleStack + + +def test_str(): + assert str(Style(bold=False)) == "not bold" + assert str(Style(color="red", bold=False)) == "not bold red" + assert str(Style(color="red", bold=False, italic=True)) == "not bold italic red" + assert str(Style()) == "none" + assert str(Style(bold=True)) == "bold" + assert str(Style(color="red", bold=True)) == "bold red" + assert str(Style(color="red", bgcolor="black", bold=True)) == "bold red on black" + all_styles = Style( + color="red", + bgcolor="black", + bold=True, + dim=True, + italic=True, + underline=True, + blink=True, + blink2=True, + reverse=True, + conceal=True, + strike=True, + underline2=True, + frame=True, + encircle=True, + overline=True, + ) + expected = "bold dim italic underline blink blink2 reverse conceal strike underline2 frame encircle overline red on black" + assert str(all_styles) == expected + assert str(Style(link="foo")) == "link foo" + + +def test_ansi_codes(): + all_styles = Style( + color="red", + bgcolor="black", + bold=True, + dim=True, + italic=True, + underline=True, + blink=True, + blink2=True, + reverse=True, + conceal=True, + strike=True, + underline2=True, + frame=True, + encircle=True, + overline=True, + ) + expected = "1;2;3;4;5;6;7;8;9;21;51;52;53;31;40" + assert all_styles._make_ansi_codes(ColorSystem.TRUECOLOR) == expected + + +def test_repr(): + assert repr(Style(bold=True, color="red")) == 'Style.parse("bold red")' + + +def test_eq(): + assert Style(bold=True, color="red") == Style(bold=True, color="red") + assert Style(bold=True, color="red") != Style(bold=True, color="green") + assert Style().__eq__("foo") == NotImplemented + + +def test_hash(): + assert isinstance(hash(Style()), int) + + +def test_empty(): + assert Style.null() == Style() + + +def test_bool(): + assert bool(Style()) is False + assert bool(Style(bold=True)) is True + assert bool(Style(color="red")) is True + assert bool(Style.parse("")) is False + + +def test_color_property(): + assert Style(color="red").color == Color("red", ColorType.STANDARD, 1, None) + + +def test_bgcolor_property(): + assert Style(bgcolor="black").bgcolor == Color("black", ColorType.STANDARD, 0, None) + + +def test_parse(): + assert Style.parse("") == Style() + assert Style.parse("red") == Style(color="red") + assert Style.parse("not bold") == Style(bold=False) + assert Style.parse("bold red on black") == Style( + color="red", bgcolor="black", bold=True + ) + assert Style.parse("bold link https://example.org") == Style( + bold=True, link="https://example.org" + ) + with pytest.raises(errors.StyleSyntaxError): + Style.parse("on") + with pytest.raises(errors.StyleSyntaxError): + Style.parse("on nothing") + with pytest.raises(errors.StyleSyntaxError): + Style.parse("rgb(999,999,999)") + with pytest.raises(errors.StyleSyntaxError): + Style.parse("not monkey") + with pytest.raises(errors.StyleSyntaxError): + Style.parse("link") + + +def test_link_id(): + assert Style().link_id == "" + assert Style.parse("").link_id == "" + assert Style.parse("red").link_id == "" + style = Style.parse("red link https://example.org") + assert isinstance(style.link_id, str) + assert len(style.link_id) > 1 + + +def test_get_html_style(): + expected = "color: #7f7fbf; background-color: #800000; font-weight: bold; font-style: italic; text-decoration: underline; text-decoration: line-through; text-decoration: overline" + assert ( + Style( + reverse=True, + dim=True, + color="red", + bgcolor="blue", + bold=True, + italic=True, + underline=True, + strike=True, + overline=True, + ).get_html_style() + == expected + ) + + +def test_chain(): + assert Style.chain(Style(color="red"), Style(bold=True)) == Style( + color="red", bold=True + ) + + +def test_copy(): + style = Style(color="red", bgcolor="black", italic=True) + assert style == style.copy() + assert style is not style.copy() + + +def test_render(): + assert Style(color="red").render("foo", color_system=None) == "foo" + assert ( + Style(color="red", bgcolor="black", bold=True).render("foo") + == "\x1b[1;31;40mfoo\x1b[0m" + ) + assert Style().render("foo") == "foo" + + +def test_test(): + Style(color="red").test("hello") + + +def test_add(): + assert Style(color="red") + None == Style(color="red") + assert Style().__add__("foo") == NotImplemented + + +def test_iadd(): + style = Style(color="red") + style += Style(bold=True) + assert style == Style(color="red", bold=True) + style += None + assert style == Style(color="red", bold=True) + + +def test_style_stack(): + stack = StyleStack(Style(color="red")) + repr(stack) + assert stack.current == Style(color="red") + stack.push(Style(bold=True)) + assert stack.current == Style(color="red", bold=True) + stack.pop() + assert stack.current == Style(color="red") + + +def test_pick_first(): + with pytest.raises(ValueError): + Style.pick_first() + + +def test_background_style(): + assert Style(bold=True, color="yellow", bgcolor="red").background_style == Style( + bgcolor="red" + ) + + +def test_without_color(): + style = Style(bold=True, color="red", bgcolor="blue") + colorless_style = style.without_color + assert colorless_style.color == None + assert colorless_style.bgcolor == None + assert colorless_style.bold == True + null_style = Style.null() + assert null_style.without_color == null_style diff --git a/tests/test_styled.py b/tests/test_styled.py new file mode 100644 index 0000000..8ebe5e8 --- /dev/null +++ b/tests/test_styled.py @@ -0,0 +1,15 @@ +import io + +from rich.console import Console +from rich.measure import Measurement +from rich.styled import Styled + + +def test_styled(): + styled_foo = Styled("foo", "on red") + console = Console(file=io.StringIO(), force_terminal=True) + assert Measurement.get(console, styled_foo, 80) == Measurement(3, 3) + console.print(styled_foo) + result = console.file.getvalue() + expected = "\x1b[41mfoo\x1b[0m\n" + assert result == expected diff --git a/tests/test_syntax.py b/tests/test_syntax.py new file mode 100644 index 0000000..e7575a6 --- /dev/null +++ b/tests/test_syntax.py @@ -0,0 +1,221 @@ +# coding=utf-8 + +import sys +import os, tempfile + +import pytest +from .render import render + +from rich.panel import Panel +from rich.style import Style +from rich.syntax import ( + Syntax, + ANSISyntaxTheme, + PygmentsSyntaxTheme, + Color, + Console, + ConsoleOptions, +) + + +CODE = ''' +def loop_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: + """Iterate and generate a tuple with a flag for first and last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + first = True + for value in iter_values: + yield first, False, previous_value + first = False + previous_value = value + yield first, True, previous_value +''' + + +def test_python_render(): + syntax = Panel.fit( + Syntax( + CODE, + lexer_name="python", + line_numbers=True, + line_range=(2, 10), + theme="foo", + code_width=60, + word_wrap=True, + ), + padding=0, + ) + rendered_syntax = render(syntax) + print(repr(rendered_syntax)) + expected = '╭────────────────────────────────────────────────────────────────╮\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 2 \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[3;38;2;186;33;33;48;2;248;248;248m"""Iterate and generate a tuple with a flag for first \x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[48;2;248;248;248m \x1b[0m\x1b[3;38;2;186;33;33;48;2;248;248;248mand last value."""\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 3 \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248miter_values\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;102;102;102;48;2;248;248;248m=\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;128;0;48;2;248;248;248miter\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m(\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mvalues\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m)\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 4 \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mtry\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m:\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 5 \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mprevious_value\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;102;102;102;48;2;248;248;248m=\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;128;0;48;2;248;248;248mnext\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m(\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248miter_values\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m)\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 6 \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mexcept\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;210;65;58;48;2;248;248;248mStopIteration\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m:\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 7 \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mreturn\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 8 \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mfirst\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;102;102;102;48;2;248;248;248m=\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mTrue\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 9 \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mfor\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mvalue\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;170;34;255;48;2;248;248;248min\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248miter_values\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m:\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m10 \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248myield\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mfirst\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m,\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mFalse\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m,\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mprevious_value\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n╰────────────────────────────────────────────────────────────────╯\n' + assert rendered_syntax == expected + + +def test_python_render_indent_guides(): + syntax = Panel.fit( + Syntax( + CODE, + lexer_name="python", + line_numbers=True, + line_range=(2, 10), + theme="foo", + code_width=60, + word_wrap=True, + indent_guides=True, + ), + padding=0, + ) + rendered_syntax = render(syntax) + print(repr(rendered_syntax)) + expected = '╭────────────────────────────────────────────────────────────────╮\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 2 \x1b[0m\x1b[2;3;38;2;64;128;128;48;2;248;248;248m│ \x1b[0m\x1b[3;38;2;186;33;33;48;2;248;248;248m"""Iterate and generate a tuple with a flag for first \x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[48;2;248;248;248m \x1b[0m\x1b[3;38;2;186;33;33;48;2;248;248;248mand last value."""\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 3 \x1b[0m\x1b[2;3;38;2;64;128;128;48;2;248;248;248m│ \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248miter_values\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;102;102;102;48;2;248;248;248m=\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;128;0;48;2;248;248;248miter\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m(\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mvalues\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m)\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 4 \x1b[0m\x1b[2;3;38;2;64;128;128;48;2;248;248;248m│ \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mtry\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m:\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 5 \x1b[0m\x1b[2;3;38;2;64;128;128;48;2;248;248;248m│ │ \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mprevious_value\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;102;102;102;48;2;248;248;248m=\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;128;0;48;2;248;248;248mnext\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m(\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248miter_values\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m)\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 6 \x1b[0m\x1b[2;3;38;2;64;128;128;48;2;248;248;248m│ \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mexcept\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;210;65;58;48;2;248;248;248mStopIteration\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m:\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 7 \x1b[0m\x1b[2;3;38;2;64;128;128;48;2;248;248;248m│ │ \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mreturn\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 8 \x1b[0m\x1b[2;3;38;2;64;128;128;48;2;248;248;248m│ \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mfirst\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;102;102;102;48;2;248;248;248m=\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mTrue\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m 9 \x1b[0m\x1b[2;3;38;2;64;128;128;48;2;248;248;248m│ \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mfor\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mvalue\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;170;34;255;48;2;248;248;248min\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248miter_values\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m:\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n│\x1b[1;38;2;24;24;24;48;2;248;248;248m \x1b[0m\x1b[38;2;173;173;173;48;2;248;248;248m10 \x1b[0m\x1b[2;3;38;2;64;128;128;48;2;248;248;248m│ │ \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248myield\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mfirst\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m,\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;128;0;48;2;248;248;248mFalse\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m,\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248mprevious_value\x1b[0m\x1b[48;2;248;248;248m \x1b[0m│\n╰────────────────────────────────────────────────────────────────╯\n' + assert rendered_syntax == expected + + +def test_pygments_syntax_theme_non_str(): + from pygments.style import Style as PygmentsStyle + + style = PygmentsSyntaxTheme(PygmentsStyle()) + assert style.get_background_style().bgcolor == Color.parse("#ffffff") + + +def test_pygments_syntax_theme(): + style = PygmentsSyntaxTheme("default") + assert style.get_style_for_token("abc") == Style.parse("none") + + +def test_get_line_color_none(): + style = PygmentsSyntaxTheme("default") + style._background_style = Style(bgcolor=None) + syntax = Syntax( + CODE, + lexer_name="python", + line_numbers=True, + line_range=(2, 10), + theme=style, + code_width=60, + word_wrap=True, + background_color="red", + ) + assert syntax._get_line_numbers_color() == Color.default() + + +def test_highlight_background_color(): + syntax = Syntax( + CODE, + lexer_name="python", + line_numbers=True, + line_range=(2, 10), + theme="foo", + code_width=60, + word_wrap=True, + background_color="red", + ) + assert syntax.highlight(CODE).style == Style.parse("on red") + + +def test_get_number_styles(): + syntax = Syntax(CODE, "python", theme="monokai", line_numbers=True) + console = Console(color_system="windows") + assert syntax._get_number_styles(console=console) == ( + Style.parse("on #272822"), + Style.parse("dim on #272822"), + Style.parse("not dim on #272822"), + ) + + +def test_get_style_for_token(): + # from pygments.style import Style as PygmentsStyle + # pygments_style = PygmentsStyle() + from pygments.style import Token + + style = PygmentsSyntaxTheme("default") + style_dict = {Token.Text: Style(color=None)} + style._style_cache = style_dict + syntax = Syntax( + CODE, + lexer_name="python", + line_numbers=True, + line_range=(2, 10), + theme=style, + code_width=60, + word_wrap=True, + background_color="red", + ) + assert syntax._get_line_numbers_color() == Color.default() + + +def test_option_no_wrap(): + + from rich.console import Console + + console = Console + + syntax = Syntax( + CODE, + lexer_name="python", + line_numbers=True, + line_range=(2, 10), + code_width=60, + word_wrap=False, + background_color="red", + ) + + rendered_syntax = render(syntax, True) + # print(repr(rendered_syntax)) + + expected = '\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 2 \x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;230;219;116;41m"""Iterate and generate a tuple with a flag for first and last value."""\x1b[0m\n\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 3 \x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;248;248;242;41miter_values\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;249;38;114;41m=\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;248;248;242;41miter\x1b[0m\x1b[38;2;248;248;242;41m(\x1b[0m\x1b[38;2;248;248;242;41mvalues\x1b[0m\x1b[38;2;248;248;242;41m)\x1b[0m\n\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 4 \x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;102;217;239;41mtry\x1b[0m\x1b[38;2;248;248;242;41m:\x1b[0m\n\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 5 \x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;248;248;242;41mprevious_value\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;249;38;114;41m=\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;248;248;242;41mnext\x1b[0m\x1b[38;2;248;248;242;41m(\x1b[0m\x1b[38;2;248;248;242;41miter_values\x1b[0m\x1b[38;2;248;248;242;41m)\x1b[0m\n\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 6 \x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;102;217;239;41mexcept\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;166;226;46;41mStopIteration\x1b[0m\x1b[38;2;248;248;242;41m:\x1b[0m\n\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 7 \x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;102;217;239;41mreturn\x1b[0m\n\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 8 \x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;248;248;242;41mfirst\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;249;38;114;41m=\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;102;217;239;41mTrue\x1b[0m\n\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m 9 \x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;102;217;239;41mfor\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;248;248;242;41mvalue\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;249;38;114;41min\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;248;248;242;41miter_values\x1b[0m\x1b[38;2;248;248;242;41m:\x1b[0m\n\x1b[1;38;2;227;227;221;48;2;39;40;34m \x1b[0m\x1b[38;2;101;102;96;48;2;39;40;34m10 \x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;102;217;239;41myield\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;248;248;242;41mfirst\x1b[0m\x1b[38;2;248;248;242;41m,\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;102;217;239;41mFalse\x1b[0m\x1b[38;2;248;248;242;41m,\x1b[0m\x1b[38;2;248;248;242;41m \x1b[0m\x1b[38;2;248;248;242;41mprevious_value\x1b[0m\n' + # console.print(syntax, no_wrap=True) + + assert rendered_syntax == expected + + +def test_ansi_theme(): + style = Style(color="red") + theme = ANSISyntaxTheme({("foo", "bar"): style}) + assert theme.get_style_for_token(("foo", "bar", "baz")) == style + assert theme.get_background_style() == Style() + + +@pytest.mark.skipif(sys.platform == "win32", reason="permissions error on Windows") +def test_from_file(): + fh, path = tempfile.mkstemp("example.py") + try: + os.write(fh, b"import this\n") + syntax = Syntax.from_path(path) + assert syntax.lexer_name == "Python" + assert syntax.code == "import this\n" + finally: + os.remove(path) + + +@pytest.mark.skipif(sys.platform == "win32", reason="permissions error on Windows") +def test_from_file_unknown_lexer(): + fh, path = tempfile.mkstemp("example.nosuchtype") + try: + os.write(fh, b"import this\n") + syntax = Syntax.from_path(path) + assert syntax.lexer_name == "default" + assert syntax.code == "import this\n" + finally: + os.remove(path) + + +if __name__ == "__main__": + syntax = Panel.fit( + Syntax( + CODE, + lexer_name="python", + line_numbers=True, + line_range=(2, 10), + theme="foo", + code_width=60, + word_wrap=True, + ), + padding=0, + ) + rendered = render(markdown) + print(rendered) + print(repr(rendered)) diff --git a/tests/test_table.py b/tests/test_table.py new file mode 100644 index 0000000..7a57167 --- /dev/null +++ b/tests/test_table.py @@ -0,0 +1,142 @@ +# encoding=utf-8 + +import io +from rich import color + +import pytest + +from rich import errors +from rich.console import Console +from rich.measure import Measurement +from rich.table import Table, Column +from rich.text import Text + + +def render_tables(): + console = Console( + width=60, + force_terminal=True, + file=io.StringIO(), + legacy_windows=False, + color_system=None, + ) + + table = Table(title="test table", caption="table caption", expand=True) + table.add_column("foo", footer=Text("total"), no_wrap=True, overflow="ellipsis") + table.add_column("bar", justify="center") + table.add_column("baz", justify="right") + + table.add_row("Averlongwordgoeshere", "banana pancakes", None) + + assert Measurement.get(console, table, 80) == Measurement(41, 48) + + for width in range(10, 60, 5): + console.print(table, width=width) + + table.expand = False + console.print(table, justify="left") + console.print(table, justify="center") + console.print(table, justify="right") + + assert table.row_count == 1 + + table.row_styles = ["red", "yellow"] + table.add_row("Coffee") + table.add_row("Coffee", "Chocolate", None, "cinnamon") + + assert table.row_count == 3 + + console.print(table) + + table.show_lines = True + console.print(table) + + table.show_footer = True + console.print(table) + + table.show_edge = False + + console.print(table) + + table.padding = 1 + console.print(table) + + table.width = 20 + assert Measurement.get(console, table, 80) == Measurement(20, 20) + console.print(table) + + table.columns[0].no_wrap = True + table.columns[1].no_wrap = True + table.columns[2].no_wrap = True + + console.print(table) + + table.padding = 0 + table.width = 60 + table.leading = 1 + console.print(table) + + return console.file.getvalue() + + +def test_render_table(): + expected = " test table \n┏━━━━━━┳━┳━┓\n┃ foo ┃ ┃ ┃\n┡━━━━━━╇━╇━┩\n│ Ave… │ │ │\n└──────┴─┴─┘\n table \n caption \n test table \n┏━━━━━━━━━━━┳━┳━┓\n┃ foo ┃ ┃ ┃\n┡━━━━━━━━━━━╇━╇━┩\n│ Averlong… │ │ │\n└───────────┴─┴─┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━┳━┳━┓\n┃ foo ┃ ┃ ┃\n┡━━━━━━━━━━━━━━━━╇━╇━┩\n│ Averlongwordg… │ │ │\n└────────────────┴─┴─┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━┳━┳━┓\n┃ foo ┃ ┃ ┃\n┡━━━━━━━━━━━━━━━━━━━━━╇━╇━┩\n│ Averlongwordgoeshe… │ │ │\n└─────────────────────┴─┴─┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━━┳━━┳━━┓\n┃ foo ┃ ┃ ┃\n┡━━━━━━━━━━━━━━━━━━━━━━╇━━╇━━┩\n│ Averlongwordgoeshere │ │ │\n└──────────────────────┴──┴──┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━┳━━━━┓\n┃ foo ┃ bar ┃ b… ┃\n┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━┩\n│ Averlongwordgoeshere │ ba… │ │\n│ │ pa… │ │\n└──────────────────────┴─────┴────┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━┓\n┃ foo ┃ bar ┃ baz ┃\n┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━┩\n│ Averlongwordgoeshere │ banana │ │\n│ │ pancak… │ │\n└──────────────────────┴─────────┴─────┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━┓\n┃ foo ┃ bar ┃ baz ┃\n┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━┩\n│ Averlongwordgoeshere │ banana │ │\n│ │ pancakes │ │\n└──────────────────────┴──────────────┴─────┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━┓\n┃ foo ┃ bar ┃ baz ┃\n┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━┩\n│ Averlongwordgoeshere │ banana pancakes │ │\n└───────────────────────┴──────────────────┴─────┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━┓\n┃ foo ┃ bar ┃ baz ┃\n┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━┩\n│ Averlongwordgoeshere │ banana pancakes │ │\n└──────────────────────────┴────────────────────┴─────┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━┓ \n┃ foo ┃ bar ┃ baz ┃ \n┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━┩ \n│ Averlongwordgoeshere │ banana pancakes │ │ \n└──────────────────────┴─────────────────┴─────┘ \n table caption \n test table \n ┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━┓ \n ┃ foo ┃ bar ┃ baz ┃ \n ┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━┩ \n │ Averlongwordgoeshere │ banana pancakes │ │ \n └──────────────────────┴─────────────────┴─────┘ \n table caption \n test table \n ┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━┓\n ┃ foo ┃ bar ┃ baz ┃\n ┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━┩\n │ Averlongwordgoeshere │ banana pancakes │ │\n └──────────────────────┴─────────────────┴─────┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━┳━━━━━━━━━━┓\n┃ foo ┃ bar ┃ baz ┃ ┃\n┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━━━━━━━┩\n│ Averlongwordgoeshere │ banana pancakes │ │ │\n│ Coffee │ │ │ │\n│ Coffee │ Chocolate │ │ cinnamon │\n└──────────────────────┴─────────────────┴─────┴──────────┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━┳━━━━━━━━━━┓\n┃ foo ┃ bar ┃ baz ┃ ┃\n┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━━━━━━━┩\n│ Averlongwordgoeshere │ banana pancakes │ │ │\n├──────────────────────┼─────────────────┼─────┼──────────┤\n│ Coffee │ │ │ │\n├──────────────────────┼─────────────────┼─────┼──────────┤\n│ Coffee │ Chocolate │ │ cinnamon │\n└──────────────────────┴─────────────────┴─────┴──────────┘\n table caption \n test table \n┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━┳━━━━━━━━━━┓\n┃ foo ┃ bar ┃ baz ┃ ┃\n┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━━━━━━━┩\n│ Averlongwordgoeshere │ banana pancakes │ │ │\n├──────────────────────┼─────────────────┼─────┼──────────┤\n│ Coffee │ │ │ │\n├──────────────────────┼─────────────────┼─────┼──────────┤\n│ Coffee │ Chocolate │ │ cinnamon │\n├──────────────────────┼─────────────────┼─────┼──────────┤\n│ total │ │ │ │\n└──────────────────────┴─────────────────┴─────┴──────────┘\n table caption \n test table \n foo ┃ bar ┃ baz ┃ \n━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━━━━━━━\n Averlongwordgoeshere │ banana pancakes │ │ \n──────────────────────┼─────────────────┼─────┼──────────\n Coffee │ │ │ \n──────────────────────┼─────────────────┼─────┼──────────\n Coffee │ Chocolate │ │ cinnamon \n──────────────────────┼─────────────────┼─────┼──────────\n total │ │ │ \n table caption \n test table \n ┃ ┃ ┃ \n foo ┃ bar ┃ baz ┃ \n ┃ ┃ ┃ \n━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━━━━━━━\n │ │ │ \n Averlongwordgoeshere │ banana pancakes │ │ \n │ │ │ \n──────────────────────┼─────────────────┼─────┼──────────\n │ │ │ \n Coffee │ │ │ \n │ │ │ \n──────────────────────┼─────────────────┼─────┼──────────\n │ │ │ \n Coffee │ Chocolate │ │ cinnamon \n │ │ │ \n──────────────────────┼─────────────────┼─────┼──────────\n │ │ │ \n total │ │ │ \n │ │ │ \n table caption \n test table \n ┃ ┃ ┃ \n foo ┃ ┃ ┃ \n ┃ ┃ ┃ \n━━━━━━━━━━━━━━━━━╇━╇━╇━\n │ │ │ \n Averlongwordgo… │ │ │ \n │ │ │ \n─────────────────┼─┼─┼─\n │ │ │ \n Coffee │ │ │ \n │ │ │ \n─────────────────┼─┼─┼─\n │ │ │ \n Coffee │ │ │ \n │ │ │ \n─────────────────┼─┼─┼─\n │ │ │ \n total │ │ │ \n │ │ │ \n table caption \n test table \n ┃ ┃ ┃ \n foo ┃ bar ┃ ┃ \n ┃ ┃ ┃ \n━━━━━━━━━━╇━━━━━━━━━╇━╇━\n │ │ │ \n Averlon… │ banana… │ │ \n │ │ │ \n──────────┼─────────┼─┼─\n │ │ │ \n Coffee │ │ │ \n │ │ │ \n──────────┼─────────┼─┼─\n │ │ │ \n Coffee │ Chocol… │ │ \n │ │ │ \n──────────┼─────────┼─┼─\n │ │ │ \n total │ │ │ \n │ │ │ \n table caption \n test table \nfoo ┃ bar ┃ baz┃ \n━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━╇━━━━━━━━━\nAverlongwordgoeshere │ banana pancakes │ │ \n │ │ │ \nCoffee │ │ │ \n │ │ │ \nCoffee │ Chocolate │ │cinnamon \n─────────────────────────┼───────────────────┼────┼─────────\ntotal │ │ │ \n table caption \n" + assert render_tables() == expected + + +def test_not_renderable(): + class Foo: + pass + + table = Table() + with pytest.raises(errors.NotRenderableError): + table.add_row(Foo()) + + +def test_init_append_column(): + header_names = ["header1", "header2", "header3"] + test_columns = [ + Column(_index=index, header=header) for index, header in enumerate(header_names) + ] + + # Test appending of strings for header names + assert Table(*header_names).columns == test_columns + # Test directly passing a Table Column objects + assert Table(*test_columns).columns == test_columns + + +def test_rich_measure(): + # Check __rich_measure__() for a negative width passed as an argument + assert Table("test_header", width=None).__rich_measure__( + Console(), -1 + ) == Measurement(0, 0) + # Check __rich_measure__() for a negative Table.width attribute + assert Table("test_header", width=-1).__rich_measure__(Console(), 1) == Measurement( + 0, 0 + ) + # Check __rich_measure__() for a positive width passed as an argument + assert Table("test_header", width=None).__rich_measure__( + Console(), 10 + ) == Measurement(10, 10) + # Check __rich_measure__() for a positive Table.width attribute + assert Table("test_header", width=10).__rich_measure__( + Console(), -1 + ) == Measurement(10, 10) + + +def test_min_width(): + table = Table("foo", min_width=30) + table.add_row("bar") + assert table.__rich_measure__(Console(), 100) == Measurement(30, 30) + console = Console(color_system=None) + console.begin_capture() + console.print(table) + output = console.end_capture() + print(output) + assert all(len(line) == 30 for line in output.splitlines()) + + +if __name__ == "__main__": + render = render_tables() + print(render) + print(repr(render)) diff --git a/tests/test_tabulate.py b/tests/test_tabulate.py new file mode 100644 index 0000000..37e86bf --- /dev/null +++ b/tests/test_tabulate.py @@ -0,0 +1,34 @@ +import itertools +from rich.style import Style +from rich.table import _Cell +from rich.tabulate import tabulate_mapping + + +def test_tabulate_mapping(): + # TODO: tabulate_mapping may not be needed shortly + table = tabulate_mapping({"foo": "1", "bar": "2"}) + assert len(table.columns) == 2 + assert len(table.columns[0]._cells) == 2 + assert len(table.columns[1]._cells) == 2 + + # add tests for title and caption justification + test_title = "Foo v. Bar" + test_caption = "approximate results" + for title_justify, caption_justify in itertools.product( + [None, "left", "center", "right"], repeat=2 + ): + table = tabulate_mapping( + {"foo": "1", "bar": "2"}, + title=test_title, + caption=test_caption, + title_justify=title_justify, + caption_justify=caption_justify, + ) + expected_title_justify = ( + title_justify if title_justify is not None else "center" + ) + expected_caption_justify = ( + caption_justify if caption_justify is not None else "center" + ) + assert expected_title_justify == table.title_justify + assert expected_caption_justify == table.caption_justify diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 0000000..3bf6888 --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,677 @@ +from io import StringIO +import pytest + +from rich.console import Console +from rich.text import Span, Text +from rich.measure import Measurement +from rich.style import Style + + +def test_span(): + span = Span(1, 10, "foo") + repr(span) + assert bool(span) + assert not Span(10, 10, "foo") + + +def test_span_split(): + assert Span(5, 10, "foo").split(2) == (Span(5, 10, "foo"), None) + assert Span(5, 10, "foo").split(15) == (Span(5, 10, "foo"), None) + assert Span(0, 10, "foo").split(5) == (Span(0, 5, "foo"), Span(5, 10, "foo")) + + +def test_span_move(): + assert Span(5, 10, "foo").move(2) == Span(7, 12, "foo") + + +def test_span_right_crop(): + assert Span(5, 10, "foo").right_crop(15) == Span(5, 10, "foo") + assert Span(5, 10, "foo").right_crop(7) == Span(5, 7, "foo") + + +def test_len(): + assert len(Text("foo")) == 3 + + +def test_cell_len(): + assert Text("foo").cell_len == 3 + assert Text("😀").cell_len == 2 + + +def test_bool(): + assert Text("foo") + assert not Text("") + + +def test_str(): + assert str(Text("foo")) == "foo" + + +def test_repr(): + assert isinstance(repr(Text("foo")), str) + + +def test_add(): + text = Text("foo") + Text("bar") + assert str(text) == "foobar" + assert Text("foo").__add__(1) == NotImplemented + + +def test_eq(): + assert Text("foo") == Text("foo") + assert Text("foo") != Text("bar") + assert Text("foo").__eq__(1) == NotImplemented + + +def test_contain(): + test = Text("foobar") + assert "foo" in test + assert "foo " not in test + assert Text("bar") in test + assert None not in test + + +def test_plain_property(): + text = Text("foo") + text.append("bar") + text.append("baz") + assert text.plain == "foobarbaz" + + +def test_plain_property_setter(): + test = Text("foo") + test.plain = "bar" + assert str(test) == "bar" + test = Text() + test.append("Hello, World", "bold") + test.plain = "Hello" + assert str(test) == "Hello" + assert test._spans == [Span(0, 5, "bold")] + + +def test_from_markup(): + text = Text.from_markup("Hello, [bold]World![/bold]") + assert str(text) == "Hello, World!" + assert text._spans == [Span(7, 13, "bold")] + + +def test_copy(): + test = Text() + test.append("Hello", "bold") + test.append(" ") + test.append("World", "italic") + test_copy = test.copy() + assert test == test_copy + assert test is not test_copy + + +def test_rstrip(): + test = Text("Hello, World! ") + test.rstrip() + assert str(test) == "Hello, World!" + + +def test_rstrip_end(): + test = Text("Hello, World! ") + test.rstrip_end(14) + assert str(test) == "Hello, World! " + + +def test_stylize(): + test = Text("Hello, World!") + test.stylize("bold", 7, 11) + assert test._spans == [Span(7, 11, "bold")] + test.stylize("bold", 20, 25) + assert test._spans == [Span(7, 11, "bold")] + + +def test_stylize_negative_index(): + test = Text("Hello, World!") + test.stylize("bold", -6, -1) + assert test._spans == [Span(7, 12, "bold")] + + +def test_highlight_regex(): + test = Text("peek-a-boo") + + count = test.highlight_regex(r"NEVER_MATCH", "red") + assert count == 0 + assert len(test._spans) == 0 + + # text: peek-a-boo + # indx: 0123456789 + count = test.highlight_regex(r"[a|e|o]+", "red") + assert count == 3 + assert sorted(test._spans) == [ + Span(1, 3, "red"), + Span(5, 6, "red"), + Span(8, 10, "red"), + ] + + test = Text("Ada Lovelace, Alan Turing") + count = test.highlight_regex( + r"(?P<yellow>[A-Za-z]+)[ ]+(?P<red>[A-Za-z]+)(?P<NEVER_MATCH>NEVER_MATCH)*" + ) + + # The number of matched name should be 2 + assert count == 2 + assert sorted(test._spans) == [ + Span(0, 3, "yellow"), # Ada + Span(4, 12, "red"), # Lovelace + Span(14, 18, "yellow"), # Alan + Span(19, 25, "red"), # Turing + ] + + +def test_highlight_regex_callable(): + test = Text("Vulnerability CVE-2018-6543 detected") + re_cve = r"CVE-\d{4}-\d+" + + def get_style(text: str) -> Style: + return Style.parse( + f"bold yellow link https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword={text}" + ) + + count = test.highlight_regex(re_cve, get_style) + assert count == 1 + assert len(test._spans) == 1 + assert test._spans[0].start == 14 + assert test._spans[0].end == 27 + assert ( + test._spans[0].style.link + == "https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE-2018-6543" + ) + + +def test_highlight_words(): + test = Text("Do NOT! touch anything!") + words = ["NOT", "!"] + count = test.highlight_words(words, "red") + assert count == 3 + assert sorted(test._spans) == [ + Span(3, 6, "red"), # NOT + Span(6, 7, "red"), # ! + Span(22, 23, "red"), # ! + ] + + # regex escape test + test = Text("[o|u]aeiou") + words = ["[a|e|i]", "[o|u]"] + count = test.highlight_words(words, "red") + assert count == 1 + assert test._spans == [Span(0, 5, "red")] + + # case sensitive + test = Text("AB Ab aB ab") + words = ["AB"] + + count = test.highlight_words(words, "red") + assert count == 1 + assert test._spans == [Span(0, 2, "red")] + + test = Text("AB Ab aB ab") + count = test.highlight_words(words, "red", case_sensitive=False) + assert count == 4 + + +def test_set_length(): + test = Text("Hello") + test.set_length(5) + assert test == Text("Hello") + + test = Text("Hello") + test.set_length(10) + assert test == Text("Hello ") + + test = Text("Hello World") + test.stylize("bold", 0, 5) + test.stylize("italic", 7, 9) + + test.set_length(3) + expected = Text() + expected.append("Hel", "bold") + assert test == expected + + +def test_console_width(): + console = Console() + test = Text("Hello World!\nfoobarbaz") + assert test.__rich_measure__(console, 80) == Measurement(9, 12) + assert Text(" " * 4).__rich_measure__(console, 80) == Measurement(4, 4) + + +def test_join(): + test = Text("bar").join([Text("foo", "red"), Text("baz", "blue")]) + assert str(test) == "foobarbaz" + assert test._spans == [Span(0, 3, "red"), Span(3, 6, ""), Span(6, 9, "blue")] + + +def test_trim_spans(): + test = Text("Hello") + test._spans[:] = [Span(0, 3, "red"), Span(3, 6, "green"), Span(6, 9, "blue")] + test._trim_spans() + assert test._spans == [Span(0, 3, "red"), Span(3, 5, "green")] + + +def test_pad_left(): + test = Text("foo") + test.pad_left(3, "X") + assert str(test) == "XXXfoo" + + +def test_pad_right(): + test = Text("foo") + test.pad_right(3, "X") + assert str(test) == "fooXXX" + + +def test_append(): + test = Text("foo") + test.append("bar") + assert str(test) == "foobar" + test.append(Text("baz", "bold")) + assert str(test) == "foobarbaz" + assert test._spans == [Span(6, 9, "bold")] + + with pytest.raises(ValueError): + test.append(Text("foo"), "bar") + + with pytest.raises(TypeError): + test.append(1) + + +def test_append_text(): + test = Text("foo") + test.append_text(Text("bar", style="bold")) + assert str(test) == "foobar" + assert test._spans == [Span(3, 6, "bold")] + + +def test_split(): + test = Text() + test.append("foo", "red") + test.append("\n") + test.append("bar", "green") + test.append("\n") + + line1 = Text() + line1.append("foo", "red") + line2 = Text() + line2.append("bar", "green") + split = test.split("\n") + assert len(split) == 2 + assert split[0] == line1 + assert split[1] == line2 + + assert list(Text("foo").split("\n")) == [Text("foo")] + + +def test_split_spans(): + test = Text.from_markup("[red]Hello\n[b]World") + lines = test.split("\n") + assert lines[0].plain == "Hello" + assert lines[1].plain == "World" + assert lines[0].spans == [Span(0, 5, "red")] + assert lines[1].spans == [Span(0, 5, "red"), Span(0, 5, "bold")] + + +def test_divide(): + lines = Text("foo").divide([]) + assert len(lines) == 1 + assert lines[0] == Text("foo") + + text = Text() + text.append("foo", "bold") + lines = text.divide([1, 2]) + assert len(lines) == 3 + assert str(lines[0]) == "f" + assert str(lines[1]) == "o" + assert str(lines[2]) == "o" + assert lines[0]._spans == [Span(0, 1, "bold")] + assert lines[1]._spans == [Span(0, 1, "bold")] + assert lines[2]._spans == [Span(0, 1, "bold")] + + text = Text() + text.append("foo", "red") + text.append("bar", "green") + text.append("baz", "blue") + lines = text.divide([8]) + assert len(lines) == 2 + assert str(lines[0]) == "foobarba" + assert str(lines[1]) == "z" + assert lines[0]._spans == [ + Span(0, 3, "red"), + Span(3, 6, "green"), + Span(6, 8, "blue"), + ] + assert lines[1]._spans == [Span(0, 1, "blue")] + + lines = text.divide([1]) + assert len(lines) == 2 + assert str(lines[0]) == "f" + assert str(lines[1]) == "oobarbaz" + assert lines[0]._spans == [Span(0, 1, "red")] + assert lines[1]._spans == [ + Span(0, 2, "red"), + Span(2, 5, "green"), + Span(5, 8, "blue"), + ] + + +def test_right_crop(): + test = Text() + test.append("foobar", "red") + test.right_crop(3) + assert str(test) == "foo" + assert test._spans == [Span(0, 3, "red")] + + +def test_wrap_3(): + test = Text("foo bar baz") + lines = test.wrap(Console(), 3) + print(repr(lines)) + assert len(lines) == 3 + assert lines[0] == Text("foo") + assert lines[1] == Text("bar") + assert lines[2] == Text("baz") + + +def test_wrap_4(): + test = Text("foo bar baz", justify="left") + lines = test.wrap(Console(), 4) + assert len(lines) == 3 + assert lines[0] == Text("foo ") + assert lines[1] == Text("bar ") + assert lines[2] == Text("baz ") + + +def test_wrap_long(): + test = Text("abracadabra", justify="left") + lines = test.wrap(Console(), 4) + assert len(lines) == 3 + assert lines[0] == Text("abra") + assert lines[1] == Text("cada") + assert lines[2] == Text("bra ") + + +def test_wrap_overflow(): + test = Text("Some more words") + lines = test.wrap(Console(), 4, overflow="ellipsis") + assert (len(lines)) == 3 + assert lines[0] == Text("Some") + assert lines[1] == Text("more") + assert lines[2] == Text("wor…") + + +def test_wrap_overflow_long(): + test = Text("bigword" * 10) + lines = test.wrap(Console(), 4, overflow="ellipsis") + assert len(lines) == 1 + assert lines[0] == Text("big…") + + +def test_wrap_long_words(): + test = Text("X 123456789", justify="left") + lines = test.wrap(Console(), 4) + + assert len(lines) == 3 + assert lines[0] == Text("X 12") + assert lines[1] == Text("3456") + assert lines[2] == Text("789 ") + + +def test_no_wrap_no_crop(): + test = Text("Hello World!" * 3) + + console = Console(width=20, file=StringIO()) + console.print(test, no_wrap=True) + console.print(test, no_wrap=True, crop=False, overflow="ignore") + + print(repr(console.file.getvalue())) + assert ( + console.file.getvalue() + == "Hello World!Hello Wo\nHello World!Hello World!Hello World!\n" + ) + + +def test_fit(): + test = Text("Hello\nWorld") + lines = test.fit(3) + assert str(lines[0]) == "Hel" + assert str(lines[1]) == "Wor" + + +def test_wrap_tabs(): + test = Text("foo\tbar", justify="left") + lines = test.wrap(Console(), 4) + assert len(lines) == 2 + assert str(lines[0]) == "foo " + assert str(lines[1]) == "bar " + + +def test_render(): + console = Console(width=15, record=True) + test = Text.from_markup( + "[u][b]Where[/b] there is a [i]Will[/i], there is a Way.[/u]" + ) + console.print(test) + output = console.export_text(styles=True) + expected = "\x1b[1;4mWhere\x1b[0m\x1b[4m there is \x1b[0m\n\x1b[4ma \x1b[0m\x1b[3;4mWill\x1b[0m\x1b[4m, there \x1b[0m\n\x1b[4mis a Way.\x1b[0m\n" + assert output == expected + + +def test_render_simple(): + console = Console(width=80) + console.begin_capture() + console.print(Text("foo")) + result = console.end_capture() + assert result == "foo\n" + + +@pytest.mark.parametrize( + "print_text,result", + [ + (("."), ".\n"), + ((".", "."), ". .\n"), + (("Hello", "World", "!"), "Hello World !\n"), + ], +) +def test_print(print_text, result): + console = Console(record=True) + console.print(*print_text) + assert console.export_text(styles=False) == result + + +@pytest.mark.parametrize( + "print_text,result", + [ + (("."), ".X"), + ((".", "."), "..X"), + (("Hello", "World", "!"), "HelloWorld!X"), + ], +) +def test_print_sep_end(print_text, result): + console = Console(record=True, file=StringIO()) + console.print(*print_text, sep="", end="X") + assert console.file.getvalue() == result + + +def test_tabs_to_spaces(): + test = Text("\tHello\tWorld", tab_size=8) + test.expand_tabs() + assert test.plain == " Hello World" + + test = Text("\tHello\tWorld", tab_size=4) + test.expand_tabs() + assert test.plain == " Hello World" + + test = Text(".\t..\t...\t....\t", tab_size=4) + test.expand_tabs() + assert test.plain == ". .. ... .... " + + test = Text("No Tabs") + test.expand_tabs() + assert test.plain == "No Tabs" + + +def test_markup_switch(): + """Test markup can be disabled.""" + console = Console(file=StringIO(), markup=False) + console.print("[bold]foo[/bold]") + assert console.file.getvalue() == "[bold]foo[/bold]\n" + + +def test_emoji(): + """Test printing emoji codes.""" + console = Console(file=StringIO()) + console.print(":+1:") + assert console.file.getvalue() == "👍\n" + + +def test_emoji_switch(): + """Test emoji can be disabled.""" + console = Console(file=StringIO(), emoji=False) + console.print(":+1:") + assert console.file.getvalue() == ":+1:\n" + + +def test_assemble(): + text = Text.assemble("foo", ("bar", "bold")) + assert str(text) == "foobar" + assert text._spans == [Span(3, 6, "bold")] + + +def test_styled(): + text = Text.styled("foo", "bold red") + assert text.style == "" + assert str(text) == "foo" + assert text._spans == [Span(0, 3, "bold red")] + + +def test_strip_control_codes(): + text = Text("foo\rbar") + assert str(text) == "foobar" + text.append("\x08") + assert str(text) == "foobar" + + +def test_get_style_at_offset(): + console = Console() + text = Text.from_markup("Hello [b]World[/b]") + assert text.get_style_at_offset(console, 0) == Style() + assert text.get_style_at_offset(console, 6) == Style(bold=True) + + +@pytest.mark.parametrize( + "input, count, expected", + [ + ("Hello", 10, "Hello"), + ("Hello", 5, "Hello"), + ("Hello", 4, "Hel…"), + ("Hello", 3, "He…"), + ("Hello", 2, "H…"), + ("Hello", 1, "…"), + ], +) +def test_truncate_ellipsis(input, count, expected): + text = Text(input) + text.truncate(count, overflow="ellipsis") + assert text.plain == expected + + +@pytest.mark.parametrize( + "input, count, expected", + [ + ("Hello", 5, "Hello"), + ("Hello", 10, "Hello "), + ("Hello", 3, "He…"), + ], +) +def test_truncate_ellipsis_pad(input, count, expected): + text = Text(input) + text.truncate(count, overflow="ellipsis", pad=True) + assert text.plain == expected + + +def test_pad(): + test = Text("foo") + test.pad(2) + assert test.plain == " foo " + + +def test_align_left(): + test = Text("foo") + test.align("left", 10) + assert test.plain == "foo " + + +def test_align_right(): + test = Text("foo") + test.align("right", 10) + assert test.plain == " foo" + + +def test_align_center(): + test = Text("foo") + test.align("center", 10) + assert test.plain == " foo " + + +def test_detect_indentation(): + test = """\ +foo + bar + """ + assert Text(test).detect_indentation() == 4 + test = """\ +foo + bar + baz + """ + assert Text(test).detect_indentation() == 2 + assert Text("").detect_indentation() == 1 + assert Text(" ").detect_indentation() == 1 + + +def test_indentation_guides(): + test = Text( + """\ +for a in range(10): + print(a) + +foo = [ + 1, + { + 2 + } +] + +""" + ) + result = test.with_indent_guides() + print(result.plain) + print(repr(result.plain)) + expected = "for a in range(10):\n│ print(a)\n\nfoo = [\n│ 1,\n│ {\n│ │ 2\n│ }\n]\n" + assert result.plain == expected + + +def test_slice(): + + text = Text.from_markup("[red]foo [bold]bar[/red] baz[/bold]") + assert text[0] == Text("f", spans=[Span(0, 1, "red")]) + assert text[4] == Text("b", spans=[Span(0, 1, "red"), Span(0, 1, "bold")]) + + assert text[:3] == Text("foo", spans=[Span(0, 3, "red")]) + assert text[:4] == Text("foo ", spans=[Span(0, 4, "red")]) + assert text[:5] == Text("foo b", spans=[Span(0, 5, "red"), Span(4, 5, "bold")]) + assert text[4:] == Text("bar baz", spans=[Span(0, 3, "red"), Span(0, 7, "bold")]) + + with pytest.raises(TypeError): + text[::-1] + + +def test_wrap_invalid_style(): + # https://github.com/willmcgugan/rich/issues/987 + console = Console(width=100, color_system="truecolor") + a = "[#######.................] xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx [#######.................]" + console.print(a, justify="full") diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 0000000..228cb18 --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,53 @@ +import io +import os +import tempfile + +import pytest + +from rich.style import Style +from rich.theme import Theme, ThemeStack, ThemeStackError + + +def test_inherit(): + theme = Theme({"warning": "red"}) + assert theme.styles["warning"] == Style(color="red") + assert theme.styles["dim"] == Style(dim=True) + + +def test_config(): + theme = Theme({"warning": "red"}) + config = theme.config + assert "warning = red\n" in config + + +def test_from_file(): + theme = Theme({"warning": "red"}) + text_file = io.StringIO() + text_file.write(theme.config) + text_file.seek(0) + + load_theme = Theme.from_file(text_file) + assert theme.styles == load_theme.styles + + +def test_read(): + theme = Theme({"warning": "red"}) + with tempfile.TemporaryDirectory("richtheme") as name: + filename = os.path.join(name, "theme.cfg") + with open(filename, "wt") as write_theme: + write_theme.write(theme.config) + load_theme = Theme.read(filename) + assert theme.styles == load_theme.styles + + +def test_theme_stack(): + theme = Theme({"warning": "red"}) + stack = ThemeStack(theme) + assert stack.get("warning") == Style.parse("red") + new_theme = Theme({"warning": "bold yellow"}) + stack.push_theme(new_theme) + assert stack.get("warning") == Style.parse("bold yellow") + stack.pop_theme() + assert stack.get("warning") == Style.parse("red") + with pytest.raises(ThemeStackError): + stack.pop_theme() diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..2e6fef7 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,38 @@ +from rich._loop import loop_first, loop_last, loop_first_last +from rich._ratio import ratio_distribute + + +def test_loop_first(): + assert list(loop_first([])) == [] + iterable = loop_first(["apples", "oranges", "pears", "lemons"]) + assert next(iterable) == (True, "apples") + assert next(iterable) == (False, "oranges") + assert next(iterable) == (False, "pears") + assert next(iterable) == (False, "lemons") + + +def test_loop_last(): + assert list(loop_last([])) == [] + iterable = loop_last(["apples", "oranges", "pears", "lemons"]) + assert next(iterable) == (False, "apples") + assert next(iterable) == (False, "oranges") + assert next(iterable) == (False, "pears") + assert next(iterable) == (True, "lemons") + + +def test_loop_first_last(): + assert list(loop_first_last([])) == [] + iterable = loop_first_last(["apples", "oranges", "pears", "lemons"]) + assert next(iterable) == (True, False, "apples") + assert next(iterable) == (False, False, "oranges") + assert next(iterable) == (False, False, "pears") + assert next(iterable) == (False, True, "lemons") + + +def test_ratio_distribute(): + assert ratio_distribute(10, [1]) == [10] + assert ratio_distribute(10, [1, 1]) == [5, 5] + assert ratio_distribute(12, [1, 3]) == [3, 9] + assert ratio_distribute(0, [1, 3]) == [0, 0] + assert ratio_distribute(0, [1, 3], [1, 1]) == [1, 1] + assert ratio_distribute(10, [1, 0]) == [10, 0] diff --git a/tests/test_traceback.py b/tests/test_traceback.py new file mode 100644 index 0000000..6941a2e --- /dev/null +++ b/tests/test_traceback.py @@ -0,0 +1,211 @@ +import io +import sys + +import pytest + +from rich.console import Console +from rich.traceback import install, Traceback + +# from .render import render + +try: + from ._exception_render import expected +except ImportError: + expected = None + + +CAPTURED_EXCEPTION = 'Traceback (most recent call last):\n╭──────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ File "/Users/willmcgugan/projects/rich/tests/test_traceback.py", line 26, in test_handler │\n│ 23 try: │\n│ 24 old_handler = install(console=console, line_numbers=False) │\n│ 25 try: │\n│ ❱ 26 1 / 0 │\n│ 27 except Exception: │\n│ 28 exc_type, exc_value, traceback = sys.exc_info() │\n│ 29 sys.excepthook(exc_type, exc_value, traceback) │\n╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\nZeroDivisionError: division by zero\n' + + +def test_handler(): + console = Console(file=io.StringIO(), width=100, color_system=None) + expected_old_handler = sys.excepthook + try: + old_handler = install(console=console) + try: + 1 / 0 + except Exception: + exc_type, exc_value, traceback = sys.exc_info() + sys.excepthook(exc_type, exc_value, traceback) + rendered_exception = console.file.getvalue() + print(repr(rendered_exception)) + assert "Traceback" in rendered_exception + assert "ZeroDivisionError" in rendered_exception + finally: + sys.excepthook = old_handler + assert old_handler == expected_old_handler + + +def text_exception_render(): + exc_render = render(get_exception()) + assert exc_render == expected + + +def test_capture(): + try: + 1 / 0 + except Exception: + tb = Traceback() + assert tb.trace.stacks[0].exc_type == "ZeroDivisionError" + + +def test_no_exception(): + with pytest.raises(ValueError): + tb = Traceback() + + +def get_exception() -> Traceback: + def bar(a): + print(1 / a) + + def foo(a): + bar(a) + + try: + try: + foo(0) + except: + foobarbaz + except: + tb = Traceback() + return tb + + +def test_print_exception(): + console = Console(width=100, file=io.StringIO()) + try: + 1 / 0 + except Exception: + console.print_exception() + exception_text = console.file.getvalue() + assert "ZeroDivisionError" in exception_text + + +def test_print_exception_locals(): + console = Console(width=100, file=io.StringIO()) + try: + 1 / 0 + except Exception: + console.print_exception(show_locals=True) + exception_text = console.file.getvalue() + print(exception_text) + assert "ZeroDivisionError" in exception_text + assert "locals" in exception_text + assert "console = <console width=100 None>" in exception_text + + +def test_syntax_error(): + console = Console(width=100, file=io.StringIO()) + try: + # raises SyntaxError: unexpected EOF while parsing + eval("(2 + 2") + except Exception: + console.print_exception() + exception_text = console.file.getvalue() + assert "SyntaxError" in exception_text + + +def test_nested_exception(): + console = Console(width=100, file=io.StringIO()) + value_error_message = "ValueError because of ZeroDivisionError" + + try: + try: + 1 / 0 + except ZeroDivisionError: + raise ValueError(value_error_message) + except Exception: + console.print_exception() + exception_text = console.file.getvalue() + + text_should_contain = [ + value_error_message, + "ZeroDivisionError", + "ValueError", + "During handling of the above exception", + ] + + for msg in text_should_contain: + assert msg in exception_text + + # ZeroDivisionError should come before ValueError + assert exception_text.find("ZeroDivisionError") < exception_text.find("ValueError") + + +def test_caused_exception(): + console = Console(width=100, file=io.StringIO()) + value_error_message = "ValueError caused by ZeroDivisionError" + + try: + try: + 1 / 0 + except ZeroDivisionError as e: + raise ValueError(value_error_message) from e + except Exception: + console.print_exception() + exception_text = console.file.getvalue() + + text_should_contain = [ + value_error_message, + "ZeroDivisionError", + "ValueError", + "The above exception was the direct cause", + ] + + for msg in text_should_contain: + assert msg in exception_text + + # ZeroDivisionError should come before ValueError + assert exception_text.find("ZeroDivisionError") < exception_text.find("ValueError") + + +def test_filename_with_bracket(): + console = Console(width=100, file=io.StringIO()) + try: + exec(compile("1/0", filename="<string>", mode="exec")) + except Exception: + console.print_exception() + exception_text = console.file.getvalue() + assert "<string>" in exception_text + + +def test_filename_not_a_file(): + console = Console(width=100, file=io.StringIO()) + try: + exec(compile("1/0", filename="string", mode="exec")) + except Exception: + console.print_exception() + exception_text = console.file.getvalue() + assert "string" in exception_text + + +def test_broken_str(): + class BrokenStr(Exception): + def __str__(self): + 1 / 0 + + console = Console(width=100, file=io.StringIO()) + try: + raise BrokenStr() + except Exception: + console.print_exception() + result = console.file.getvalue() + print(result) + assert "<exception str() failed>" in result + + +def test_guess_lexer(): + assert Traceback._guess_lexer("foo.py", "code") == "python" + code_python = "#! usr/bin/env python\nimport this" + assert Traceback._guess_lexer("foo", code_python) == "python" + assert Traceback._guess_lexer("foo", "foo\nbnar") == "text" + + +if __name__ == "__main__": # pragma: no cover + + expected = render(get_exception()) + + with open("_exception_render.py", "wt") as fh: + exc_render = render(get_exception()) + print(exc_render) + fh.write(f"expected={exc_render!r}") diff --git a/tests/test_tree.py b/tests/test_tree.py new file mode 100644 index 0000000..90dcd77 --- /dev/null +++ b/tests/test_tree.py @@ -0,0 +1,103 @@ +import sys + +import pytest + +from rich.console import Console +from rich.measure import Measurement +from rich.tree import Tree + + +def test_render_single_node(): + tree = Tree("foo") + console = Console(color_system=None, width=20) + console.begin_capture() + console.print(tree) + assert console.end_capture() == "foo \n" + + +def test_render_single_branch(): + tree = Tree("foo") + tree.add("bar") + console = Console(color_system=None, width=20) + console.begin_capture() + console.print(tree) + result = console.end_capture() + print(repr(result)) + expected = "foo \n└── bar \n" + assert result == expected + + +def test_render_double_branch(): + tree = Tree("foo") + tree.add("bar") + tree.add("baz") + console = Console(color_system=None, width=20) + console.begin_capture() + console.print(tree) + result = console.end_capture() + print(repr(result)) + expected = "foo \n├── bar \n└── baz \n" + assert result == expected + + +def test_render_ascii(): + tree = Tree("foo") + tree.add("bar") + tree.add("baz") + + class AsciiConsole(Console): + @property + def encoding(self): + return "ascii" + + console = AsciiConsole(color_system=None, width=20) + console.begin_capture() + console.print(tree) + result = console.end_capture() + expected = "foo \n+-- bar \n`-- baz \n" + assert result == expected + + +@pytest.mark.skipif(sys.platform == "win32", reason="different on Windows") +def test_render(): + tree = Tree("foo") + tree.add("bar", style="italic") + baz_tree = tree.add("baz", guide_style="bold red", style="on blue") + baz_tree.add("1") + baz_tree.add("2") + tree.add("egg") + + console = Console(width=20, force_terminal=True, color_system="standard") + console.begin_capture() + console.print(tree) + result = console.end_capture() + print(repr(result)) + expected = "foo \n├── \x1b[3mbar\x1b[0m\x1b[3m \x1b[0m\n\x1b[44m├── \x1b[0m\x1b[44mbaz\x1b[0m\x1b[44m \x1b[0m\n\x1b[44m│ \x1b[0m\x1b[31;44m┣━━ \x1b[0m\x1b[44m1\x1b[0m\x1b[44m \x1b[0m\n\x1b[44m│ \x1b[0m\x1b[31;44m┗━━ \x1b[0m\x1b[44m2\x1b[0m\x1b[44m \x1b[0m\n└── egg \n" + assert result == expected + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows specific") +def test_render(): + tree = Tree("foo") + tree.add("bar", style="italic") + baz_tree = tree.add("baz", guide_style="bold red", style="on blue") + baz_tree.add("1") + baz_tree.add("2") + tree.add("egg") + + console = Console(width=20, force_terminal=True, color_system="standard") + console.begin_capture() + console.print(tree) + result = console.end_capture() + print(repr(result)) + expected = "foo \n├── \x1b[3mbar\x1b[0m\x1b[3m \x1b[0m\n\x1b[44m├── \x1b[0m\x1b[44mbaz\x1b[0m\x1b[44m \x1b[0m\n\x1b[44m│ \x1b[0m\x1b[31;44m├── \x1b[0m\x1b[44m1\x1b[0m\x1b[44m \x1b[0m\n\x1b[44m│ \x1b[0m\x1b[31;44m└── \x1b[0m\x1b[44m2\x1b[0m\x1b[44m \x1b[0m\n└── egg \n" + assert result == expected + + +def test_tree_measure(): + tree = Tree("foo") + tree.add("bar") + tree.add("musroom risotto") + console = Console() + measurement = Measurement.get(console, tree) + assert measurement == Measurement(11, 19) diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..e6d6903 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,3 @@ +# Tools + +These are scripts used in the development of Rich, and aren't for general use. But feel free to look around. diff --git a/tools/cats.json b/tools/cats.json new file mode 100644 index 0000000..d622aba --- /dev/null +++ b/tools/cats.json @@ -0,0 +1,3287 @@ +{ + "all": [ + { + "_id": "58e009550aac31001185ed12", + "text": "The oldest cat video on YouTube dates back to 1894.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 8, + "userUpvoted": null + }, + { + "_id": "58e008340aac31001185ecfb", + "text": "Cats sleep 70% of their lives.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 6, + "userUpvoted": null + }, + { + "_id": "599f87db9a11040c4a16343f", + "text": "The goddess of love, beauty, and fertility in Norse mythology, Freyja was the first cat lady. She is depicted in stories as riding a chariot that was drawn by cats.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 6, + "userUpvoted": null + }, + { + "_id": "5894af975cdc7400113ef7f9", + "text": "The technical term for a cat’s hairball is a bezoar.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 6, + "userUpvoted": null + }, + { + "_id": "5b0c5e3e7ab3c50014df65fe", + "text": "People who own cats have on average 2.1 pets per household, where dog owners have about 1.6.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 6, + "userUpvoted": null + }, + { + "_id": "58e00a000aac31001185ed15", + "text": "Female cats are typically right-pawed while male cats are typically left-pawed.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 6, + "userUpvoted": null + }, + { + "_id": "58e00a1a0aac31001185ed18", + "text": "Cats and humans have nearly identical sections of the brain that control emotion.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 6, + "userUpvoted": null + }, + { + "_id": "58e0088b0aac31001185ed09", + "text": "The world's largest cat measured 48.5 inches long.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 6, + "userUpvoted": null + }, + { + "_id": "58e00b4d0aac31001185ed22", + "text": "Original kitty litter was made out of sand but it was replaced by more absorbent clay in 1948.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 5, + "userUpvoted": null + }, + { + "_id": "58e00a7e0aac31001185ed19", + "text": "A cat's cerebral cortex (the part of the brain in charge of cognitive information processing) has 300 million neurons, compared with a dog's 160 million.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 5, + "userUpvoted": null + }, + { + "_id": "58e00b1f0aac31001185ed1e", + "text": "In the 15th century, Pope Innocent VIII began ordering the killing of cats, pronouncing them demonic.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 5, + "userUpvoted": null + }, + { + "_id": "5b01a447c6914f0014cc9a30", + "text": "The special sensory organ called the Jacobson's organ allows a cat to have 14 times the sense of smell of a human. It consists of two fluid-filled sacs that connect to the cat's nasal cavity and is located on the roof of their mouth behind their teeth.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 5, + "userUpvoted": null + }, + { + "_id": "5b1b4055841d9700146158d3", + "text": "Scottish sailer Alexander Selkirk once survived for 4 years on a deserted island thanks to feral cats that protected him from large rats during the night.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 5, + "userUpvoted": null + }, + { + "_id": "58e00b2b0aac31001185ed1f", + "text": "A cat has five toes on his front paws, and four on the back, unless he's a polydactyl.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 5, + "userUpvoted": null + }, + { + "_id": "58e00be30aac31001185edfe", + "text": "Cats use their whiskers to detect if they can fit through a space.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 5, + "userUpvoted": null + }, + { + "_id": "58f89cff11658e00113ddd26", + "text": "Cats love to eat olives or for that matter anything preserved in brine.", + "type": "cat", + "user": { + "_id": "58f89c8d11658e00113ddd24", + "name": { + "first": "Malvika", + "last": "Tewari" + } + }, + "upvotes": 5, + "userUpvoted": null + }, + { + "_id": "599f89639a11040c4a163440", + "text": "Here is a video of some cats in zero gravity. youtu.be/O9XtK6R1QAk", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 5, + "userUpvoted": null + }, + { + "_id": "5955792c7b77520020799431", + "text": "Cats \"knead\" because of seperation from their mothers", + "type": "cat", + "user": { + "_id": "595579027b77520020799430", + "name": { + "first": "Is It Still Memes That", + "last": "Make You Sweat?" + } + }, + "upvotes": 5, + "userUpvoted": null + }, + { + "_id": "590c752b5363e000200d5141", + "text": "Kangaroos can't hop backwards", + "type": "cat", + "user": { + "_id": "590c74045363e000200d5140", + "name": { + "first": "Hampton", + "last": "McGrath" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "59951d5ef2db18002031693c", + "text": "America’s cats, including housecats that adventure outdoors and feral cats, kill between 1.3 billion and 4.0 billion birds in a year.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "58e008800aac31001185ed07", + "text": "Wikipedia has a recording of a cat meowing, because why not?", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "5b3bd7d24cf4e10014bfd199", + "text": "The myth that a cat has nine lives comes from their ability to jump and land from high places. The number 9 is believed by some to originate from William Shakespeare's Romeo and Juliet: \"A cat has nine lives. For three he plays, for three he strays, and for the last three he stays.\"", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "5b453e380fd3a600147f32f3", + "text": "Exposure to UV light with hairless or partially-hairless cats can result in sunburn, even during cloudy or shady conditions. If your cat risks overexposure, consider applying sunscreen daily.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "58e007db0aac31001185ecf7", + "text": "There are cats who have survived falls from over 32 stories (320 meters) onto concrete.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "59a60ba66acf530020f35873", + "text": "Most cats don't like water because their coats do not insulate them well enough.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "5a4d76916ef087002174c28b", + "text": "A cat’s nose pad is ridged with a unique pattern, just like the fingerprint of a human.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "58e009790aac31001185ed14", + "text": "The technical term for \"hairball\" is \"bezoar.\"", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "58e00a090aac31001185ed16", + "text": "Cats make more than 100 different sounds whereas dogs make around 10.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "58e00a120aac31001185ed17", + "text": "A cat's brain is 90% similar to a human's — more similar than to a dog's.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "5974fb4dfedacb0020b5b4cc", + "text": "Cats can survive falls from up to 65 feet or more.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "5b1b4014841d9700146158d0", + "text": "In 1879, Belgium unsuccessfully tried to use cats to deliver mail.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 4, + "userUpvoted": null + }, + { + "_id": "5a038dae8e3dbc001f719792", + "text": "Cucumbers look enough like a snake to cause a cat's instinctive fear of snakes to kick in, causing it to panic and flee.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 3, + "userUpvoted": null + }, + { + "_id": "5a4bfbbab0810f0021748b91", + "text": "Lil' Bunny Sue Roux is a cat who was born with no front legs, and walks upright like a kangaroo. https://www.instagram.com/lilbunnysueroux", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 3, + "userUpvoted": null + }, + { + "_id": "590b9d90229d260020af0b06", + "text": "Evidence suggests domesticated cats have been around since 3600 B.C., 2,000 years before Egypt's pharaohs.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58923f2fc3878c0011784c79", + "text": "I don't know anything about cats.", + "type": "cat", + "user": { + "_id": "5887e9f65c873e001103688d", + "name": { + "first": "Jackson", + "last": "Sippe" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "59cd7a97c828120020f7d3a1", + "text": "Since cats treat us like cats and depend on us for things like food, water, and opening the door to let them out, they do recognize, that we are in some way in charge — the “big cat” in the shared territory. As territorial animals, our cats are constantly wondering why we’re not doing other cat things that the big cat would normally do in their territory. In fact, the “let me in, let me out, let me in” phenomenon is a good example. The bigger cat ostensibly rules the territory and therefore should be the one patrolling and marking it with pee so other cats stay away. But since humans don’t do this, indoor-outdoor cats reluctantly take on the role themselves. The apparent neuroticism of cats wanting to go outside every five minutes only to be let right back in is funny to us because it seems so silly and unnecessary. But to the cats, it’s very necessary (and frustrating) to cover for their dumb pals.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e007cc0aac31001185ecf5", + "text": "Cats are the most popular pet in the United States: There are 88 million pet cats and 74 million dogs.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e008510aac31001185ecfe", + "text": "In tigers and tabbies, the middle of the tongue is covered in backward-pointing spines, used for breaking off and gripping meat.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e008c50aac31001185ed0e", + "text": "The world's richest cat is worth $13 million after his human passed away and left her fortune to him.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e008630aac31001185ed01", + "text": "When cats grimace, they are usually \"taste-scenting.\" They have an extra organ that, with some breathing control, allows the cats to taste-sense the air.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e008a30aac31001185ed0b", + "text": "A cat's purr may be a form of self-healing, as it can be a sign of nervousness as well as contentment.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e008d00aac31001185ed0f", + "text": "Your cat recognizes your voice but just acts too cool to care (probably because they are).", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e00a850aac31001185ed1a", + "text": "Cats have a longer-term memory than dogs, especially when they learn by actually doing rather than simply seeing.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e00b3a0aac31001185ed20", + "text": "Polydactyl cats are also referred to as \"Hemingway cats\" because the author was so fond of them.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e00b5f0aac31001185ed24", + "text": "When asked if her husband had any hobbies, Mary Todd Lincoln is said to have replied \"cats.\"", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e00bdb0aac31001185edfd", + "text": "Cats can change their meow to manipulate a human. They often imitate a human baby when they need food, for example.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e00b820aac31001185edf7", + "text": "One legend claims that cats were created when a lion on Noah's Ark sneezed and two kittens came out.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "591d9bce227c1a0020d26827", + "text": "In Korea and Japan, there is a Cat Cafe where you can go to drink coffee and hang out with cats for hours.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e00ba00aac31001185edfa", + "text": "When cats leave their poop uncovered, it is a sign of aggression to let you know they don't fear you.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "591d9bab227c1a0020d26825", + "text": "Owning a cat can reduce the risk of heart attacks and strokes by more than a third, researchers found.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5a4d75a38827521790281b99", + "text": "A cat can’t climb head first down a tree because every claw on a cat’s paw points the same way. To get down from a tree, a cat must back down.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5a4d766c6ef087002174c288", + "text": "Some Siamese cats appear cross-eyed because the nerves from the left side of the brain go to mostly the right eye and the nerves from the right side of the brain go mostly to the left eye. This causes some double vision, which the cat tries to correct by “crossing” its eyes.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5a4d776e6ef087002174c291", + "text": "The claws on the cat’s back paws aren’t as sharp as the claws on the front paws because the claws in the back don’t retract and, consequently, become worn.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5b49110d0508220014ccfe91", + "text": "Issac Newton decided to invent the cat flap because his own cat, Spithead, kept opening the door and spoiling his light experiments.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e008780aac31001185ed05", + "text": "Owning a cat can reduce the risk of stroke and heart attack by a third.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5b1b3f6c841d9700146158cd", + "text": "The Indiana State Prison allows prisoners to adopt a cat and keep it in their cell. They are meant to improve the mood of the prisoners.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5b1b3feb841d9700146158cf", + "text": "In Islam, cats are revered for their cleanliness. Muhammad is reported to have said that \"a love of cats is an aspect of faith\".", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5b1b4065841d9700146158d4", + "text": "A cat named Emmy lived aboard the RMS Empress of Ireland and she never missed a voyage. On May 28,1914, however, she refused to board. The ship left without her and then sank the following day.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5b1b408a841d9700146158d5", + "text": "Domestic cats will try not to drink from a water bowl that is next to their food. This is because in the wild, water next to their kill could be contaminated.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5b3d8e4960d3890713ca39a8", + "text": "A Chinese cat named Baidianr (meaning \"white spot\") had a unique ability to choose World Cup winners. He predicted the winner of the soccer competition 6 years in a row, before he died on June 2, 2018, just before the event ended.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5b4909af0508220014ccfe8a", + "text": "Cats have a layer called the tapetum lucidum just behind their retina which reflects light inside the eye, helping it capture more light. This reflected light is the glow you see when taking a photo of a cat with the flash on.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e009390aac31001185ed10", + "text": "Most cats are lactose intolerant, and milk can cause painful stomach cramps and diarrhea. It's best to forego the milk and just give your cat the standard: clean, cool drinking water.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5c6a105dc2d7a200140f69a0", + "text": "When cats run, their backs contract and extend to give them maximum stride. Their shoulder blades are not attached with bone, but with muscle, and this gives a cat even greater extension and speed.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5b1b40cc841d9700146158d7", + "text": "In most US states, declawing cats is legal but in the European Union it is not.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "59d297b7c6671e0020957eb9", + "text": "Kittens sleep so much because the growth hormone is only released when they sleep.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5a36ec5fae877e0021ed79f5", + "text": "It has been estimated that a cat yawns on the average of 109,500 times in his life.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e00b8b0aac31001185edf8", + "text": "A cat can jump up to six times its length.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "58e00c080aac31001185ee01", + "text": "Cats only sweat through their foot pads.", + "type": "cat", + "user": { + "_id": "58e007480aac31001185ecef", + "name": { + "first": "Kasimir", + "last": "Schulz" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5b4911e60508220014ccfe95", + "text": "A female cat is called a “molly” or a “queen”.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "596e4989b863f300203102f4", + "text": "Black cats are less likely to be adopted because of their \"appearance\".", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "59a60bae6acf530020f35875", + "text": "Cats were mythic symbols of divinity in ancient Egypt.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5a038d5a8e3dbc001f719791", + "text": "Cats have 38 chromosomes in each zygote cell.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5a4d76516ef087002174c287", + "text": "If they have ample water, cats can tolerate temperatures up to 133 °F.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "5c471b30a99c3f00140fe4c5", + "text": "The collective noun for kittens is a \"kindle\".", + "type": "cat", + "user": { + "_id": "5c471b15a99c3f00140fe4c4", + "name": { + "first": "Beth", + "last": "Rothwell" + } + }, + "upvotes": 2, + "userUpvoted": null + }, + { + "_id": "59664b1f474ba80020ef8592", + "text": "Your cat's instincts tell her that a paperweight or knickknack could turn out to be a mouse. Her poking paw would send it scurrying, giving her a good game. This is probably why cats always seem to be knocking the glasses off your counter tops!", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a026058134ec2001f032f92", + "text": "For a cat at rest, the average heart rate usually is between 150 and 180 bpm, more than twice that of a human, which averages 70 bpm.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a026092134ec2001f032f95", + "text": "Compared to other felines, domestic cats have narrowly spaced canine teeth, adapted to their preferred prey of small rodents.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a0260d1134ec2001f032f98", + "text": "Cats, like dogs, are digitigrades. They walk directly on their toes, with the bones of their feet making up the lower part of the visible leg.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a038c178e3dbc001f71978e", + "text": "A cat's kidneys are so efficient, it can survive on a diet consisting only of meat, with no additional water, and can even rehydrate by drinking seawater.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a038c628e3dbc001f71978f", + "text": "Most breeds of cat have a noted fondness for settling in high places, or perching. In the wild, a higher place may serve as a concealed site from which to hunt.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a038cdf8e3dbc001f719790", + "text": "The ability of a cat to reflexively twist its body and balance itself during a fall is known as the \"cat righting reflex\".", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a038e188e3dbc001f719793", + "text": "The cat's tongue has backwards-facing spines about 500 μm long, which are called papillae. These contain keratin which makes them rigid so the papillae act like a hairbrush.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a21a9fb425e43002132f045", + "text": "Hank the Cat was a Maine Coon feline that was put up as a joke candidate in the 2012 United States Senate election in Virginia, a feat which gained international coverage after Hank reportedly came third behind the two major candidates, including Vice Presidential candidate Tim Kaine.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a456246255f4b0021f54c04", + "text": "A cat can die from essential oils", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4aab2c2c99ee00219e11c4", + "text": "Hearing is the strongest of cat's senses: They can hear sounds as high as 64 kHz — compared with humans, who can hear only as high as 20 kHz.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4aab322c99ee00219e11c5", + "text": "Cats have free-floating clavicle bones that attach their shoulders to their forelimbs, which allows them to squeeze through very small spaces.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4bfcd3b0810f0021748b93", + "text": "Colonel Meow, a Himalayan-Persian mix who became famous on social media websites for his extremely long fur and scowling face, holds the Guinness world record for longest hair on a cat (nine inches).", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4bfdf8b0810f0021748b94", + "text": "Towser \"The Mouser\" of Glenturret Distillery in Crieff, Scotland, holds the Guinness World Record for the most mice caught (28,899).", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d75ac8827521790281b9a", + "text": "The cat who holds the record for the longest non-fatal fall is Andy. He fell from the 16th floor of an apartment building (about 200 ft/.06 km) and survived.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d76106ef087002174c285", + "text": "A cat’s eyesight is both better and worse than humans. It is better because cats can see in much dimmer light and they have a wider peripheral view. It’s worse because they don’t see color as well as humans do. Scientists believe grass appears red to cats.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d76736ef087002174c289", + "text": "The lightest cat on record is a blue point Himalayan called Tinker Toy, who weighed 1 pound, 6 ounces (616 g). Tinker Toy was 2.75 inches (7 cm) tall and 7.5 inches (19 cm) long.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d76b16ef087002174c28c", + "text": "The first cartoon cat was Felix the Cat in 1919. In 1940, Tom and Jerry starred in the first theatrical cartoon “Puss Gets the Boot.” In 1981 Andrew Lloyd Weber created the musical Cats, based on T.S. Eliot’s Old Possum’s Book of Practical Cats.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d79016ef087002174c293", + "text": "The most traveled cat is Hamlet, who escaped from his carrier while on a flight. He hid for seven weeks behind a panel on the airplane. By the time he was discovered, he had traveled nearly 373,000 miles (600,000 km).", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d79fb6ef087002174c295", + "text": "The little tufts of hair in a cat’s ear that help keep out dirt direct sounds into the ear, and insulate the ears are called \"ear furnishings.\"", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b085f85be3157001432164f", + "text": "The Australian Wildlife Conservancy completed a 44km cat-proof fence in 2018 to prevent feral cats from entering the Newhaven wildlife sanctuary, who kill about a million native birds every night across Australia and have caused the extinction of 20 native species.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "59a60baa6acf530020f35874", + "text": "Black cats are bad luck in the United States, but they are good luck in the United Kingdom and Australia.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a026087134ec2001f032f94", + "text": "The cat skull is unusual among mammals in having very large eye sockets and a powerful and specialized jaw.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4aab132c99ee00219e11c2", + "text": "Cats have scent glands along their tail, their forehead, lips, chin, and the underside of their front paws.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4aab372c99ee00219e11c6", + "text": "The french tuxedo kitty, Félix, aka \"Astrocat\", was the first cat to go to space. She survived the trip.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b0ecc7287c6b21e6fafdf01", + "text": "A male cat is called a tom.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b1b3fd8841d9700146158ce", + "text": "Thank to an extremely efficient pair of kidneys, cats can hydrate themselves by drinking salt water.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b183fea64c2b20014b38f52", + "text": "A cats whiskers are the exact width of their body.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b1b3f48841d9700146158cb", + "text": "At night, Disneyland is overrun by cats. The theme park feeds them and takes care of them though, because they keep the rodent population in check.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b1b3f56841d9700146158cc", + "text": "Cats lack antibodies against dog blood so they can only receive it via a transfusion once. The second time would kill them.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b1b4022841d9700146158d1", + "text": "Oftentimes shelters won't let black cats be adopted around Halloween out of a fear that they may be sacrificed.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b1b4039841d9700146158d2", + "text": "When cats bring dead animals back to their humans, they are \"teaching them to hunt\" as they would with a younger cat.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b1b4241841d9700146158da", + "text": "Cat fanciers bred and exhibited Maus in Europe until World War II, when attention toward the cat waned and it nearly went extinct.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b1b455f841d9700146158db", + "text": "The Egyptian Mau breed was saved from extinciton when Russian princess Natalie Trubetskaya was given a Mau that was imported from the Middle East. When she emigrated to New York City in 1956, she brought along three Mau cats. She used these kitties to establish the Fatima Egyptian Mau cattery, which produced many of the ancestors of today’s Egyptian Maus in America.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b4911770508220014ccfe93", + "text": "Unlike kittens, adult cats don’t release any particular key hormones during sleep. They snooze all day just because they can. :)", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b4912650508220014ccfe98", + "text": "Russian scientists discovered in the 1930s that Siamese kittens kept in very warm rooms didn't develop the breed's signature dark patches.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b4912000508220014ccfe96", + "text": "Cats have a third eyelid called a “haw”. It’s generally only visible when they’re unwell.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b4912320508220014ccfe97", + "text": "In the original Italian version of Cinderella, the benevolent fairy godmother figure was a cat.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "58e008450aac31001185ecfd", + "text": "A cat was mayor of Talkeetna, Alaska, for 20 years. His name is Stubbs, and he died on July 23, 2017.", + "type": "cat", + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c3551458e0b8d00148d45e3", + "text": "Many people think that the Turkish Van is simply a color variation of the Turkish Angora, but in fact, they are distinct breeds that developed in different parts of Turkey.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c3551d48e0b8d00148d45e4", + "text": "The Bengal is the result of crossbreeding between domestic cats and Asian leopard cats, and its name is derived from the scientific name for the Asian leopard cat (Felis bengalensis).", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c3552058e0b8d00148d45e5", + "text": "Despite its traditionally wild roots, the Bengal is domestic and will gladly make itself in the indoor \"jungle\" of your home.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c3552738e0b8d00148d45e6", + "text": "Cat's cannot see in total darkness, however their vision is much better than a human's in semidarkness because their retinas are much more sensitive to light.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c3bbb236bd7ea00141eff53", + "text": "The gene that codes orange fur is on the X chromosome, so female cats must inherit two orange genes to end up with orange fur, while male cats only need one.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c475c86a99c3f00140fe4cf", + "text": "The Manx hails from the Isle of Man in the Irish Sea, and many Manx are tailless, a condition believed to have been caused by a dominant genetic mutation that spread among the island's cat population.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c489117e4f6130014c987b9", + "text": "Leonardo da Vinci created a series of drawings titled \"Study of Cat Movements and Positions\", which consists of more than twenty drawings of cats and lions lying a sleep, sitting, playing, and fighting.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c60982ce549020014533033", + "text": "The Chartreux is distinguished by a woolly blue-grey coat, a robust body poised over small paws, and full cheeks with a mouth that always appear to be smiling.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c609852e549020014533036", + "text": "While Chartreux cats aren't very vocal, they are sociable and communicate through eye contact and body language.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c60999ae549020014533038", + "text": "Cats love playing with yarn, ribbons, and fishing-rode style toys. However, cats should not be left alone with anything that they could get tangled in.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c609a02e549020014533039", + "text": "All Scottish Folds descended from Susie, a white barn cat discovered in Scotland in the early 1960s. Susie sported the breed's folded ears – the result of a genetic mutation – and passed on the trait through breeding.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c609a90e54902001453303a", + "text": "Mark Twain was undeniably an ailurophile. He had up to 19 cats at one time and gave them such memorable names as Beelzebub, Buffalo Bill, and Soapy Sal.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c69c35cc2d7a200140f67ea", + "text": "If you really want to treat your cat, get some lactose-free milk at the grocery store and offer some to the kitty.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c69c47dc2d7a200140f67ee", + "text": "Abraham Lincoln, being the ailurophile that he was, once rescued three stray kittens and ensured that they were fed and found homes.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c69c4eec2d7a200140f67ef", + "text": "The Sphynx was originally known as the Canadian Hairless Cat. The breed originated in Toronto, and was renamed to Sphynx, a nod to the famous limestone sculpture in the Egyptian Desert.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c6a104fc2d7a200140f699f", + "text": "A cat’s spine can rotate more than the spines of most other animals, and their vertebrae have a special, flexible, elastic cushioning on the disks, which gives it even more flexibility.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c72d9b651021f001415f00c", + "text": "Cats have been domesticated for around 4,000 years. While they were once valued for their hunting abilities, they are now valued for their companionship and loving behavior.", + "type": "cat", + "user": { + "_id": "5c6fd96df4256a001498a73f", + "name": { + "first": "Hedvig", + "last": "Annersten" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c72dbd851021f001415f010", + "text": "The most popular pedigreed cat in North America is the Persian cat, followed by the Main Coon and the Siamese cat.", + "type": "cat", + "user": { + "_id": "5c72dbb751021f001415f00f", + "name": { + "first": "Hedvig", + "last": "Annersten" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38afd60f1c57001592f127", + "type": "cat", + "text": "In the early 1980s, a Chinchilla Persian cat named Jemari Sanquist mated with a Lilac Burmese named Bambino Lilac Fabergé, producing the Burmilla breed.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b0310f1c57001592f128", + "type": "cat", + "text": "Scratching allows a cat to shed the outer layers of her claws, mark her territory, and stretch her body. Rather than discouraging the scratching behavior altogether, try redirecting your cat to a few scratching posts placed strategically around your home.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b0c40f1c57001592f129", + "type": "cat", + "text": "The elegant Balinese is a longhaired variety of the Siamese, and like the Siamese, this stunning breed is sociable and intelligent. The breed was named for the grace of the dancers on the island of Bali in Indonesia.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b16c0f1c57001592f12b", + "type": "cat", + "text": "A large number of cats inhabit the Largo di Torre Argentina, an ancient Roman temple site near where Caesar was killed. Local volunteers care for the cats.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b2270f1c57001592f12d", + "type": "cat", + "text": "Among the British government officials who keep things running smoothly at 10 Downing Street is Larry, a cat that holds the distinguished position of Chief Mouser. Larry's duties, according to the U.K. government, include \"greeting guests\", \"testing antique furniture for napping quality\", and \"contemplating a solution to the mouse occupancy of the house.\".", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b27b0f1c57001592f12f", + "type": "cat", + "text": "The Norwegian Forest Cat is called \"skogcatt\" in its homeland of Norway and plays a role in the Norwegian fairy tales and legends.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b29f0f1c57001592f130", + "type": "cat", + "text": "The Abyssinian, the Siamese, and the American Shorthair were crossbred to produce the Ocicat breed.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b6280f1c57001592f133", + "type": "cat", + "text": "Some animal shelters require kittens be adopted in pairs to ensure they came from the same litter.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b64e0f1c57001592f134", + "type": "cat", + "text": "The Havana Brown breed hails from England, where it was created by crossbreeding Siamese cats with domestic black cats.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b69a0f1c57001592f135", + "type": "cat", + "text": "While we don't know the exact origins of the Russian Blue, it's commonly believed the breed comes from the Archangel Isles in northwestern Russia—the region's cold climate would certainly explain the breed's plush coat.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b7170f1c57001592f137", + "type": "cat", + "text": "The wild-looking but domestic Ocicat is named for its resemblance to the ocelot, a small South American wild cat.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38bd180f1c57001592f154", + "type": "cat", + "text": "Cats are among only a few animals that walk by moving their two right legs one after another and then their two left legs, rather than moving diagonal limbs simultaneously. Giraffes and camels also have this quality.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38be200f1c57001592f157", + "type": "cat", + "text": "The Turkish Van is often called the \"swimming cat\" because they are naturals in the water, thanks in part to their uniquely textured, water-resistant coat.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5daa192179186100154250c4", + "type": "cat", + "text": "GitHub is a cloud source version control system where its mascot is an octocat, an anthropomorphized cat with five tentacles.", + "user": { + "_id": "5daa103779186100154250bd", + "name": { + "first": "Daniel", + "last": "Carvalho" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5de780600013130015a3ccaf", + "type": "cat", + "text": "About one in two cats respond to catnip, and only develop a sensitivity to it at around 3 to 6 months of age.", + "user": { + "_id": "5de681752c455b00153df068", + "name": { + "first": "Leonard", + "last": "Wohlfarth" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "596f4f50a8d3440020e2d77d", + "text": "Due to the controversy, though loved by most, the Kashmir is overlooked by many cat fanciers.", + "type": "cat", + "user": { + "_id": "596ea14ed4d9720020401f7b", + "name": { + "first": "Kenny", + "last": "Corsig" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "59a60b9e6acf530020f35871", + "text": "The Egyptian Mau is the oldest breed of cat, and is the fastest pedigreed cat.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a038bb98e3dbc001f71978d", + "text": "The domestic cat (Felis catus) is a small, typically furry, carnivorous mammal.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4bfc91b0810f0021748b92", + "text": "Blackie became the richest cat in history when he inherited 15 million British Pounds.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d75e46ef087002174c284", + "text": "Cats have about 130,000 hairs per square inch (20,155 hairs per square centimeter).", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d767c6ef087002174c28a", + "text": "Many Egyptians worshipped the goddess Bast, who had a woman’s body and a cat’s head.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d77446ef087002174c290", + "text": "Approximately 1/3 of cat owners think their pets are able to read their minds.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c69c3ecc2d7a200140f67eb", + "text": "The average female housecat could give birth to ove 100 kittens in her lifetime.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c69c422c2d7a200140f67ed", + "text": "Cats don't go through menopause, so they can continue to breed into their senior years.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c470572a99c3f00140fe4bd", + "text": "Cats like being brushed by their owners, because it reminds them of their mother.", + "type": "cat", + "user": { + "_id": "5c470492a99c3f00140fe4bc", + "name": { + "first": "David", + "last": "Gippner" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c475ca8a99c3f00140fe4d0", + "text": "Loyal, playful, and alert, Manx are considered to be more \"doglike\" than other cats.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c609758e54902001453302c", + "text": "According to the American Society for the Prevention of Cruelty of Animals, newborn kittens get all the nutrition they need duing the first four weeks of life fom their mother's milk. If you are taking care of a kitten without its mother, or if the mother isn't producing enough milk, you can feed the kitten a commercial milk substitute.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c609776e54902001453302d", + "text": "When kittens ages to weeks five and six, they should start making the transition to dry food.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c6097eee549020014533030", + "text": "Legend holds that the Chartreux once lived alongside the Carthusian monks of France.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b1290f1c57001592f12a", + "type": "cat", + "text": "Bobtail cats owe their shortened tails to a natural genetic mutation that has appeared in cats across time and in various regions of the world. The American Bobtail can be traced back to Yodi, a cat with the mutation that was found in Arizona in the 1960s. Yodi passed the genetic quirk on to his kittens, thus creating a new breed.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b6f10f1c57001592f136", + "type": "cat", + "text": "Traveling with cats can be stressful for everyone involved. Whether you're taking a kitty to the vet, or on a road trip, or on a flight, there are things you can do to help her stay relaxed in the carrier. Pet shops sell calming treats and sprays that can reduce a cat's anxiety. It might also help to put a beloved toy or a blanket in the carrier.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d9c556168a764001553b382", + "type": "cat", + "text": "A cat has 244 bones in its entire body—even more than a human, who only has 206 bones.", + "user": { + "_id": "5d84ce8abf541a0015b5febb", + "name": { + "first": "Veronika", + "last": "Koval" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "59669484bf604b00205c20e3", + "text": "The fear of cats is called \"Ailurophobia\"", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "59a60b896acf530020f3586d", + "text": "Cat owners are 25% likely to pick George Harrison as their favorite Beatle.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "59a60b936acf530020f3586f", + "text": "Only 11.5% of people consider themselves \"cat people\".", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "59a60b996acf530020f35870", + "text": "\"Cat people\" are 11% more likely to be introverted than others.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "59b15b18d6eb960020d6767a", + "text": "The place where Julius Caesar was murdered is now a cat sanctuary.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "59ceab6d5c87bf0020b94e0a", + "text": "The Bombay cat breed was developed to resemble a miniature panther.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "59d297abc6671e0020957eb8", + "text": "Cats use their whiskers to judge whether they’ll fit through a space.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a038fba8e3dbc001f719794", + "text": "Among domestic cats, males are more likely to fight than females.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a12460add20f9001fb45060", + "text": "The softest part of a cat is most definitely its cheek area.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a36eb68ae877e0021ed79f3", + "text": "When a cat yawns, it's mouth opens so wide that you can count every tooth.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4aab252c99ee00219e11c3", + "text": "Cats can move their ears 180 degrees.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d78136ef087002174c292", + "text": "Like humans, cats tend to favor one paw over another.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5a4d79496ef087002174c294", + "text": "Approximately 40,000 people are bitten by cats in the U.S. annually.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5b4911340508220014ccfe92", + "text": "Kittens start to dream when they’re about a week old.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c609ae8e54902001453303b", + "text": "Approximately 40% of cats have a dominant paw. The rest are ambidextrous.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c69c409c2d7a200140f67ec", + "text": "Cats can have three litters a year, or approximately 12 kittens.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c35531e8e0b8d00148d45e8", + "text": "If you grow your own catnip, here's how to prepare it for kitty's enjoyment: Cut several stalks of the plant from the base. Hang them upside down in a dark and dry room for several weeks. Then cut the catnip into small pieces, rub some on your cat's favorite toys or scratching post, and let the games begin!", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c3bba466bd7ea00141eff4f", + "text": "Approximately 80% of orange tabbies are male.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c3bbab76bd7ea00141eff50", + "text": "If you need to trim a cat's nails, choose a moment when he is relaxed. Massage his paws gently before clipping. Make sure you're using a pair of clippers specifically for cat's nails, not human nail clippers. Avoid cutting the nail too close to the \"quick,\" the pink part of a cat's nail, which is full of blood vessels and nerves.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c609945e549020014533037", + "text": "Do you ever wake up in the middle of the night and find your cat sleeping on your head? Cat behavioral experts believe that cats are drawn to the warmth and the familiar scent of the owner. Resting on your head also keeps kitty safe from your arms and legs if you toss and turn through the night.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c7408a14dd87d001490f02b", + "text": "The cheetah is the only cat that doesn't have retractable claws.", + "type": "cat", + "user": { + "_id": "5c72e94751021f001415f012", + "name": { + "first": "Tests", + "last": "HiQ" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c75f56272681400147b988a", + "text": "There are only two escalators in the state of Wyoming.", + "type": "cat", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c84248f731a60001540a193", + "text": "When a cat lifts its butt in the air when scratched, it is a sign that your feline is giving your pats an A+. However, if you have a female kitty who hasn't been spayed, \"elevator butt\" could be a sign that she's ready to mate. The proper name for this stance is lordosis, and cats adopt it when they're in heat.", + "type": "cat", + "user": { + "_id": "5c7da4bd70008708fb17c88f", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5c87d03dec1e5c0aa37e0a6b", + "type": "cat", + "text": "On October 24, 1963, a tuxedo cat named Félicette entered outer space aboard a French Véronique AG1 rocket and made feline history. Félicette returned from the 15-minute trip in once piece and earned the praise of French scientists, who said she made \"a valuable contribution to research.\".", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5cd42b77cfdf230015bd7a45", + "text": "Cats can sense distress from a human being.", + "type": "cat", + "user": { + "_id": "5cd42b40cfdf230015bd7a44", + "name": { + "first": "Chloe", + "last": "Simmons" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5cd86b0e1dc6d50015ec2f08", + "text": "Norwegian Forest Cats are usually very people friendly.", + "type": "cat", + "user": { + "_id": "5cd86adb1dc6d50015ec2f07", + "name": { + "first": "Shay", + "last": "Zhang" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b1d40f1c57001592f12c", + "type": "cat", + "text": "During the heat of the summer, set out an extra bowl of water and place ice cubes in it to keep it cold and refreshing. Cats love to lie on cool surfaces when it's hot—if you don't have an tile in your home, consider buying a few ceramic tiles from a home improvement store for your cat to rest on.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b2510f1c57001592f12e", + "type": "cat", + "text": "Courgars are the largest wild cats that can purr.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b5b50f1c57001592f131", + "type": "cat", + "text": "Andy Warhol amassed quite the collection of feline friends during his lifetime, beginning with a Siamese cat named Hester that was given to him by actress Gloria Swanson. By breeding Hester with a cat named Sam, Warhol ended up with multiple litters of kittens, at one point housing 25 cats in his Upper East Side townhouse in NYC.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38b6090f1c57001592f132", + "type": "cat", + "text": "While two kittens might sound like double trouble, adopting two at a time can actually make your job easier. Two cans can keep each other company, reducing their need for socialization, and they will expend energy by playing together. Kittens in the same litter often form tight sibling bonds.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38bb440f1c57001592f14c", + "type": "cat", + "text": "It's important for cats to have regular ear exams—this is something you can do at home! Gently fold back the ears and look into the ear canal. The inner ear should be pale pink with little to no earwax. If you notice redness, swelling, discharge, or a lot of earwax, it's time to see a veterinarian.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d38bb910f1c57001592f14e", + "type": "cat", + "text": "Dr. Seuss wrote The Cat in the Hat using only 236 words.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5d9d4ae168a764001553b388", + "type": "cat", + "text": "Cats conserve energy by sleeping for an average of 13 to 14 hours a day.", + "user": { + "_id": "5d9d4a4468a764001553b387", + "name": { + "first": "Erick", + "last": "Vazquez" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5e83ff18f53272001515690c", + "type": "cat", + "text": "Cats are capable of walking very precisely because, like all felines, they directly register; that is, they place each hind paw (almost) directly in the print of the corresponding fore paw, minimizing noise and visible tracks. This also provides sure footing for their hind paws when they navigate rough terrain.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 1, + "userUpvoted": null + }, + { + "_id": "5e10d7841c78ab0015bc5b15", + "type": "cat", + "text": "Some cats do not eat fish.", + "user": { + "_id": "5e10d6e51c78ab0015bc5b14", + "name": { + "first": "Max", + "last": "Bilohatnyuk" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5e80e7cc2d4b850015003072", + "type": "cat", + "text": "Cats can climb on trees faster than squirels.", + "user": { + "_id": "5e80e6c72d4b85001500306e", + "name": { + "first": "Łukasz", + "last": "Wiktorko" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5e19da391fd6150015fa7356", + "type": "cat", + "text": "\"Cats can hear the ultrasonic noises that rodents and dolphins make to communicate.\".", + "user": { + "_id": "5e19d99e1fd6150015fa7353", + "name": { + "first": "Crystal Gale", + "last": "Scott" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5e1efba858449a0015ce4783", + "type": "cat", + "text": "\"A house cat is faster than Usain Bolt.\".", + "user": { + "_id": "5e1efb5958449a0015ce4781", + "name": { + "first": "oyaji", + "last": "55" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5eafdaf354ad960017b3703d", + "type": "cat", + "text": "Yjyyu.", + "user": { + "_id": "5eafdad954ad960017b37038", + "name": { + "first": "Lulu", + "last": "Dauphin" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ebbf5dd8046d00017776020", + "type": "cat", + "text": "Cats are fat, sometimes.", + "user": { + "_id": "5ebbf5cd8046d0001777601f", + "name": { + "first": "George", + "last": "Miguel" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ec4aad30c796a00174d7b26", + "type": "cat", + "text": "Sss.", + "user": { + "_id": "5ec4a7d60c796a00174d7b25", + "name": { + "first": "Minh", + "last": "Nguyễn" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ec93d1de11bba0017c67e19", + "type": "cat", + "text": "In a sense, cats can see in the dark.", + "user": { + "_id": "5ec93c89e11bba0017c67e18", + "name": { + "first": "Mandy", + "last": "c" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ecb032ab0d92c0017cab214", + "type": "cat", + "text": "Cats are the best pets.", + "user": { + "_id": "5ecb02e4b0d92c0017cab213", + "name": { + "first": "Victor Manuel", + "last": "Díaz de La Gasca" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ecb0333b0d92c0017cab215", + "type": "cat", + "text": "Hello cats.", + "user": { + "_id": "5ecb02e4b0d92c0017cab213", + "name": { + "first": "Victor Manuel", + "last": "Díaz de La Gasca" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ecdda946253f20017f53ec5", + "type": "cat", + "text": "Cats love womans, dog loves mans.", + "user": { + "_id": "5ecdda686253f20017f53ec4", + "name": { + "first": "Jorge Alberto", + "last": "Gil Rendon" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ece7253a0d2ec00178ae28c", + "type": "cat", + "text": "Cat is cat.", + "user": { + "_id": "5eccdd64ba0a92001760e263", + "name": { + "first": "Paulius", + "last": "Bertašius" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ece9fa9a0d2ec00178ae28e", + "type": "cat", + "text": "Z.", + "user": { + "_id": "5eccdd64ba0a92001760e263", + "name": { + "first": "Paulius", + "last": "Bertašius" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ed11e643c15f700172e3856", + "type": "cat", + "text": "Los gatos tienen más huesos que los seres humanos, nos ganan por 24.", + "user": { + "_id": "5ed11e353c15f700172e3855", + "name": { + "first": "Luciano", + "last": "Garrido Sepulveda" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5eddfb501ed5570017aaf3b9", + "type": "cat", + "text": "Take a try.", + "user": { + "_id": "5eddfb351ed5570017aaf3b8", + "name": { + "first": "calix", + "last": "cheng" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5eebc851515a030017471024", + "type": "cat", + "text": "Cats are cats.", + "user": { + "_id": "5eebc848515a030017471023", + "name": { + "first": "Ricky", + "last": "Chon" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ef556dff61f300017030d4c", + "type": "cat", + "text": "Lucy, the oldest cat ever, lived to be 39 years old which is equivalent to 172 cat years.", + "user": { + "_id": "5e1a9b981fd6150015fa736f", + "name": { + "first": "Andrew", + "last": "Pobrica" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ef5bbdbf61f300017030d4f", + "type": "cat", + "text": "Fdfd.", + "user": { + "_id": "5ef5bbcdf61f300017030d4e", + "name": { + "first": "Touch", + "last": "Akhil" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5f0371215ac1ed0017c647a9", + "type": "cat", + "text": "Cxcbc.", + "user": { + "_id": "5f03708f5ac1ed0017c647a8", + "name": { + "first": "Marylange", + "last": "Souza" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5f03712d5ac1ed0017c647aa", + "type": "cat", + "text": "Jfhfhf.", + "user": { + "_id": "5f03708f5ac1ed0017c647a8", + "name": { + "first": "Marylange", + "last": "Souza" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5f0a15090fc09b00174aec96", + "type": "cat", + "text": "1123405258.", + "user": { + "_id": "5f0a15010fc09b00174aec95", + "name": { + "first": "nelson enrique", + "last": "redondo b" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5f119d925ada6000174e785c", + "type": "cat", + "text": "Cat are cute.", + "user": { + "_id": "5f119d3e5ada6000174e785b", + "name": { + "first": "Prof", + "last": "MATH DZ" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5f27be5f1fb098001709b5fb", + "type": "cat", + "text": "Hello.", + "user": { + "_id": "5f27bda31fb098001709b5fa", + "name": { + "first": "Tiến", + "last": "Lê Đức" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5f2c80477329f500172d13bb", + "type": "cat", + "text": "Potato.", + "user": { + "_id": "5f2c649a7329f500172d13ba", + "name": { + "first": "Black", + "last": "Rose" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5f2ca50758ea8e0017ed1266", + "type": "cat", + "text": "Testing.", + "user": { + "_id": "5f2c649a7329f500172d13ba", + "name": { + "first": "Black", + "last": "Rose" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5f2ddb9358ea8e0017ed1268", + "type": "cat", + "text": "Zoe.", + "user": { + "_id": "5f2ddb7e58ea8e0017ed1267", + "name": { + "first": "Sebastian", + "last": "Leal" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5d38baf20f1c57001592f14b", + "type": "cat", + "text": "The stationmaster of the Kishi rail station in western Japan until 2015 was a cat named Tama. The calico cat wore a stationmaster's cap and greeted visitors by the ticket gate. After her death, Tama was elevated to the status of a goddess in a Shinto-style funeral.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5d38bb7a0f1c57001592f14d", + "type": "cat", + "text": "In Norse mythology, the goddess Freya travels in a chariot pulled by two cats—specifically Norwegian Forest Cats.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5d38bbb20f1c57001592f14f", + "type": "cat", + "text": "American Curl kittens are born with straight ears, but the ears begin to curl back after just a few days.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5d38bc0c0f1c57001592f150", + "type": "cat", + "text": "If your cat licks from the toilet, bathtub, or sink, she may be telling you something about the water bowl. Make sure it's clean, replace the water regularly to keep it from getting stale or developing a film on top, and if necessary, get a new bowl. Some cats prefer glass bowls to plastic.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5d38bc630f1c57001592f151", + "type": "cat", + "text": "Georgia O'Keeffe was a celebrated painter, a figurehead of American modernism, a fashion icon, and... a cat owner! O'Keeffe kept a number of animals at her home in New Mexico, including her pet Siamese cat, who appears alongside O'Keeffe in a portrait shot by photographer John Candelario.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5d38bc970f1c57001592f152", + "type": "cat", + "text": "The pink substance inside a cat's nail is called the \"quick\" or the \"dermis\". When trimming your cat's nails, be sure to only cut the upper white part of the nail, not the quick.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5d38bcc00f1c57001592f153", + "type": "cat", + "text": "The irresistable and cuddly Ragamuffin is the result of crossbreeding Ragdoll cats with Persians, Himalayans, and other larger longhaired breeds.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5d38bd750f1c57001592f155", + "type": "cat", + "text": "Legend holds that a goddess rewarded a temple cat's piety by turning the cat's eyes blue and his coat golden, thus creating the first Birman cat.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5d38bdab0f1c57001592f156", + "type": "cat", + "text": "While some cats love being brushed, others don't take to it naturally. Try to groom your cat in the same spot at the same time of day to create a sense of routine.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5d38be370f1c57001592f158", + "type": "cat", + "text": "Kittens typically begin to engage in playful behavior at around four weeks of age.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5ea7496761cd4d0017498a94", + "type": "cat", + "text": "Ancient sailors believed that cats were magic and would often risk their own lives, even in an \"every man for himself\" situation, to save a cat from a sinking ship. All ships had at least one ship's cat as cats would eat the rats that have always plagued ships. The ship's cat would often be given a rank and sailors would generally take excessively good care of their cats.", + "user": { + "_id": "5a9ac18c7478810ea6c06381", + "name": { + "first": "Alex", + "last": "Wohlbruck" + } + }, + "upvotes": 0, + "userUpvoted": null + }, + { + "_id": "5eaafca69105ce00177573de", + "type": "cat", + "text": "Cats sleep most of the time.", + "user": { + "_id": "5ea977d1cd53d20017d7d8b2", + "name": { + "first": "Quang", + "last": "Pham" + } + }, + "upvotes": 0, + "userUpvoted": null + } + ] +}
\ No newline at end of file diff --git a/tools/make_emoji.py b/tools/make_emoji.py new file mode 100644 index 0000000..b23803d --- /dev/null +++ b/tools/make_emoji.py @@ -0,0 +1,14 @@ +from unicodedata import normalize + +try: + import emoji +except ImportError: + print("pip install emoji") + raise + +from emoji.unicode_codes import EMOJI_ALIAS_UNICODE + +emoji = {k.lower().strip(":"): v for k, v in EMOJI_ALIAS_UNICODE.items()} + +with open("_emoji_codes.py", "wt") as f: + f.write("EMOJI=" + str(emoji)) diff --git a/tools/make_terminal_widths.py b/tools/make_terminal_widths.py new file mode 100644 index 0000000..850f67a --- /dev/null +++ b/tools/make_terminal_widths.py @@ -0,0 +1,90 @@ +import subprocess +from typing import List, Tuple +import sys + +from rich.progress import Progress + +from wcwidth import wcwidth + + +progress = Progress() + + +def make_widths_table() -> List[Tuple[int, int, int]]: + table: List[Tuple[int, int, int]] = [] + append = table.append + + make_table_task = progress.add_task("Calculating table...") + + widths = ( + (codepoint, wcwidth(chr(codepoint))) + for codepoint in range(0, sys.maxunicode + 1) + ) + + _widths = [(codepoint, width) for codepoint, width in widths if width != 1] + iter_widths = iter(_widths) + + endpoint, group_cell_size = next(iter_widths) + start_codepoint = end_codepoint = endpoint + for codepoint, cell_size in progress.track( + iter_widths, task_id=make_table_task, total=len(_widths) - 1 + ): + if cell_size != group_cell_size or codepoint != end_codepoint + 1: + append((start_codepoint, end_codepoint, group_cell_size)) + start_codepoint = end_codepoint = codepoint + group_cell_size = cell_size + else: + end_codepoint = codepoint + append((start_codepoint, end_codepoint, group_cell_size)) + return table + + +def get_cell_size(table: List[Tuple[int, int, int]], character: str) -> int: + + codepoint = ord(character) + lower_bound = 0 + upper_bound = len(table) - 1 + index = (lower_bound + upper_bound) // 2 + while True: + start, end, width = table[index] + if codepoint < start: + upper_bound = index - 1 + elif codepoint > end: + lower_bound = index + 1 + else: + return width + if upper_bound < lower_bound: + break + index = (lower_bound + upper_bound) // 2 + return 1 + + +def test(widths_table): + for codepoint in progress.track( + range(0, sys.maxunicode + 1), description="Testing..." + ): + character = chr(codepoint) + width1 = get_cell_size(widths_table, character) + width2 = wcwidth(character) + if width1 != width2: + print(f"{width1} != {width2}") + break + + +def run(): + with progress: + widths_table = make_widths_table() + test(widths_table) + table_file = f"""# Auto generated by make_terminal_widths.py + +CELL_WIDTHS = {widths_table!r} + +""" + with open("../rich/_cell_widths.py", "wt") as fh: + fh.write(table_file) + + subprocess.run("black ../rich/_cell_widths.py", shell=True) + + +if __name__ == "__main__": + run() diff --git a/tools/profile_pretty.py b/tools/profile_pretty.py new file mode 100644 index 0000000..c1a08b8 --- /dev/null +++ b/tools/profile_pretty.py @@ -0,0 +1,23 @@ +import json +import io +from time import time +from rich.console import Console +from rich.pretty import Pretty + + +console = Console(file=io.StringIO(), color_system="truecolor", width=100) + +with open("cats.json") as fh: + cats = json.load(fh) + + +console.begin_capture() +start = time() +pretty = Pretty(cats) +console.print(pretty, overflow="ignore", crop=False) +result = console.end_capture() +taken = (time() - start) * 1000 +print(result) + +print(console.file.getvalue()) +print(f"{taken:.1f}") diff --git a/tools/stress_test_pretty.py b/tools/stress_test_pretty.py new file mode 100644 index 0000000..eb27f33 --- /dev/null +++ b/tools/stress_test_pretty.py @@ -0,0 +1,19 @@ +from rich.console import Console +from rich.panel import Panel +from rich.pretty import Pretty + +DATA = { + "foo": [1, 2, 3, (), {}, (1, 2, 3), {4, 5, 6, (7, 8, 9)}, "Hello, World"], + "bar": [None, (False, True)] * 2, + "Dune": { + "names": { + "Paul Atriedies", + "Vladimir Harkonnen", + "Thufir Haway", + "Duncan Idaho", + } + }, +} +console = Console() +for w in range(130): + console.print(Panel(Pretty(DATA, indent_guides=True), width=w)) @@ -0,0 +1,45 @@ +[tox] +minversion = 3.9.0 +envlist = + lint + docs + py{36,37,38,39} +isolated_build = True + +[testenv] +description = Run unit-testing +# develop temporary disabled as project packaging does not work with it yet: +# https://github.com/willmcgugan/rich/issues/345 +usedevelop = False +deps = + -r requirements-dev.txt +# do not put * in passenv as it may break builds due to reduced isolation +passenv = + CI + GITHUB_* + HOME + PYTEST_* + SSH_AUTH_SOCK + TERM +setenv = + PYTHONDONTWRITEBYTECODE=1 + PYTHONUNBUFFERED=1 +commands = + # failsafe as older pip may install incompatible dependencies + pip check + pytest --cov-report term-missing --cov=rich tests/ {posargs} + +[testenv:lint] +description = Runs all linting tasks +commands = + black . + mypy -p rich --ignore-missing-imports --warn-unreachable +skip_install = true + +[testenv:docs] +description = Builds documentation +changedir = docs +deps = + -r docs/requirements.txt +commands = + sphinx-build -M html source build |