pyadata-cli 0.5.0__py3-none-any.whl

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,8 @@
1
+ from adata.commands.info import show_info
2
+ from adata.commands.subset import subset_h5ad
3
+ from adata.commands.export import export_table, export_image, export_json, export_mtx, export_npy
4
+ from adata.commands.import_data import import_object
5
+ from adata.commands.ls import list_store
6
+ from adata.commands.create import create_store
7
+ from adata.commands.split import split_store
8
+ from adata.commands.concat import MERGE_STRATEGIES, concat_stores
@@ -0,0 +1,21 @@
1
+ """Command wrapper for `concat`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional, Sequence
7
+
8
+ from rich.console import Console
9
+
10
+ from adata.core.concat import MERGE_STRATEGIES, concat_on_disk
11
+
12
+ __all__ = ["MERGE_STRATEGIES", "concat_stores"]
13
+
14
+
15
+ def concat_stores(
16
+ files: Sequence[Path],
17
+ output: Path,
18
+ console: Console,
19
+ **kwargs: object,
20
+ ) -> None:
21
+ concat_on_disk(files, output, console, **kwargs) # type: ignore[arg-type]
@@ -0,0 +1,93 @@
1
+ """`create` -- write a new, empty AnnData store.
2
+
3
+ Gives `import` something to write into. The result is a complete AnnData
4
+ object in its own right: root attributes, obs and var dataframes with real
5
+ indices, and the optional mapping groups, so anndata can open it before a
6
+ single matrix has been attached.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+ from rich.console import Console
15
+
16
+ from adata.elements.write import ensure_anndata_skeleton, write_dataframe_header
17
+ from adata.storage import open_store
18
+
19
+
20
+ def _read_names(path: Path) -> List[str]:
21
+ """Read a newline-delimited name list, ignoring blank lines."""
22
+ names = [
23
+ line.strip()
24
+ for line in path.read_text(encoding="utf-8").splitlines()
25
+ if line.strip()
26
+ ]
27
+ if not names:
28
+ raise ValueError(f"'{path}' contains no names.")
29
+ if len(set(names)) != len(names):
30
+ raise ValueError(f"'{path}' contains duplicate names.")
31
+ return names
32
+
33
+
34
+ def _resolve_axis(
35
+ axis: str, n: Optional[int], names_file: Optional[Path]
36
+ ) -> List[str]:
37
+ """Determine an axis's index, from an explicit list or a generated range."""
38
+ if names_file is not None:
39
+ names = _read_names(names_file)
40
+ if n is not None and n != len(names):
41
+ raise ValueError(
42
+ f"--n-{axis} is {n} but '{names_file}' has {len(names)} names."
43
+ )
44
+ return names
45
+
46
+ if n is None:
47
+ raise ValueError(f"Provide either --n-{axis} or --{axis}-names.")
48
+ if n < 0:
49
+ raise ValueError(f"--n-{axis} must not be negative.")
50
+
51
+ width = len(str(max(n - 1, 0)))
52
+ prefix = "cell" if axis == "obs" else "gene"
53
+ return [f"{prefix}_{i:0{width}d}" for i in range(n)]
54
+
55
+
56
+ def create_store(
57
+ output: Path,
58
+ console: Console,
59
+ n_obs: Optional[int] = None,
60
+ n_var: Optional[int] = None,
61
+ obs_names: Optional[Path] = None,
62
+ var_names: Optional[Path] = None,
63
+ zarr_format: Optional[int] = None,
64
+ force: bool = False,
65
+ ) -> None:
66
+ """Create an empty AnnData store at `output`.
67
+
68
+ Names come from `obs_names`/`var_names` when given, otherwise they are
69
+ generated as `cell_0000`-style labels wide enough for the axis length.
70
+ """
71
+ if output.exists() and not force:
72
+ raise FileExistsError(
73
+ f"'{output}' already exists. Pass --force to overwrite it."
74
+ )
75
+
76
+ obs_index = _resolve_axis("obs", n_obs, obs_names)
77
+ var_index = _resolve_axis("var", n_var, var_names)
78
+
79
+ if output.exists() and force:
80
+ import shutil
81
+
82
+ shutil.rmtree(output) if output.is_dir() else output.unlink()
83
+
84
+ with open_store(output, "w", zarr_format=zarr_format) as store:
85
+ root = store.root
86
+ write_dataframe_header(root, "obs", obs_index, [])
87
+ write_dataframe_header(root, "var", var_index, [])
88
+ ensure_anndata_skeleton(root)
89
+
90
+ console.print(
91
+ f"[green]Created[/] {output} "
92
+ f"({len(obs_index)} obs x {len(var_index)} var, empty)"
93
+ )
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List, Optional
5
+
6
+ from rich.console import Console
7
+
8
+ from adata.formats.array import export_npy as export_npy_format
9
+ from adata.formats.dataframe import export_dataframe
10
+ from adata.formats.image import export_image as export_image_format
11
+ from adata.formats.json_data import export_json as export_json_format
12
+ from adata.formats.sparse import export_mtx as export_mtx_format
13
+ from adata.storage import open_store
14
+
15
+
16
+ def export_table(
17
+ file: Path,
18
+ axis: str,
19
+ columns: Optional[List[str]],
20
+ out: Optional[Path],
21
+ chunk_rows: int,
22
+ head: Optional[int],
23
+ console: Console,
24
+ ) -> None:
25
+ with open_store(file, "r") as store:
26
+ export_dataframe(
27
+ store.root,
28
+ axis=axis,
29
+ columns=columns,
30
+ out=out,
31
+ chunk_rows=chunk_rows,
32
+ head=head,
33
+ console=console,
34
+ )
35
+
36
+
37
+ def export_npy(
38
+ file: Path,
39
+ obj: str,
40
+ out: Optional[Path],
41
+ chunk_elements: int,
42
+ console: Console,
43
+ ) -> None:
44
+ with open_store(file, "r") as store:
45
+ export_npy_format(
46
+ store.root,
47
+ obj=obj,
48
+ out=out,
49
+ chunk_elements=chunk_elements,
50
+ console=console,
51
+ )
52
+
53
+
54
+ def export_mtx(
55
+ file: Path,
56
+ obj: str,
57
+ out: Optional[Path],
58
+ head: Optional[int],
59
+ chunk_elements: int,
60
+ in_memory: bool,
61
+ console: Console,
62
+ ) -> None:
63
+ with open_store(file, "r") as store:
64
+ export_mtx_format(
65
+ store.root,
66
+ obj=obj,
67
+ out=out,
68
+ head=head,
69
+ chunk_elements=chunk_elements,
70
+ in_memory=in_memory,
71
+ console=console,
72
+ )
73
+
74
+
75
+ def export_json(
76
+ file: Path,
77
+ obj: str,
78
+ out: Optional[Path],
79
+ max_elements: int,
80
+ include_attrs: bool,
81
+ console: Console,
82
+ ) -> None:
83
+ with open_store(file, "r") as store:
84
+ export_json_format(
85
+ store.root,
86
+ obj=obj,
87
+ out=out,
88
+ max_elements=max_elements,
89
+ include_attrs=include_attrs,
90
+ console=console,
91
+ )
92
+
93
+
94
+ def export_image(file: Path, obj: str, out: Path, console: Console) -> None:
95
+ with open_store(file, "r") as store:
96
+ export_image_format(store.root, obj=obj, out=out, console=console)
97
+
98
+
99
+ __all__ = [
100
+ "export_image",
101
+ "export_json",
102
+ "export_mtx",
103
+ "export_npy",
104
+ "export_table",
105
+ ]
@@ -0,0 +1,144 @@
1
+ """Import command helpers for creating/replacing objects in h5ad/zarr stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from rich.console import Console
9
+
10
+ from adata.formats.array import import_npy
11
+ from adata.formats.dataframe import import_dataframe
12
+ from adata.formats.image import import_image
13
+ from adata.formats.json_data import import_json
14
+ from adata.formats.sparse import import_mtx
15
+ from adata.storage import copy_path, copy_store_contents, detect_backend, open_store
16
+
17
+
18
+ EXTENSION_FORMAT = {
19
+ ".csv": "csv",
20
+ ".npy": "npy",
21
+ ".mtx": "mtx",
22
+ ".json": "json",
23
+ }
24
+
25
+
26
+ def _prepare_target_path(
27
+ file: Path,
28
+ output_file: Optional[Path],
29
+ inplace: bool,
30
+ console: Console,
31
+ ) -> Path:
32
+ if inplace:
33
+ return file
34
+ if output_file is None:
35
+ raise ValueError("Output file is required unless --inplace is specified.")
36
+
37
+ src_backend = detect_backend(file)
38
+ dst_backend = detect_backend(output_file)
39
+
40
+ if src_backend == dst_backend:
41
+ copy_path(file, output_file)
42
+ console.print(f"[dim]Copied {file} → {output_file}[/]")
43
+ return output_file
44
+
45
+ with open_store(file, "r") as src_store, open_store(output_file, "w") as dst_store:
46
+ copy_store_contents(src_store.root, dst_store.root)
47
+ console.print(
48
+ f"[dim]Converted {file} ({src_backend}) → {output_file} ({dst_backend})[/]"
49
+ )
50
+ return output_file
51
+
52
+
53
+ def import_object(
54
+ file: Path,
55
+ obj: str,
56
+ input_file: Path,
57
+ output_file: Optional[Path],
58
+ inplace: bool,
59
+ index_column: Optional[str],
60
+ console: Console,
61
+ ) -> None:
62
+ target_file = _prepare_target_path(file, output_file, inplace, console)
63
+ ext = input_file.suffix.lower()
64
+
65
+ if ext not in EXTENSION_FORMAT:
66
+ raise ValueError(
67
+ f"Unsupported input file extension '{ext}'. "
68
+ f"Supported: {', '.join(sorted(EXTENSION_FORMAT.keys()))}"
69
+ )
70
+
71
+ fmt = EXTENSION_FORMAT[ext]
72
+
73
+ if index_column and (fmt != "csv" or obj not in ("obs", "var")):
74
+ raise ValueError("--index-column is only valid for CSV import into 'obs' or 'var'.")
75
+
76
+ if fmt == "csv":
77
+ _import_csv(target_file, obj, input_file, index_column, console)
78
+ elif fmt == "npy":
79
+ _import_npy(target_file, obj, input_file, console)
80
+ elif fmt == "mtx":
81
+ _import_mtx(target_file, obj, input_file, console)
82
+ elif fmt == "json":
83
+ _import_json(target_file, obj, input_file, console)
84
+
85
+
86
+ def _import_csv(
87
+ file: Path,
88
+ obj: str,
89
+ input_file: Path,
90
+ index_column: Optional[str],
91
+ console: Console,
92
+ categorical: Optional[list] = None,
93
+ auto_categorical: bool = True,
94
+ ) -> None:
95
+ with open_store(file, "a") as store:
96
+ import_dataframe(
97
+ store.root,
98
+ obj=obj,
99
+ input_file=input_file,
100
+ index_column=index_column,
101
+ console=console,
102
+ categorical=categorical,
103
+ auto_categorical=auto_categorical,
104
+ )
105
+
106
+
107
+ def _import_npy(
108
+ file: Path,
109
+ obj: str,
110
+ input_file: Path,
111
+ console: Console,
112
+ ) -> None:
113
+ with open_store(file, "a") as store:
114
+ import_npy(store.root, obj=obj, input_file=input_file, console=console)
115
+
116
+
117
+ def _import_mtx(
118
+ file: Path,
119
+ obj: str,
120
+ input_file: Path,
121
+ console: Console,
122
+ ) -> None:
123
+ with open_store(file, "a") as store:
124
+ import_mtx(store.root, obj=obj, input_file=input_file, console=console)
125
+
126
+
127
+ def _import_image(
128
+ file: Path,
129
+ obj: str,
130
+ input_file: Path,
131
+ console: Console,
132
+ ) -> None:
133
+ with open_store(file, "a") as store:
134
+ import_image(store.root, obj=obj, input_file=input_file, console=console)
135
+
136
+
137
+ def _import_json(
138
+ file: Path,
139
+ obj: str,
140
+ input_file: Path,
141
+ console: Console,
142
+ ) -> None:
143
+ with open_store(file, "a") as store:
144
+ import_json(store.root, obj=obj, input_file=input_file, console=console)
adata/commands/info.py ADDED
@@ -0,0 +1,199 @@
1
+ from pathlib import Path
2
+ from typing import Any, Optional
3
+
4
+ from rich.console import Console
5
+ from rich.tree import Tree
6
+
7
+ from adata.core.info import axis_len, format_type_info, get_entry_type
8
+ from adata.storage import is_dataset, is_group, open_store
9
+
10
+ # Preferred display order for top-level keys
11
+ KEY_ORDER = ["X", "obs", "var", "obsm", "varm", "layers", "obsp", "varp", "uns"]
12
+
13
+
14
+ def _sort_keys(keys: list) -> list:
15
+ """Sort keys according to KEY_ORDER, with unknown keys at the end."""
16
+ order_map = {k: i for i, k in enumerate(KEY_ORDER)}
17
+ return sorted(keys, key=lambda k: (order_map.get(k, len(KEY_ORDER)), k))
18
+
19
+
20
+ def show_info(
21
+ file: Path,
22
+ console: Console,
23
+ show_types: bool = False,
24
+ depth: Optional[int] = None,
25
+ entry_path: Optional[str] = None,
26
+ out_console: Optional[Console] = None,
27
+ ) -> None:
28
+ """
29
+ Show high-level information about the .h5ad file.
30
+ Args:
31
+ file (Path): Path to the .h5ad file
32
+ console (Console): Rich console for output
33
+ show_types (bool): Show detailed type information for each entry
34
+ depth (Optional[int]): Maximum recursion depth for type display (only with show_types=True)
35
+ entry_path (Optional[str]): Specific entry path to inspect (e.g., 'obsm/X_pca')
36
+ out_console (Optional[Console]): Where results go. Defaults to stdout so
37
+ that output pipes cleanly; `console` keeps status and errors.
38
+ """
39
+ out = out_console if out_console is not None else Console()
40
+ with open_store(file, "r") as store:
41
+ f = store.root
42
+ # If a specific path is requested, show detailed info for that object
43
+ if entry_path:
44
+ _show_object_info(f, entry_path, out)
45
+ return
46
+
47
+ # Get n_obs and n_var
48
+ n_obs = axis_len(f, "obs")
49
+ n_var = axis_len(f, "var")
50
+ out.print(
51
+ "[bold cyan]An object with n_obs × n_var: "
52
+ f"{n_obs if n_obs is not None else '?'} × "
53
+ f"{n_var if n_var is not None else '?'}[/]"
54
+ )
55
+
56
+ if show_types:
57
+ _show_types_tree(f, out, root_label=str(file), depth=depth)
58
+ else:
59
+ # List top-level keys and their sub-keys (original behavior)
60
+ for key in _sort_keys(list(f.keys())):
61
+ obj = f[key]
62
+ # Only process Groups, skip Datasets like X
63
+ if is_group(obj):
64
+ sub_keys = [
65
+ k
66
+ for k in obj.keys()
67
+ if k not in ("_index", "__categories", "obs_names", "var_names")
68
+ ]
69
+ if sub_keys and key != "X":
70
+ out.print(
71
+ f"\t[bold yellow]{key}:[/]\t"
72
+ + ", ".join(f"[bright_white]{sub}[/]" for sub in sub_keys)
73
+ )
74
+
75
+
76
+ def _show_types_tree(
77
+ f: Any, console: Console, root_label: str, depth: Optional[int] = None
78
+ ) -> None:
79
+ """Show a tree view with type information for all entries.
80
+
81
+ Recursion depth by group:
82
+ - obs/var: top level only (no children)
83
+ - X: top level only
84
+ - obsm/obsp/varm/varp/layers: 1 level (show matrices)
85
+ - uns: 2 levels deep
86
+ """
87
+ tree = Tree(f"[bold]{root_label}[/]")
88
+
89
+ # Define max depth for each top-level group
90
+ max_depth_map = {
91
+ "obs": 0,
92
+ "var": 0,
93
+ "X": 0,
94
+ "obsm": 1,
95
+ "obsp": 1,
96
+ "varm": 1,
97
+ "varp": 1,
98
+ "layers": 1,
99
+ "uns": 2,
100
+ }
101
+
102
+ def add_node(
103
+ parent_tree: Tree,
104
+ name: str,
105
+ obj: Any,
106
+ current_depth: int,
107
+ max_depth: int,
108
+ ) -> None:
109
+ info = get_entry_type(obj)
110
+ type_str = format_type_info(info)
111
+
112
+ if is_dataset(obj):
113
+ shape_str = f"[dim]{obj.shape}[/]" if obj.shape else ""
114
+ node_text = f"[bright_white]{name}[/] {shape_str} {type_str}"
115
+ parent_tree.add(node_text)
116
+ else:
117
+ # Group
118
+ node_text = f"[bold yellow]{name}/[/] {type_str}"
119
+ subtree = parent_tree.add(node_text)
120
+
121
+ # Recurse only if within allowed depth
122
+ if current_depth < max_depth:
123
+ for child_name in sorted(obj.keys()):
124
+ if child_name in ("_index", "__categories"):
125
+ continue
126
+ child_obj = obj[child_name]
127
+ add_node(
128
+ subtree, child_name, child_obj, current_depth + 1, max_depth
129
+ )
130
+
131
+ # Add top-level items in preferred order
132
+ for key in _sort_keys(list(f.keys())):
133
+ obj = f[key]
134
+ # Skip empty groups
135
+ if is_group(obj):
136
+ children = [
137
+ k
138
+ for k in obj.keys()
139
+ if k not in ("_index", "__categories", "obs_names", "var_names")
140
+ ]
141
+ if not children:
142
+ continue
143
+ max_depth = (
144
+ depth if depth is not None else max_depth_map.get(key, 1)
145
+ ) # default to 1 level for unknown groups
146
+ add_node(tree, key, obj, current_depth=0, max_depth=max_depth)
147
+
148
+ console.print(tree)
149
+
150
+
151
+ def _show_object_info(f: Any, entry_path: str, console: Console) -> None:
152
+ """Show detailed info for a specific object path."""
153
+ # Normalize path
154
+ entry_path = entry_path.strip().lstrip("/")
155
+
156
+ if entry_path not in f:
157
+ console.print(f"[bold red]Error:[/] '{entry_path}' not found in the file.")
158
+ return
159
+
160
+ entry = f[entry_path]
161
+ info = get_entry_type(entry)
162
+
163
+ console.print(f"\n[bold cyan]Path:[/] {entry_path}")
164
+ console.print(f"[bold cyan]Type:[/] {info['type']}")
165
+
166
+ if info["encoding"]:
167
+ console.print(f"[bold cyan]Encoding:[/] {info['encoding']}")
168
+
169
+ if info["shape"]:
170
+ console.print(f"[bold cyan]Shape:[/] {info['shape']}")
171
+
172
+ if info["dtype"]:
173
+ console.print(f"[bold cyan]Dtype:[/] {info['dtype']}")
174
+
175
+ console.print(f"[bold cyan]Details:[/] {info['details']}")
176
+
177
+ # Show attributes if any
178
+ if entry.attrs:
179
+ console.print(f"\n[bold cyan]Attributes:[/]")
180
+ for k, v in entry.attrs.items():
181
+ v_str = v.decode("utf-8") if isinstance(v, bytes) else str(v)
182
+ if len(v_str) > 80:
183
+ v_str = v_str[:77] + "..."
184
+ console.print(f" [dim]{k}:[/] {v_str}")
185
+
186
+ # If it's a group, show children
187
+ if is_group(entry):
188
+ children = [
189
+ k
190
+ for k in entry.keys()
191
+ if k not in ("_index", "__categories", "obs_names", "var_names")
192
+ ]
193
+ if children:
194
+ console.print(f"\n[bold cyan]Children:[/]")
195
+ for child_name in sorted(children):
196
+ child_entry = entry[child_name]
197
+ child_info = get_entry_type(child_entry)
198
+ type_str = format_type_info(child_info)
199
+ console.print(f" [bright_white]{child_name}[/] {type_str}")
adata/commands/ls.py ADDED
@@ -0,0 +1,114 @@
1
+ """`ls` -- list the contents of any HDF5 or Zarr store.
2
+
3
+ Unlike `view`, this makes no AnnData assumptions: there is no n_obs x n_var
4
+ header and no requirement that `obs`/`var` exist, so it works on `.loom` files
5
+ and arbitrary `.h5` stores as well as AnnData ones.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any, Iterator, Optional, Tuple
13
+
14
+ from rich.console import Console
15
+ from rich.tree import Tree
16
+
17
+ from adata.core.info import format_type_info, get_entry_type
18
+ from adata.storage import is_dataset, is_group, open_store
19
+ from adata.util.path import norm_path
20
+
21
+
22
+ def walk(obj: Any, prefix: str = "", depth: Optional[int] = None) -> Iterator[Tuple[str, Any]]:
23
+ """Yield ``(path, element)`` for every member below `obj`, depth-first."""
24
+ if not is_group(obj):
25
+ return
26
+ for key in sorted(obj.keys()):
27
+ child = obj[key]
28
+ path = f"{prefix}/{key}" if prefix else key
29
+ yield path, child
30
+ if is_group(child) and (depth is None or depth > 1):
31
+ next_depth = None if depth is None else depth - 1
32
+ yield from walk(child, path, next_depth)
33
+
34
+
35
+ def _describe(obj: Any, long: bool) -> str:
36
+ """Render the trailing annotation for one entry."""
37
+ if not long:
38
+ return ""
39
+ info = get_entry_type(obj)
40
+ bits = [format_type_info(info)]
41
+ shape = getattr(obj, "shape", None)
42
+ if shape:
43
+ bits.append(f"[dim]{tuple(shape)}[/]")
44
+ if is_dataset(obj) and getattr(obj, "dtype", None) is not None:
45
+ bits.append(f"[dim]{obj.dtype}[/]")
46
+ if info["encoding"]:
47
+ bits.append(f"[dim]{info['encoding']}[/]")
48
+ return " " + " ".join(bits)
49
+
50
+
51
+ def list_store(
52
+ file: Path,
53
+ console: Console,
54
+ entry_path: Optional[str] = None,
55
+ depth: Optional[int] = None,
56
+ long: bool = False,
57
+ plain: bool = False,
58
+ ) -> None:
59
+ """Print the structure of a store, as a tree or as bare paths.
60
+
61
+ `plain` emits one path per line with no markup, so the output pipes
62
+ cleanly into grep or xargs.
63
+ """
64
+ with open_store(file, "r", require_anndata=False) as store:
65
+ root = store.root
66
+ if entry_path:
67
+ entry_path = norm_path(entry_path)
68
+ if entry_path not in root:
69
+ raise KeyError(f"'{entry_path}' not found in the store.")
70
+ root = root[entry_path]
71
+
72
+ if plain:
73
+ _print_plain(root, entry_path or "", depth)
74
+ return
75
+
76
+ _print_tree(root, console, str(file), entry_path, depth, long)
77
+
78
+
79
+ def _print_plain(root: Any, prefix: str, depth: Optional[int]) -> None:
80
+ if is_dataset(root):
81
+ sys.stdout.write(f"{prefix}\n")
82
+ return
83
+ for path, _ in walk(root, prefix, depth):
84
+ sys.stdout.write(f"{path}\n")
85
+
86
+
87
+ def _print_tree(
88
+ root: Any,
89
+ console: Console,
90
+ label: str,
91
+ entry_path: Optional[str],
92
+ depth: Optional[int],
93
+ long: bool,
94
+ ) -> None:
95
+ root_label = f"{label}:{entry_path}" if entry_path else label
96
+ tree = Tree(f"[bold]{root_label}[/]")
97
+
98
+ if is_dataset(root):
99
+ tree.add(f"[bright_white]{entry_path or label}[/]{_describe(root, long)}")
100
+ console.print(tree)
101
+ return
102
+
103
+ nodes = {"": tree}
104
+ for path, obj in walk(root, "", depth):
105
+ parent_path, _, name = path.rpartition("/")
106
+ parent = nodes.get(parent_path, tree)
107
+ if is_group(obj):
108
+ nodes[path] = parent.add(
109
+ f"[bold yellow]{name}/[/]{_describe(obj, long)}"
110
+ )
111
+ else:
112
+ parent.add(f"[bright_white]{name}[/]{_describe(obj, long)}")
113
+
114
+ console.print(tree)