├── .github ├── CONTRIBUTING.md ├── dependabot.yml ├── release.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── docs ├── _images │ ├── iterm.png │ └── uproot-browser-logo.png └── make_logo.py ├── noxfile.py ├── pyproject.toml ├── src └── uproot_browser │ ├── __init__.py │ ├── __main__.py │ ├── _version.pyi │ ├── exceptions.py │ ├── plot.py │ ├── plot_mpl.py │ ├── py.typed │ ├── tree.py │ └── tui │ ├── README.md │ ├── __init__.py │ ├── browser.css │ ├── browser.py │ ├── header.py │ ├── help.py │ ├── left_panel.py │ └── right_panel.py └── tests ├── test_printouts.py └── test_tui.py /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | See the [Scikit-HEP Developer introduction][skhep-dev-intro] for a 2 | detailed description of best practices for developing Scikit-HEP packages. 3 | 4 | [skhep-dev-intro]: https://scikit-hep.org/developer/intro 5 | 6 | # Quick development 7 | 8 | The fastest way to start with development is to use nox. If you don't have nox, 9 | you can use `pipx run nox` to run it without installing, or `pipx install nox`. 10 | If you don't have pipx (pip for applications), then you can install with with 11 | `pip install pipx` (the only case were installing an application with regular 12 | pip is reasonable). If you use macOS, then pipx and nox are both in brew, use 13 | `brew install pipx nox`. 14 | 15 | To use, run `nox`. This will lint and test using every installed version of 16 | Python on your system, skipping ones that are not installed. You can also run 17 | specific jobs: 18 | 19 | ```console 20 | $ nox -l # List all the defined sessions 21 | $ nox -s lint # Lint only 22 | $ nox -s tests # Run the tests 23 | $ nox -s build # Make an SDist and wheel 24 | ``` 25 | 26 | Nox handles everything for you, including setting up an temporary virtual 27 | environment for each run. 28 | 29 | # Setting up a development environment manually 30 | 31 | You can set up a development environment by running: 32 | 33 | ```bash 34 | python3 -m venv .venv 35 | source ./.env/bin/activate 36 | pip install -U pip dependency-groups 37 | pip install -e. $(dependency-groups dev) 38 | ``` 39 | 40 | If you use `uv`, you can do: 41 | 42 | ```bash 43 | uv sync 44 | ``` 45 | 46 | instead. 47 | 48 | # Post setup 49 | 50 | You should prepare pre-commit, which will help you by checking that commits 51 | pass required checks: 52 | 53 | ```bash 54 | pip install pre-commit # or brew install pre-commit on macOS 55 | pre-commit install # Will install a pre-commit hook into the git repo 56 | ``` 57 | 58 | You can also/alternatively run `pre-commit run` (changes only) or `pre-commit 59 | run --all-files` to check even without installing the hook. 60 | 61 | # Testing 62 | 63 | Use pytest to run the unit checks: 64 | 65 | ```bash 66 | pytest 67 | ``` 68 | 69 | # Pre-commit 70 | 71 | This project uses pre-commit for all style checking. While you can run it with 72 | nox, this is such an important tool that it deserves to be installed on its 73 | own. Install pre-commit and run: 74 | 75 | ```bash 76 | pre-commit run -a 77 | ``` 78 | 79 | to check all files. 80 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | groups: 9 | actions: 10 | patterns: 11 | - "*" 12 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - published 8 | 9 | concurrency: 10 | group: ${ github.workflow }-${ github.ref } 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | dist: 15 | name: Distribution build 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: hynek/build-and-inspect-python-package@v2 21 | 22 | 23 | publish: 24 | name: Publish 25 | environment: pypi 26 | permissions: 27 | id-token: write 28 | attestations: write 29 | needs: [dist] 30 | if: github.event_name == 'release' && github.event.action == 'published' 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - uses: actions/download-artifact@v4 35 | with: 36 | path: dist 37 | name: Packages 38 | 39 | - name: Generate artifact attestation for sdist and wheel 40 | uses: actions/attest-build-provenance@v2 41 | with: 42 | subject-path: "dist/uproot_browser-*" 43 | 44 | - uses: pypa/gh-action-pypi-publish@release/v1 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | concurrency: 11 | group: ${ github.workflow }-${ github.ref } 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | pre-commit: 16 | name: Format 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.x" 23 | - uses: pre-commit/action@v3.0.1 24 | - name: PyLint 25 | run: pipx run nox -s pylint 26 | 27 | checks: 28 | name: Check Python ${{ matrix.python-version }} on ${{ matrix.runs-on }} 29 | runs-on: ${{ matrix.runs-on }} 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | python-version: ["3.9", "3.11"] 34 | runs-on: [ubuntu-latest, macos-13, windows-latest] 35 | include: 36 | - python-version: "3.13" 37 | runs-on: ubuntu-latest 38 | - python-version: "3.13" 39 | runs-on: macos-latest 40 | - python-version: pypy-3.10 41 | runs-on: ubuntu-latest 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | 46 | - uses: actions/setup-python@v5 47 | with: 48 | python-version: ${{ matrix.python-version }} 49 | allow-prereleases: true 50 | 51 | - uses: astral-sh/setup-uv@v5 52 | 53 | - name: Install nox 54 | run: uv tool install nox 55 | 56 | - name: Test package 57 | run: nox -s tests 58 | 59 | - name: Test minimum versions 60 | if: matrix.python-version != 'pypy-3.10' && matrix.python-version != '3.13' 61 | run: nox -s minimums 62 | 63 | pass: 64 | if: always() 65 | needs: [pre-commit, checks] 66 | runs-on: ubuntu-latest 67 | steps: 68 | - name: Decide whether the needed jobs succeeded or failed 69 | uses: re-actors/alls-green@release/v1 70 | with: 71 | jobs: ${{ toJSON(needs) }} 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv* 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # setuptools_scm 141 | src/*/_version.py 142 | 143 | # Common debug file 144 | /uproot-Event.root 145 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-merge-conflict 8 | - id: check-symlinks 9 | - id: check-yaml 10 | - id: debug-statements 11 | - id: end-of-file-fixer 12 | - id: mixed-line-ending 13 | - id: requirements-txt-fixer 14 | - id: trailing-whitespace 15 | 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: "v0.9.2" 18 | hooks: 19 | - id: ruff 20 | args: ["--fix", "--show-fixes"] 21 | - id: ruff-format 22 | 23 | - repo: https://github.com/pre-commit/mirrors-mypy 24 | rev: v1.14.1 25 | hooks: 26 | - id: mypy 27 | files: src 28 | additional_dependencies: 29 | - rich>=12 30 | - click 31 | - hist 32 | - numpy 33 | - textual>=0.86 34 | 35 | - repo: https://github.com/codespell-project/codespell 36 | rev: v2.3.0 37 | hooks: 38 | - id: codespell 39 | args: [-L, "hist,iterm"] 40 | 41 | - repo: local 42 | hooks: 43 | - id: disallow-caps 44 | name: Disallow improper capitalization 45 | language: pygrep 46 | entry: PyBind|Numpy|Cmake|CCache|Github|PyTest 47 | exclude: .pre-commit-config.yaml 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Henry Schreiner 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | uproot-browser 2 | 3 | # uproot-browser 4 | 5 | [![Actions Status][actions-badge]][actions-link] 6 | [![PyPI version][pypi-version]][pypi-link] 7 | [![PyPI platforms][pypi-platforms]][pypi-link] 8 | [![GitHub Discussion][github-discussions-badge]][github-discussions-link] 9 | [![Gitter][gitter-badge]][gitter-link] 10 | [![License][license-badge]][license-link] 11 | [![Scikit-HEP][sk-badge]](https://scikit-hep.org/) 12 | [![Conda-Forge][conda-badge]][conda-link] 13 | 14 | uproot-browser is a [plotext](https://github.com/piccolomo/plotext) based command line library in which the command line interface is provided by [Click](https://github.com/pallets/click). It is powered by [Hist](https://github.com/scikit-hep/hist) and it's TUI is put together by [Textual](https://github.com/Textualize/textual). Its aim is to enable a user to browse and look inside a ROOT file, completely via the terminal. It takes its inspiration from the [ROOT object browser](https://root.cern/doc/master/classTRootBrowser.html). 15 | 16 | ## Installation 17 | 18 | You can install this library from [PyPI](https://pypi.org/project/uproot-browser/) with `pip`: 19 | 20 | ```bash 21 | python3 -m pip install uproot-browser 22 | ``` 23 | 24 | You can also use `pipx` to run the library without installing it: 25 | 26 | ```bash 27 | pipx run uproot-browser 28 | ``` 29 | 30 | ## Features 31 | 32 | uproot-browser currently provides the following features (get help with `-h` or `--help`, view the current version with `--version`): 33 | 34 | - `browse` can be used to display a TUI (text user interface), acts as default if no subcommand specified. 35 | - `plot` can be used to display a plot. 36 | - `tree` can be used to display a tree. 37 | 38 | 39 | ## Examples 40 | 41 | This example uses data from the [scikit-hep-testdata](https://github.com/scikit-hep/scikit-hep-testdata) package. It is placed in the same directory as the uproot-browser repository. 42 | 43 | **`browse` command:** 44 | 45 | ```bash 46 | uproot-browser browse ../scikit-hep-testdata/src/skhep_testdata/data/uproot-Event.root 47 | ``` 48 | 49 | ![GIF of the TUI functionality](https://github.com/scikit-hep/uproot-browser/releases/download/v0.5.0/tui.gif) 50 | 51 | **`plot` command:** 52 | 53 | ```bash 54 | uproot-browser plot ../scikit-hep-testdata/src/skhep_testdata/data/uproot-Event.root:hstat 55 | hstat -- Entries: 1000 56 | ┌───────────────────────────────────────────────────────────────┐ 57 | 18.0┤▐▌ │ 58 | │▐▌ ▗▖ ▄│ 59 | 15.6┤▐▌▗▖ ▐▌ █│ 60 | │███▌ █ █ ▐▌ ▐█│ 61 | 13.1┤████▟▌ ▗▖ ▗▖ █▌▗▖ ▐▌ ▄ █▌ ▄ ▟▌█ ▗▄▐▙▗▖ ▐▌▐█│ 62 | 10.6┤█████▌ ▐▌ ▐▙▖ █▌▐▌ ▐▙ █▄ █▙ █ █▌█ ▐█▟█▐▌ ▗▄▟▌▐█│ 63 | │█████▌ █▌▐█▌ ████▌█▌▐█ ▐█▐▌ ▐▌ ███▐██ ▐█ ▐████▐███▐▌ ▐███▌▐█│ 64 | 8.2┤█████▌▐█▌▐█▌ █████▌██▐█ ██▐█ ▐▌▐████▐███▌▐█ █████▐███▐██▐████▐█│ 65 | │████████▙██▌█████████▟█▖████▖▟██████▟██████▖████████████▟██████│ 66 | 5.8┤███████████▙███████████▙████▌██████████████▌███████████████████│ 67 | │████████████████████████████▌██████████████████████████████████│ 68 | 3.3┤███████████████████████████████████████████████████████████████│ 69 | └┬───────────────┬──────────────┬───────────────┬──────────────┬┘ 70 | 0.00 0.25 0.50 0.75 1.00 71 | [x] xaxis 72 | ``` 73 | 74 |
75 | If you're on macOS and using iTerm2, click here:
76 | 77 | You can get an iterm plot, the required dependencies can be installed via: 78 | 79 | ```bash 80 | python3 -m pip install uproot-browser[iterm] 81 | ``` 82 | 83 | Or can be run via `pipx` without installing: 84 | 85 | ```bash 86 | pipx run uproot-browser[iterm] 87 | ``` 88 | 89 | Adding the argument `--iterm` gives us the plot: 90 | 91 | ```bash 92 | uproot-browser plot ../scikit-hep-testdata/src/skhep_testdata/data/uproot-Event.root:hstat --iterm 93 | ``` 94 | 95 | iterm example 96 | 97 |

98 | 99 | **`tree` command:** 100 | 101 | ```bash 102 | uproot-browser tree ../scikit-hep-testdata/src/skhep_testdata/data/uproot-Event.root 103 | 📁 uproot-Event.root 104 | ┣━━ ❓ TProcessID 105 | ┣━━ 🌴 T (1000) 106 | ┃ ┗━━ 🌿 event Event 107 | ┃ ┣━━ 🌿 TObject (group of fUniqueID:uint32_t, fBits:uint32_t) 108 | ┃ ┃ ┣━━ 🍁 fBits uint32_t 109 | ┃ ┃ ┗━━ 🍁 fUniqueID uint32_t 110 | ┃ ┣━━ 🍁 fClosestDistance unknown[] 111 | ┃ ┣━━ 🍁 fEventName char* 112 | ┃ ┣━━ 🌿 fEvtHdr EventHeader 113 | ┃ ┃ ┣━━ 🍁 fEvtHdr.fDate int32_t 114 | ┃ ┃ ┣━━ 🍁 fEvtHdr.fEvtNum int32_t 115 | ┃ ┃ ┗━━ 🍁 fEvtHdr.fRun int32_t 116 | ┃ ┣━━ 🍁 fFlag uint32_t 117 | ┃ ┣━━ 🍁 fH TH1F 118 | ┃ ┣━━ 🍁 fHighPt TRefArray* 119 | ┃ ┣━━ 🍁 fIsValid bool 120 | ┃ ┣━━ 🍁 fLastTrack TRef 121 | ┃ ┣━━ 🍁 fMatrix[4][4] float[4][4] 122 | ┃ ┣━━ 🍁 fMeasures[10] int32_t[10] 123 | ┃ ┣━━ 🍁 fMuons TRefArray* 124 | ┃ ┣━━ 🍁 fNseg int32_t 125 | ┃ ┣━━ 🍁 fNtrack int32_t 126 | ┃ ┣━━ 🍁 fNvertex uint32_t 127 | ┃ ┣━━ 🍁 fTemperature float 128 | ┃ ┣━━ 🌿 fTracks TClonesArray* 129 | ┃ ┃ ┣━━ 🍃 fTracks.fBits uint32_t[] 130 | ┃ ┃ ┣━━ 🍃 fTracks.fBx Float16_t[] 131 | ┃ ┃ ┣━━ 🍃 fTracks.fBy Float16_t[] 132 | ┃ ┃ ┣━━ 🍃 fTracks.fCharge Double32_t[] 133 | ┃ ┃ ┣━━ 🍃 fTracks.fMass2 Float16_t[] 134 | ┃ ┃ ┣━━ 🍃 fTracks.fMeanCharge float[] 135 | ┃ ┃ ┣━━ 🍃 fTracks.fNpoint int32_t[] 136 | ┃ ┃ ┣━━ 🍃 fTracks.fNsp uint32_t[] 137 | ┃ ┃ ┣━━ 🍁 fTracks.fPointValue unknown[][] 138 | ┃ ┃ ┣━━ 🍃 fTracks.fPx float[] 139 | ┃ ┃ ┣━━ 🍃 fTracks.fPy float[] 140 | ┃ ┃ ┣━━ 🍃 fTracks.fPz float[] 141 | ┃ ┃ ┣━━ 🍃 fTracks.fRandom float[] 142 | ┃ ┃ ┣━━ 🍃 fTracks.fTArray[3] float[][3] 143 | ┃ ┃ ┣━━ 🍁 fTracks.fTriggerBits.fAllBits uint8_t[][] 144 | ┃ ┃ ┣━━ 🍃 fTracks.fTriggerBits.fBits uint32_t[] 145 | ┃ ┃ ┣━━ 🍃 fTracks.fTriggerBits.fNbits uint32_t[] 146 | ┃ ┃ ┣━━ 🍃 fTracks.fTriggerBits.fNbytes uint32_t[] 147 | ┃ ┃ ┣━━ 🍃 fTracks.fTriggerBits.fUniqueID uint32_t[] 148 | ┃ ┃ ┣━━ 🍃 fTracks.fUniqueID uint32_t[] 149 | ┃ ┃ ┣━━ 🍃 fTracks.fValid int16_t[] 150 | ┃ ┃ ┣━━ 🍃 fTracks.fVertex[3] Double32_t[][3] 151 | ┃ ┃ ┣━━ 🍃 fTracks.fXfirst Float16_t[] 152 | ┃ ┃ ┣━━ 🍃 fTracks.fXlast Float16_t[] 153 | ┃ ┃ ┣━━ 🍃 fTracks.fYfirst Float16_t[] 154 | ┃ ┃ ┣━━ 🍃 fTracks.fYlast Float16_t[] 155 | ┃ ┃ ┣━━ 🍃 fTracks.fZfirst Float16_t[] 156 | ┃ ┃ ┗━━ 🍃 fTracks.fZlast Float16_t[] 157 | ┃ ┣━━ 🌿 fTriggerBits TBits 158 | ┃ ┃ ┣━━ 🌿 fTriggerBits.TObject (group of fTriggerBits.fUniqueID:uint32_t, fTriggerBits.fBits:uint32_t) 159 | ┃ ┃ ┃ ┣━━ 🍁 fTriggerBits.fBits uint32_t 160 | ┃ ┃ ┃ ┗━━ 🍁 fTriggerBits.fUniqueID uint32_t 161 | ┃ ┃ ┣━━ 🍃 fTriggerBits.fAllBits uint8_t[] 162 | ┃ ┃ ┣━━ 🍁 fTriggerBits.fNbits uint32_t 163 | ┃ ┃ ┗━━ 🍁 fTriggerBits.fNbytes uint32_t 164 | ┃ ┣━━ 🍁 fType[20] int8_t[20] 165 | ┃ ┗━━ 🍁 fWebHistogram TRef 166 | ┣━━ 📊 hstat TH1F (100) 167 | ┗━━ 📊 htime TH1F (10) 168 | ``` 169 | 170 | ## Development 171 | 172 | [![pre-commit.ci status][pre-commit-badge]][pre-commit-link] 173 | 174 | See [CONTRIBUTING.md](https://github.com/scikit-hep/uproot-browser/blob/main/.github/CONTRIBUTING.md) for details on how to set up a development environment. 175 | 176 | [actions-badge]: https://github.com/scikit-hep/uproot-browser/workflows/CI/badge.svg 177 | [actions-link]: https://github.com/scikit-hep/uproot-browser/actions 178 | [conda-badge]: https://img.shields.io/conda/vn/conda-forge/uproot-browser 179 | [conda-link]: https://github.com/conda-forge/uproot-browser-feedstock 180 | [github-discussions-badge]: https://img.shields.io/static/v1?label=Discussions&message=Ask&color=blue&logo=github 181 | [github-discussions-link]: https://github.com/scikit-hep/uproot-browser/discussions 182 | [gitter-badge]: https://badges.gitter.im/Scikit-HEP/community.svg 183 | [gitter-link]: https://gitter.im/Scikit-HEP/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge 184 | [license-badge]: https://img.shields.io/badge/License-BSD_3--Clause-blue.svg 185 | [license-link]: https://opensource.org/licenses/BSD-3-Clause 186 | [pypi-link]: https://pypi.org/project/uproot-browser/ 187 | [pypi-platforms]: https://img.shields.io/pypi/pyversions/uproot-browser 188 | [pypi-version]: https://badge.fury.io/py/uproot-browser.svg 189 | [sk-badge]: https://scikit-hep.org/assets/images/Scikit--HEP-Project-blue.svg 190 | [pre-commit-badge]: https://results.pre-commit.ci/badge/github/scikit-hep/uproot-browser/main.svg 191 | [pre-commit-link]: https://results.pre-commit.ci/repo/github/scikit-hep/uproot-browser 192 | -------------------------------------------------------------------------------- /docs/_images/iterm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scikit-hep/uproot-browser/055535039114db16ba640b73f673e4df4adf0ef9/docs/_images/iterm.png -------------------------------------------------------------------------------- /docs/_images/uproot-browser-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scikit-hep/uproot-browser/055535039114db16ba640b73f673e4df4adf0ef9/docs/_images/uproot-browser-logo.png -------------------------------------------------------------------------------- /docs/make_logo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import PIL.Image 4 | import PIL.ImageDraw 5 | import PIL.ImageFilter 6 | import PIL.ImageFont 7 | 8 | LOGO = """\ 9 | ┬ ┬┌─┐┬─┐┌─┐┌─┐┌┬┐5 ┌┐ ┬─┐┌─┐┬ ┬┌─┐┌─┐┬─┐ 10 | │ │├─┘├┬┘│ ││ │ │───├┴┐├┬┘│ ││││└─┐├┤ ├┬┘ 11 | └─┘┴ ┴└─└─┘└─┘ ┴ └─┘┴└─└─┘└┴┘└─┘└─┘┴└─ 12 | """ 13 | 14 | image = PIL.Image.new("RGBA", (810, 120), color=(0, 0, 0, 0)) 15 | 16 | draw = PIL.ImageDraw.Draw(image) 17 | 18 | font = PIL.ImageFont.truetype("Sauce Code Pro Medium Nerd Font Complete.ttf", 32) 19 | 20 | draw.text((10, 0), LOGO, font=font, fill=(128, 128, 128, 255)) 21 | 22 | image.save("docs/_images/uproot-browser-logo.png") 23 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import nox 4 | 5 | nox.needs_version = ">=2024.4.15" 6 | nox.options.default_venv_backend = "uv|virtualenv" 7 | 8 | 9 | def dep_group(group: str) -> list[str]: 10 | return nox.project.load_toml("pyproject.toml")["dependency-groups"][group] # type: ignore[no-any-return] 11 | 12 | 13 | @nox.session(reuse_venv=True) 14 | def lint(session: nox.Session) -> None: 15 | """ 16 | Run the linter. 17 | """ 18 | session.install("pre-commit") 19 | session.run("pre-commit", "run", "--all-files", *session.posargs) 20 | 21 | 22 | @nox.session 23 | def pylint(session: nox.Session) -> None: 24 | """ 25 | Run pylint. 26 | """ 27 | 28 | session.install("-e.", "pylint", "matplotlib") 29 | session.run("pylint", "src", *session.posargs) 30 | 31 | 32 | @nox.session 33 | def tests(session: nox.Session) -> None: 34 | """ 35 | Run the unit and regular tests. 36 | """ 37 | session.install("-e.", *dep_group("test")) 38 | session.run("pytest", *session.posargs) 39 | 40 | 41 | @nox.session(venv_backend="uv") 42 | def minimums(session: nox.Session) -> None: 43 | """ 44 | Run the unit and regular tests. 45 | """ 46 | session.install( 47 | "-e.", *dep_group("test"), "--resolution=lowest-direct", "--only-binary=:all:" 48 | ) 49 | session.run("pytest", *session.posargs) 50 | 51 | 52 | @nox.session(default=False) 53 | def run(session: nox.Session) -> None: 54 | """ 55 | Install and run. 56 | """ 57 | session.install("-e.", "--compile") 58 | session.run("uproot-browser", *session.posargs) 59 | 60 | 61 | @nox.session(reuse_venv=True, default=False) 62 | def build(session: nox.Session) -> None: 63 | """ 64 | Build an SDist and wheel. 65 | """ 66 | 67 | session.install("build") 68 | session.run("python", "-m", "build") 69 | 70 | 71 | @nox.session(default=False) 72 | def make_logo(session: nox.Session) -> None: 73 | """ 74 | Rerender the logo. 75 | """ 76 | 77 | session.install("pillow") 78 | session.run("python", "docs/make_logo.py") 79 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "uproot_browser" 7 | authors = [ 8 | { name = "Henry Schreiner", email = "henryfs@princeton.edu" }, 9 | ] 10 | maintainers = [ 11 | { name = "The Scikit-HEP admins", email = "scikit-hep-admins@googlegroups.com" }, 12 | ] 13 | 14 | description = "Tools to inspect ROOT files with uproot" 15 | readme = "README.md" 16 | 17 | requires-python = ">=3.9" 18 | 19 | classifiers = [ 20 | "License :: OSI Approved :: BSD License", 21 | "Topic :: Scientific/Engineering", 22 | "Intended Audience :: Science/Research", 23 | "Intended Audience :: Developers", 24 | "Operating System :: OS Independent", 25 | "License :: OSI Approved :: BSD License", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | "Programming Language :: Python :: 3.13", 33 | "Development Status :: 4 - Beta", 34 | "Typing :: Typed", 35 | ] 36 | 37 | dynamic = ["version"] 38 | dependencies = [ 39 | 'awkward >=2', 40 | 'click >=8', 41 | 'click-default-group >=1.2', 42 | 'hist >=2.4', 43 | 'lz4>=2', 44 | 'numpy >=1.18', 45 | 'plotext >=5.2.8', 46 | 'rich >=13.3.3', 47 | 'textual >=0.86.0', 48 | 'uproot >=5', 49 | ] 50 | 51 | [project.optional-dependencies] 52 | iterm = [ 53 | "matplotlib", 54 | "itermplot==0.5", 55 | "mplhep", 56 | ] 57 | 58 | [project.urls] 59 | homepage = "https://github.com/scikit-hep/uproot-browser" 60 | repository = "https://github.com/scikit-hep/uproot-browser" 61 | 62 | [project.scripts] 63 | uproot-browser = "uproot_browser.__main__:main" 64 | 65 | [dependency-groups] 66 | test = [ 67 | "pytest >=8", 68 | "pytest-asyncio >=0.24", 69 | "scikit-hep-testdata >=0.4.10", 70 | ] 71 | dev = [ 72 | "ipython >=6", 73 | "textual-dev", 74 | {include-group = "test"}, 75 | ] 76 | 77 | [tool.hatch] 78 | version.source = "vcs" 79 | build.hooks.vcs.version-file = "src/uproot_browser/_version.py" 80 | 81 | [tool.pytest.ini_options] 82 | minversion = "8.0" 83 | addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] 84 | xfail_strict = true 85 | filterwarnings = [ 86 | "error", 87 | "ignore:can't resolve package from __spec__ or __package__, falling back on __name__ and __path__:ImportWarning", # PyPy NumPy 88 | ] 89 | log_cli_level = "info" 90 | testpaths = ["tests"] 91 | asyncio_mode = "auto" 92 | asyncio_default_fixture_loop_scope = "function" 93 | 94 | 95 | [tool.mypy] 96 | files = "src" 97 | python_version = "3.9" 98 | warn_unused_configs = true 99 | strict = true 100 | 101 | [[tool.mypy.overrides]] 102 | module = ["plotext.*", "awkward.*", "uproot.*", "matplotlib.*"] 103 | ignore_missing_imports = true 104 | 105 | 106 | [tool.pylint] 107 | master.py-version = "3.9" 108 | master.jobs = "0" 109 | reports.output-format = "colorized" 110 | similarities.ignore-imports = "yes" 111 | messages_control.enable = [ 112 | "useless-suppression", 113 | ] 114 | messages_control.disable = [ 115 | "broad-except", 116 | "design", 117 | "invalid-name", 118 | "line-too-long", 119 | "missing-class-docstring", 120 | "missing-function-docstring", 121 | "missing-module-docstring", 122 | "duplicate-code", 123 | "unused-argument", # Handled by Ruff 124 | "wrong-import-position", # Handled by Ruff 125 | ] 126 | 127 | 128 | [tool.ruff.lint] 129 | extend-select = [ 130 | "B", # flake8-bugbear 131 | "I", # isort 132 | "ARG", # flake8-unused-arguments 133 | "C4", # flake8-comprehensions 134 | "EM", # flake8-errmsg 135 | "ICN", # flake8-import-conventions 136 | "ISC", # flake8-implicit-str-concat 137 | "PGH", # pygrep-hooks 138 | "PIE", # flake8-pie 139 | "PL", # pylint 140 | "PT", # flake8-pytest-style 141 | "PTH", # flake8-use-pathlib 142 | "RET", # flake8-return 143 | "RUF", # Ruff-specific 144 | "SIM", # flake8-simplify 145 | "T20", # flake8-print 146 | "UP", # pyupgrade 147 | "YTT", # flake8-2020 148 | ] 149 | ignore = [ 150 | "E501", 151 | "E722", 152 | "RUF001", # Unicode chars 153 | "PLR", 154 | "ISC001", # Conflicts with formatter 155 | ] 156 | unfixable = [ 157 | "SIM118", # Dict .keys() removal (uproot) 158 | ] 159 | 160 | [tool.ruff.lint.per-file-ignores] 161 | "noxfile.py" = ["T20"] 162 | "tests/*" = ["T20"] 163 | "src/uproot_browser/tree.py" = ["UP006"] 164 | -------------------------------------------------------------------------------- /src/uproot_browser/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is uproot-browser. There is no user accessible API; only a terminal 3 | interface is provided currently. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from ._version import version as __version__ 9 | 10 | __all__ = ("__version__",) 11 | -------------------------------------------------------------------------------- /src/uproot_browser/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the click-powered CLI. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import functools 8 | import os 9 | import typing 10 | from typing import Any, Callable 11 | 12 | import click 13 | import uproot 14 | 15 | from ._version import version as __version__ 16 | 17 | CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} 18 | 19 | VERSION = __version__ 20 | 21 | if typing.TYPE_CHECKING: 22 | DefaultGroup = click.Group 23 | else: 24 | from click_default_group import DefaultGroup 25 | 26 | 27 | @click.group(context_settings=CONTEXT_SETTINGS, cls=DefaultGroup, default="browse") 28 | @click.version_option(version=VERSION) 29 | def main() -> None: 30 | """ 31 | Must provide a subcommand. 32 | """ 33 | 34 | 35 | @main.command() 36 | @click.argument("filename") 37 | def tree(filename: str) -> None: 38 | """ 39 | Display a tree. 40 | """ 41 | import uproot_browser.tree # pylint: disable=import-outside-toplevel 42 | 43 | uproot_browser.tree.print_tree(filename) 44 | 45 | 46 | def intercept(func: Callable[..., Any], *names: str) -> Callable[..., Any]: 47 | """ 48 | Intercept function arguments and remove them 49 | """ 50 | 51 | @functools.wraps(func) 52 | def new_func(*args: Any, **kwargs: Any) -> Any: 53 | for name in names: 54 | kwargs.pop(name) 55 | return func(*args, **kwargs) 56 | 57 | return new_func 58 | 59 | 60 | @main.command() 61 | @click.argument("filename") 62 | @click.option( 63 | "--iterm", is_flag=True, help="Display an iTerm plot (requires [iterm] extra)." 64 | ) 65 | def plot(filename: str, iterm: bool) -> None: 66 | """ 67 | Display a plot. 68 | """ 69 | if iterm: 70 | os.environ.setdefault("MPLBACKEND", r"module://itermplot") 71 | 72 | import matplotlib.pyplot as plt # pylint: disable=import-outside-toplevel 73 | 74 | import uproot_browser.plot_mpl # pylint: disable=import-outside-toplevel 75 | else: 76 | import uproot_browser.plot # pylint: disable=import-outside-toplevel 77 | 78 | item = uproot.open(filename) 79 | 80 | if iterm: 81 | uproot_browser.plot_mpl.plot(item) 82 | if plt.get_backend() == r"module://itermplot": 83 | fm = plt.get_current_fig_manager() 84 | canvas = fm.canvas 85 | canvas.__class__.print_figure = intercept( 86 | canvas.__class__.print_figure, "facecolor", "edgecolor" 87 | ) 88 | 89 | plt.show() 90 | else: 91 | uproot_browser.plot.clf() 92 | uproot_browser.plot.plot(item) 93 | uproot_browser.plot.show() 94 | 95 | 96 | @main.command() 97 | @click.argument("filename") 98 | def browse(filename: str) -> None: 99 | """ 100 | Display a TUI. 101 | """ 102 | import uproot_browser.tui.browser # pylint: disable=import-outside-toplevel 103 | 104 | app = uproot_browser.tui.browser.Browser( 105 | path=filename, 106 | ) 107 | 108 | app.run() 109 | 110 | 111 | if __name__ == "__main__": 112 | main() 113 | -------------------------------------------------------------------------------- /src/uproot_browser/_version.pyi: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | version: str 4 | version_tuple: tuple[int, int, int] | tuple[int, int, int, str, str] 5 | -------------------------------------------------------------------------------- /src/uproot_browser/exceptions.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions for uproot-browser""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | class EmptyTreeError(ValueError): 7 | pass 8 | -------------------------------------------------------------------------------- /src/uproot_browser/plot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Display tools for making plots via plotext. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import functools 8 | import math 9 | from typing import Any 10 | 11 | import awkward as ak 12 | import hist 13 | import numpy as np 14 | import plotext as plt 15 | import uproot 16 | 17 | from uproot_browser.exceptions import EmptyTreeError 18 | 19 | 20 | def clf() -> None: 21 | """ 22 | Clear the plot. 23 | """ 24 | plt.clf() 25 | 26 | 27 | def show() -> None: 28 | """ 29 | Show the plot. 30 | """ 31 | plt.show() 32 | 33 | 34 | def make_hist_title(item: Any, histogram: hist.Hist) -> str: 35 | inner_sum: float = np.sum(histogram.values()) 36 | full_sum: float = np.sum(histogram.values(flow=True)) 37 | 38 | if math.isclose(inner_sum, full_sum): 39 | return f"{item.name} -- Entries: {inner_sum:g}" 40 | 41 | return f"{item.name} -- Entries: {inner_sum:g} ({full_sum:g} with flow)" 42 | 43 | 44 | @functools.singledispatch 45 | def plot(tree: Any) -> None: # noqa: ARG001 46 | """ 47 | Implement this for each type of plottable. 48 | """ 49 | msg = "This object is not plottable yet" 50 | raise RuntimeError(msg) 51 | 52 | 53 | @plot.register 54 | def plot_branch(tree: uproot.TBranch) -> None: 55 | """ 56 | Plot a single tree branch. 57 | """ 58 | array = tree.array() 59 | values = ak.flatten(array) if array.ndim > 1 else array 60 | finite = values[np.isfinite(values)] 61 | if len(finite) < 1: 62 | msg = f"Branch {tree.name} is empty." 63 | raise EmptyTreeError(msg) 64 | histogram: hist.Hist = hist.numpy.histogram(finite, bins=100, histogram=hist.Hist) 65 | plt.bar(histogram.axes[0].centers, histogram.values().astype(float)) 66 | plt.ylim(lower=0) 67 | plt.xticks(np.linspace(histogram.axes[0][0][0], histogram.axes[0][-1][-1], 5)) 68 | plt.xlabel(histogram.axes[0].name) 69 | plt.title(make_hist_title(tree, histogram)) 70 | 71 | 72 | @plot.register 73 | def plot_hist(tree: uproot.behaviors.TH1.Histogram) -> None: 74 | """ 75 | Plot a 1-D Histogram. 76 | """ 77 | histogram = hist.Hist(tree.to_hist()) 78 | plt.bar(histogram.axes[0].centers, histogram.values().astype(float)) 79 | plt.ylim(lower=0) 80 | plt.xticks(np.linspace(histogram.axes[0][0][0], histogram.axes[0][-1][-1], 5)) 81 | plt.xlabel(histogram.axes[0].name) 82 | plt.title(make_hist_title(tree, histogram)) 83 | -------------------------------------------------------------------------------- /src/uproot_browser/plot_mpl.py: -------------------------------------------------------------------------------- 1 | """ 2 | Display tools for making plots via plotext. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import functools 8 | from typing import Any 9 | 10 | import awkward as ak 11 | import hist 12 | import matplotlib.pyplot as plt 13 | import uproot 14 | import uproot.behaviors.TH1 15 | 16 | import uproot_browser.plot 17 | 18 | 19 | @functools.singledispatch 20 | def plot(tree: Any) -> None: # noqa: ARG001 21 | """ 22 | Implement this for each type of plottable. 23 | """ 24 | msg = "This object is not plottable yet" 25 | raise RuntimeError(msg) 26 | 27 | 28 | @plot.register 29 | def plot_branch(tree: uproot.TBranch) -> None: 30 | """ 31 | Plot a single tree branch. 32 | """ 33 | array = tree.array() 34 | histogram: hist.Hist = hist.numpy.histogram( 35 | ak.flatten(array) if array.ndim > 1 else array, bins=50, histogram=hist.Hist 36 | ) 37 | histogram.plot() 38 | plt.title(uproot_browser.plot.make_hist_title(tree, histogram)) 39 | 40 | 41 | @plot.register 42 | def plot_hist(tree: uproot.behaviors.TH1.Histogram) -> None: 43 | """ 44 | Plot a 1-D Histogram. 45 | """ 46 | histogram = hist.Hist(tree.to_hist()) 47 | histogram.plot() 48 | plt.title(uproot_browser.plot.make_hist_title(tree, histogram)) 49 | -------------------------------------------------------------------------------- /src/uproot_browser/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scikit-hep/uproot-browser/055535039114db16ba640b73f673e4df4adf0ef9/src/uproot_browser/py.typed -------------------------------------------------------------------------------- /src/uproot_browser/tree.py: -------------------------------------------------------------------------------- 1 | """ 2 | Display tools for TTrees. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import dataclasses 8 | import functools 9 | from pathlib import Path 10 | from typing import Any, TypedDict 11 | 12 | import uproot 13 | import uproot.reading 14 | from rich.console import Console 15 | from rich.markup import escape 16 | from rich.text import Text 17 | from rich.tree import Tree 18 | 19 | console = Console() 20 | 21 | __all__ = ( 22 | "MetaDict", 23 | "UprootEntry", 24 | "console", 25 | "make_tree", 26 | "print_tree", 27 | "process_item", 28 | ) 29 | 30 | 31 | def __dir__() -> tuple[str, ...]: 32 | return __all__ 33 | 34 | 35 | class MetaDictRequired(TypedDict, total=True): 36 | label_text: Text 37 | label_icon: str 38 | 39 | 40 | class MetaDict(MetaDictRequired, total=False): 41 | guide_style: str 42 | 43 | 44 | @dataclasses.dataclass 45 | class UprootEntry: 46 | path: str 47 | item: Any 48 | 49 | @property 50 | def is_dir(self) -> bool: 51 | if isinstance(self.item, uproot.reading.ReadOnlyDirectory): 52 | return True 53 | if isinstance(self.item, uproot.behaviors.TBranch.HasBranches): 54 | return len(self.item.branches) > 0 55 | return False 56 | 57 | def meta(self) -> MetaDict: 58 | return process_item(self.item) 59 | 60 | def label(self) -> Text: 61 | meta = self.meta() 62 | return Text.assemble(meta["label_icon"], meta["label_text"]) 63 | 64 | def tree_args(self) -> dict[str, Any]: 65 | d: dict[str, Text | str] = {"label": self.label()} 66 | if "guide_style" in self.meta(): 67 | d["guide_style"] = self.meta()["guide_style"] 68 | return d 69 | 70 | @property 71 | def children(self) -> list[UprootEntry]: 72 | if not self.is_dir: 73 | return [] 74 | if isinstance(self.item, uproot.reading.ReadOnlyDirectory): 75 | items = { 76 | key.split(";")[0] 77 | for key in self.item.keys() # noqa: SIM118 78 | if "/" not in key 79 | } 80 | elif isinstance(self.item, uproot.behaviors.TBranch.HasBranches): 81 | items = {item.name for item in self.item.branches} 82 | else: 83 | items = {obj.name.split(";")[0] for obj in self.item.branches} 84 | return [ 85 | UprootEntry(f"{self.path}/{key}", self.item[key]) for key in sorted(items) 86 | ] 87 | 88 | 89 | def make_tree(node: UprootEntry, *, tree: Tree | None = None) -> Tree: 90 | """ 91 | Given an object, build a rich.tree.Tree output. 92 | """ 93 | 94 | tree = Tree(**node.tree_args()) if tree is None else tree.add(**node.tree_args()) 95 | 96 | for child in node.children: 97 | make_tree(child, tree=tree) 98 | 99 | return tree 100 | 101 | 102 | @functools.singledispatch 103 | def process_item(uproot_object: Any) -> MetaDict: 104 | """ 105 | Given an unknown object, return a rich.tree.Tree output. Specialize for known objects. 106 | """ 107 | name = getattr(uproot_object, "name", "") 108 | classname = getattr(uproot_object, "classname", uproot_object.__class__.__name__) 109 | label_text = Text.assemble( 110 | (f"{name} ", "bold"), 111 | (classname, "italic"), 112 | ) 113 | return MetaDict(label_icon="❓ ", label_text=label_text) 114 | 115 | 116 | @process_item.register 117 | def _process_item_tfile( 118 | uproot_object: uproot.reading.ReadOnlyDirectory, 119 | ) -> MetaDict: 120 | """ 121 | Given an TFile, return a rich.tree.Tree output. 122 | """ 123 | path = Path(uproot_object.file_path) 124 | 125 | if uproot_object.path: 126 | # path is to a TDirectory on tree 127 | path_name = escape(uproot_object.path[0]) 128 | link_text = f"file://{path}:/{path_name}" 129 | else: 130 | # path is the top of the tree: the file 131 | path_name = escape(path.name) 132 | link_text = f"file://{path}" 133 | 134 | label_text = Text.from_markup(f"[link {link_text}]{path_name}") 135 | 136 | return MetaDict( 137 | label_icon="📁 ", 138 | label_text=label_text, 139 | guide_style="bold bright_blue", 140 | ) 141 | 142 | 143 | @process_item.register 144 | def _process_item_ttree(uproot_object: uproot.TTree) -> MetaDict: 145 | """ 146 | Given an tree, return a rich.tree.Tree output. 147 | """ 148 | label_text = Text.assemble( 149 | (f"{uproot_object.name} ", "bold"), 150 | f"({uproot_object.num_entries:g})", 151 | ) 152 | 153 | return MetaDict( 154 | label_icon="🌴 ", 155 | label_text=label_text, 156 | guide_style="bold bright_green", 157 | ) 158 | 159 | 160 | @process_item.register 161 | def _process_item_tbranch(uproot_object: uproot.TBranch) -> MetaDict: 162 | """ 163 | Given an branch, return a rich.tree.Tree output. 164 | """ 165 | 166 | jagged = isinstance( 167 | uproot_object.interpretation, uproot.interpretation.jagged.AsJagged 168 | ) 169 | icon = "🍃 " if jagged else "🍁 " 170 | 171 | if len(uproot_object.branches): 172 | icon = "🌿 " 173 | 174 | label_text = Text.assemble( 175 | (f"{uproot_object.name} ", "bold"), 176 | (f"{uproot_object.typename}", "italic"), 177 | ) 178 | 179 | return MetaDict( 180 | label_icon=icon, 181 | label_text=label_text, 182 | guide_style="bold bright_green", 183 | ) 184 | 185 | 186 | @process_item.register 187 | def _process_item_th(uproot_object: uproot.behaviors.TH1.Histogram) -> MetaDict: 188 | """ 189 | Given an histogram, return a rich.tree.Tree output. 190 | """ 191 | icon = "📊 " if uproot_object.kind == "COUNT" else "📈 " 192 | sizes = " × ".join(f"{len(ax)}" for ax in uproot_object.axes) 193 | 194 | label_text = Text.assemble( 195 | (f"{uproot_object.name} ", "bold"), 196 | (f"{uproot_object.classname} ", "italic"), 197 | f"({sizes})", 198 | ) 199 | return MetaDict( 200 | label_icon=icon, 201 | label_text=label_text, 202 | ) 203 | 204 | 205 | # pylint: disable-next=redefined-outer-name 206 | def print_tree(entry: str, *, console: Console = console) -> None: 207 | """ 208 | Prints a tree given a specification string. Currently, that must be a 209 | single filename. Colons are not allowed currently in the filename. 210 | """ 211 | 212 | upfile = uproot.open(entry) 213 | tree = make_tree(UprootEntry("/", upfile)) 214 | console.print(tree) 215 | -------------------------------------------------------------------------------- /src/uproot_browser/tui/README.md: -------------------------------------------------------------------------------- 1 | # Help 2 | 3 | Welcome to uproot-browser's help! 4 | 5 | ## Navigation 6 | 7 | Use the arrow keys to navigate the tree view. Press `enter` to select a 8 | something to plot. Press `spacebar` to open/close a directory or tree. You can 9 | also use the VIM keys: `j` to move down, `k` to move up, `l` to open a folder, 10 | and `h` to close a folder. 11 | 12 | ## Plotting 13 | 14 | Histograms, rectangular simple data (e.g. TTree's), and jagged arrays can 15 | be plotted. Click on an item or press `enter` to plot. If something can't 16 | be plotted, you'll see a scrollable error traceback. If you think it should 17 | be plottable, feel free to open an issue. 2D plots are not yet supported. 18 | 19 | ## Themes 20 | 21 | You can press `t` to toggle light and dark mode. 22 | 23 | ## Leaving 24 | 25 | You can press `q` to quit. You can also press `d` to quit with a dump of the 26 | current plot and how to get the object being plotted in Python uproot code. 27 | 28 | ## Exiting the help. 29 | 30 | You can press `esc` or `q` to exit this help screen. You can also use `tab` to 31 | switch between the panes in the help screen. You can use `F1` (or `?`) to access 32 | the help again. 33 | 34 | # Credits 35 | 36 | Uproot browser was created by Henry Schreiner and Aman Goel. It was rewritten 37 | by Elie Svoll and Jose Ayala Garcia to use the modern CSS-based Textual 38 | library. 39 | 40 | Uproot browser is made possible by the Scikit-HEP ecosystem, including uproot 41 | and awkward-array for data reading, hist and boost-histogram for computation. 42 | The TUI (terminal user interface) was built using the Textual library. 43 | Text-based plotting is provided by plotext. 44 | -------------------------------------------------------------------------------- /src/uproot_browser/tui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scikit-hep/uproot-browser/055535039114db16ba640b73f673e4df4adf0ef9/src/uproot_browser/tui/__init__.py -------------------------------------------------------------------------------- /src/uproot_browser/tui/browser.css: -------------------------------------------------------------------------------- 1 | Browser Widget { 2 | scrollbar-color: $secondary-darken-1; 3 | scrollbar-color-hover: $secondary-darken-3; 4 | scrollbar-color-active: $secondary-darken-3; 5 | scrollbar-background: $surface-darken-1; 6 | scrollbar-background-hover: $surface-darken-1; 7 | scrollbar-background-active: $surface-darken-1; 8 | scrollbar-size: 1 1; 9 | } 10 | 11 | #tree-view { 12 | display: none; 13 | overflow: auto; 14 | width: 25%; 15 | height: 100%; 16 | dock: left; 17 | padding-top: 1; 18 | } 19 | 20 | Tree > .tree--cursor { 21 | text-style: bold; 22 | } 23 | 24 | Browser.-show-tree #tree-view { 25 | display: block; 26 | max-width: 50%; 27 | } 28 | 29 | ContentSwitcher#main-view { 30 | height: 100%; 31 | } 32 | 33 | #logo { 34 | content-align: center middle; 35 | } 36 | 37 | #plot { 38 | overflow: auto; 39 | min-width: 100%; 40 | padding-left: 1; 41 | padding-right: 1; 42 | } 43 | 44 | #error{ 45 | overflow: auto; 46 | min-width: 100%; 47 | } 48 | 49 | #empty { 50 | content-align: center middle; 51 | } 52 | 53 | Footer > .footer--highlight { 54 | background: $secondary-darken-2; 55 | } 56 | 57 | Footer > .footer--key { 58 | text-style: bold; 59 | background: $secondary-darken-3; 60 | } 61 | 62 | Footer > .footer--highlight-key { 63 | background: $secondary-darken-2; 64 | text-style: bold; 65 | } 66 | 67 | Footer { 68 | background: $secondary-lighten-2; 69 | } 70 | 71 | HelpScreen { 72 | align: center middle; 73 | } 74 | 75 | .dialog { 76 | padding: 0 1; 77 | width: 80%; 78 | height: 90%; 79 | border: thick $background 80%; 80 | background: $surface; 81 | } 82 | 83 | #help-buttons { 84 | align: right middle; 85 | height: 3; 86 | background: $surface-lighten-1; 87 | } 88 | -------------------------------------------------------------------------------- /src/uproot_browser/tui/browser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | if not __package__: 4 | __package__ = "uproot_browser.tui" # pylint: disable=redefined-builtin 5 | 6 | import contextlib 7 | import sys 8 | from typing import Any, ClassVar 9 | 10 | import plotext as plt 11 | import rich.syntax 12 | import textual.app 13 | import textual.binding 14 | import textual.containers 15 | import textual.events 16 | import textual.widgets 17 | from textual.reactive import var 18 | 19 | with contextlib.suppress(AttributeError): 20 | light_background = 0xF5, 0xF5, 0xF5 21 | # pylint: disable-next=protected-access 22 | plt._dict.themes["default"][0] = light_background 23 | # pylint: disable-next=protected-access 24 | plt._dict.themes["default"][1] = light_background 25 | 26 | dark_background = 0x1E, 0x1E, 0x1E 27 | dark_text = 0xFF, 0xA6, 0x2B 28 | # pylint: disable-next=protected-access 29 | plt._dict.themes["dark"][0] = dark_background 30 | # pylint: disable-next=protected-access 31 | plt._dict.themes["dark"][1] = dark_background 32 | # pylint: disable-next=protected-access 33 | plt._dict.themes["dark"][2] = dark_text 34 | 35 | from uproot_browser.exceptions import EmptyTreeError 36 | 37 | from .header import Header 38 | from .help import HelpScreen 39 | from .left_panel import UprootSelected, UprootTree 40 | from .right_panel import ( 41 | EmptyWidget, 42 | Error, 43 | ErrorWidget, 44 | LogoWidget, 45 | Plotext, 46 | PlotWidget, 47 | make_plot, 48 | ) 49 | 50 | 51 | class Browser(textual.app.App[object]): 52 | """A basic implementation of the uproot-browser TUI""" 53 | 54 | CSS_PATH = "browser.css" 55 | BINDINGS: ClassVar[ 56 | list[textual.binding.Binding | tuple[str, str] | tuple[str, str, str]] 57 | ] = [ 58 | textual.binding.Binding("b", "toggle_files", "Navbar"), 59 | textual.binding.Binding("q", "quit", "Quit"), 60 | textual.binding.Binding("d", "quit_with_dump", "Dump & Quit"), 61 | textual.binding.Binding("t", "toggle_theme", "Theme"), 62 | textual.binding.Binding("f1", "help", "Help"), 63 | textual.binding.Binding("?", "help", "Help", show=False), 64 | ] 65 | 66 | show_tree = var(True) 67 | 68 | def __init__(self, path: str, **kwargs: Any) -> None: 69 | self.path = path 70 | super().__init__(**kwargs) 71 | 72 | self.plot_widget = PlotWidget(id="plot") 73 | self.error_widget = ErrorWidget(id="error") 74 | 75 | def compose(self) -> textual.app.ComposeResult: 76 | """Compose our UI.""" 77 | yield Header("uproot-browser") 78 | with textual.containers.Container(): 79 | # left_panel 80 | yield UprootTree(self.path, id="tree-view") 81 | # right_panel 82 | yield textual.widgets.ContentSwitcher( 83 | LogoWidget(id="logo"), 84 | self.plot_widget, 85 | self.error_widget, 86 | EmptyWidget(id="empty"), 87 | id="main-view", 88 | initial="logo", 89 | ) 90 | yield textual.widgets.Footer() 91 | 92 | def on_mount(self, _event: textual.events.Mount) -> None: 93 | self.query_one("#tree-view", UprootTree).focus() 94 | 95 | def watch_show_tree(self, show_tree: bool) -> None: 96 | """Called when show_tree is modified.""" 97 | self.set_class(show_tree, "-show-tree") 98 | 99 | def action_help(self) -> None: 100 | self.push_screen(HelpScreen()) 101 | 102 | def action_toggle_files(self) -> None: 103 | """Called in response to key binding.""" 104 | self.show_tree = not self.show_tree 105 | 106 | def action_quit_with_dump(self) -> None: 107 | """Dump the current state of the application.""" 108 | 109 | content_switcher = self.query_one("#main-view", textual.widgets.ContentSwitcher) 110 | err_widget = content_switcher.query_one("#error", ErrorWidget) 111 | 112 | msg = f'\nimport uproot\nuproot_file = uproot.open("{self.path}")' 113 | 114 | items: list[Plotext | Error] = [] 115 | if content_switcher.current == "plot": 116 | assert self.plot_widget.item 117 | msg += ( 118 | f'\nitem = uproot_file["{self.plot_widget.item.selection.lstrip("/")}"]' 119 | ) 120 | items = [self.plot_widget.item] 121 | elif content_switcher.current == "error": 122 | assert err_widget.exc 123 | items = [err_widget.exc] 124 | 125 | dark = self.theme != "textual-light" 126 | 127 | theme = "ansi_dark" if dark else "ansi_light" 128 | 129 | results = rich.console.Group( 130 | *items, 131 | rich.syntax.Syntax(f"\n{msg}\n", "python", theme=theme), 132 | ) 133 | 134 | self.exit(message=results) 135 | 136 | def action_toggle_theme(self) -> None: 137 | """An action to toggle dark mode.""" 138 | dark = self.theme != "textual-light" 139 | theme = "textual-light" if dark else "textual-dark" 140 | 141 | if self.plot_widget.item: 142 | self.plot_widget.item.theme = "dark" if dark else "default" 143 | self.theme = theme 144 | 145 | def on_uproot_selected(self, message: UprootSelected) -> None: 146 | """A message sent by the tree when a file is clicked.""" 147 | 148 | content_switcher = self.query_one("#main-view", textual.widgets.ContentSwitcher) 149 | 150 | try: 151 | dark = self.theme != "textual-light" 152 | theme = "dark" if dark else "default" 153 | make_plot(message.upfile[message.path], theme, 20) 154 | self.plot_widget.item = Plotext(message.upfile, message.path, theme) 155 | content_switcher.current = "plot" 156 | 157 | except EmptyTreeError: 158 | content_switcher.current = "empty" 159 | 160 | except Exception: 161 | exc = sys.exc_info() 162 | assert exc[1] 163 | self.error_widget.exc = Error(exc) 164 | content_switcher.current = "error" 165 | 166 | 167 | if __name__ in {"", "__main__"}: 168 | fname = "../scikit-hep-testdata/src/skhep_testdata/data/uproot-Event.root" 169 | app = Browser(path=fname) 170 | app.run() 171 | -------------------------------------------------------------------------------- /src/uproot_browser/tui/header.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from typing import Any 5 | 6 | import rich.text 7 | import textual.app 8 | import textual.reactive 9 | import textual.widget 10 | 11 | if typing.TYPE_CHECKING: 12 | from .browser import Browser 13 | 14 | 15 | class HeaderCloseIcon(textual.widget.Widget): 16 | DEFAULT_CSS = """ 17 | HeaderCloseIcon { 18 | dock: left; 19 | padding: 0 1; 20 | width: 4; 21 | content-align: left middle; 22 | } 23 | HeaderCloseIcon:hover { 24 | background: $panel-lighten-2; 25 | } 26 | """ 27 | 28 | def render(self) -> textual.app.RenderResult: 29 | return "❌" 30 | 31 | def on_click(self) -> None: 32 | self.app.exit() 33 | 34 | 35 | class HeaderHelpIcon(textual.widget.Widget): 36 | DEFAULT_CSS = """ 37 | HeaderHelpIcon { 38 | dock: right; 39 | padding: 0 1; 40 | width: 4; 41 | content-align: right middle; 42 | } 43 | HeaderHelpIcon:hover { 44 | background: $panel-lighten-2; 45 | } 46 | """ 47 | 48 | app: Browser 49 | 50 | def render(self) -> textual.app.RenderResult: 51 | return "❓" 52 | 53 | def on_click(self) -> None: 54 | self.app.action_help() 55 | 56 | 57 | class HeaderTitle(textual.widget.Widget): 58 | DEFAULT_CSS = """ 59 | HeaderTitle { 60 | content-align: center middle; 61 | width: 100%; 62 | } 63 | """ 64 | 65 | text = textual.reactive.Reactive("") 66 | sub_text = textual.reactive.Reactive("") 67 | 68 | def render(self) -> textual.app.RenderResult: 69 | text = rich.text.Text(self.text, no_wrap=True, overflow="ellipsis") 70 | if self.sub_text: 71 | text.append(" — ") 72 | text.append(self.sub_text, "dim") 73 | return text 74 | 75 | 76 | class Header(textual.widget.Widget): 77 | DEFAULT_CSS = """ 78 | Header { 79 | dock: top; 80 | width: 100%; 81 | background: $foreground 5%; 82 | color: $text; 83 | height: 1; 84 | } 85 | """ 86 | 87 | DEFAULT_CLASSES = "" 88 | 89 | def __init__(self, title: str, **kwargs: Any): 90 | super().__init__(**kwargs) 91 | self.title = title 92 | 93 | def compose(self) -> textual.app.ComposeResult: 94 | yield HeaderCloseIcon() 95 | yield HeaderTitle() 96 | yield HeaderHelpIcon() 97 | 98 | def on_mount(self) -> None: 99 | self.query_one(HeaderTitle).text = self.title 100 | -------------------------------------------------------------------------------- /src/uproot_browser/tui/help.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from importlib.resources import files 5 | from typing import ClassVar 6 | 7 | import textual.app 8 | import textual.binding 9 | import textual.containers 10 | import textual.screen 11 | import textual.widgets 12 | 13 | if typing.TYPE_CHECKING: 14 | from .browser import Browser 15 | 16 | 17 | class HelpScreen(textual.screen.ModalScreen[None]): 18 | BINDINGS: ClassVar[ 19 | list[textual.binding.Binding | tuple[str, str] | tuple[str, str, str]] 20 | ] = [ 21 | textual.binding.Binding("d", "", "Nothing", show=False), 22 | textual.binding.Binding("b", "", "Nothing", show=False), 23 | textual.binding.Binding("f1", "", "Nothing", show=False), 24 | textual.binding.Binding("q", "done", "Done", show=True), 25 | textual.binding.Binding("esc", "done", "Done", show=True), 26 | textual.binding.Binding("t", "toggle_theme", "Theme", show=True), 27 | ] 28 | 29 | app: Browser 30 | 31 | def compose(self) -> textual.app.ComposeResult: 32 | markdown = files("uproot_browser.tui").joinpath("README.md").read_text() 33 | with textual.containers.Container(id="help-dialog", classes="dialog"): 34 | yield textual.widgets.MarkdownViewer(markdown, id="help-text") 35 | with textual.containers.Container(id="help-buttons"): 36 | yield textual.widgets.Button("Done", variant="primary", id="help-done") 37 | 38 | def on_mount(self) -> None: 39 | self.query_one("#help-text", textual.widgets.MarkdownViewer).focus() 40 | 41 | def on_button_pressed(self, _event: textual.widgets.Button.Pressed) -> None: 42 | self.app.pop_screen() 43 | 44 | def action_done(self) -> None: 45 | self.app.pop_screen() 46 | 47 | def action_toggle_theme(self) -> None: 48 | self.app.action_toggle_theme() 49 | -------------------------------------------------------------------------------- /src/uproot_browser/tui/left_panel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import Any, ClassVar 5 | 6 | import rich.panel 7 | import rich.repr 8 | import rich.text 9 | import textual.binding 10 | import textual.message 11 | import textual.widget 12 | import textual.widgets 13 | import textual.widgets.tree 14 | import uproot 15 | from rich.style import Style 16 | 17 | from ..tree import UprootEntry 18 | 19 | 20 | @rich.repr.auto 21 | class UprootSelected(textual.message.Message, bubble=True): 22 | def __init__(self, upfile: Any, path: str) -> None: 23 | self.upfile = upfile 24 | self.path = path 25 | super().__init__() 26 | 27 | 28 | class UprootTree(textual.widgets.Tree[UprootEntry]): 29 | """currently just extending DirectoryTree, showing current path""" 30 | 31 | BINDINGS: ClassVar[ 32 | list[textual.binding.Binding | tuple[str, str] | tuple[str, str, str]] 33 | ] = [ 34 | textual.binding.Binding("h", "cursor_out", "Cursor out", show=False), 35 | textual.binding.Binding("j", "cursor_down", "Cursor Down", show=False), 36 | textual.binding.Binding("k", "cursor_up", "Cursor Up", show=False), 37 | textual.binding.Binding("l", "cursor_in", "Cursor in", show=False), 38 | ] 39 | 40 | def __init__(self, path: str, **args: Any) -> None: 41 | self.upfile = uproot.open(path) 42 | file_path = Path(self.upfile.file_path) 43 | data = UprootEntry("/", self.upfile) 44 | super().__init__(name=str(file_path), data=data, label=file_path.stem, **args) 45 | 46 | def render_label( 47 | self, 48 | node: textual.widgets.tree.TreeNode[UprootEntry], 49 | base_style: Style, 50 | style: Style, # , 51 | ) -> rich.text.Text: 52 | assert node.data 53 | meta = node.data.meta() 54 | label_icon = rich.text.Text(meta["label_icon"]) 55 | label_icon.stylize(base_style) 56 | 57 | label = rich.text.Text.assemble(label_icon, meta["label_text"]) 58 | label.stylize(style) 59 | return label 60 | 61 | def on_mount(self) -> None: 62 | self.load_directory(self.root) 63 | self.root.expand() 64 | 65 | def load_directory(self, node: textual.widgets.tree.TreeNode[UprootEntry]) -> None: 66 | assert node.data 67 | if not node.children: 68 | children = node.data.children 69 | for child in children: 70 | node.add(child.path, child) 71 | 72 | def on_tree_node_selected( 73 | self, event: textual.widgets.Tree.NodeSelected[UprootEntry] 74 | ) -> None: 75 | event.stop() 76 | item = event.node.data 77 | assert item 78 | if not item.is_dir: 79 | self.post_message(UprootSelected(self.upfile, item.path)) 80 | 81 | def on_tree_node_expanded( 82 | self, event: textual.widgets.Tree.NodeSelected[UprootEntry] 83 | ) -> None: 84 | event.stop() 85 | item = event.node.data 86 | assert item 87 | if item.is_dir: 88 | self.load_directory(event.node) 89 | 90 | def _node_expanded( 91 | self, node: textual.widgets.tree.TreeNode[UprootEntry] 92 | ) -> textual.widgets.Tree.NodeExpanded[UprootEntry]: 93 | try: 94 | return self.NodeExpanded(node) 95 | except TypeError: # textual 0.24-0.26 96 | # pylint: disable-next=too-many-function-args 97 | return self.NodeExpanded(self, node) # type:ignore[call-arg,arg-type] 98 | 99 | def _node_collapsed( 100 | self, node: textual.widgets.tree.TreeNode[UprootEntry] 101 | ) -> textual.widgets.Tree.NodeCollapsed[UprootEntry]: 102 | try: 103 | return self.NodeCollapsed(node) 104 | except TypeError: # textual 0.24-0.26 105 | # pylint: disable-next=too-many-function-args 106 | return self.NodeCollapsed(self, node) # type:ignore[call-arg,arg-type] 107 | 108 | def action_cursor_in(self) -> None: 109 | node = self.cursor_node 110 | if node is None: 111 | return 112 | if node.allow_expand and not node.is_expanded: 113 | node.expand() 114 | self.post_message(self._node_expanded(node)) 115 | 116 | def action_cursor_out(self) -> None: 117 | node = self.cursor_node 118 | if node is None: 119 | return 120 | if node.allow_expand and node.is_expanded: 121 | node.collapse() 122 | self.post_message(self._node_collapsed(node)) 123 | elif ( 124 | node.parent is not None 125 | and node.parent.allow_expand 126 | and node.parent.is_expanded 127 | ): 128 | node.parent.collapse() 129 | self.post_message(self._node_collapsed(node)) 130 | self.cursor_line = node.parent.line 131 | self.scroll_to_line(self.cursor_line) 132 | -------------------------------------------------------------------------------- /src/uproot_browser/tui/right_panel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | from collections.abc import Iterable 5 | from types import TracebackType 6 | from typing import Any 7 | 8 | import numpy as np 9 | import plotext as plt # plots in text 10 | import rich.panel 11 | import rich.text 12 | import rich.traceback 13 | import textual.widget 14 | import textual.widgets 15 | 16 | try: 17 | from textual.widgets import RichLog 18 | except ImportError: 19 | from textual.widgets import ( # type: ignore[attr-defined,no-redef] 20 | TextLog as RichLog, 21 | ) 22 | 23 | import uproot_browser.plot 24 | 25 | LOGO = """\ 26 | Scikit-HEP 27 | ┬ ┬┌─┐┬─┐┌─┐┌─┐┌┬┐5 ┌┐ ┬─┐┌─┐┬ ┬┌─┐┌─┐┬─┐ 28 | │ │├─┘├┬┘│ ││ │ │───├┴┐├┬┘│ ││││└─┐├┤ ├┬┘ 29 | └─┘┴ ┴└─└─┘└─┘ ┴ └─┘┴└─└─┘└┴┘└─┘└─┘┴└─ 30 | Powered by Textual & Hist""" 31 | 32 | LOGO_PANEL = rich.text.Text.from_ansi(LOGO, no_wrap=True) 33 | 34 | 35 | placeholder = np.random.rand(1000) 36 | 37 | 38 | def apply_selection(tree: Any, selection: Iterable[str]) -> Iterable[Any]: 39 | """ 40 | Apply a colon-separated selection to an uproot tree. Slashes are handled by uproot. 41 | """ 42 | for sel in selection: 43 | tree = tree[sel] 44 | yield tree 45 | 46 | 47 | def make_plot(item: Any, theme: str, *size: int) -> Any: 48 | plt.clf() 49 | plt.plotsize(*size) 50 | uproot_browser.plot.plot(item) 51 | plt.theme(theme) 52 | return plt.build() 53 | 54 | 55 | # wrapper for plotext into a textual widget 56 | @dataclasses.dataclass 57 | class Plotext: 58 | upfile: Any 59 | selection: str 60 | theme: str 61 | 62 | def __rich_console__( 63 | self, console: rich.console.Console, options: rich.console.ConsoleOptions 64 | ) -> rich.console.RenderResult: 65 | *_, item = apply_selection(self.upfile, self.selection.split(":")) 66 | 67 | if item is None: 68 | yield rich.text.Text() 69 | return 70 | width = options.max_width or console.width 71 | height = options.height or console.height 72 | 73 | canvas = make_plot(item, self.theme, width, height) 74 | yield rich.text.Text.from_ansi(canvas) 75 | 76 | 77 | class PlotWidget(textual.widget.Widget): 78 | _item: Plotext | None 79 | 80 | @property 81 | def item(self) -> Plotext | None: 82 | return self._item 83 | 84 | @item.setter 85 | def item(self, value: Plotext) -> None: 86 | self._item = value 87 | self.refresh() 88 | 89 | def __init__(self, **kargs: Any): 90 | super().__init__(**kargs) 91 | self._item = None 92 | 93 | def render(self) -> rich.console.RenderableType: 94 | return self.item or "" 95 | 96 | 97 | class EmptyWidget(textual.widget.Widget): 98 | # if the plot is empty 99 | 100 | def render(self) -> rich.console.RenderableType: 101 | return rich.text.Text("Plot is Empty") 102 | 103 | 104 | class LogoWidget(textual.widget.Widget): 105 | def render(self) -> rich.console.RenderableType: 106 | return LOGO_PANEL 107 | 108 | 109 | @dataclasses.dataclass 110 | class Error: 111 | exc: tuple[type[BaseException], BaseException, TracebackType] 112 | 113 | def __rich_console__( 114 | self, console: rich.console.Console, options: rich.console.ConsoleOptions 115 | ) -> rich.console.RenderResult: 116 | width = options.max_width or console.width 117 | 118 | yield rich.traceback.Traceback.from_exception(*self.exc, width=width) 119 | 120 | 121 | class ErrorWidget(RichLog): 122 | _exc: Error | None 123 | 124 | @property 125 | def exc(self) -> Error | None: 126 | return self._exc 127 | 128 | @exc.setter 129 | def exc(self, value: Error) -> None: 130 | self._exc = value 131 | self.clear() 132 | self.write(self._exc) 133 | # self.refresh() 134 | 135 | def __init__(self, **kargs: Any): 136 | super().__init__(**kargs) 137 | self.write("No Exception set!") 138 | self._exc = None 139 | -------------------------------------------------------------------------------- /tests/test_printouts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | import pytest 6 | import rich.console 7 | from skhep_testdata import data_path 8 | 9 | from uproot_browser.tree import print_tree 10 | 11 | OUT1 = """\ 12 | 📁 uproot-Event.root 13 | ┣━━ ❓ TProcessID 14 | ┣━━ 🌴 T (1000) 15 | ┃ ┗━━ 🌿 event Event 16 | ┃ ┣━━ 🌿 TObject (group of fUniqueID:uint32_t, fBits:uint32_t) 17 | ┃ ┃ ┣━━ 🍁 fBits uint32_t 18 | ┃ ┃ ┗━━ 🍁 fUniqueID uint32_t 19 | ┃ ┣━━ 🍁 fClosestDistance unknown[] 20 | ┃ ┣━━ 🍁 fEventName char* 21 | ┃ ┣━━ 🌿 fEvtHdr EventHeader 22 | ┃ ┃ ┣━━ 🍁 fEvtHdr.fDate int32_t 23 | ┃ ┃ ┣━━ 🍁 fEvtHdr.fEvtNum int32_t 24 | ┃ ┃ ┗━━ 🍁 fEvtHdr.fRun int32_t 25 | ┃ ┣━━ 🍁 fFlag uint32_t 26 | ┃ ┣━━ 🍁 fH TH1F 27 | ┃ ┣━━ 🍁 fHighPt TRefArray* 28 | ┃ ┣━━ 🍁 fIsValid bool 29 | ┃ ┣━━ 🍁 fLastTrack TRef 30 | ┃ ┣━━ 🍁 fMatrix[4][4] float[4][4] 31 | ┃ ┣━━ 🍁 fMeasures[10] int32_t[10] 32 | ┃ ┣━━ 🍁 fMuons TRefArray* 33 | ┃ ┣━━ 🍁 fNseg int32_t 34 | ┃ ┣━━ 🍁 fNtrack int32_t 35 | ┃ ┣━━ 🍁 fNvertex uint32_t 36 | ┃ ┣━━ 🍁 fTemperature float 37 | ┃ ┣━━ 🌿 fTracks TClonesArray* 38 | ┃ ┃ ┣━━ 🍃 fTracks.fBits uint32_t[] 39 | ┃ ┃ ┣━━ 🍃 fTracks.fBx Float16_t[] 40 | ┃ ┃ ┣━━ 🍃 fTracks.fBy Float16_t[] 41 | ┃ ┃ ┣━━ 🍃 fTracks.fCharge Double32_t[] 42 | ┃ ┃ ┣━━ 🍃 fTracks.fMass2 Float16_t[] 43 | ┃ ┃ ┣━━ 🍃 fTracks.fMeanCharge float[] 44 | ┃ ┃ ┣━━ 🍃 fTracks.fNpoint int32_t[] 45 | ┃ ┃ ┣━━ 🍃 fTracks.fNsp uint32_t[] 46 | ┃ ┃ ┣━━ 🍁 fTracks.fPointValue unknown[][] 47 | ┃ ┃ ┣━━ 🍃 fTracks.fPx float[] 48 | ┃ ┃ ┣━━ 🍃 fTracks.fPy float[] 49 | ┃ ┃ ┣━━ 🍃 fTracks.fPz float[] 50 | ┃ ┃ ┣━━ 🍃 fTracks.fRandom float[] 51 | ┃ ┃ ┣━━ 🍃 fTracks.fTArray[3] float[][3] 52 | ┃ ┃ ┣━━ 🍁 fTracks.fTriggerBits.fAllBits uint8_t[][] 53 | ┃ ┃ ┣━━ 🍃 fTracks.fTriggerBits.fBits uint32_t[] 54 | ┃ ┃ ┣━━ 🍃 fTracks.fTriggerBits.fNbits uint32_t[] 55 | ┃ ┃ ┣━━ 🍃 fTracks.fTriggerBits.fNbytes uint32_t[] 56 | ┃ ┃ ┣━━ 🍃 fTracks.fTriggerBits.fUniqueID uint32_t[] 57 | ┃ ┃ ┣━━ 🍃 fTracks.fUniqueID uint32_t[] 58 | ┃ ┃ ┣━━ 🍃 fTracks.fValid int16_t[] 59 | ┃ ┃ ┣━━ 🍃 fTracks.fVertex[3] Double32_t[][3] 60 | ┃ ┃ ┣━━ 🍃 fTracks.fXfirst Float16_t[] 61 | ┃ ┃ ┣━━ 🍃 fTracks.fXlast Float16_t[] 62 | ┃ ┃ ┣━━ 🍃 fTracks.fYfirst Float16_t[] 63 | ┃ ┃ ┣━━ 🍃 fTracks.fYlast Float16_t[] 64 | ┃ ┃ ┣━━ 🍃 fTracks.fZfirst Float16_t[] 65 | ┃ ┃ ┗━━ 🍃 fTracks.fZlast Float16_t[] 66 | ┃ ┣━━ 🌿 fTriggerBits TBits 67 | ┃ ┃ ┣━━ 🌿 fTriggerBits.TObject (group of fTriggerBits.fUniqueID:uint32_t, fTriggerBits.fBits:uint32_t) 68 | ┃ ┃ ┃ ┣━━ 🍁 fTriggerBits.fBits uint32_t 69 | ┃ ┃ ┃ ┗━━ 🍁 fTriggerBits.fUniqueID uint32_t 70 | ┃ ┃ ┣━━ 🍃 fTriggerBits.fAllBits uint8_t[] 71 | ┃ ┃ ┣━━ 🍁 fTriggerBits.fNbits uint32_t 72 | ┃ ┃ ┗━━ 🍁 fTriggerBits.fNbytes uint32_t 73 | ┃ ┣━━ 🍁 fType[20] int8_t[20] 74 | ┃ ┗━━ 🍁 fWebHistogram TRef 75 | ┣━━ 📊 hstat TH1F (100) 76 | ┗━━ 📊 htime TH1F (10) 77 | """ 78 | 79 | 80 | @pytest.mark.xfail( 81 | sys.platform.startswith("win"), 82 | reason="Unicode is different on Windows, for some reason?", 83 | ) 84 | def test_tree(capsys): 85 | filename = data_path("uproot-Event.root") 86 | console = rich.console.Console(width=120) 87 | 88 | print_tree(filename, console=console) 89 | out, err = capsys.readouterr() 90 | 91 | assert not err 92 | assert out == OUT1 93 | -------------------------------------------------------------------------------- /tests/test_tui.py: -------------------------------------------------------------------------------- 1 | import skhep_testdata 2 | 3 | from uproot_browser.tui.browser import Browser 4 | 5 | 6 | async def test_browse_logo() -> None: 7 | async with Browser( 8 | skhep_testdata.data_path("uproot-Event.root") 9 | ).run_test() as pilot: 10 | assert pilot.app.query_one("#main-view").current == "logo" 11 | 12 | 13 | async def test_browse_plot() -> None: 14 | async with Browser( 15 | skhep_testdata.data_path("uproot-Event.root") 16 | ).run_test() as pilot: 17 | await pilot.press("down", "down", "down", "enter") 18 | assert pilot.app.query_one("#main-view").current == "plot" 19 | 20 | 21 | async def test_browse_empty() -> None: 22 | async with Browser( 23 | skhep_testdata.data_path("uproot-empty.root") 24 | ).run_test() as pilot: 25 | await pilot.press("down", "space", "down", "enter") 26 | assert pilot.app.query_one("#main-view").current == "empty" 27 | 28 | 29 | async def test_browse_empty_vim() -> None: 30 | async with Browser( 31 | skhep_testdata.data_path("uproot-empty.root") 32 | ).run_test() as pilot: 33 | await pilot.press("j", "l", "j", "enter") 34 | assert pilot.app.query_one("#main-view").current == "empty" 35 | 36 | 37 | async def test_help_focus() -> None: 38 | async with Browser( 39 | skhep_testdata.data_path("uproot-empty.root") 40 | ).run_test() as pilot: 41 | await pilot.press("?") 42 | focus_chain = [widget.id for widget in pilot.app.screen.focus_chain] 43 | assert len(focus_chain) == 3 44 | assert focus_chain[-1] == "help-done" 45 | --------------------------------------------------------------------------------