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.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ .pytest_cache/
3
+ dist/
4
+ *.egg-info/
5
+ .ipynb_checkpoints/
@@ -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"