rootfileviewer 0.5.1__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,54 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch: {}
7
+
8
+ jobs:
9
+ build:
10
+ name: Build distribution
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.x"
18
+
19
+ - name: Install build tooling
20
+ run: python -m pip install --upgrade pip build
21
+
22
+ - name: Install package (for tests)
23
+ run: python -m pip install .
24
+
25
+ - name: Run tests
26
+ run: python -m pip install pytest && python -m pytest
27
+
28
+ - name: Build sdist and wheel
29
+ run: python -m build
30
+
31
+ - name: Upload dist artifact
32
+ uses: actions/upload-artifact@v4
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+
37
+ publish:
38
+ name: Publish to PyPI
39
+ needs: build
40
+ runs-on: ubuntu-latest
41
+ environment:
42
+ name: pypi
43
+ url: https://pypi.org/project/rootfileviewer/
44
+ permissions:
45
+ id-token: write # required for PyPI trusted publishing (OIDC)
46
+ steps:
47
+ - name: Download dist artifact
48
+ uses: actions/download-artifact@v4
49
+ with:
50
+ name: dist
51
+ path: dist/
52
+
53
+ - name: Publish to PyPI
54
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 matplo
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,273 @@
1
+ Metadata-Version: 2.5
2
+ Name: rootfileviewer
3
+ Version: 0.5.1
4
+ Summary: Terminal viewer for ROOT files using uproot — ASCII tree, branch tables, and an interactive TUI.
5
+ Project-URL: Homepage, https://github.com/matplo/rootfileviewer
6
+ Project-URL: Issues, https://github.com/matplo/rootfileviewer/issues
7
+ Author: matplo
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: cern,cli,physics,root,terminal,tui,uproot
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering :: Physics
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: plotext>=5.0
19
+ Requires-Dist: rich>=13.0
20
+ Requires-Dist: textual-plotext>=1.0
21
+ Requires-Dist: textual>=0.50
22
+ Requires-Dist: uproot>=5.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # rootfileviewer
26
+
27
+ Inspect a [ROOT](https://root.cern) file's contents from the terminal —
28
+ directory/object hierarchy, TTree branches, and file-level stats — using
29
+ [`uproot`](https://github.com/scikit-hep/uproot5), with no PyROOT/ROOT
30
+ installation required.
31
+
32
+ - **One-shot mode** (default): prints a summary panel, an ASCII object tree,
33
+ and per-`TTree` branch tables, rendered with [`rich`](https://github.com/Textualize/rich).
34
+ - **Interactive TUI** (`--tui`): a navigable [`textual`](https://github.com/Textualize/textual)
35
+ app — arrow keys to browse the object tree, select a node to see its
36
+ details in a side panel. Selecting a 1D histogram (`TH1*`/`TProfile`)
37
+ plots it as an ASCII bar chart in a panel below, via
38
+ [`textual-plotext`](https://github.com/Textualize/textual-plotext)/[`plotext`](https://github.com/piccolomo/plotext).
39
+ 2D/3D histograms aren't plotted yet — the detail panel notes this instead.
40
+ A `TTree`/`TNtuple` node expands into its branches — selecting a branch
41
+ plots its value distribution the same way (vector/jagged branches are
42
+ flattened first; very large trees are capped at 200,000 entries, noted
43
+ in the detail panel).
44
+ - **Terse mode** (`--terse`/`-t`): flat, tab-separated, no-color output —
45
+ for piping into `grep`/`awk`/other scripts.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install git+https://github.com/matplo/rootfileviewer.git
51
+ ```
52
+
53
+ This installs both `rootfileviewer` and the shorter `rfv` command; they are
54
+ equivalent, so `rfv examples/sample.root` works anywhere the long form does.
55
+
56
+ Or clone and install locally:
57
+
58
+ ```bash
59
+ git clone https://github.com/matplo/rootfileviewer.git
60
+ cd rootfileviewer
61
+ pip install -e .
62
+ ```
63
+
64
+ ## Examples
65
+
66
+ The examples below all use [`examples/sample.root`](examples/sample.root),
67
+ committed in this repo (regenerate it with `python examples/make_sample.py`),
68
+ containing:
69
+ - a `TTree` `events` with branches `pt`, `eta` (`double`), `n_jets` (`int32_t`), 2,000 entries
70
+ - a `TH1D` histogram `pt_hist` of the `pt` values, 25 bins
71
+ - a subdirectory `aux` holding a second `TTree`, `meta`, with one branch `run_number`, 5 entries
72
+
73
+ Clone the repo and run these directly:
74
+
75
+ ```bash
76
+ git clone https://github.com/matplo/rootfileviewer.git
77
+ cd rootfileviewer
78
+ rootfileviewer examples/sample.root
79
+ ```
80
+
81
+ ### One-shot mode
82
+
83
+ ```bash
84
+ rootfileviewer examples/sample.root
85
+ ```
86
+
87
+ ```
88
+ ╭───────── ROOT file summary ──────────╮
89
+ │ File: examples/sample.root │
90
+ │ Size: 80.5 KB Compression: ZLIB(1) │
91
+ │ uproot: 5.7.5 │
92
+ │ Keys: 3 TTrees: 2 Histograms: 1 │
93
+ ╰──────────────────────────────────────╯
94
+ sample.root
95
+ ├── events (TTree) - 2,000 entries, 3 branches
96
+ ├── pt_hist (TH1D) - 25 bins
97
+ └── aux (TDirectory)
98
+ └── meta (TTree) - 5 entries, 1 branches
99
+ TTree: events
100
+ (2,000 entries)
101
+ ┏━━━━━━━━┳━━━━━━━━━┓
102
+ ┃ Branch ┃ Type ┃
103
+ ┡━━━━━━━━╇━━━━━━━━━┩
104
+ │ pt │ double │
105
+ │ eta │ double │
106
+ │ n_jets │ int32_t │
107
+ └────────┴─────────┘
108
+ TTree: aux/meta (5
109
+ entries)
110
+ ┏━━━━━━━━━━━━┳━━━━━━━━━┓
111
+ ┃ Branch ┃ Type ┃
112
+ ┡━━━━━━━━━━━━╇━━━━━━━━━┩
113
+ │ run_number │ int32_t │
114
+ └────────────┴─────────┘
115
+ ```
116
+
117
+ Other one-shot flags:
118
+
119
+ ```bash
120
+ rootfileviewer examples/sample.root --depth 0 # don't recurse into subdirectories
121
+ rootfileviewer examples/sample.root --filter 'events' # only show keys matching a regex
122
+ rootfileviewer examples/sample.root --no-branches # skip the per-TTree branch tables
123
+ ```
124
+
125
+ ### Interactive TUI
126
+
127
+ ```bash
128
+ rootfileviewer examples/sample.root --tui
129
+ ```
130
+
131
+ Arrow keys navigate the tree on the left; `Enter`/click selects a node and
132
+ updates the panel on the right. Expand `events` to see its branches; select
133
+ `pt_hist` or the `pt` branch to plot it below. `q` quits.
134
+
135
+ ```
136
+ ┌─ rootfileviewer: sample.root ──────────────────────────────────────────────────┐
137
+ │ ┌─ tree ───────────────────┐ ┌─ detail ─────────────────────────────┐ │
138
+ │ │ ▼ sample.root │ │ Field Value │ │
139
+ │ │ ▼ events (TTree) - ... │ │ branch pt │ │
140
+ │ │ │ ▶ pt (double) ◀ │ │ type double │ │
141
+ │ │ │ eta (double) │ │ sampled 2,000 entries │ │
142
+ │ │ │ n_jets (int32_t) │ │ │ │
143
+ │ │ pt_hist (TH1D) - ... │ │ │ │
144
+ │ │ ▼ aux (TDirectory) │ │ │ │
145
+ │ │ meta (TTree) - ... │ │ │ │
146
+ │ └──────────────────────── ┘ └─────────────────────────────────── ┘ │
147
+ │ ┌─ histplot ────────────────────────────────────────────────────────┐ │
148
+ │ │ pt │ │
149
+ │ │ 208.0┤ ███████ │ │
150
+ │ │ │ ████████████████ │ │
151
+ │ │ │ █████████████████████████ │ │
152
+ │ │ 0.0 ┤█████████████████████████████████████████████████████████ │ │
153
+ │ │ └────────────┬──────────────────┬───────────────────────── │ │
154
+ │ │ 18.5 65.7 │ │
155
+ │ └───────────────────────────────────────────────────────────────── ┘ │
156
+ │ q Quit │
157
+ └─────────────────────────────────────────────────────────────────────── ┘
158
+ ```
159
+
160
+ The plot panel is the same [`plotext`](https://github.com/piccolomo/plotext)
161
+ render whether you selected the `pt_hist` histogram or the `pt` branch
162
+ directly (they happen to look similar here since `pt_hist` was built from
163
+ `pt`) — actual captures below:
164
+
165
+ <details>
166
+ <summary>Selecting <code>pt_hist</code> (TH1D) — exact terminal capture</summary>
167
+
168
+ ```
169
+ pt_hist
170
+ ┌─────────────────────────────────────────────────────────────────┐
171
+ 248.0┤ ████ │
172
+ │ ███████████ │
173
+ 206.7┤ ██████████████ │
174
+ │ ██████████████ │
175
+ 165.3┤ ██████████████ │
176
+ │ ████████████████ │
177
+ 124.0┤ █████████████████████ │
178
+ │ █████████████████████ │
179
+ │ ████████████████████████ │
180
+ 82.7┤ ████████████████████████ │
181
+ │████████████████████████████████ │
182
+ 41.3┤██████████████████████████████████ │
183
+ │██████████████████████████████████████████ │
184
+ 0.0┤█████████████████████████████████████████████████████████████████│
185
+ └──────────────────────┬──────────────┬─────────────────────────┬─┘
186
+ 33.27650853248193 55.95309492725528 93.74740558521088
187
+ ```
188
+
189
+ (`plotext`'s axis tick count/labels can shift slightly with terminal width —
190
+ the bars themselves are what matters here.)
191
+
192
+ </details>
193
+
194
+ <details>
195
+ <summary>Selecting the <code>pt</code> branch under <code>events</code> — exact terminal capture</summary>
196
+
197
+ ```
198
+ pt
199
+ ┌─────────────────────────────────────────────────────────────────┐
200
+ 208.0┤ ███████ │
201
+ │ █████████ │
202
+ 173.3┤ ████████████ │
203
+ │ ████████████████ │
204
+ 138.7┤ ████████████████ │
205
+ │ ████████████████ │
206
+ 104.0┤ ████████████████████ │
207
+ │ ██████████████████████ │
208
+ │ █████████████████████████ │
209
+ 69.3┤ ███████████████████████████ │
210
+ │█████████████████████████████████ │
211
+ 34.7┤█████████████████████████████████ │
212
+ │██████████████████████████████████████████ ███ │
213
+ 0.0┤█████████████████████████████████████████████████████████████████│
214
+ └─────┬─────────────────────────┬──────────────────┬──────────────┘
215
+ 9.025159193627086 46.81946985158268 75.1652028450494
216
+ ```
217
+
218
+ Detail panel for this selection: `branch: pt`, `type: double`,
219
+ `sampled: 2,000 entries`. On a tree with more than 200,000 entries the
220
+ `sampled` row would instead read e.g. `200,000/5,000,000 entries` — the
221
+ plot is always built from a capped, uniformly-sampled prefix for
222
+ responsiveness, and vector/jagged branches are flattened first (noted as
223
+ `..., N values (flattened)`).
224
+
225
+ </details>
226
+
227
+ ### Terse mode
228
+
229
+ `--terse`/`-t` prints flat, tab-separated lines instead of panels/trees/tables —
230
+ each line starts with a record-type tag (`summary`/`object`/`branch`) so a
231
+ consumer can pick out what it needs:
232
+
233
+ ```bash
234
+ rootfileviewer examples/sample.root -t
235
+ ```
236
+
237
+ ```
238
+ summary path examples/sample.root
239
+ summary size_bytes 82443
240
+ summary uproot_version 5.7.5
241
+ summary compression ZLIB(1)
242
+ summary num_trees 2
243
+ summary num_histograms 1
244
+ summary total_keys 3
245
+ object events TTree entries=2000 branches=3
246
+ object pt_hist TH1D bins=25
247
+ object aux TDirectory
248
+ object aux/meta TTree entries=5 branches=1
249
+ branch events pt double
250
+ branch events eta double
251
+ branch events n_jets int32_t
252
+ branch aux/meta run_number int32_t
253
+ ```
254
+
255
+ ```bash
256
+ rootfileviewer examples/sample.root -t | grep '^branch'
257
+ rootfileviewer examples/sample.root -t | awk -F'\t' '$1 == "branch" && $2 == "events" {print $3, $4}'
258
+ rootfileviewer examples/sample.root -t | awk -F'\t' '$1 == "object" && $3 == "TTree" {print $2}'
259
+ ```
260
+
261
+ ### Options
262
+
263
+ | Flag | Description |
264
+ |-------------------|----------------------------------------------------------|
265
+ | `--tui` | launch the interactive textual TUI instead of printing |
266
+ | `--terse`, `-t` | flat, tab-separated output with no borders/colors |
267
+ | `--depth N` | limit directory recursion depth |
268
+ | `--filter REGEX` | only show keys whose name matches REGEX |
269
+ | `--no-branches` | skip per-TTree branch info in one-shot/terse mode |
270
+
271
+ ## License
272
+
273
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,249 @@
1
+ # rootfileviewer
2
+
3
+ Inspect a [ROOT](https://root.cern) file's contents from the terminal —
4
+ directory/object hierarchy, TTree branches, and file-level stats — using
5
+ [`uproot`](https://github.com/scikit-hep/uproot5), with no PyROOT/ROOT
6
+ installation required.
7
+
8
+ - **One-shot mode** (default): prints a summary panel, an ASCII object tree,
9
+ and per-`TTree` branch tables, rendered with [`rich`](https://github.com/Textualize/rich).
10
+ - **Interactive TUI** (`--tui`): a navigable [`textual`](https://github.com/Textualize/textual)
11
+ app — arrow keys to browse the object tree, select a node to see its
12
+ details in a side panel. Selecting a 1D histogram (`TH1*`/`TProfile`)
13
+ plots it as an ASCII bar chart in a panel below, via
14
+ [`textual-plotext`](https://github.com/Textualize/textual-plotext)/[`plotext`](https://github.com/piccolomo/plotext).
15
+ 2D/3D histograms aren't plotted yet — the detail panel notes this instead.
16
+ A `TTree`/`TNtuple` node expands into its branches — selecting a branch
17
+ plots its value distribution the same way (vector/jagged branches are
18
+ flattened first; very large trees are capped at 200,000 entries, noted
19
+ in the detail panel).
20
+ - **Terse mode** (`--terse`/`-t`): flat, tab-separated, no-color output —
21
+ for piping into `grep`/`awk`/other scripts.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install git+https://github.com/matplo/rootfileviewer.git
27
+ ```
28
+
29
+ This installs both `rootfileviewer` and the shorter `rfv` command; they are
30
+ equivalent, so `rfv examples/sample.root` works anywhere the long form does.
31
+
32
+ Or clone and install locally:
33
+
34
+ ```bash
35
+ git clone https://github.com/matplo/rootfileviewer.git
36
+ cd rootfileviewer
37
+ pip install -e .
38
+ ```
39
+
40
+ ## Examples
41
+
42
+ The examples below all use [`examples/sample.root`](examples/sample.root),
43
+ committed in this repo (regenerate it with `python examples/make_sample.py`),
44
+ containing:
45
+ - a `TTree` `events` with branches `pt`, `eta` (`double`), `n_jets` (`int32_t`), 2,000 entries
46
+ - a `TH1D` histogram `pt_hist` of the `pt` values, 25 bins
47
+ - a subdirectory `aux` holding a second `TTree`, `meta`, with one branch `run_number`, 5 entries
48
+
49
+ Clone the repo and run these directly:
50
+
51
+ ```bash
52
+ git clone https://github.com/matplo/rootfileviewer.git
53
+ cd rootfileviewer
54
+ rootfileviewer examples/sample.root
55
+ ```
56
+
57
+ ### One-shot mode
58
+
59
+ ```bash
60
+ rootfileviewer examples/sample.root
61
+ ```
62
+
63
+ ```
64
+ ╭───────── ROOT file summary ──────────╮
65
+ │ File: examples/sample.root │
66
+ │ Size: 80.5 KB Compression: ZLIB(1) │
67
+ │ uproot: 5.7.5 │
68
+ │ Keys: 3 TTrees: 2 Histograms: 1 │
69
+ ╰──────────────────────────────────────╯
70
+ sample.root
71
+ ├── events (TTree) - 2,000 entries, 3 branches
72
+ ├── pt_hist (TH1D) - 25 bins
73
+ └── aux (TDirectory)
74
+ └── meta (TTree) - 5 entries, 1 branches
75
+ TTree: events
76
+ (2,000 entries)
77
+ ┏━━━━━━━━┳━━━━━━━━━┓
78
+ ┃ Branch ┃ Type ┃
79
+ ┡━━━━━━━━╇━━━━━━━━━┩
80
+ │ pt │ double │
81
+ │ eta │ double │
82
+ │ n_jets │ int32_t │
83
+ └────────┴─────────┘
84
+ TTree: aux/meta (5
85
+ entries)
86
+ ┏━━━━━━━━━━━━┳━━━━━━━━━┓
87
+ ┃ Branch ┃ Type ┃
88
+ ┡━━━━━━━━━━━━╇━━━━━━━━━┩
89
+ │ run_number │ int32_t │
90
+ └────────────┴─────────┘
91
+ ```
92
+
93
+ Other one-shot flags:
94
+
95
+ ```bash
96
+ rootfileviewer examples/sample.root --depth 0 # don't recurse into subdirectories
97
+ rootfileviewer examples/sample.root --filter 'events' # only show keys matching a regex
98
+ rootfileviewer examples/sample.root --no-branches # skip the per-TTree branch tables
99
+ ```
100
+
101
+ ### Interactive TUI
102
+
103
+ ```bash
104
+ rootfileviewer examples/sample.root --tui
105
+ ```
106
+
107
+ Arrow keys navigate the tree on the left; `Enter`/click selects a node and
108
+ updates the panel on the right. Expand `events` to see its branches; select
109
+ `pt_hist` or the `pt` branch to plot it below. `q` quits.
110
+
111
+ ```
112
+ ┌─ rootfileviewer: sample.root ──────────────────────────────────────────────────┐
113
+ │ ┌─ tree ───────────────────┐ ┌─ detail ─────────────────────────────┐ │
114
+ │ │ ▼ sample.root │ │ Field Value │ │
115
+ │ │ ▼ events (TTree) - ... │ │ branch pt │ │
116
+ │ │ │ ▶ pt (double) ◀ │ │ type double │ │
117
+ │ │ │ eta (double) │ │ sampled 2,000 entries │ │
118
+ │ │ │ n_jets (int32_t) │ │ │ │
119
+ │ │ pt_hist (TH1D) - ... │ │ │ │
120
+ │ │ ▼ aux (TDirectory) │ │ │ │
121
+ │ │ meta (TTree) - ... │ │ │ │
122
+ │ └──────────────────────── ┘ └─────────────────────────────────── ┘ │
123
+ │ ┌─ histplot ────────────────────────────────────────────────────────┐ │
124
+ │ │ pt │ │
125
+ │ │ 208.0┤ ███████ │ │
126
+ │ │ │ ████████████████ │ │
127
+ │ │ │ █████████████████████████ │ │
128
+ │ │ 0.0 ┤█████████████████████████████████████████████████████████ │ │
129
+ │ │ └────────────┬──────────────────┬───────────────────────── │ │
130
+ │ │ 18.5 65.7 │ │
131
+ │ └───────────────────────────────────────────────────────────────── ┘ │
132
+ │ q Quit │
133
+ └─────────────────────────────────────────────────────────────────────── ┘
134
+ ```
135
+
136
+ The plot panel is the same [`plotext`](https://github.com/piccolomo/plotext)
137
+ render whether you selected the `pt_hist` histogram or the `pt` branch
138
+ directly (they happen to look similar here since `pt_hist` was built from
139
+ `pt`) — actual captures below:
140
+
141
+ <details>
142
+ <summary>Selecting <code>pt_hist</code> (TH1D) — exact terminal capture</summary>
143
+
144
+ ```
145
+ pt_hist
146
+ ┌─────────────────────────────────────────────────────────────────┐
147
+ 248.0┤ ████ │
148
+ │ ███████████ │
149
+ 206.7┤ ██████████████ │
150
+ │ ██████████████ │
151
+ 165.3┤ ██████████████ │
152
+ │ ████████████████ │
153
+ 124.0┤ █████████████████████ │
154
+ │ █████████████████████ │
155
+ │ ████████████████████████ │
156
+ 82.7┤ ████████████████████████ │
157
+ │████████████████████████████████ │
158
+ 41.3┤██████████████████████████████████ │
159
+ │██████████████████████████████████████████ │
160
+ 0.0┤█████████████████████████████████████████████████████████████████│
161
+ └──────────────────────┬──────────────┬─────────────────────────┬─┘
162
+ 33.27650853248193 55.95309492725528 93.74740558521088
163
+ ```
164
+
165
+ (`plotext`'s axis tick count/labels can shift slightly with terminal width —
166
+ the bars themselves are what matters here.)
167
+
168
+ </details>
169
+
170
+ <details>
171
+ <summary>Selecting the <code>pt</code> branch under <code>events</code> — exact terminal capture</summary>
172
+
173
+ ```
174
+ pt
175
+ ┌─────────────────────────────────────────────────────────────────┐
176
+ 208.0┤ ███████ │
177
+ │ █████████ │
178
+ 173.3┤ ████████████ │
179
+ │ ████████████████ │
180
+ 138.7┤ ████████████████ │
181
+ │ ████████████████ │
182
+ 104.0┤ ████████████████████ │
183
+ │ ██████████████████████ │
184
+ │ █████████████████████████ │
185
+ 69.3┤ ███████████████████████████ │
186
+ │█████████████████████████████████ │
187
+ 34.7┤█████████████████████████████████ │
188
+ │██████████████████████████████████████████ ███ │
189
+ 0.0┤█████████████████████████████████████████████████████████████████│
190
+ └─────┬─────────────────────────┬──────────────────┬──────────────┘
191
+ 9.025159193627086 46.81946985158268 75.1652028450494
192
+ ```
193
+
194
+ Detail panel for this selection: `branch: pt`, `type: double`,
195
+ `sampled: 2,000 entries`. On a tree with more than 200,000 entries the
196
+ `sampled` row would instead read e.g. `200,000/5,000,000 entries` — the
197
+ plot is always built from a capped, uniformly-sampled prefix for
198
+ responsiveness, and vector/jagged branches are flattened first (noted as
199
+ `..., N values (flattened)`).
200
+
201
+ </details>
202
+
203
+ ### Terse mode
204
+
205
+ `--terse`/`-t` prints flat, tab-separated lines instead of panels/trees/tables —
206
+ each line starts with a record-type tag (`summary`/`object`/`branch`) so a
207
+ consumer can pick out what it needs:
208
+
209
+ ```bash
210
+ rootfileviewer examples/sample.root -t
211
+ ```
212
+
213
+ ```
214
+ summary path examples/sample.root
215
+ summary size_bytes 82443
216
+ summary uproot_version 5.7.5
217
+ summary compression ZLIB(1)
218
+ summary num_trees 2
219
+ summary num_histograms 1
220
+ summary total_keys 3
221
+ object events TTree entries=2000 branches=3
222
+ object pt_hist TH1D bins=25
223
+ object aux TDirectory
224
+ object aux/meta TTree entries=5 branches=1
225
+ branch events pt double
226
+ branch events eta double
227
+ branch events n_jets int32_t
228
+ branch aux/meta run_number int32_t
229
+ ```
230
+
231
+ ```bash
232
+ rootfileviewer examples/sample.root -t | grep '^branch'
233
+ rootfileviewer examples/sample.root -t | awk -F'\t' '$1 == "branch" && $2 == "events" {print $3, $4}'
234
+ rootfileviewer examples/sample.root -t | awk -F'\t' '$1 == "object" && $3 == "TTree" {print $2}'
235
+ ```
236
+
237
+ ### Options
238
+
239
+ | Flag | Description |
240
+ |-------------------|----------------------------------------------------------|
241
+ | `--tui` | launch the interactive textual TUI instead of printing |
242
+ | `--terse`, `-t` | flat, tab-separated output with no borders/colors |
243
+ | `--depth N` | limit directory recursion depth |
244
+ | `--filter REGEX` | only show keys whose name matches REGEX |
245
+ | `--no-branches` | skip per-TTree branch info in one-shot/terse mode |
246
+
247
+ ## License
248
+
249
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env python3
2
+ """Regenerates examples/sample.root — the file used throughout the README.
3
+
4
+ Run from the repo root:
5
+ python examples/make_sample.py
6
+ """
7
+
8
+ import os
9
+
10
+ import numpy as np
11
+ import uproot
12
+
13
+ HERE = os.path.dirname(os.path.abspath(__file__))
14
+ OUT = os.path.join(HERE, "sample.root")
15
+
16
+
17
+ def main() -> None:
18
+ rng = np.random.default_rng(42)
19
+ pt = rng.gamma(shape=3.0, scale=8.0, size=2000)
20
+ eta = rng.normal(0, 1.2, 2000)
21
+ n_jets = rng.integers(0, 6, 2000).astype("int32")
22
+
23
+ with uproot.recreate(OUT) as f:
24
+ f.mktree("events", {"pt": "float64", "eta": "float64", "n_jets": "int32"})
25
+ f["events"].extend({"pt": pt, "eta": eta, "n_jets": n_jets})
26
+
27
+ counts, edges = np.histogram(pt, bins=25)
28
+ f["pt_hist"] = (counts, edges)
29
+
30
+ f.mkdir("aux")
31
+ f.mktree("aux/meta", {"run_number": "int32"})
32
+ f["aux/meta"].extend({"run_number": np.full(5, 367123, dtype="int32")})
33
+
34
+ print(f"wrote {OUT}")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rootfileviewer"
7
+ version = "0.5.1"
8
+ description = "Terminal viewer for ROOT files using uproot — ASCII tree, branch tables, and an interactive TUI."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "matplo" },
14
+ ]
15
+ keywords = ["root", "uproot", "cern", "physics", "terminal", "tui", "cli"]
16
+ classifiers = [
17
+ "Environment :: Console",
18
+ "Intended Audience :: Science/Research",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Scientific/Engineering :: Physics",
23
+ ]
24
+ dependencies = [
25
+ "uproot>=5.0",
26
+ "rich>=13.0",
27
+ "textual>=0.50",
28
+ "plotext>=5.0",
29
+ "textual-plotext>=1.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/matplo/rootfileviewer"
34
+ Issues = "https://github.com/matplo/rootfileviewer/issues"
35
+
36
+ [project.scripts]
37
+ rootfileviewer = "rootfileviewer.cli:main"
38
+ rfv = "rootfileviewer.cli:main"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/rootfileviewer"]
@@ -0,0 +1,7 @@
1
+ """rootfileviewer — inspect ROOT files from the terminal via uproot."""
2
+
3
+ from rootfileviewer.core import Node, file_summary, tree_branch_info, walk_directory
4
+
5
+ __version__ = "0.5.1"
6
+
7
+ __all__ = ["Node", "file_summary", "tree_branch_info", "walk_directory", "__version__"]
@@ -0,0 +1,70 @@
1
+ """Command-line entry point for rootfileviewer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+
9
+ import uproot
10
+
11
+ from rootfileviewer.core import file_summary, walk_directory
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = argparse.ArgumentParser(
16
+ description="Inspect a ROOT file's contents in the terminal (via uproot).",
17
+ )
18
+ parser.add_argument("rootfile", help="path to the .root file")
19
+ parser.add_argument("--tui", action="store_true", help="launch interactive textual TUI instead of one-shot print")
20
+ parser.add_argument(
21
+ "--terse", "-t",
22
+ action="store_true",
23
+ help="plain, tab-separated output with no borders/colors, for scripts/grep/awk",
24
+ )
25
+ parser.add_argument("--depth", type=int, default=None, help="limit directory recursion depth")
26
+ parser.add_argument("--filter", dest="name_filter", default=None, help="regex to filter key names")
27
+ parser.add_argument("--no-branches", action="store_true", help="skip per-TTree branch tables in CLI mode")
28
+ parser.add_argument("--version", action="version", version=f"%(prog)s {_version()}")
29
+ args = parser.parse_args(argv)
30
+
31
+ if args.tui and args.terse:
32
+ print("error: --tui and --terse/-t are mutually exclusive", file=sys.stderr)
33
+ return 1
34
+
35
+ if not os.path.isfile(args.rootfile):
36
+ print(f"error: no such file: {args.rootfile}", file=sys.stderr)
37
+ return 1
38
+
39
+ try:
40
+ with uproot.open(args.rootfile) as f:
41
+ nodes = walk_directory(f, depth=args.depth, name_filter=args.name_filter)
42
+ summary = file_summary(f, args.rootfile, nodes)
43
+
44
+ if args.tui:
45
+ from rootfileviewer.tui import run_tui
46
+
47
+ run_tui(args.rootfile, nodes, summary)
48
+ elif args.terse:
49
+ from rootfileviewer.render import render_terse
50
+
51
+ render_terse(args.rootfile, nodes, summary, show_branches=not args.no_branches)
52
+ else:
53
+ from rootfileviewer.render import render_cli
54
+
55
+ render_cli(args.rootfile, nodes, summary, show_branches=not args.no_branches)
56
+ except Exception as exc:
57
+ print(f"error: failed to read {args.rootfile}: {exc}", file=sys.stderr)
58
+ return 1
59
+
60
+ return 0
61
+
62
+
63
+ def _version() -> str:
64
+ from rootfileviewer import __version__
65
+
66
+ return __version__
67
+
68
+
69
+ if __name__ == "__main__":
70
+ raise SystemExit(main())
@@ -0,0 +1,221 @@
1
+ """Shared core: uproot-only data extraction, no rendering dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field
8
+
9
+ import numpy as np
10
+ import uproot
11
+
12
+ _HIST_CLASS_PREFIXES = ("TH1", "TH2", "TH3", "TProfile")
13
+
14
+ #: Default cap on how many entries to read when plotting a branch's value
15
+ #: distribution, so selecting a branch on a huge tree stays responsive.
16
+ DEFAULT_BRANCH_PLOT_MAX_ENTRIES = 200_000
17
+ DEFAULT_BRANCH_PLOT_BINS = 30
18
+
19
+
20
+ @dataclass
21
+ class Node:
22
+ name: str
23
+ classname: str
24
+ obj: object
25
+ children: list["Node"] = field(default_factory=list)
26
+ is_branch: bool = False
27
+ """True if this node is a synthetic TBranch child of a TTree/TNtuple node
28
+ (as opposed to a real file object from walk_directory)."""
29
+
30
+ @property
31
+ def is_dir(self) -> bool:
32
+ return self.classname.startswith("TDirectory")
33
+
34
+ @property
35
+ def is_tree(self) -> bool:
36
+ return self.classname in ("TTree", "TNtuple")
37
+
38
+ @property
39
+ def is_hist(self) -> bool:
40
+ return self.classname.startswith(_HIST_CLASS_PREFIXES)
41
+
42
+
43
+ def walk_directory(directory, depth: int | None = None, name_filter: str | None = None) -> list[Node]:
44
+ """Recursively list keys under a TDirectory/file into a Node tree."""
45
+ pattern = re.compile(name_filter) if name_filter else None
46
+ nodes: list[Node] = []
47
+ for key, classname in directory.classnames(recursive=False).items():
48
+ short_name = key.split(";")[0]
49
+ if pattern and not pattern.search(short_name):
50
+ continue
51
+ try:
52
+ obj = directory[key]
53
+ except Exception:
54
+ obj = None
55
+ node = Node(name=short_name, classname=classname, obj=obj)
56
+ if classname.startswith("TDirectory") and obj is not None:
57
+ if depth is None or depth > 0:
58
+ next_depth = None if depth is None else depth - 1
59
+ node.children = walk_directory(obj, depth=next_depth, name_filter=name_filter)
60
+ nodes.append(node)
61
+ return nodes
62
+
63
+
64
+ def tree_branch_info(ttree) -> list[dict]:
65
+ """Per-branch details for a TTree: name, type, entry count."""
66
+ n_entries = ttree.num_entries
67
+ rows = []
68
+ for branch in ttree.branches:
69
+ try:
70
+ typename = branch.typename
71
+ except Exception:
72
+ typename = "?"
73
+ rows.append({"name": branch.name, "typename": typename, "num_entries": n_entries})
74
+ return rows
75
+
76
+
77
+ def _count_classes(nodes: list[Node]) -> dict[str, int]:
78
+ counts: dict[str, int] = {}
79
+ for node in nodes:
80
+ if node.is_dir:
81
+ sub = _count_classes(node.children)
82
+ for k, v in sub.items():
83
+ counts[k] = counts.get(k, 0) + v
84
+ continue
85
+ key = "TTree" if node.is_tree else "Histogram" if node.is_hist else node.classname
86
+ counts[key] = counts.get(key, 0) + 1
87
+ return counts
88
+
89
+
90
+ def file_summary(uproot_file, path: str, nodes: list[Node]) -> dict:
91
+ counts = _count_classes(nodes)
92
+ total_keys = sum(counts.values())
93
+ return {
94
+ "path": path,
95
+ "size_bytes": os.path.getsize(path),
96
+ "uproot_version": uproot.__version__,
97
+ "compression": str(uproot_file.file.compression),
98
+ "num_trees": counts.get("TTree", 0),
99
+ "num_histograms": counts.get("Histogram", 0),
100
+ "total_keys": total_keys,
101
+ }
102
+
103
+
104
+ def human_size(n: int) -> str:
105
+ size = float(n)
106
+ for unit in ("B", "KB", "MB", "GB", "TB"):
107
+ if size < 1024 or unit == "TB":
108
+ return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} {unit}"
109
+ size /= 1024
110
+ return f"{size:.1f} TB"
111
+
112
+
113
+ def node_facts(node: Node) -> dict[str, int]:
114
+ """Raw numeric facts about a node (entries/branches/bins), for machine consumption.
115
+
116
+ See `node_hint` for the human-readable, formatted equivalent.
117
+ """
118
+ if node.is_tree and node.obj is not None:
119
+ try:
120
+ return {"entries": node.obj.num_entries, "branches": len(node.obj.branches)}
121
+ except Exception:
122
+ return {}
123
+ if node.is_hist and node.obj is not None:
124
+ try:
125
+ return {"bins": len(node.obj.axis())}
126
+ except Exception:
127
+ return {}
128
+ return {}
129
+
130
+
131
+ def node_hint(node: Node) -> str:
132
+ """Short, human-readable annotation shown next to a node's name."""
133
+ facts = node_facts(node)
134
+ if node.is_tree:
135
+ if "entries" not in facts:
136
+ return ""
137
+ return f"{facts['entries']:,} entries, {facts['branches']} branches"
138
+ if node.is_hist:
139
+ if "bins" not in facts:
140
+ return ""
141
+ return f"{facts['bins']} bins"
142
+ return ""
143
+
144
+
145
+ def is_1d_histogram(classname: str) -> bool:
146
+ """Whether a histogram classname is 1D and thus bar-plottable (TH1*, TProfile)."""
147
+ return classname.startswith(("TH1", "TProfile"))
148
+
149
+
150
+ def histogram_data(hist_obj) -> tuple[list[float], list[float]]:
151
+ """Bin centers and values for a 1D histogram-like object, for bar plotting."""
152
+ values, edges = hist_obj.to_numpy()
153
+ centers = [(edges[i] + edges[i + 1]) / 2 for i in range(len(values))]
154
+ return centers, [float(v) for v in values]
155
+
156
+
157
+ def branch_nodes(tree_obj) -> list[Node]:
158
+ """Synthetic leaf Nodes for a TTree/TNtuple's branches, for TUI tree display."""
159
+ nodes = []
160
+ for branch in tree_obj.branches:
161
+ try:
162
+ typename = branch.typename
163
+ except Exception:
164
+ typename = "?"
165
+ nodes.append(Node(name=branch.name, classname=typename, obj=branch, is_branch=True))
166
+ return nodes
167
+
168
+
169
+ def branch_histogram_data(
170
+ branch,
171
+ max_entries: int = DEFAULT_BRANCH_PLOT_MAX_ENTRIES,
172
+ bins: int = DEFAULT_BRANCH_PLOT_BINS,
173
+ ) -> tuple[list[float], list[float], str]:
174
+ """Bin centers/values (via numpy.histogram) for a TBranch's values, plus a note
175
+ describing how many entries/values were actually used.
176
+
177
+ Vector/jagged branches are flattened across all their elements first.
178
+ Raises ValueError if the branch has no numeric values to histogram.
179
+ """
180
+ total = branch.num_entries
181
+ entry_stop = min(total, max_entries)
182
+ arr = branch.array(library="np", entry_stop=entry_stop)
183
+ if arr.dtype.kind not in "iuf":
184
+ import awkward as ak
185
+
186
+ arr = ak.flatten(branch.array(library="ak", entry_stop=entry_stop), axis=None).to_numpy()
187
+ if arr.dtype.kind not in "iuf":
188
+ raise ValueError(f"branch '{branch.name}' is not numeric (dtype {arr.dtype})")
189
+
190
+ if len(arr) == 0:
191
+ raise ValueError(f"branch '{branch.name}' has no values to plot")
192
+
193
+ values, edges = np.histogram(arr, bins=bins)
194
+ centers = [(edges[i] + edges[i + 1]) / 2 for i in range(len(values))]
195
+
196
+ note = f"{entry_stop:,}/{total:,} entries" if entry_stop < total else f"{total:,} entries"
197
+ if len(arr) != entry_stop:
198
+ note += f", {len(arr):,} values (flattened)"
199
+ return centers, [float(v) for v in values], note
200
+
201
+
202
+ def flatten_trees(nodes: list[Node], prefix: str = "") -> list[tuple[str, Node]]:
203
+ result = []
204
+ for node in nodes:
205
+ path = f"{prefix}/{node.name}" if prefix else node.name
206
+ if node.is_tree and node.obj is not None:
207
+ result.append((path, node))
208
+ elif node.is_dir:
209
+ result.extend(flatten_trees(node.children, path))
210
+ return result
211
+
212
+
213
+ def flatten_nodes(nodes: list[Node], prefix: str = "") -> list[tuple[str, Node]]:
214
+ """Flatten the tree into (full_path, node) pairs, depth-first, directories included."""
215
+ result = []
216
+ for node in nodes:
217
+ path = f"{prefix}/{node.name}" if prefix else node.name
218
+ result.append((path, node))
219
+ if node.is_dir and node.children:
220
+ result.extend(flatten_nodes(node.children, path))
221
+ return result
@@ -0,0 +1,85 @@
1
+ """One-shot terminal renderer using `rich`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from rootfileviewer.core import Node, flatten_nodes, flatten_trees, human_size, node_facts, node_hint, tree_branch_info
8
+
9
+
10
+ def render_cli(path: str, nodes: list[Node], summary: dict, show_branches: bool = True) -> None:
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from rich.tree import Tree
15
+
16
+ console = Console()
17
+
18
+ summary_lines = (
19
+ f"[bold]File:[/bold] {summary['path']}\n"
20
+ f"[bold]Size:[/bold] {human_size(summary['size_bytes'])} "
21
+ f"[bold]Compression:[/bold] {summary['compression']}\n"
22
+ f"[bold]uproot:[/bold] {summary['uproot_version']}\n"
23
+ f"[bold]Keys:[/bold] {summary['total_keys']} "
24
+ f"[bold]TTrees:[/bold] {summary['num_trees']} "
25
+ f"[bold]Histograms:[/bold] {summary['num_histograms']}"
26
+ )
27
+ console.print(Panel(summary_lines, title="ROOT file summary", expand=False))
28
+
29
+ root_label = f"[bold]{os.path.basename(path)}[/bold]"
30
+ rich_tree = Tree(root_label)
31
+ _fill_rich_tree(rich_tree, nodes)
32
+ console.print(rich_tree)
33
+
34
+ if show_branches:
35
+ for tree_path, node in flatten_trees(nodes):
36
+ rows = tree_branch_info(node.obj)
37
+ table = Table(title=f"TTree: {tree_path} ({node.obj.num_entries:,} entries)")
38
+ table.add_column("Branch")
39
+ table.add_column("Type")
40
+ for row in rows:
41
+ table.add_row(row["name"], row["typename"])
42
+ console.print(table)
43
+
44
+
45
+ def render_terse(path: str, nodes: list[Node], summary: dict, show_branches: bool = True) -> None:
46
+ """Flat, tab-separated, no-color output for scripts/grep/awk.
47
+
48
+ Every line starts with a record-type tag (summary/object/branch) so a
49
+ consumer can select what it wants, e.g.:
50
+ rootfileviewer file.root -t | awk -F'\\t' '$1 == "branch" && $2 == "tree1"'
51
+ rootfileviewer file.root -t | grep '^object.*TTree'
52
+ """
53
+ for key, value in summary.items():
54
+ print(f"summary\t{key}\t{value}")
55
+
56
+ for obj_path, node in flatten_nodes(nodes):
57
+ fields = [f"{k}={v}" for k, v in node_facts(node).items()]
58
+ print("\t".join(["object", obj_path, node.classname, *fields]))
59
+
60
+ if show_branches:
61
+ for tree_path, node in flatten_trees(nodes):
62
+ for row in tree_branch_info(node.obj):
63
+ print(f"branch\t{tree_path}\t{row['name']}\t{row['typename']}")
64
+
65
+
66
+ def _class_style(node: Node) -> str:
67
+ if node.is_dir:
68
+ return "cyan"
69
+ if node.is_tree:
70
+ return "green"
71
+ if node.is_hist:
72
+ return "magenta"
73
+ return "white"
74
+
75
+
76
+ def _fill_rich_tree(rich_parent, nodes: list[Node]) -> None:
77
+ for node in nodes:
78
+ style = _class_style(node)
79
+ hint = node_hint(node)
80
+ label = f"[{style}]{node.name}[/{style}] [dim]({node.classname})[/dim]"
81
+ if hint:
82
+ label += f" [dim]- {hint}[/dim]"
83
+ branch = rich_parent.add(label)
84
+ if node.children:
85
+ _fill_rich_tree(branch, node.children)
@@ -0,0 +1,165 @@
1
+ """Interactive terminal UI using `textual`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from rootfileviewer.core import (
8
+ Node,
9
+ branch_histogram_data,
10
+ branch_nodes,
11
+ histogram_data,
12
+ is_1d_histogram,
13
+ node_hint,
14
+ tree_branch_info,
15
+ )
16
+
17
+
18
+ def run_tui(path: str, nodes: list[Node], summary: dict) -> None:
19
+ from textual.app import App, ComposeResult
20
+ from textual.containers import Horizontal, Vertical
21
+ from textual.widgets import DataTable, Footer, Header
22
+ from textual.widgets import Tree as TextualTree
23
+ from textual_plotext import PlotextPlot
24
+
25
+ class RootFileViewerApp(App):
26
+ CSS = """
27
+ #top { height: 1fr; }
28
+ #tree { width: 45%; border: solid $accent; }
29
+ #detail { width: 55%; border: solid $accent; }
30
+ #histplot { height: 15; border: solid $accent; }
31
+ """
32
+ BINDINGS = [("q", "quit", "Quit")]
33
+ TITLE = f"rootfileviewer: {os.path.basename(path)}"
34
+
35
+ def compose(self) -> ComposeResult:
36
+ yield Header()
37
+ with Vertical():
38
+ with Horizontal(id="top"):
39
+ yield TextualTree(os.path.basename(path), id="tree")
40
+ yield DataTable(id="detail")
41
+ yield PlotextPlot(id="histplot")
42
+ yield Footer()
43
+
44
+ def on_mount(self) -> None:
45
+ tree_widget = self.query_one("#tree", TextualTree)
46
+ tree_widget.root.expand()
47
+ self._populate(tree_widget.root, nodes)
48
+ table = self.query_one("#detail", DataTable)
49
+ self._set_table(table, ("Field", "Value"), [(k, str(v)) for k, v in summary.items()])
50
+ self.query_one("#histplot", PlotextPlot).display = False
51
+
52
+ @staticmethod
53
+ def _set_table(table: DataTable, headers: tuple[str, str], rows: list[tuple[str, str]]) -> None:
54
+ """Replace a DataTable's columns/rows with explicit content-based widths.
55
+
56
+ `add_columns()` leaves column width to DataTable's lazy auto-sizing,
57
+ which can render stale/inconsistent widths across rows when the
58
+ table's columns are repeatedly cleared and rebuilt (e.g. selecting
59
+ different TTrees in quick succession). Computing widths ourselves
60
+ avoids that entirely.
61
+ """
62
+ table.clear(columns=True)
63
+ max_width = 60
64
+ widths = [
65
+ min(max(len(headers[i]), *(len(str(row[i])) for row in rows)) + 2, max_width) if rows else len(headers[i]) + 2
66
+ for i in range(2)
67
+ ]
68
+ for header, width in zip(headers, widths):
69
+ table.add_column(header, width=width)
70
+ for row in rows:
71
+ table.add_row(*row)
72
+
73
+ def _populate(self, parent, node_list: list[Node]) -> None:
74
+ for node in node_list:
75
+ hint = node_hint(node)
76
+ label = f"{node.name} ({node.classname})"
77
+ if hint:
78
+ label += f" - {hint}"
79
+ child = parent.add(label, data=node)
80
+ if node.children:
81
+ self._populate(child, node.children)
82
+ elif node.is_tree and node.obj is not None:
83
+ # TTree/TNtuple: expand into its branches, so a specific
84
+ # branch can be selected and plotted on its own.
85
+ for bnode in branch_nodes(node.obj):
86
+ child.add_leaf(f"{bnode.name} ({bnode.classname})", data=bnode)
87
+ else:
88
+ # Leaf node (histogram, TList, or anything else with
89
+ # nothing to descend into): disable the expand arrow.
90
+ # Otherwise Tree's default auto_expand behavior toggles
91
+ # it expanded/collapsed on every Enter press with nothing
92
+ # to actually show, which reads as the row's formatting
93
+ # randomly changing each time you select it.
94
+ child.allow_expand = False
95
+
96
+ def on_tree_node_selected(self, event) -> None:
97
+ node: Node | None = event.node.data
98
+ table = self.query_one("#detail", DataTable)
99
+ plot_widget = self.query_one("#histplot", PlotextPlot)
100
+ plot_widget.display = False
101
+
102
+ if node is None:
103
+ self._set_table(table, ("Field", "Value"), [])
104
+ return
105
+
106
+ if node.is_branch:
107
+ rows = [("branch", node.name), ("type", node.classname)]
108
+ note, error = self._plot_branch(plot_widget, node)
109
+ if note:
110
+ rows.append(("sampled", note))
111
+ if error:
112
+ rows.append(("plot error", error))
113
+ self._set_table(table, ("Field", "Value"), rows)
114
+ return
115
+
116
+ if node.is_tree and node.obj is not None:
117
+ rows = [(row["name"], row["typename"]) for row in tree_branch_info(node.obj)]
118
+ self._set_table(table, ("Branch", "Type"), rows)
119
+ return
120
+
121
+ rows = [("name", node.name), ("classname", node.classname)]
122
+ hint = node_hint(node)
123
+ if hint:
124
+ rows.append(("info", hint))
125
+
126
+ if node.is_hist and node.obj is not None:
127
+ if is_1d_histogram(node.classname):
128
+ error = self._plot_histogram(plot_widget, node)
129
+ if error:
130
+ rows.append(("plot error", error))
131
+ else:
132
+ rows.append(("plot", "not supported yet (2D/3D histogram)"))
133
+
134
+ self._set_table(table, ("Field", "Value"), rows)
135
+
136
+ @staticmethod
137
+ def _render_plot(plot_widget: "PlotextPlot", title: str, centers: list[float], values: list[float]) -> None:
138
+ plt = plot_widget.plt
139
+ plt.clear_figure()
140
+ plt.title(title)
141
+ plt.bar(centers, values, width=1.0)
142
+ plot_widget.display = True
143
+ plot_widget.refresh()
144
+
145
+ def _plot_histogram(self, plot_widget: "PlotextPlot", node: Node) -> str | None:
146
+ """Render node's histogram into plot_widget. Returns an error message, if any."""
147
+ try:
148
+ centers, values = histogram_data(node.obj)
149
+ self._render_plot(plot_widget, node.name, centers, values)
150
+ except Exception as exc:
151
+ plot_widget.display = False
152
+ return str(exc)
153
+ return None
154
+
155
+ def _plot_branch(self, plot_widget: "PlotextPlot", node: Node) -> tuple[str | None, str | None]:
156
+ """Render a branch's value distribution. Returns (sampling note, error message)."""
157
+ try:
158
+ centers, values, note = branch_histogram_data(node.obj)
159
+ self._render_plot(plot_widget, node.name, centers, values)
160
+ return note, None
161
+ except Exception as exc:
162
+ plot_widget.display = False
163
+ return None, str(exc)
164
+
165
+ RootFileViewerApp().run()
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ import unittest
6
+ from importlib import metadata
7
+ from pathlib import Path
8
+
9
+ import rootfileviewer
10
+
11
+
12
+ REPO_ROOT = Path(__file__).resolve().parents[1]
13
+ SAMPLE_FILE = REPO_ROOT / "examples" / "sample.root"
14
+
15
+
16
+ class InstalledCliTests(unittest.TestCase):
17
+ def run_cli(self, command: str, *args: str) -> subprocess.CompletedProcess[str]:
18
+ executable = shutil.which(command)
19
+ self.assertIsNotNone(executable, f"{command} is not installed")
20
+ return subprocess.run(
21
+ [executable, *args],
22
+ cwd=REPO_ROOT,
23
+ check=False,
24
+ capture_output=True,
25
+ text=True,
26
+ )
27
+
28
+ def test_distribution_exposes_both_commands(self) -> None:
29
+ entry_points = {
30
+ entry_point.name: entry_point.value
31
+ for entry_point in metadata.distribution("rootfileviewer").entry_points
32
+ if entry_point.group == "console_scripts"
33
+ }
34
+ expected = "rootfileviewer.cli:main"
35
+ self.assertEqual(entry_points.get("rootfileviewer"), expected)
36
+ self.assertEqual(entry_points.get("rfv"), expected)
37
+
38
+ def test_package_version(self) -> None:
39
+ self.assertEqual(rootfileviewer.__version__, "0.5.1")
40
+
41
+ def test_both_commands_report_their_invoked_name(self) -> None:
42
+ for command in ("rootfileviewer", "rfv"):
43
+ with self.subTest(command=command):
44
+ result = self.run_cli(command, "--version")
45
+ self.assertEqual(result.returncode, 0, result.stderr)
46
+ self.assertEqual(result.stdout.strip(), f"{command} 0.5.1")
47
+
48
+ def test_both_commands_read_the_sample_file(self) -> None:
49
+ for command in ("rootfileviewer", "rfv"):
50
+ with self.subTest(command=command):
51
+ result = self.run_cli(command, str(SAMPLE_FILE), "--terse")
52
+ self.assertEqual(result.returncode, 0, result.stderr)
53
+ self.assertIn(f"summary\tpath\t{SAMPLE_FILE}", result.stdout)
54
+ self.assertIn("object\tevents\tTTree", result.stdout)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ unittest.main()