Skip to content

Commit 8fe170a

Browse files
authored
Merge branch 'master' into ansi-to-win32
2 parents 9b76da2 + 27c2ba6 commit 8fe170a

File tree

10 files changed

+258
-14
lines changed

10 files changed

+258
-14
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
# Changelog
23

34
All notable changes to this project will be documented in this file.
@@ -15,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1516
but displays values as ints, does not convert to floats or add bit/bytes units).
1617
https://github.com/Textualize/rich/pull/1941
1718
- Remove Colorama dependency, call Windows Console API from Rich https://github.com/Textualize/rich/pull/1993
19+
- Add support for namedtuples to `Pretty` https://github.com/Textualize/rich/pull/2031
1820

1921
### Fixed
2022

@@ -23,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2325
- Fix syntax lexer guessing.
2426
- Fixed Pretty measure not respecting expand_all https://github.com/Textualize/rich/issues/1998
2527
- Collapsed definitions for single-character spinners, to save memory and reduce import time.
28+
- Fix print_json indent type in __init__.py
29+
- Fix error when inspecting object defined in REPL https://github.com/Textualize/rich/pull/2037
30+
- Fix incorrect highlighting of non-indented JSON https://github.com/Textualize/rich/pull/2038
2631

2732
### Changed
2833

CONTRIBUTORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ The following people have contributed to the development of Rich:
3030
- [Clément Robert](https://github.com/neutrinoceros)
3131
- [Brian Rutledge](https://github.com/bhrutledge)
3232
- [Tushar Sadhwani](https://github.com/tusharsadhwani)
33+
- [Paul Sanders](https://github.com/sanders41)
3334
- [Tim Savage](https://github.com/timsavage)
3435
- [Nicolas Simonds](https://github.com/0xDEC0DE)
3536
- [Aaron Stephens](https://github.com/aaronst)

rich/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Rich text and beautiful formatting in the terminal."""
22

33
import os
4-
from typing import Callable, IO, TYPE_CHECKING, Any, Optional
4+
from typing import Callable, IO, TYPE_CHECKING, Any, Optional, Union
55

66
from ._extension import load_ipython_extension
77

@@ -73,7 +73,7 @@ def print_json(
7373
json: Optional[str] = None,
7474
*,
7575
data: Any = None,
76-
indent: int = 2,
76+
indent: Union[None, int, str] = 2,
7777
highlight: bool = True,
7878
skip_keys: bool = False,
7979
ensure_ascii: bool = True,

rich/_inspect.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ def _get_signature(self, name: str, obj: Any) -> Optional[Text]:
9898
source_filename: Optional[str] = None
9999
try:
100100
source_filename = getfile(obj)
101-
except TypeError:
101+
except (OSError, TypeError):
102+
# OSError is raised if obj has no source file, e.g. when defined in REPL.
102103
pass
103104

104105
callable_name = Text(name, style="inspect.callable")

rich/console.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2222,3 +2222,5 @@ def save_html(
22222222
}
22232223
)
22242224
console.log("foo")
2225+
2226+
console.print_json(data={"name": "apple", "count": 1}, indent=None)

rich/highlighter.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import re
2+
import string
13
from abc import ABC, abstractmethod
24
from typing import List, Union
35

4-
from .text import Text
6+
from .text import Span, Text
57

68

79
def _combine_regex(*regexes: str) -> str:
@@ -104,17 +106,39 @@ class ReprHighlighter(RegexHighlighter):
104106
class JSONHighlighter(RegexHighlighter):
105107
"""Highlights JSON"""
106108

109+
# Captures the start and end of JSON strings, handling escaped quotes
110+
JSON_STR = r"(?<![\\\w])(?P<str>b?\".*?(?<!\\)\")"
111+
JSON_WHITESPACE = {" ", "\n", "\r", "\t"}
112+
107113
base_style = "json."
108114
highlights = [
109115
_combine_regex(
110116
r"(?P<brace>[\{\[\(\)\]\}])",
111117
r"\b(?P<bool_true>true)\b|\b(?P<bool_false>false)\b|\b(?P<null>null)\b",
112118
r"(?P<number>(?<!\w)\-?[0-9]+\.?[0-9]*(e[\-\+]?\d+?)?\b|0x[0-9a-fA-F]*)",
113-
r"(?<![\\\w])(?P<str>b?\".*?(?<!\\)\")",
119+
JSON_STR,
114120
),
115-
r"(?<![\\\w])(?P<key>b?\".*?(?<!\\)\")\:",
116121
]
117122

123+
def highlight(self, text: Text) -> None:
124+
super().highlight(text)
125+
126+
# Additional work to handle highlighting JSON keys
127+
plain = text.plain
128+
append = text.spans.append
129+
whitespace = self.JSON_WHITESPACE
130+
for match in re.finditer(self.JSON_STR, plain):
131+
start, end = match.span()
132+
cursor = end
133+
while cursor < len(plain):
134+
char = plain[cursor]
135+
cursor += 1
136+
if char == ":":
137+
append(Span(start, end, "json.key"))
138+
elif char in whitespace:
139+
continue
140+
break
141+
118142

119143
if __name__ == "__main__": # pragma: no cover
120144
from .console import Console
@@ -145,3 +169,6 @@ class JSONHighlighter(RegexHighlighter):
145169
console.print(
146170
"127.0.1.1 bar 192.168.1.4 2001:0db8:85a3:0000:0000:8a2e:0370:7334 foo"
147171
)
172+
import json
173+
174+
console.print_json(json.dumps(obj={"name": "apple", "count": 1}), indent=None)

rich/pretty.py

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import builtins
2+
import collections
23
import dataclasses
34
import inspect
45
import os
@@ -30,7 +31,6 @@
3031
except ImportError: # pragma: no cover
3132
_attr_module = None # type: ignore
3233

33-
3434
from . import get_console
3535
from ._loop import loop_last
3636
from ._pick import pick_bool
@@ -79,6 +79,29 @@ def _is_dataclass_repr(obj: object) -> bool:
7979
return False
8080

8181

82+
_dummy_namedtuple = collections.namedtuple("_dummy_namedtuple", [])
83+
84+
85+
def _has_default_namedtuple_repr(obj: object) -> bool:
86+
"""Check if an instance of namedtuple contains the default repr
87+
88+
Args:
89+
obj (object): A namedtuple
90+
91+
Returns:
92+
bool: True if the default repr is used, False if there's a custom repr.
93+
"""
94+
obj_file = None
95+
try:
96+
obj_file = inspect.getfile(obj.__repr__)
97+
except (OSError, TypeError):
98+
# OSError handles case where object is defined in __main__ scope, e.g. REPL - no filename available.
99+
# TypeError trapped defensively, in case of object without filename slips through.
100+
pass
101+
default_repr_file = inspect.getfile(_dummy_namedtuple.__repr__)
102+
return obj_file == default_repr_file
103+
104+
82105
def _ipy_display_hook(
83106
value: Any,
84107
console: Optional["Console"] = None,
@@ -383,6 +406,7 @@ class Node:
383406
empty: str = ""
384407
last: bool = False
385408
is_tuple: bool = False
409+
is_namedtuple: bool = False
386410
children: Optional[List["Node"]] = None
387411
key_separator = ": "
388412
separator: str = ", "
@@ -397,7 +421,7 @@ def iter_tokens(self) -> Iterable[str]:
397421
elif self.children is not None:
398422
if self.children:
399423
yield self.open_brace
400-
if self.is_tuple and len(self.children) == 1:
424+
if self.is_tuple and not self.is_namedtuple and len(self.children) == 1:
401425
yield from self.children[0].iter_tokens()
402426
yield ","
403427
else:
@@ -524,6 +548,25 @@ def __str__(self) -> str:
524548
)
525549

526550

551+
def _is_namedtuple(obj: Any) -> bool:
552+
"""Checks if an object is most likely a namedtuple. It is possible
553+
to craft an object that passes this check and isn't a namedtuple, but
554+
there is only a minuscule chance of this happening unintentionally.
555+
556+
Args:
557+
obj (Any): The object to test
558+
559+
Returns:
560+
bool: True if the object is a namedtuple. False otherwise.
561+
"""
562+
try:
563+
fields = getattr(obj, "_fields", None)
564+
except Exception:
565+
# Being very defensive - if we cannot get the attr then its not a namedtuple
566+
return False
567+
return isinstance(obj, tuple) and isinstance(fields, tuple)
568+
569+
527570
def traverse(
528571
_object: Any,
529572
max_length: Optional[int] = None,
@@ -731,7 +774,25 @@ def iter_attrs() -> Iterable[
731774
append(child_node)
732775

733776
pop_visited(obj_id)
734-
777+
elif _is_namedtuple(obj) and _has_default_namedtuple_repr(obj):
778+
if reached_max_depth:
779+
node = Node(value_repr="...")
780+
else:
781+
children = []
782+
class_name = obj.__class__.__name__
783+
node = Node(
784+
open_brace=f"{class_name}(",
785+
close_brace=")",
786+
children=children,
787+
empty=f"{class_name}()",
788+
)
789+
append = children.append
790+
for last, (key, value) in loop_last(obj._asdict().items()):
791+
child_node = _traverse(value, depth=depth + 1)
792+
child_node.key_repr = key
793+
child_node.last = last
794+
child_node.key_separator = "="
795+
append(child_node)
735796
elif _safe_isinstance(obj, _CONTAINERS):
736797
for container_type in _CONTAINERS:
737798
if _safe_isinstance(obj, container_type):
@@ -780,14 +841,15 @@ def iter_attrs() -> Iterable[
780841
child_node.last = index == last_item_index
781842
append(child_node)
782843
if max_length is not None and num_items > max_length:
783-
append(Node(value_repr=f"... +{num_items-max_length}", last=True))
844+
append(Node(value_repr=f"... +{num_items - max_length}", last=True))
784845
else:
785846
node = Node(empty=empty, children=[], last=root)
786847

787848
pop_visited(obj_id)
788849
else:
789850
node = Node(value_repr=to_repr(obj), last=root)
790851
node.is_tuple = _safe_isinstance(obj, tuple)
852+
node.is_namedtuple = _is_namedtuple(obj)
791853
return node
792854

793855
node = _traverse(_object, root=True)
@@ -878,6 +940,15 @@ def __repr__(self) -> str:
878940
1 / 0
879941
return "this will fail"
880942

943+
from typing import NamedTuple
944+
945+
class StockKeepingUnit(NamedTuple):
946+
name: str
947+
description: str
948+
price: float
949+
category: str
950+
reviews: List[str]
951+
881952
d = defaultdict(int)
882953
d["foo"] = 5
883954
data = {
@@ -904,6 +975,13 @@ def __repr__(self) -> str:
904975
]
905976
),
906977
"atomic": (False, True, None),
978+
"namedtuple": StockKeepingUnit(
979+
"Sparkling British Spring Water",
980+
"Carbonated spring water",
981+
0.9,
982+
"water",
983+
["its amazing!", "its terrible!"],
984+
),
907985
"Broken": BrokenRepr(),
908986
}
909987
data["foo"].append(data) # type: ignore

tests/test_console.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
Console,
1515
ConsoleDimensions,
1616
ConsoleOptions,
17-
group,
1817
ScreenUpdate,
18+
group,
1919
)
2020
from rich.control import Control
2121
from rich.measure import measure_renderables
@@ -185,6 +185,15 @@ def test_print_json_ensure_ascii():
185185
assert result == expected
186186

187187

188+
def test_print_json_indent_none():
189+
console = Console(file=io.StringIO(), color_system="truecolor")
190+
data = {"name": "apple", "count": 1}
191+
console.print_json(data=data, indent=None)
192+
result = console.file.getvalue()
193+
expected = '\x1b[1m{\x1b[0m\x1b[1;34m"name"\x1b[0m: \x1b[32m"apple"\x1b[0m, \x1b[1;34m"count"\x1b[0m: \x1b[1;36m1\x1b[0m\x1b[1m}\x1b[0m\n'
194+
assert result == expected
195+
196+
188197
def test_log():
189198
console = Console(
190199
file=io.StringIO(),

tests/test_highlighter.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Tests for the highlighter classes."""
2-
import pytest
2+
import json
33
from typing import List
44

5-
from rich.highlighter import NullHighlighter, ReprHighlighter
5+
import pytest
6+
7+
from rich.highlighter import JSONHighlighter, NullHighlighter, ReprHighlighter
68
from rich.text import Span, Text
79

810

@@ -92,3 +94,53 @@ def test_highlight_regex(test: str, spans: List[Span]):
9294
highlighter.highlight(text)
9395
print(text.spans)
9496
assert text.spans == spans
97+
98+
99+
def test_highlight_json_with_indent():
100+
json_string = json.dumps({"name": "apple", "count": 1}, indent=4)
101+
text = Text(json_string)
102+
highlighter = JSONHighlighter()
103+
highlighter.highlight(text)
104+
assert text.spans == [
105+
Span(0, 1, "json.brace"),
106+
Span(6, 12, "json.str"),
107+
Span(14, 21, "json.str"),
108+
Span(27, 34, "json.str"),
109+
Span(36, 37, "json.number"),
110+
Span(38, 39, "json.brace"),
111+
Span(6, 12, "json.key"),
112+
Span(27, 34, "json.key"),
113+
]
114+
115+
116+
def test_highlight_json_string_only():
117+
json_string = '"abc"'
118+
text = Text(json_string)
119+
highlighter = JSONHighlighter()
120+
highlighter.highlight(text)
121+
assert text.spans == [Span(0, 5, "json.str")]
122+
123+
124+
def test_highlight_json_empty_string_only():
125+
json_string = '""'
126+
text = Text(json_string)
127+
highlighter = JSONHighlighter()
128+
highlighter.highlight(text)
129+
assert text.spans == [Span(0, 2, "json.str")]
130+
131+
132+
def test_highlight_json_no_indent():
133+
json_string = json.dumps({"name": "apple", "count": 1}, indent=None)
134+
text = Text(json_string)
135+
highlighter = JSONHighlighter()
136+
highlighter.highlight(text)
137+
assert text.spans == [
138+
Span(0, 1, "json.brace"),
139+
Span(1, 7, "json.str"),
140+
Span(9, 16, "json.str"),
141+
Span(18, 25, "json.str"),
142+
Span(27, 28, "json.number"),
143+
Span(28, 29, "json.brace"),
144+
Span(1, 7, "json.key"),
145+
Span(18, 25, "json.key"),
146+
]

0 commit comments

Comments
 (0)