rich-struct 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rich_struct-0.1.0/.gitignore +5 -0
- rich_struct-0.1.0/LICENSE +21 -0
- rich_struct-0.1.0/PKG-INFO +82 -0
- rich_struct-0.1.0/README.md +63 -0
- rich_struct-0.1.0/pyproject.toml +33 -0
- rich_struct-0.1.0/rich_struct/__init__.py +137 -0
- rich_struct-0.1.0/rich_struct/py.typed +0 -0
- rich_struct-0.1.0/rich_struct/tables.py +174 -0
- rich_struct-0.1.0/rich_struct/themes.py +240 -0
- rich_struct-0.1.0/tests/test_print.py +127 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mirek Wiertel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: rich-struct
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: rich.print with 21 popular editor themes (Tokyo Night, Dracula, Nord, ...) and print_md/json/yaml/xml/object/table helpers
|
|
5
|
+
Project-URL: Homepage, https://github.com/wiertmir/rich-struct
|
|
6
|
+
Project-URL: Issues, https://github.com/wiertmir/rich-struct/issues
|
|
7
|
+
Author: Mirek Wiertel
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: markdown,print,rich,syntax highlighting,terminal,theme
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Terminals
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: rich>=13
|
|
16
|
+
Provides-Extra: yaml
|
|
17
|
+
Requires-Dist: pyyaml; extra == 'yaml'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# rich-struct
|
|
21
|
+
|
|
22
|
+
[`rich`](https://github.com/Textualize/rich)'s `print`, with 21 popular editor themes and helpers for Markdown, JSON, YAML, XML, Python objects and tables (lists, numpy arrays, pandas DataFrames and Series).
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install rich-struct # add [yaml] to dump objects with print_yaml
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from rich.markdown import Markdown
|
|
30
|
+
from rich.theme import Theme
|
|
31
|
+
from rich_struct import ThemeName, print, print_md, print_json, print_yaml, print_xml, print_object, print_syntax, print_table
|
|
32
|
+
|
|
33
|
+
print("[bold]hello[/bold]", {"a": 1}) # drop-in rich.print, Tokyo Night by default
|
|
34
|
+
print(Markdown("## Printing structs"), theme=ThemeName.DRACULA)
|
|
35
|
+
|
|
36
|
+
print_md("# Title\n\n```python\nprint('hi')\n```")
|
|
37
|
+
print_json({"a": [1, 2, None]}, theme="Nord") # enum value as a string works too
|
|
38
|
+
print_yaml("a: 1\nb: [x, y]")
|
|
39
|
+
print_xml("<a><b c='d'/></a>")
|
|
40
|
+
print_object(my_dataclass, theme=ThemeName.GITHUB_LIGHT)
|
|
41
|
+
print_syntax("SELECT 1", "sql") # any pygments lexer
|
|
42
|
+
print_table(df, title="Sales") # DataFrame, Series, numpy array, list...
|
|
43
|
+
print(df) # arrays and frames become tables in print too
|
|
44
|
+
|
|
45
|
+
print_md("# Mine", theme=Theme({"markdown.h1": "bold magenta", "syntax.keyword": "red"}))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Every function takes `theme=`: a `ThemeName`, its string value, or a rich `Theme`.
|
|
49
|
+
`ThemeName.X.theme` gives you the underlying rich `Theme` to use with your own `Console`.
|
|
50
|
+
|
|
51
|
+
## Tables
|
|
52
|
+
|
|
53
|
+
`print_table(data, title=None, max_rows=20, max_cols=10)` accepts:
|
|
54
|
+
|
|
55
|
+
- pandas `DataFrame` and `Series`, numpy arrays of any dimension (more than 2: one table per slice)
|
|
56
|
+
- lists of dicts (records), lists of lists (a grid), and any other sequence such as `array.array` or `range` (one column)
|
|
57
|
+
|
|
58
|
+
Numbers are right-aligned, floats in a column share their decimals, and missing values are dimmed.
|
|
59
|
+
Long data shows its head and tail around a `…` gap, and columns that don't fit the terminal width go into the gap as well.
|
|
60
|
+
`to_table(data)` returns the renderable instead of printing it. numpy and pandas are not dependencies.
|
|
61
|
+
|
|
62
|
+
## Themes
|
|
63
|
+
|
|
64
|
+
| Dark | Light |
|
|
65
|
+
|---|---|
|
|
66
|
+
| Tokyo Night (default) | GitHub Light |
|
|
67
|
+
| Dracula | Solarized Light |
|
|
68
|
+
| One Dark Pro | One Light |
|
|
69
|
+
| Monokai | Catppuccin Latte |
|
|
70
|
+
| Nord | Gruvbox Light |
|
|
71
|
+
| Gruvbox Dark | Tokyo Night Light |
|
|
72
|
+
| Catppuccin Mocha | Ayu Light |
|
|
73
|
+
| Solarized Dark | Night Owl Light |
|
|
74
|
+
| GitHub Dark | Light+ |
|
|
75
|
+
| Night Owl | Everforest Light |
|
|
76
|
+
| Winter is Coming | |
|
|
77
|
+
|
|
78
|
+
Themes are built from each theme's colour palette, not ported token-by-token from the editor.
|
|
79
|
+
|
|
80
|
+
Output is painted on the theme's background across the full terminal width. Pass `background=False` to keep the terminal's own background; nothing is painted or padded when the output has no colour (e.g. piped to a file). A custom `Theme` gets a background by defining a `background` style, e.g. `"background": "#c0caf5 on #1a1b26"`.
|
|
81
|
+
|
|
82
|
+
Code colours come from `syntax.*` styles (`text`, `comment`, `keyword`, `string`, `number`, `function`, `type`, `tag`, `attribute`, `operator`); a custom `Theme` may override any of them.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# rich-struct
|
|
2
|
+
|
|
3
|
+
[`rich`](https://github.com/Textualize/rich)'s `print`, with 21 popular editor themes and helpers for Markdown, JSON, YAML, XML, Python objects and tables (lists, numpy arrays, pandas DataFrames and Series).
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install rich-struct # add [yaml] to dump objects with print_yaml
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from rich.markdown import Markdown
|
|
11
|
+
from rich.theme import Theme
|
|
12
|
+
from rich_struct import ThemeName, print, print_md, print_json, print_yaml, print_xml, print_object, print_syntax, print_table
|
|
13
|
+
|
|
14
|
+
print("[bold]hello[/bold]", {"a": 1}) # drop-in rich.print, Tokyo Night by default
|
|
15
|
+
print(Markdown("## Printing structs"), theme=ThemeName.DRACULA)
|
|
16
|
+
|
|
17
|
+
print_md("# Title\n\n```python\nprint('hi')\n```")
|
|
18
|
+
print_json({"a": [1, 2, None]}, theme="Nord") # enum value as a string works too
|
|
19
|
+
print_yaml("a: 1\nb: [x, y]")
|
|
20
|
+
print_xml("<a><b c='d'/></a>")
|
|
21
|
+
print_object(my_dataclass, theme=ThemeName.GITHUB_LIGHT)
|
|
22
|
+
print_syntax("SELECT 1", "sql") # any pygments lexer
|
|
23
|
+
print_table(df, title="Sales") # DataFrame, Series, numpy array, list...
|
|
24
|
+
print(df) # arrays and frames become tables in print too
|
|
25
|
+
|
|
26
|
+
print_md("# Mine", theme=Theme({"markdown.h1": "bold magenta", "syntax.keyword": "red"}))
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Every function takes `theme=`: a `ThemeName`, its string value, or a rich `Theme`.
|
|
30
|
+
`ThemeName.X.theme` gives you the underlying rich `Theme` to use with your own `Console`.
|
|
31
|
+
|
|
32
|
+
## Tables
|
|
33
|
+
|
|
34
|
+
`print_table(data, title=None, max_rows=20, max_cols=10)` accepts:
|
|
35
|
+
|
|
36
|
+
- pandas `DataFrame` and `Series`, numpy arrays of any dimension (more than 2: one table per slice)
|
|
37
|
+
- lists of dicts (records), lists of lists (a grid), and any other sequence such as `array.array` or `range` (one column)
|
|
38
|
+
|
|
39
|
+
Numbers are right-aligned, floats in a column share their decimals, and missing values are dimmed.
|
|
40
|
+
Long data shows its head and tail around a `…` gap, and columns that don't fit the terminal width go into the gap as well.
|
|
41
|
+
`to_table(data)` returns the renderable instead of printing it. numpy and pandas are not dependencies.
|
|
42
|
+
|
|
43
|
+
## Themes
|
|
44
|
+
|
|
45
|
+
| Dark | Light |
|
|
46
|
+
|---|---|
|
|
47
|
+
| Tokyo Night (default) | GitHub Light |
|
|
48
|
+
| Dracula | Solarized Light |
|
|
49
|
+
| One Dark Pro | One Light |
|
|
50
|
+
| Monokai | Catppuccin Latte |
|
|
51
|
+
| Nord | Gruvbox Light |
|
|
52
|
+
| Gruvbox Dark | Tokyo Night Light |
|
|
53
|
+
| Catppuccin Mocha | Ayu Light |
|
|
54
|
+
| Solarized Dark | Night Owl Light |
|
|
55
|
+
| GitHub Dark | Light+ |
|
|
56
|
+
| Night Owl | Everforest Light |
|
|
57
|
+
| Winter is Coming | |
|
|
58
|
+
|
|
59
|
+
Themes are built from each theme's colour palette, not ported token-by-token from the editor.
|
|
60
|
+
|
|
61
|
+
Output is painted on the theme's background across the full terminal width. Pass `background=False` to keep the terminal's own background; nothing is painted or padded when the output has no colour (e.g. piped to a file). A custom `Theme` gets a background by defining a `background` style, e.g. `"background": "#c0caf5 on #1a1b26"`.
|
|
62
|
+
|
|
63
|
+
Code colours come from `syntax.*` styles (`text`, `comment`, `keyword`, `string`, `number`, `function`, `type`, `tag`, `attribute`, `operator`); a custom `Theme` may override any of them.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rich-struct"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "rich.print with 21 popular editor themes (Tokyo Night, Dracula, Nord, ...) and print_md/json/yaml/xml/object/table helpers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Mirek Wiertel" }]
|
|
14
|
+
dependencies = ["rich>=13"]
|
|
15
|
+
keywords = ["rich", "print", "theme", "terminal", "markdown", "syntax highlighting"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Topic :: Terminals",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
yaml = ["pyyaml"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/wiertmir/rich-struct"
|
|
27
|
+
Issues = "https://github.com/wiertmir/rich-struct/issues"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["rich_struct"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
exclude = ["demos.ipynb"]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Themed drop-in extension of ``rich.print``."""
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from typing import IO, Any, Optional, Union
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.json import JSON
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.padding import Padding
|
|
10
|
+
from rich.pretty import Pretty
|
|
11
|
+
from rich.protocol import is_renderable
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.theme import Theme
|
|
14
|
+
|
|
15
|
+
from .tables import is_array, to_table
|
|
16
|
+
from .themes import DEFAULT_THEME, ThemeLiteral, ThemeName, syntax_theme
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DEFAULT_THEME",
|
|
20
|
+
"ThemeLiteral",
|
|
21
|
+
"ThemeName",
|
|
22
|
+
"ThemeType",
|
|
23
|
+
"print",
|
|
24
|
+
"print_json",
|
|
25
|
+
"print_md",
|
|
26
|
+
"print_object",
|
|
27
|
+
"print_syntax",
|
|
28
|
+
"print_table",
|
|
29
|
+
"print_xml",
|
|
30
|
+
"print_yaml",
|
|
31
|
+
"to_table",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
ThemeType = Union[ThemeName, Theme, ThemeLiteral]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def print(
|
|
38
|
+
*objects: Any,
|
|
39
|
+
sep: str = " ",
|
|
40
|
+
end: str = "\n",
|
|
41
|
+
file: Optional[IO[str]] = None,
|
|
42
|
+
theme: ThemeType = DEFAULT_THEME,
|
|
43
|
+
background: bool = True,
|
|
44
|
+
**kwargs: Any,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""``rich.print`` with a theme.
|
|
47
|
+
|
|
48
|
+
``theme`` is a :class:`ThemeName` (or its string value) or a rich ``Theme``.
|
|
49
|
+
``Markdown`` and ``Syntax`` objects get their code colours from the theme too,
|
|
50
|
+
and numpy arrays, pandas DataFrames and Series are shown as tables.
|
|
51
|
+
The theme's background is painted across the full terminal width unless
|
|
52
|
+
``background=False`` (or the output has no colour, e.g. when piped to a file).
|
|
53
|
+
Extra keyword arguments go to ``Console.print`` (``style``, ``justify``, ...).
|
|
54
|
+
"""
|
|
55
|
+
rich_theme = theme if isinstance(theme, Theme) else ThemeName(theme).theme
|
|
56
|
+
code_theme = syntax_theme(rich_theme)
|
|
57
|
+
objects = tuple(to_table(obj) if is_array(obj) else obj for obj in objects)
|
|
58
|
+
for obj in objects:
|
|
59
|
+
if isinstance(obj, Markdown):
|
|
60
|
+
obj.code_theme = code_theme
|
|
61
|
+
elif isinstance(obj, Syntax):
|
|
62
|
+
# ponytail: Syntax has no public theme setter; rebuild the object if rich renames this
|
|
63
|
+
obj._theme = code_theme
|
|
64
|
+
console = Console(theme=rich_theme, file=file)
|
|
65
|
+
base = rich_theme.styles.get("background")
|
|
66
|
+
if background and base and console.color_system:
|
|
67
|
+
# fill whole lines: Padding expands renderables, justify pads plain text
|
|
68
|
+
objects = tuple(
|
|
69
|
+
Padding(obj, 0) if is_renderable(obj) and not isinstance(obj, str) else obj
|
|
70
|
+
for obj in objects
|
|
71
|
+
)
|
|
72
|
+
kwargs.setdefault("justify", "left")
|
|
73
|
+
kwargs["style"] = base + console.get_style(kwargs.get("style") or "none")
|
|
74
|
+
console.print(*objects, sep=sep, end=end, **kwargs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def print_md(markdown: str, *, theme: ThemeType = DEFAULT_THEME, **kwargs: Any) -> None:
|
|
78
|
+
print(Markdown(markdown), theme=theme, **kwargs)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def print_syntax(
|
|
82
|
+
code: str, lexer: str, *, theme: ThemeType = DEFAULT_THEME, **kwargs: Any
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Print ``code`` highlighted with any pygments ``lexer`` ("python", "toml", ...)."""
|
|
85
|
+
print(Syntax(code, lexer, word_wrap=True), theme=theme, **kwargs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def print_json(data: Any, *, theme: ThemeType = DEFAULT_THEME, **kwargs: Any) -> None:
|
|
89
|
+
"""Print a JSON string, or any JSON-serialisable object."""
|
|
90
|
+
json = JSON(data) if isinstance(data, str) else JSON.from_data(data, default=str)
|
|
91
|
+
print(json, theme=theme, **kwargs)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def print_yaml(data: Any, *, theme: ThemeType = DEFAULT_THEME, **kwargs: Any) -> None:
|
|
95
|
+
"""Print a YAML string, or dump an object as YAML (needs ``rich-struct[yaml]``)."""
|
|
96
|
+
if not isinstance(data, str):
|
|
97
|
+
import yaml
|
|
98
|
+
|
|
99
|
+
data = yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
|
|
100
|
+
print_syntax(data.rstrip("\n"), "yaml", theme=theme, **kwargs)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def print_xml(
|
|
104
|
+
data: Union[str, ET.Element, ET.ElementTree],
|
|
105
|
+
*,
|
|
106
|
+
theme: ThemeType = DEFAULT_THEME,
|
|
107
|
+
**kwargs: Any,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Print an XML string as-is, or an ElementTree element indented."""
|
|
110
|
+
if not isinstance(data, str):
|
|
111
|
+
root = data.getroot() if isinstance(data, ET.ElementTree) else data
|
|
112
|
+
ET.indent(root)
|
|
113
|
+
data = ET.tostring(root, encoding="unicode")
|
|
114
|
+
print_syntax(data.rstrip("\n"), "xml", theme=theme, **kwargs)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def print_object(obj: Any, *, theme: ThemeType = DEFAULT_THEME, **kwargs: Any) -> None:
|
|
118
|
+
"""Pretty-print any Python object (dataclasses, dicts, nested structures...)."""
|
|
119
|
+
print(Pretty(obj, expand_all=True), theme=theme, **kwargs)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def print_table(
|
|
123
|
+
data: Any,
|
|
124
|
+
*,
|
|
125
|
+
title: Optional[str] = None,
|
|
126
|
+
max_rows: int = 20,
|
|
127
|
+
max_cols: int = 10,
|
|
128
|
+
theme: ThemeType = DEFAULT_THEME,
|
|
129
|
+
**kwargs: Any,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Print a list, array, numpy array, pandas DataFrame or Series as a table.
|
|
132
|
+
|
|
133
|
+
Lists of dicts become records, lists of lists a grid. Data longer than
|
|
134
|
+
``max_rows``/``max_cols`` shows its head and tail around a "…" gap.
|
|
135
|
+
"""
|
|
136
|
+
table = to_table(data, title=title, max_rows=max_rows, max_cols=max_cols)
|
|
137
|
+
print(table, theme=theme, **kwargs)
|
|
File without changes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Lists, arrays, numpy arrays and pandas objects as rich tables.
|
|
2
|
+
|
|
3
|
+
Everything is duck-typed, so neither numpy nor pandas is imported (or required).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from numbers import Integral, Real
|
|
8
|
+
from typing import Any, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from rich import box
|
|
11
|
+
from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
_MISSING = {"None", "nan", "NaN", "NaT", "<NA>"}
|
|
16
|
+
_GAP = "…"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_array(obj: Any) -> bool:
|
|
20
|
+
"""numpy arrays, pandas DataFrames/Series and anything else array-like with a shape."""
|
|
21
|
+
return hasattr(obj, "__array__") and getattr(obj, "ndim", 0) > 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _clip(n: int, limit: int) -> List[Optional[int]]:
|
|
25
|
+
"""Positions to show out of ``n``: head and tail, with None marking the gap."""
|
|
26
|
+
if n <= limit:
|
|
27
|
+
return list(range(n))
|
|
28
|
+
head = (limit + 1) // 2
|
|
29
|
+
return [*range(head), None, *range(n - (limit - head), n)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_float(value: Any) -> bool:
|
|
33
|
+
return isinstance(value, Real) and not isinstance(value, Integral) and not _is_bool(value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_bool(value: Any) -> bool:
|
|
37
|
+
return type(value).__name__ in ("bool", "bool_")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cell(value: Any) -> Text:
|
|
41
|
+
text = format(value, ".6g") if _is_float(value) else str(value)
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
style = "repr.ellipsis" if value == _GAP else "repr.str"
|
|
44
|
+
elif text in _MISSING:
|
|
45
|
+
style = "repr.none"
|
|
46
|
+
elif _is_bool(value):
|
|
47
|
+
style = "repr.bool_true" if value else "repr.bool_false"
|
|
48
|
+
else:
|
|
49
|
+
style = "repr.number" if isinstance(value, Real) else "none"
|
|
50
|
+
return Text(text, style=style)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _column(values: List[Any]) -> List[Text]:
|
|
54
|
+
"""Cells of one column; its floats get the same number of decimals."""
|
|
55
|
+
cells = [_cell(value) for value in values]
|
|
56
|
+
floats = [i for i, value in enumerate(values) if _is_float(value) and value == value]
|
|
57
|
+
if floats and not any("e" in cells[i].plain for i in floats):
|
|
58
|
+
decimals = min(6, max(1, *(len(cells[i].plain.partition(".")[2]) for i in floats)))
|
|
59
|
+
for i in floats:
|
|
60
|
+
cells[i].plain = f"{values[i]:.{decimals}f}"
|
|
61
|
+
return cells
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Fitted:
|
|
65
|
+
"""Renders ``data`` with as many columns (up to ``max_cols``) as fit the console."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self, data: Any, title: Optional[str], max_rows: int, max_cols: int, caption: bool = True
|
|
69
|
+
) -> None:
|
|
70
|
+
self.data, self.title, self.max_rows, self.max_cols = data, title, max_rows, max_cols
|
|
71
|
+
self.caption = caption
|
|
72
|
+
|
|
73
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
74
|
+
max_cols = self.max_cols
|
|
75
|
+
unlimited = options.update_width(10_000) # measure the table before rich squeezes it
|
|
76
|
+
while True:
|
|
77
|
+
table, caption = _build(self.data, self.max_rows, max_cols)
|
|
78
|
+
if max_cols <= 2 or console.measure(table, options=unlimited).maximum <= options.max_width:
|
|
79
|
+
break
|
|
80
|
+
max_cols -= 1
|
|
81
|
+
if self.title:
|
|
82
|
+
yield Text(self.title, style="table.title")
|
|
83
|
+
yield table
|
|
84
|
+
if self.caption:
|
|
85
|
+
yield Text(caption, style="table.caption")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def to_table(
|
|
89
|
+
data: Any, *, title: Optional[str] = None, max_rows: int = 20, max_cols: int = 10
|
|
90
|
+
) -> RenderableType:
|
|
91
|
+
"""A renderable table for a list, array, numpy array, DataFrame or Series.
|
|
92
|
+
|
|
93
|
+
Lists of dicts become records, lists of lists a grid, anything else one column.
|
|
94
|
+
Long data shows its first and last rows/columns around a "…" gap, and columns
|
|
95
|
+
that don't fit the console width are dropped into that gap too.
|
|
96
|
+
"""
|
|
97
|
+
if is_array(data) and data.ndim > 2 and not hasattr(data, "iloc"):
|
|
98
|
+
shown = min(len(data), 3)
|
|
99
|
+
slices = [
|
|
100
|
+
_Fitted(data[i], f"[{i}]", max_rows, max_cols, caption=False)
|
|
101
|
+
if data.ndim == 3
|
|
102
|
+
else to_table(data[i], title=f"[{i}]", max_rows=max_rows, max_cols=max_cols)
|
|
103
|
+
for i in range(shown)
|
|
104
|
+
]
|
|
105
|
+
if len(data) > shown:
|
|
106
|
+
slices.append(Text(f"… {len(data) - shown} more", style="repr.ellipsis"))
|
|
107
|
+
whole = f"{type(data).__name__} · shape {tuple(data.shape)} · dtype {data.dtype}"
|
|
108
|
+
heading = [Text(title, style="table.title")] if title else []
|
|
109
|
+
return Group(*heading, *slices, Text(whole, style="table.caption"))
|
|
110
|
+
return _Fitted(data, title, max_rows, max_cols)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build(data: Any, max_rows: int, max_cols: int) -> Tuple[Table, str]:
|
|
114
|
+
corner = ""
|
|
115
|
+
if hasattr(data, "iloc"): # pandas
|
|
116
|
+
if data.ndim == 1:
|
|
117
|
+
caption = f"Series · dtype {data.dtype} · {len(data)} items"
|
|
118
|
+
data = data.to_frame(name="value" if data.name is None else data.name)
|
|
119
|
+
else:
|
|
120
|
+
caption = f"DataFrame · {data.shape[0]} rows × {data.shape[1]} columns"
|
|
121
|
+
shown_rows, shown_cols = _clip(data.shape[0], max_rows), _clip(data.shape[1], max_cols)
|
|
122
|
+
part = data.iloc[
|
|
123
|
+
[i for i in shown_rows if i is not None], [j for j in shown_cols if j is not None]
|
|
124
|
+
]
|
|
125
|
+
corner = "" if data.index.name is None else str(data.index.name)
|
|
126
|
+
columns, index = list(part.columns), list(part.index)
|
|
127
|
+
rows = [list(row) for row in part.itertuples(index=False, name=None)]
|
|
128
|
+
elif is_array(data): # numpy and friends
|
|
129
|
+
caption = f"{type(data).__name__} · shape {tuple(data.shape)} · dtype {data.dtype}"
|
|
130
|
+
grid = data.reshape(-1, 1) if data.ndim == 1 else data
|
|
131
|
+
shown_rows, shown_cols = _clip(grid.shape[0], max_rows), _clip(grid.shape[1], max_cols)
|
|
132
|
+
index = [i for i in shown_rows if i is not None]
|
|
133
|
+
columns = [j for j in shown_cols if j is not None]
|
|
134
|
+
rows = grid[index][:, columns].tolist()
|
|
135
|
+
if data.ndim == 1:
|
|
136
|
+
columns = ["value"]
|
|
137
|
+
else: # any other sequence: list, tuple, array.array, range, generator...
|
|
138
|
+
kind = type(data).__name__
|
|
139
|
+
data = list(data)
|
|
140
|
+
caption = f"{kind} · {len(data)} items"
|
|
141
|
+
shown_rows = _clip(len(data), max_rows)
|
|
142
|
+
index = [i for i in shown_rows if i is not None]
|
|
143
|
+
items = [data[i] for i in index]
|
|
144
|
+
if items and all(isinstance(item, Mapping) for item in items):
|
|
145
|
+
keys = list(dict.fromkeys(key for item in items for key in item))
|
|
146
|
+
grid = [[item.get(key) for key in keys] for item in items]
|
|
147
|
+
elif items and all(isinstance(item, (list, tuple)) for item in items):
|
|
148
|
+
keys = list(range(max(map(len, items))))
|
|
149
|
+
grid = [[*item, *[None] * (len(keys) - len(item))] for item in items]
|
|
150
|
+
else:
|
|
151
|
+
keys, grid = ["value"], [[item] for item in items]
|
|
152
|
+
shown_cols = _clip(len(keys), max_cols)
|
|
153
|
+
columns = [keys[j] for j in shown_cols if j is not None]
|
|
154
|
+
rows = [[row[j] for j in shown_cols if j is not None] for row in grid]
|
|
155
|
+
|
|
156
|
+
if None in shown_cols:
|
|
157
|
+
gap = shown_cols.index(None)
|
|
158
|
+
columns.insert(gap, _GAP)
|
|
159
|
+
for row in rows:
|
|
160
|
+
row.insert(gap, _GAP)
|
|
161
|
+
if None in shown_rows:
|
|
162
|
+
gap = shown_rows.index(None)
|
|
163
|
+
index.insert(gap, _GAP)
|
|
164
|
+
rows.insert(gap, [_GAP] * len(columns))
|
|
165
|
+
|
|
166
|
+
cells = [_column([row[j] for row in rows]) for j in range(len(columns))]
|
|
167
|
+
table = Table(box=box.ROUNDED, border_style="rule.line", header_style="table.header")
|
|
168
|
+
table.add_column(corner, style="table.header", justify="right", no_wrap=True)
|
|
169
|
+
for name, column in zip(columns, cells):
|
|
170
|
+
numeric = all(cell.style in ("repr.number", "repr.none", "repr.ellipsis") for cell in column)
|
|
171
|
+
table.add_column(str(name), justify="right" if numeric else "left")
|
|
172
|
+
for label, *row in zip(index, *cells):
|
|
173
|
+
table.add_row(str(label), *row)
|
|
174
|
+
return table, caption
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Theme palettes and their translation into rich ``Theme`` objects."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pygments.token import (
|
|
8
|
+
Comment,
|
|
9
|
+
Error,
|
|
10
|
+
Generic,
|
|
11
|
+
Keyword,
|
|
12
|
+
Name,
|
|
13
|
+
Number,
|
|
14
|
+
Operator,
|
|
15
|
+
String,
|
|
16
|
+
Token,
|
|
17
|
+
)
|
|
18
|
+
from rich.style import Style
|
|
19
|
+
from rich.syntax import ANSISyntaxTheme, SyntaxTheme
|
|
20
|
+
from rich.theme import Theme
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ThemeName(str, Enum):
|
|
24
|
+
# dark
|
|
25
|
+
TOKYO_NIGHT = "Tokyo Night"
|
|
26
|
+
DRACULA = "Dracula"
|
|
27
|
+
ONE_DARK_PRO = "One Dark Pro"
|
|
28
|
+
MONOKAI = "Monokai"
|
|
29
|
+
NORD = "Nord"
|
|
30
|
+
GRUVBOX_DARK = "Gruvbox Dark"
|
|
31
|
+
CATPPUCCIN_MOCHA = "Catppuccin Mocha"
|
|
32
|
+
SOLARIZED_DARK = "Solarized Dark"
|
|
33
|
+
GITHUB_DARK = "GitHub Dark"
|
|
34
|
+
NIGHT_OWL = "Night Owl"
|
|
35
|
+
WINTER_IS_COMING = "Winter is Coming"
|
|
36
|
+
# light
|
|
37
|
+
GITHUB_LIGHT = "GitHub Light"
|
|
38
|
+
SOLARIZED_LIGHT = "Solarized Light"
|
|
39
|
+
ONE_LIGHT = "One Light"
|
|
40
|
+
CATPPUCCIN_LATTE = "Catppuccin Latte"
|
|
41
|
+
GRUVBOX_LIGHT = "Gruvbox Light"
|
|
42
|
+
TOKYO_NIGHT_LIGHT = "Tokyo Night Light"
|
|
43
|
+
AYU_LIGHT = "Ayu Light"
|
|
44
|
+
NIGHT_OWL_LIGHT = "Night Owl Light"
|
|
45
|
+
LIGHT_PLUS = "Light+"
|
|
46
|
+
EVERFOREST_LIGHT = "Everforest Light"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_dark(self) -> bool:
|
|
50
|
+
return self in _DARK
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def theme(self) -> Theme:
|
|
54
|
+
return _build(self)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# The ThemeName values again, spelled out so editors can autocomplete theme="...".
|
|
58
|
+
# Static type checkers can't derive this from the enum; a test keeps the two in sync.
|
|
59
|
+
ThemeLiteral = Literal[
|
|
60
|
+
"Tokyo Night",
|
|
61
|
+
"Dracula",
|
|
62
|
+
"One Dark Pro",
|
|
63
|
+
"Monokai",
|
|
64
|
+
"Nord",
|
|
65
|
+
"Gruvbox Dark",
|
|
66
|
+
"Catppuccin Mocha",
|
|
67
|
+
"Solarized Dark",
|
|
68
|
+
"GitHub Dark",
|
|
69
|
+
"Night Owl",
|
|
70
|
+
"Winter is Coming",
|
|
71
|
+
"GitHub Light",
|
|
72
|
+
"Solarized Light",
|
|
73
|
+
"One Light",
|
|
74
|
+
"Catppuccin Latte",
|
|
75
|
+
"Gruvbox Light",
|
|
76
|
+
"Tokyo Night Light",
|
|
77
|
+
"Ayu Light",
|
|
78
|
+
"Night Owl Light",
|
|
79
|
+
"Light+",
|
|
80
|
+
"Everforest Light",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
DEFAULT_THEME = ThemeName.TOKYO_NIGHT
|
|
84
|
+
|
|
85
|
+
T = ThemeName
|
|
86
|
+
# bg fg comment red orange yellow green cyan blue purple
|
|
87
|
+
_PALETTES = {
|
|
88
|
+
T.TOKYO_NIGHT: "1a1b26 c0caf5 565f89 f7768e ff9e64 e0af68 9ece6a 7dcfff 7aa2f7 bb9af7",
|
|
89
|
+
T.DRACULA: "282a36 f8f8f2 6272a4 ff5555 ffb86c f1fa8c 50fa7b 8be9fd bd93f9 ff79c6",
|
|
90
|
+
T.ONE_DARK_PRO: "282c34 abb2bf 5c6370 e06c75 d19a66 e5c07b 98c379 56b6c2 61afef c678dd",
|
|
91
|
+
T.MONOKAI: "272822 f8f8f2 75715e f92672 fd971f e6db74 a6e22e a1efe4 66d9ef ae81ff",
|
|
92
|
+
T.NORD: "2e3440 d8dee9 616e88 bf616a d08770 ebcb8b a3be8c 88c0d0 81a1c1 b48ead",
|
|
93
|
+
T.GRUVBOX_DARK: "282828 ebdbb2 928374 fb4934 fe8019 fabd2f b8bb26 8ec07c 83a598 d3869b",
|
|
94
|
+
T.CATPPUCCIN_MOCHA: "1e1e2e cdd6f4 6c7086 f38ba8 fab387 f9e2af a6e3a1 89dceb 89b4fa cba6f7",
|
|
95
|
+
T.SOLARIZED_DARK: "002b36 839496 586e75 dc322f cb4b16 b58900 859900 2aa198 268bd2 6c71c4",
|
|
96
|
+
T.GITHUB_DARK: "0d1117 c9d1d9 8b949e ff7b72 ffa657 e3b341 7ee787 a5d6ff 79c0ff d2a8ff",
|
|
97
|
+
T.NIGHT_OWL: "011627 d6deeb 637777 ef5350 f78c6c ecc48d addb67 7fdbca 82aaff c792ea",
|
|
98
|
+
T.WINTER_IS_COMING: "011627 a7dbf7 999999 ef5350 8dec95 f7ecb5 bcf0c0 7fdbca 87aff4 00bff9",
|
|
99
|
+
T.GITHUB_LIGHT: "ffffff 24292f 6e7781 cf222e 953800 9a6700 116329 1b7c83 0550ae 8250df",
|
|
100
|
+
T.SOLARIZED_LIGHT: "fdf6e3 657b83 93a1a1 dc322f cb4b16 b58900 859900 2aa198 268bd2 6c71c4",
|
|
101
|
+
T.ONE_LIGHT: "fafafa 383a42 a0a1a7 e45649 986801 c18401 50a14f 0184bc 4078f2 a626a4",
|
|
102
|
+
T.CATPPUCCIN_LATTE: "eff1f5 4c4f69 9ca0b0 d20f39 fe640b df8e1d 40a02b 04a5e5 1e66f5 8839ef",
|
|
103
|
+
T.GRUVBOX_LIGHT: "fbf1c7 3c3836 928374 9d0006 af3a03 b57614 79740e 427b58 076678 8f3f71",
|
|
104
|
+
T.TOKYO_NIGHT_LIGHT: "d5d6db 343b58 9699a3 8c4351 965027 8f5e15 485e30 0f4b6e 34548a 5a4a78",
|
|
105
|
+
T.AYU_LIGHT: "fafafa 5c6166 abb0b6 f07171 fa8d3e f2ae49 86b300 4cbf99 399ee6 a37acc",
|
|
106
|
+
T.NIGHT_OWL_LIGHT: "fbfbfb 403f53 989fb1 de3d3b c96765 daaa01 08916a 0c969b 4876d6 994cc3",
|
|
107
|
+
T.LIGHT_PLUS: "ffffff 000000 008000 a31515 811f3f 795e26 098658 267f99 0000ff af00db",
|
|
108
|
+
T.EVERFOREST_LIGHT: "fdf6e3 5c6a72 939f91 f85552 f57d26 dfa000 8da101 35a77c 3a94c5 df69ba",
|
|
109
|
+
}
|
|
110
|
+
_DARK = frozenset(list(T)[:11])
|
|
111
|
+
del T
|
|
112
|
+
|
|
113
|
+
# pygments token -> name of the "syntax.*" style that colours it
|
|
114
|
+
_TOKENS = {
|
|
115
|
+
Token: "text",
|
|
116
|
+
Comment: "comment",
|
|
117
|
+
Keyword: "keyword",
|
|
118
|
+
Keyword.Constant: "number",
|
|
119
|
+
Keyword.Type: "type",
|
|
120
|
+
Name.Function: "function",
|
|
121
|
+
Name.Class: "type",
|
|
122
|
+
Name.Builtin: "type",
|
|
123
|
+
Name.Tag: "tag",
|
|
124
|
+
Name.Attribute: "attribute",
|
|
125
|
+
Name.Decorator: "attribute",
|
|
126
|
+
Name.Constant: "number",
|
|
127
|
+
String: "string",
|
|
128
|
+
String.Escape: "operator",
|
|
129
|
+
Number: "number",
|
|
130
|
+
Operator: "operator",
|
|
131
|
+
Generic.Deleted: "tag",
|
|
132
|
+
Generic.Inserted: "string",
|
|
133
|
+
Generic.Heading: "function",
|
|
134
|
+
Error: "tag",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@lru_cache(maxsize=None)
|
|
139
|
+
def _build(name: ThemeName) -> Theme:
|
|
140
|
+
bg, fg, comment, red, orange, yellow, green, cyan, blue, purple = (
|
|
141
|
+
"#" + c for c in _PALETTES[name].split()
|
|
142
|
+
)
|
|
143
|
+
return Theme(
|
|
144
|
+
{
|
|
145
|
+
"background": f"{fg} on {bg}",
|
|
146
|
+
# code (see _TOKENS)
|
|
147
|
+
"syntax.text": fg,
|
|
148
|
+
"syntax.comment": f"italic {comment}",
|
|
149
|
+
"syntax.keyword": purple,
|
|
150
|
+
"syntax.string": green,
|
|
151
|
+
"syntax.number": orange,
|
|
152
|
+
"syntax.function": blue,
|
|
153
|
+
"syntax.type": cyan,
|
|
154
|
+
"syntax.tag": red,
|
|
155
|
+
"syntax.attribute": yellow,
|
|
156
|
+
"syntax.operator": cyan,
|
|
157
|
+
# markdown
|
|
158
|
+
"markdown.paragraph": fg,
|
|
159
|
+
"markdown.text": fg,
|
|
160
|
+
"markdown.h1": f"bold {purple}",
|
|
161
|
+
"markdown.h1.border": comment,
|
|
162
|
+
"markdown.h2": f"bold {blue}",
|
|
163
|
+
"markdown.h3": f"bold {cyan}",
|
|
164
|
+
"markdown.h4": f"bold {green}",
|
|
165
|
+
"markdown.h5": f"bold {yellow}",
|
|
166
|
+
"markdown.h6": f"italic {comment}",
|
|
167
|
+
"markdown.h7": f"italic {comment}",
|
|
168
|
+
"markdown.strong": f"bold {orange}",
|
|
169
|
+
"markdown.em": f"italic {yellow}",
|
|
170
|
+
"markdown.emph": f"italic {yellow}",
|
|
171
|
+
"markdown.s": f"strike {comment}",
|
|
172
|
+
"markdown.code": green,
|
|
173
|
+
"markdown.code_block": fg,
|
|
174
|
+
"markdown.block_quote": f"italic {comment}",
|
|
175
|
+
"markdown.item": fg,
|
|
176
|
+
"markdown.item.bullet": f"bold {yellow}",
|
|
177
|
+
"markdown.item.number": f"bold {yellow}",
|
|
178
|
+
"markdown.hr": comment,
|
|
179
|
+
"markdown.link": blue,
|
|
180
|
+
"markdown.link_url": f"underline {cyan}",
|
|
181
|
+
"markdown.table.border": comment,
|
|
182
|
+
"markdown.table.header": f"bold {blue}",
|
|
183
|
+
# json
|
|
184
|
+
"json.key": f"bold {blue}",
|
|
185
|
+
"json.str": green,
|
|
186
|
+
"json.number": orange,
|
|
187
|
+
"json.bool_true": f"italic {orange}",
|
|
188
|
+
"json.bool_false": f"italic {orange}",
|
|
189
|
+
"json.null": f"italic {purple}",
|
|
190
|
+
"json.brace": f"bold {fg}",
|
|
191
|
+
# repr / pretty-printed objects
|
|
192
|
+
"repr.str": green,
|
|
193
|
+
"repr.number": orange,
|
|
194
|
+
"repr.number_complex": orange,
|
|
195
|
+
"repr.bool_true": f"italic {orange}",
|
|
196
|
+
"repr.bool_false": f"italic {orange}",
|
|
197
|
+
"repr.none": f"italic {purple}",
|
|
198
|
+
"repr.brace": f"bold {fg}",
|
|
199
|
+
"repr.comma": fg,
|
|
200
|
+
"repr.ellipsis": comment,
|
|
201
|
+
"repr.indent": f"dim {comment}",
|
|
202
|
+
"repr.tag_start": fg,
|
|
203
|
+
"repr.tag_name": f"bold {red}",
|
|
204
|
+
"repr.tag_contents": fg,
|
|
205
|
+
"repr.tag_end": fg,
|
|
206
|
+
"repr.attrib_name": yellow,
|
|
207
|
+
"repr.attrib_equal": cyan,
|
|
208
|
+
"repr.attrib_value": purple,
|
|
209
|
+
"repr.call": f"bold {blue}",
|
|
210
|
+
"repr.url": f"underline {cyan}",
|
|
211
|
+
"repr.path": cyan,
|
|
212
|
+
"repr.filename": f"bold {cyan}",
|
|
213
|
+
"repr.uuid": yellow,
|
|
214
|
+
"repr.ipv4": green,
|
|
215
|
+
"repr.ipv6": green,
|
|
216
|
+
"repr.eui48": green,
|
|
217
|
+
"repr.eui64": green,
|
|
218
|
+
"repr.error": f"bold {red}",
|
|
219
|
+
# misc rich output
|
|
220
|
+
"table.header": f"bold {blue}",
|
|
221
|
+
"table.title": f"bold {purple}",
|
|
222
|
+
"table.caption": f"italic {comment}",
|
|
223
|
+
"rule.line": comment,
|
|
224
|
+
"rule.text": blue,
|
|
225
|
+
"pretty": fg,
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def syntax_theme(theme: Theme) -> SyntaxTheme:
|
|
231
|
+
"""Code-highlighting theme from a rich Theme's ``syntax.*`` styles.
|
|
232
|
+
|
|
233
|
+
Styles the theme doesn't define fall back to the default theme's.
|
|
234
|
+
"""
|
|
235
|
+
fallback = _build(DEFAULT_THEME).styles
|
|
236
|
+
styles = {
|
|
237
|
+
name: theme.styles.get(f"syntax.{name}", fallback[f"syntax.{name}"])
|
|
238
|
+
for name in set(_TOKENS.values())
|
|
239
|
+
}
|
|
240
|
+
return ANSISyntaxTheme({token: styles[name] for token, name in _TOKENS.items()})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from array import array
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import get_args
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from rich.theme import Theme
|
|
12
|
+
|
|
13
|
+
import rich_struct as rt
|
|
14
|
+
from rich_struct import ThemeName
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render(fn, *args, **kwargs) -> str:
|
|
18
|
+
out = io.StringIO()
|
|
19
|
+
fn(*args, file=out, **kwargs)
|
|
20
|
+
return out.getvalue()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_theme_colours_reach_the_output(monkeypatch):
|
|
24
|
+
monkeypatch.setenv("FORCE_COLOR", "1")
|
|
25
|
+
monkeypatch.setenv("COLORTERM", "truecolor")
|
|
26
|
+
keyword = "38;2;187;154;247" # Tokyo Night purple, #bb9af7
|
|
27
|
+
assert keyword in render(rt.print_syntax, "def f(): pass", "python")
|
|
28
|
+
assert keyword in render(rt.print_md, "```python\ndef f(): pass\n```")
|
|
29
|
+
assert keyword not in render(rt.print_syntax, "def f(): pass", "python", theme="Nord")
|
|
30
|
+
assert "38;2;122;162;247" in render(rt.print_json, {"a": 1}) # key, blue #7aa2f7
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Point:
|
|
35
|
+
x: int
|
|
36
|
+
y: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
MD = "## Printing structs\n\nSome `code` and:\n\n```python\nprint('hi')\n```\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_enum_has_11_dark_10_light_and_all_build():
|
|
43
|
+
assert sum(t.is_dark for t in ThemeName) == 11
|
|
44
|
+
assert ThemeName.WINTER_IS_COMING.is_dark and not ThemeName.GITHUB_LIGHT.is_dark
|
|
45
|
+
assert sum(not t.is_dark for t in ThemeName) == 10
|
|
46
|
+
assert rt.DEFAULT_THEME is ThemeName.TOKYO_NIGHT
|
|
47
|
+
for t in ThemeName:
|
|
48
|
+
assert isinstance(t.theme, Theme)
|
|
49
|
+
assert get_args(rt.ThemeLiteral) == tuple(t.value for t in ThemeName) # autocomplete list
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize("theme", [*ThemeName, "Dracula", Theme({"syntax.keyword": "red"})])
|
|
53
|
+
def test_every_printer_with_every_theme_kind(theme):
|
|
54
|
+
assert "Printing structs" in render(rt.print, Markdown(MD), theme=theme)
|
|
55
|
+
assert "Printing structs" in render(rt.print_md, MD, theme=theme)
|
|
56
|
+
assert '"a": 1' in render(rt.print_json, {"a": 1}, theme=theme)
|
|
57
|
+
assert '"a": 1' in render(rt.print_json, '{"a": 1}', theme=theme)
|
|
58
|
+
assert "a: 1" in render(rt.print_yaml, "a: 1\n", theme=theme)
|
|
59
|
+
assert "<a>" in render(rt.print_xml, "<a><b/></a>", theme=theme)
|
|
60
|
+
assert "<b />" in render(rt.print_xml, ET.fromstring("<a><b/></a>"), theme=theme)
|
|
61
|
+
assert "Point(" in render(rt.print_object, Point(1, "y"), theme=theme)
|
|
62
|
+
assert "def" in render(rt.print_syntax, "def f(): pass", "python", theme=theme)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_plain_print_matches_builtin_semantics():
|
|
66
|
+
assert render(rt.print, "a", "b", sep="-", end="!") == "a-b!"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_unknown_theme_name_raises():
|
|
70
|
+
with pytest.raises(ValueError):
|
|
71
|
+
rt.print("x", theme="Nope")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_background_fills_full_width(monkeypatch):
|
|
75
|
+
monkeypatch.setenv("FORCE_COLOR", "1")
|
|
76
|
+
monkeypatch.setenv("COLORTERM", "truecolor")
|
|
77
|
+
monkeypatch.setenv("COLUMNS", "40")
|
|
78
|
+
bg = "48;2;26;27;38" # Tokyo Night #1a1b26
|
|
79
|
+
table = Table("col")
|
|
80
|
+
table.add_row("x")
|
|
81
|
+
for args in [("hi",), ({"a": 1},), (Markdown(MD),), (table,), ("a", Markdown("b"), 3)]:
|
|
82
|
+
out = render(rt.print, *args)
|
|
83
|
+
assert out.strip(), args
|
|
84
|
+
for line in Text.from_ansi(out).split():
|
|
85
|
+
assert line.cell_len == 40, (args, line.plain)
|
|
86
|
+
painted = sum(s.end - s.start for s in line.spans if s.style.bgcolor)
|
|
87
|
+
assert painted == len(line), (args, line.plain)
|
|
88
|
+
assert bg in out
|
|
89
|
+
assert bg not in render(rt.print, "hi", background=False)
|
|
90
|
+
assert bg not in render(rt.print, "hi", theme=Theme({"syntax.keyword": "red"}))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_tables_from_lists():
|
|
94
|
+
out = render(rt.print_table, [{"a": 1, "b": "x"}, {"a": 2.5, "c": None}])
|
|
95
|
+
assert all(word in out for word in ("a", "b", "c", "2.5", "None", "list · 2 items"))
|
|
96
|
+
assert "array · 3 items" in render(rt.print_table, array("d", [1.0, 2.0, 0.1 + 0.2]))
|
|
97
|
+
assert "0.30 " in render(rt.print_table, [0.1 + 0.2, 1.25]) # rounded, decimals aligned
|
|
98
|
+
grid = render(rt.print_table, [[1, 2], [3]])
|
|
99
|
+
assert "None" in grid # ragged rows are padded
|
|
100
|
+
assert "0 items" in render(rt.print_table, [])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_long_data_is_clipped_to_head_and_tail():
|
|
104
|
+
out = render(rt.print_table, list(range(1000)), max_rows=4)
|
|
105
|
+
labels = [line.split()[1] for line in out.splitlines() if line.startswith("│")]
|
|
106
|
+
assert labels[1:] == ["0", "1", "…", "998", "999"]
|
|
107
|
+
wide = render(rt.print_table, [list(range(50))], max_cols=4)
|
|
108
|
+
assert "…" in wide and " 49 " in wide and " 25 " not in wide
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_numpy_and_pandas_tables():
|
|
112
|
+
np = pytest.importorskip("numpy")
|
|
113
|
+
pd = pytest.importorskip("pandas")
|
|
114
|
+
assert "shape (2, 3) · dtype int64" in render(rt.print_table, np.arange(6).reshape(2, 3))
|
|
115
|
+
assert "shape (5,)" in render(rt.print_table, np.linspace(0, 1, 5))
|
|
116
|
+
assert "[1]" in render(rt.print_table, np.zeros((2, 2, 2)))
|
|
117
|
+
df = pd.DataFrame({"name": ["a", "b"], "x": [1.5, float("nan")], "ok": [True, False]})
|
|
118
|
+
out = render(rt.print_table, df.set_index("name"))
|
|
119
|
+
assert all(word in out for word in ("name", "x", "ok", "1.5", "nan", "True", "DataFrame · 2 rows × 2 columns"))
|
|
120
|
+
assert "Series · dtype float64 · 2 items" in render(rt.print_table, df["x"])
|
|
121
|
+
big = render(rt.print_table, pd.DataFrame(np.zeros((1000, 100))))
|
|
122
|
+
assert "1000 rows × 100 columns" in big and len(big.splitlines()) < 30
|
|
123
|
+
assert max(map(len, big.splitlines())) <= 80 and "0.0…" not in big # columns dropped to fit
|
|
124
|
+
# plain print() turns arrays and frames into tables too, but leaves lists alone
|
|
125
|
+
assert "2 rows × 3 columns" in render(rt.print, df)
|
|
126
|
+
assert "shape (2,)" in render(rt.print, np.array([1, 2]))
|
|
127
|
+
assert render(rt.print, [1, 2]) == "[1, 2]\n"
|