1- import io
2- import os
1+ import sys
2+ from collections import deque
33from collections .abc import Sequence
4+ from html import escape as html_escape
45from typing import Any
56
67from zarr .core .group import AsyncGroup
78
8- try :
9- import rich
10- import rich .console
11- import rich .tree
12- except ImportError as e :
13- raise ImportError ("'rich' is required for Group.tree" ) from e
14-
159
1610class TreeRepr :
1711 """
@@ -21,45 +15,120 @@ class TreeRepr:
2115 of Zarr's public API.
2216 """
2317
24- def __init__ (self , tree : rich .tree .Tree ) -> None :
25- self ._tree = tree
18+ def __init__ (self , text : str , html : str , truncated : str = "" ) -> None :
19+ self ._text = text
20+ self ._html = html
21+ self ._truncated = truncated
2622
2723 def __repr__ (self ) -> str :
28- color_system = os .environ .get ("OVERRIDE_COLOR_SYSTEM" , rich .get_console ().color_system )
29- console = rich .console .Console (file = io .StringIO (), color_system = color_system )
30- console .print (self ._tree )
31- return str (console .file .getvalue ())
24+ if self ._truncated :
25+ return self ._truncated + self ._text
26+ return self ._text
3227
3328 def _repr_mimebundle_ (
3429 self ,
35- include : Sequence [str ],
36- exclude : Sequence [str ],
30+ include : Sequence [str ] | None = None ,
31+ exclude : Sequence [str ] | None = None ,
3732 ** kwargs : Any ,
3833 ) -> dict [str , str ]:
34+ text = self ._truncated + self ._text if self ._truncated else self ._text
3935 # For jupyter support.
40- # Unsure why mypy infers the return type to by Any
41- return self ._tree ._repr_mimebundle_ (include = include , exclude = exclude , ** kwargs ) # type: ignore[no-any-return]
36+ html_body = self ._truncated + self ._html if self ._truncated else self ._html
37+ html = (
38+ '<pre style="white-space:pre;overflow-x:auto;line-height:normal;'
39+ "font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\" >"
40+ f"{ html_body } </pre>\n "
41+ )
42+ return {"text/plain" : text , "text/html" : html }
43+
4244
45+ async def group_tree_async (
46+ group : AsyncGroup ,
47+ max_depth : int | None = None ,
48+ * ,
49+ max_nodes : int = 500 ,
50+ plain : bool = False ,
51+ ) -> TreeRepr :
52+ members : list [tuple [str , Any ]] = []
53+ truncated = False
54+ async for item in group .members (max_depth = max_depth ):
55+ if len (members ) == max_nodes :
56+ truncated = True
57+ break
58+ members .append (item )
59+ members .sort (key = lambda key_node : key_node [0 ])
4360
44- async def group_tree_async (group : AsyncGroup , max_depth : int | None = None ) -> TreeRepr :
45- tree = rich .tree .Tree (label = f"[bold]{ group .name } [/bold]" )
46- nodes = {"" : tree }
47- members = sorted ([x async for x in group .members (max_depth = max_depth )])
61+ # Set up styling tokens: ANSI bold for terminals, HTML <b> for Jupyter,
62+ # or empty strings when plain=True (useful for LLMs, logging, files).
63+ if plain :
64+ ansi_open = ansi_close = html_open = html_close = ""
65+ else :
66+ # Avoid emitting ANSI escape codes when output is piped or in CI.
67+ use_ansi = sys .stdout .isatty ()
68+ ansi_open = "\x1b [1m" if use_ansi else ""
69+ ansi_close = "\x1b [0m" if use_ansi else ""
70+ html_open = "<b>"
71+ html_close = "</b>"
4872
73+ # Group members by parent key so we can render the tree level by level.
74+ nodes : dict [str , list [tuple [str , Any ]]] = {}
4975 for key , node in members :
5076 if key .count ("/" ) == 0 :
5177 parent_key = ""
5278 else :
5379 parent_key = key .rsplit ("/" , 1 )[0 ]
54- parent = nodes [ parent_key ]
80+ nodes . setdefault ( parent_key , []). append (( key , node ))
5581
56- # We want what the spec calls the node "name", the part excluding all leading
57- # /'s and path segments. But node.name includes all that, so we build it here.
82+ # Render the tree iteratively (not recursively) to avoid hitting
83+ # Python's recursion limit on deeply nested hierarchies.
84+ # Each stack frame is (prefix_string, remaining_children_at_this_level).
85+ text_lines = [f"{ ansi_open } { group .name } { ansi_close } " ]
86+ html_lines = [f"{ html_open } { html_escape (group .name )} { html_close } " ]
87+ stack = [("" , deque (nodes .get ("" , [])))]
88+ while stack :
89+ prefix , remaining = stack [- 1 ]
90+ if not remaining :
91+ stack .pop ()
92+ continue
93+ key , node = remaining .popleft ()
5894 name = key .rsplit ("/" )[- 1 ]
95+ escaped_name = html_escape (name )
96+ # if we popped the last item then remaining will
97+ # now be empty - that's how we got past the if not remaining
98+ # above, but this can still be true.
99+ is_last = not remaining
100+ connector = "└── " if is_last else "├── "
59101 if isinstance (node , AsyncGroup ):
60- label = f"[bold]{ name } [/bold]"
102+ text_lines .append (f"{ prefix } { connector } { ansi_open } { name } { ansi_close } " )
103+ html_lines .append (f"{ prefix } { connector } { html_open } { escaped_name } { html_close } " )
61104 else :
62- label = f"[bold]{ name } [/bold] { node .shape } { node .dtype } "
63- nodes [key ] = parent .add (label )
64-
65- return TreeRepr (tree )
105+ text_lines .append (
106+ f"{ prefix } { connector } { ansi_open } { name } { ansi_close } { node .shape } { node .dtype } "
107+ )
108+ html_lines .append (
109+ f"{ prefix } { connector } { html_open } { escaped_name } { html_close } "
110+ f" { html_escape (str (node .shape ))} { html_escape (str (node .dtype ))} "
111+ )
112+ # Descend into children with an accumulated prefix:
113+ # Example showing how prefix accumulates:
114+ # /
115+ # ├── a prefix = ""
116+ # │ ├── b prefix = "" + "│ "
117+ # │ │ └── x prefix = "" + "│ " + "│ "
118+ # │ └── c prefix = "" + "│ "
119+ # └── d prefix = ""
120+ # └── e prefix = "" + " "
121+ if children := nodes .get (key , []):
122+ if is_last :
123+ child_prefix = prefix + " "
124+ else :
125+ child_prefix = prefix + "│ "
126+ stack .append ((child_prefix , deque (children )))
127+ text = "\n " .join (text_lines ) + "\n "
128+ html = "\n " .join (html_lines ) + "\n "
129+ note = (
130+ f"Truncated at max_nodes={ max_nodes } , some nodes and their children may be missing\n "
131+ if truncated
132+ else ""
133+ )
134+ return TreeRepr (text , html , truncated = note )
0 commit comments