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.
- rootfileviewer-0.5.1/.github/workflows/publish.yml +54 -0
- rootfileviewer-0.5.1/.gitignore +11 -0
- rootfileviewer-0.5.1/LICENSE +21 -0
- rootfileviewer-0.5.1/PKG-INFO +273 -0
- rootfileviewer-0.5.1/README.md +249 -0
- rootfileviewer-0.5.1/examples/make_sample.py +38 -0
- rootfileviewer-0.5.1/examples/sample.root +0 -0
- rootfileviewer-0.5.1/pyproject.toml +41 -0
- rootfileviewer-0.5.1/src/rootfileviewer/__init__.py +7 -0
- rootfileviewer-0.5.1/src/rootfileviewer/cli.py +70 -0
- rootfileviewer-0.5.1/src/rootfileviewer/core.py +221 -0
- rootfileviewer-0.5.1/src/rootfileviewer/render.py +85 -0
- rootfileviewer-0.5.1/src/rootfileviewer/tui.py +165 -0
- rootfileviewer-0.5.1/tests/test_installed_cli.py +58 -0
|
@@ -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,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()
|
|
Binary file
|
|
@@ -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()
|