├── .github └── workflows │ ├── check-code-coverage.yaml │ ├── check-code-quality.yaml │ ├── check-with-pytest.yaml │ ├── install-from-pypi-run.yaml │ └── install-local-and-run.yaml ├── .gitignore ├── LICENSE.txt ├── README.md ├── docs ├── advanced-usage.md ├── integrations.md └── v3-notice.md ├── examples ├── brown2d.py ├── download_v1.py ├── download_v2.py ├── tictactoe.py └── training.py ├── hooks.py ├── images ├── example-output4.gif ├── examples-brown2d.gif ├── examples-download.gif ├── examples-tictactoe.gif ├── examples-training.gif ├── progress-after3.gif ├── progress-after4.gif ├── progress-before3.gif └── progress-table-example.png ├── progress_table ├── __init__.py ├── common.py ├── progress_table.py └── styles.py ├── pyproject.toml └── tests ├── test_auto_docs.py ├── test_auto_examples.py ├── test_end_to_end.py ├── test_unit.py ├── test_unit_from_claude.py ├── test_unit_from_gemini.py └── test_unit_from_gpt4o.py /.github/workflows/check-code-coverage.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will install Progress Table and test whether basic importing works correctly 2 | # For more information see: 3 | # https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 4 | 5 | name: check code coverage 6 | 7 | on: 8 | push: 9 | branches: [ "main" ] 10 | pull_request: 11 | branches: [ "main" ] 12 | 13 | jobs: 14 | build: 15 | strategy: 16 | matrix: 17 | python-version: [ 18 | "3.10", 19 | ] 20 | os: [ 21 | "ubuntu-22.04", 22 | ] 23 | 24 | runs-on: ${{ matrix.os }} 25 | 26 | steps: 27 | - uses: actions/checkout@v3 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v3 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - name: Install dependencies 33 | run: | 34 | pip install . 35 | pip install pytest pytest-cov numpy pandas scikit-learn 36 | 37 | - name: Test with pytest 38 | run: pytest . --cov=progress_table --cov-report=xml 39 | 40 | - name: Upload Coverage to Codecov 41 | uses: codecov/codecov-action@v5 42 | -------------------------------------------------------------------------------- /.github/workflows/check-code-quality.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and linting 2 | # For more information see: 3 | # https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 4 | 5 | name: check code quality 6 | 7 | on: 8 | push: 9 | branches: [ "main" ] 10 | pull_request: 11 | branches: [ "main" ] 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | build: 18 | 19 | runs-on: ubuntu-22.04 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python 3.10 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: "3.10" 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install ruff pyright 31 | pip install colorama numpy pandas 32 | - name: Run ruff linting 33 | run: ruff check . 34 | - name: Run ruff formatting 35 | run: ruff format . --check 36 | - name: Run pyright 37 | run: pyright progress_table/ 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/check-with-pytest.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will install Progress Table and test whether basic importing works correctly 2 | # For more information see: 3 | # https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 4 | 5 | name: run tests 6 | 7 | on: 8 | push: 9 | branches: [ "main" ] 10 | pull_request: 11 | branches: [ "main" ] 12 | 13 | jobs: 14 | build: 15 | strategy: 16 | matrix: 17 | python-version: [ 18 | "3.7", 19 | "3.8", 20 | "3.9", 21 | "3.10", 22 | "3.11", 23 | "3.12", 24 | "3.13" , 25 | ] 26 | os: [ 27 | "ubuntu-22.04", 28 | "windows-latest", 29 | ] 30 | 31 | runs-on: ${{ matrix.os }} 32 | 33 | steps: 34 | - uses: actions/checkout@v3 35 | - name: Set up Python ${{ matrix.python-version }} 36 | uses: actions/setup-python@v3 37 | with: 38 | python-version: ${{ matrix.python-version }} 39 | - name: Install dependencies 40 | run: | 41 | pip install . 42 | pip install pytest numpy pandas scikit-learn 43 | 44 | - name: Test with pytest 45 | run: pytest . 46 | -------------------------------------------------------------------------------- /.github/workflows/install-from-pypi-run.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will install Progress Table and test whether basic importing works correctly 2 | # For more information see: 3 | # https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 4 | 5 | name: install from pypi 6 | 7 | on: 8 | push: 9 | branches: [ "main" ] 10 | pull_request: 11 | branches: [ "main" ] 12 | 13 | jobs: 14 | build: 15 | strategy: 16 | matrix: 17 | python-version: [ 18 | "3.7", 19 | "3.8", 20 | "3.9", 21 | "3.10", 22 | "3.11", 23 | "3.12", 24 | "3.13" , 25 | ] 26 | os: [ 27 | "ubuntu-22.04", 28 | "windows-latest", 29 | ] 30 | 31 | runs-on: ${{ matrix.os }} 32 | 33 | steps: 34 | - name: Set up Python ${{ matrix.python-version }} 35 | uses: actions/setup-python@v3 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | - name: Install the library and test basic imports 39 | run: | 40 | pip install progress-table 41 | pip show progress-table 42 | 43 | python -c "import progress_table" 44 | python -c "from progress_table import ProgressTable" 45 | python -c "from progress_table import ProgressTable; table = ProgressTable()" 46 | 47 | -------------------------------------------------------------------------------- /.github/workflows/install-local-and-run.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will install Progress Table and test whether basic importing works correctly 2 | # For more information see: 3 | # https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 4 | 5 | name: install local 6 | 7 | on: 8 | push: 9 | branches: [ "main" ] 10 | pull_request: 11 | branches: [ "main" ] 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | build: 18 | strategy: 19 | matrix: 20 | python-version: [ 21 | "3.7", 22 | "3.8", 23 | "3.9", 24 | "3.10", 25 | "3.11", 26 | "3.12", 27 | "3.13" , 28 | ] 29 | os: [ 30 | "ubuntu-22.04", 31 | "windows-latest", 32 | ] 33 | 34 | runs-on: ${{ matrix.os }} 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | - name: Set up Python ${{ matrix.python-version }} 39 | uses: actions/setup-python@v3 40 | with: 41 | python-version: ${{ matrix.python-version }} 42 | - name: Install the library and test basic imports 43 | run: | 44 | pip install . 45 | cd .. 46 | pip show progress-table 47 | rm -r progress-table/* 48 | 49 | python -c "import progress_table" 50 | python -c "from progress_table import ProgressTable" 51 | python -c "from progress_table import ProgressTable; table = ProgressTable()" 52 | 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .ipynb_checkpoints 3 | *.ipynb 4 | __pycache__ 5 | *.egg-info 6 | 7 | .coverage 8 | coverage.xml 9 | README_pypi.md 10 | 11 | devel/ 12 | dist 13 | build 14 | *.sh 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 Szymon Mikler 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Progress Table 2 | 3 | [![PyPi version](https://img.shields.io/badge/dynamic/json?label=latest&query=info.version&url=https%3A%2F%2Fpypi.org%2Fpypi%2Fprogress-table%2Fjson)](https://pypi.org/project/progress-table) 4 | [![PyPI license](https://img.shields.io/badge/dynamic/json?label=license&query=info.license&url=https%3A%2F%2Fpypi.org%2Fpypi%2Fprogress-table%2Fjson)](https://github.com/sjmikler/progress-table/blob/main/LICENSE.txt) 5 | [![codecov](https://codecov.io/gh/sjmikler/progress-table/graph/badge.svg?token=CDJKF0FFAQ)](https://codecov.io/gh/sjmikler/progress-table) 6 | 7 | Lightweight utility to display the progress of your process as a pretty table in the command line. 8 | 9 | * Alternative to TQDM whenever you want to track metrics produced by your process 10 | * Designed to monitor ML experiments, but works for any metrics-producing process 11 | * Allows you to see at a glance what's going on with your process 12 | * Increases readability and simplifies your command line logging 13 | 14 | ### Change this: 15 | 16 | ![example](images/progress-before3.gif) 17 | 18 | ### Into this: 19 | 20 | ![example](images/progress-after4.gif) 21 | 22 | ## Examples 23 | 24 | From `examples/` directory: 25 | 26 | * Neural network training 27 | 28 | ![example-training](images/examples-training.gif) 29 | 30 | * Progress of multi-threaded downloads 31 | 32 | ![example-download](images/examples-download.gif) 33 | 34 | * Simulation and interactive display of Brownian motion 35 | 36 | ![example-brown2d](images/examples-brown2d.gif) 37 | 38 | * Display of a game board 39 | 40 | ![example-tictactoe](images/examples-tictactoe.gif) 41 | 42 | ## Quick start code 43 | 44 | ```python 45 | import random 46 | import time 47 | 48 | from progress_table import ProgressTable 49 | 50 | # Create table object: 51 | table = ProgressTable(num_decimal_places=1) 52 | 53 | # You can (optionally) define the columns at the beginning 54 | table.add_column("x", color="bold red") 55 | 56 | for step in range(10): 57 | x = random.randint(0, 200) 58 | 59 | # You can add entries in a compact way 60 | table["x"] = x 61 | 62 | # Or you can use the update method 63 | table.update("x", value=x, weight=1.0) 64 | 65 | # Display the progress bar by wrapping an iterator or an integer 66 | for _ in table(10): # -> Equivalent to `table(range(10))` 67 | # Set and get values from the table 68 | table["y"] = random.randint(0, 200) 69 | table["x-y"] = table["x"] - table["y"] 70 | table.update("average x-y", value=table["x-y"], weight=1.0, aggregate="mean") 71 | time.sleep(0.1) 72 | 73 | # Go to the next row when you're ready 74 | table.next_row() 75 | 76 | # Close the table when it's finished 77 | table.close() 78 | 79 | ``` 80 | 81 | > Go to [integrations](docs/integrations.md) 82 | > page to see examples of integration with deep learning libraries. 83 | 84 | ## Advanced usage 85 | 86 | Go to [advanced usage](docs/advanced-usage.md) page for more information. 87 | 88 | ## Troubleshooting 89 | 90 | ### Exceesive output 91 | 92 | Progress Table works correctly in most consoles, but there are some exceptions: 93 | 94 | * Some cloud logging consoles (e.g. kubernetes) don't support `\r` properly. You can still use ProgressTable, but with `interactive=0` option. This mode will not display progress bars. 95 | 96 | * Some consoles like 'PyCharm Python Console' or 'IDLE' don't support cursor movement. You can still use ProgressTable, but with `interactive=1` option. In this mode, you can display only a single progress bar. 97 | 98 | > By default `interactive=2`. You can change the default 'interactive' with an argument when creating the table object or by setting 'PTABLE_INTERACTIVE' environment variable, e.g. `PTABLE_INTERACTIVE=1`. 99 | 100 | ### Other problems 101 | 102 | If you encounter different messy outputs or other unexpected behavior: please create an issue! 103 | 104 | ## Installation 105 | 106 | Install Progress Table easily with pip: 107 | 108 | ``` 109 | pip install progress-table 110 | ``` 111 | 112 | ## Links 113 | 114 | * [See on GitHub](https://github.com/gahaalt/progress-table) 115 | * [See on PyPI](https://pypi.org/project/progress-table) 116 | 117 | ## Alternatives 118 | 119 | * Progress bars: great for tracking progress, but they don't provide ways to display data in clear and compact way 120 | * `tqdm` 121 | * `rich.progress` 122 | * `keras.utils.Progbar` 123 | 124 | * Libraries displaying data: great for presenting tabular data, but they lack the progress tracking aspect 125 | * `rich.table` 126 | * `tabulate` 127 | * `texttable` 128 | -------------------------------------------------------------------------------- /docs/advanced-usage.md: -------------------------------------------------------------------------------- 1 | # Advanced usage 2 | 3 | ## Indexing 4 | 5 | Progress Table with `interactive>=2` supports modyfing already closed rows (rows above the current row). 6 | This can be done either with `.update` method or using `.at` indexing which is a shorthand for `AtIndexer` object. 7 | 8 | 9 | > When using `interactive<2` mode, you can only modify the current row. 10 | > Any changes made to other rows will not be displayed. 11 | 12 | ### `.update` method 13 | 14 | When using `.update` method you update one value at once. 15 | Use column name and row index to specify the cell location. 16 | Example of using `.update` method: 17 | 18 | ```python 19 | from progress_table import ProgressTable 20 | 21 | table = ProgressTable() 22 | table.add_column("Value") 23 | table.add_rows(3) # Adding empty rows 24 | 25 | table.update(name="Value", value=1.0, row=1) 26 | table.update(name="Value", value=2.0, row=0, cell_color="red bold") 27 | table.update(name="Value", value=3.0, row=2) # modify last-but-one row 28 | table.close() 29 | ``` 30 | 31 | Which might give you the following: 32 | 33 | ```output 34 | ╭──────────╮ 35 | │ Value │ 36 | ├──────────┤ 37 | │ 2.0000 │ 38 | │ 1.0000 │ 39 | │ 3.0000 │ 40 | ╰──────────╯ 41 | ``` 42 | 43 | ### `.at` indexing 44 | 45 | When using `.at` indexing you do not use column names, instead you use column indices. 46 | First column has index 0, second 1, etc. Slicing operations are also supported with `.at` indexing. 47 | Treat the table similarly to a numpy array: 48 | 49 | ```python 50 | from progress_table import ProgressTable 51 | 52 | table = ProgressTable() 53 | table.add_columns(4) # Add more columns with automatic names 54 | table.add_rows(4) # Adding empty rows 55 | 56 | table.at[:] = 0.0 # Initialize all values to 0.0 57 | table.at[0, :] = 2.0 # Set all values in the first row to 2.0 58 | table.at[:, 1] = 2.0 # Set all values in the second column to 2.0 59 | table.at[2, 0] = 3.0 # Set the first column in the second-to-last row to 3.0 60 | 61 | table.close() 62 | ``` 63 | 64 | Which should give you the following: 65 | 66 | ``` 67 | ╭──────────┬──────────┬──────────┬──────────╮ 68 | │ 0 │ 1 │ 2 │ 3 │ 69 | ├──────────┼──────────┼──────────┼──────────┤ 70 | │ 2.0000 │ 2.0000 │ 2.0000 │ 2.0000 │ 71 | │ 0.0000 │ 2.0000 │ 0.0000 │ 0.0000 │ 72 | │ 3.0000 │ 2.0000 │ 0.0000 │ 0.0000 │ 73 | │ 0.0000 │ 2.0000 │ 0.0000 │ 0.0000 │ 74 | ╰──────────┴──────────┴──────────┴──────────╯ 75 | ``` 76 | 77 | ## Progress Bars 78 | 79 | There are two types of progress bars in Progress Table: embedded and non-embedded. 80 | 81 | 82 | > When using `interactive=0` mode, the progress bars will not be displayed. 83 | 84 | > When using `interactive=1` mode, progress bars are be displayed only in the bottom row. 85 | 86 | ### Embedded progress bars 87 | 88 | Embedded progress bars are displayed as an overlay under a table row that contains data. 89 | This allows you to see the progress and the new data all at once. 90 | Those progress bars are displayed 'under' the row and are aligned with the data columns. 91 | It is possible to customize the looks of the embedded progress bar by specyfing an argument when creating a table: 92 | 93 | ```python 94 | from progress_table import ProgressTable 95 | 96 | table = ProgressTable(pbar_style_embed="dash") 97 | ``` 98 | 99 | Below we show a sample of available styles for embedded progress bars: 100 | 101 | `dash` 102 | 103 | ``` 104 | | Name | Value | Number | 105 | |-----------------------------------| 106 | | test1 | 1.0 | 42 | 107 | |---test2--|----2.0-> | 37 | 108 | ``` 109 | 110 | `rich` 111 | 112 | ``` 113 | | Name | Value | Number | 114 | |-----------------------------------| 115 | | test1 | 1.0 | 42 | 116 | |━━━test2━━|━━━━2.0━━ | 37 | 117 | ``` 118 | 119 | `under` 120 | 121 | ``` 122 | | Name | Value | Number | 123 | |-----------------------------------| 124 | | test1 | 1.0 | 42 | 125 | |___test2__|____2.0__ | 37 | 126 | ``` 127 | 128 | --- 129 | 130 | To see the exahustive list of available options, one can use the following code: 131 | 132 | ```python 133 | from progress_table import styles 134 | 135 | print(styles.available_pbar_styles()) 136 | ``` 137 | 138 | ### Non-embedded progress bars 139 | 140 | Non-embedded progress bars are displayed as a separate row under the row that contains data. 141 | Optionally, you can force every progress bar, including the embedded ones, to look like a non-embedded and cover the data. 142 | You might want to do that if you aim for specific looks of your progress bars. 143 | 144 | ```python 145 | from progress_table import ProgressTable 146 | 147 | table = ProgressTable( 148 | pbar_style="square", # specify specific style 149 | pbar_embedded=False, # force all progress bars to be non-embedded 150 | ) 151 | ``` 152 | 153 | Below we show a sample of available styles for non-embedded progress bars 154 | 155 | --- 156 | 157 | `square` 158 | 159 | ``` 160 | | Name | Value | Number | 161 | |-----------------------------------| 162 | | test1 | 1.0 | 42 | 163 | | test2 | 2.0 | 37 | 164 | |■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□| 165 | ``` 166 | 167 | `circle` 168 | 169 | ``` 170 | | Name | Value | Number | 171 | |-----------------------------------| 172 | | test1 | 1.0 | 42 | 173 | | test2 | 2.0 | 37 | 174 | | ●●●●●●●●●●●●●●●●●●◉○○○○○○○○○○○○○○○| 175 | ``` 176 | 177 | --- 178 | 179 | As previously, you can see the exhaustive list of available options by using the following code: 180 | 181 | ```python 182 | from progress_table import styles 183 | 184 | print(styles.available_pbar_styles()) 185 | ``` 186 | 187 | > You can create custom styles by following code in `styles.py` file. 188 | 189 | ### Infobar 190 | 191 | You can display detailed information like: 192 | 193 | * throughput 194 | * progress in percents 195 | * progress as the current step and total steps 196 | * ETA (estimated time arrival) - estimated time in seconds, minutes or hours when the progress bar will finish 197 | * custom description 198 | 199 | To add or remove some of this information, specify the arguments like below. 200 | Additionaly, you can create a progress bar object and specify 201 | all the options only for this specific progress bar. 202 | 203 | ```python 204 | from progress_table import ProgressTable 205 | 206 | table = ProgressTable( 207 | pbar_show_throughput=True, 208 | pbar_show_progress=False, 209 | pbar_show_percents=False, 210 | pbar_show_eta=False, 211 | ) 212 | 213 | pbar = table.pbar( 214 | range(1000), 215 | description="train epoch", 216 | show_throughput=True, 217 | show_progress=False, 218 | show_percents=False, 219 | show_eta=True, 220 | style="circle", 221 | style_embed="cdots", 222 | ) 223 | ``` 224 | 225 | Which, when used, might look like this: 226 | 227 | ``` 228 | │ │ │ │ │ 229 | │[train epoch, 24.1 it/s, ETA 53s] ●●●●●●●◉○○○○○│ 230 | ``` 231 | 232 | ### More progress bar customization 233 | 234 | There is more ways to customize the look and feel of your progress bars 235 | by adding special keywords to the style string: 236 | 237 | ```python 238 | from progress_table import ProgressTable 239 | 240 | table = ProgressTable(pbar_style="angled alt red blue") 241 | ``` 242 | 243 | The example above would result in an angled-type pbar with alternative way of displaying empty bars, 244 | with filled bars colored red and empty bars colored blue. There's a separate style string 245 | for the embedded type progress bars - styling of embedded and non-embedded progress bars is indepedent. 246 | 247 | ```python 248 | from progress_table import ProgressTable 249 | 250 | table = ProgressTable(pbar_style_embed="cdots red blue") 251 | ``` 252 | 253 | The available keywords are: 254 | 255 | * `alt` - alternative way of displaying empty bars, for example `■` instead of `□`. 256 | This is only useful when using colors, otherwise you might not be able to differentiate between filled and empty bars. 257 | * `clean` - removes empty bars from the progress bar. Spaces are used instead. 258 | * `red`, `green`, `blue`, `yellow` and other colors available in colorama. If more than one is provided, 259 | the first one is used for filled bars and the last one is used for empty bars. 260 | 261 | --- 262 | 263 | Additionaly, you can specify the color of the progress bar using 264 | `color` and `color_empty` arguments when creating a progress bar object. 265 | This will override whatever color is set in `style` or `style_embed`. 266 | We can combine this option with `colorama.Back` to modify colors 267 | of the background instead of the foreground symbols. 268 | 269 | ```python 270 | from progress_table import ProgressTable 271 | import colorama 272 | import time 273 | 274 | table = ProgressTable("a", "b", "c") 275 | table.add_rows(1) 276 | 277 | pbar = table.pbar( 278 | range(100), 279 | style_embed="hidden", 280 | color=colorama.Back.RED, 281 | color_empty=colorama.Back.BLUE, 282 | ) 283 | 284 | for _ in pbar: 285 | time.sleep(0.1) 286 | ``` 287 | 288 | Try the example above. It contains a different type of embedded progress bar. 289 | Here the typical progress bar symbols will are hidden, 290 | but the background color will show us the progress of the process. 291 | -------------------------------------------------------------------------------- /docs/integrations.md: -------------------------------------------------------------------------------- 1 | # Integrations 2 | 3 | Progress Table is not tied up to any specific deep learning framework. 4 | Because of it, it's lightweight and minimal. You can still easily integrate it with your code and your favourite framework. Below you will find examples of how to do this. 5 | 6 | # PyTorch 7 | 8 | This is an example of a simple custom training loop in PyTorch. We present two versions: without and with Progress Bar. 9 | 10 | ### Without Progress Table 11 | 12 | Manual logging is often done in haste. It can look like this: 13 | 14 | ```python 15 | ... 16 | 17 | for epoch in range(n_epochs): 18 | print(f'Epoch {epoch}') 19 | cumulated_loss = 0 20 | cumulated_accuracy = 0 21 | 22 | for batch_idx, (x, y) in enumerate(train_loader): 23 | loss, predictions = training_step(x, y) 24 | accuracy = torch.mean((predictions == y).float()).item() 25 | 26 | cumulated_loss += loss.item() 27 | cumulated_accuracy += accuracy 28 | 29 | if (batch_idx) % 100 == 0: 30 | print('Epoch [{}/{}], Step [{}/{}], Loss: {:.4f}, Accuracy: {:.4f}'.format( 31 | epoch, 32 | n_epochs, 33 | batch_idx, 34 | len(train_loader), 35 | cumulated_loss / (batch_idx + 1), 36 | cumulated_accuracy / (batch_idx + 1), 37 | )) 38 | 39 | print('Epoch [{}/{}], Step [FINISHED], Loss: {:.4f}, Accuracy: {:.4f}'.format( 40 | epoch, 41 | n_epochs, 42 | cumulated_loss / len(train_loader), 43 | cumulated_accuracy / len(train_loader), 44 | )) 45 | ``` 46 | 47 | ### With Progress Table 48 | 49 | When using Progress Table, you get detailed and clean logs. Moreover, your code is shorter, simpler and you get more functionality out of it. 50 | 51 | ```python 52 | ... 53 | 54 | table = ProgressTable("Epoch", "Step") 55 | table.add_columns(["Loss", "Accuracy"], aggregate="mean") 56 | 57 | for epoch in range(n_epochs): 58 | table["Epoch"] = f"{epoch}/{n_epochs}" 59 | 60 | for x, y in table(train_loader): 61 | loss, predictions = training_step(x, y) 62 | accuracy = torch.mean((predictions == y).float()).item() 63 | 64 | table["Loss"] = loss 65 | table["Accuracy"] = accuracy 66 | table.next_row() 67 | 68 | table.close() 69 | ``` 70 | 71 | # Keras 72 | 73 | In case of PyTorch or TensorFlow, we often use custom training loops 74 | where we can integrate Progress Table as shown above. 75 | What about Keras, where the progress bar is built-in the `model.fit` method? 76 | You can create a callback that will replace the progress bar built-in Keras 77 | with a progress table. Callback should inherit from `ProgbarLogger`. 78 | 79 | Here's an example: 80 | 81 | ```python 82 | ... 83 | 84 | table = ProgressTable() 85 | table.add_columns(["loss", "accuracy", "val_loss", "val_accuracy"], aggregate="mean") 86 | 87 | 88 | class TableCallback(tf.keras.callbacks.ProgbarLogger): 89 | def __init__(self, table): 90 | super().__init__() 91 | self.table = table 92 | 93 | def on_epoch_begin(self, epoch, logs=None): 94 | pass 95 | 96 | def on_epoch_end(self, epoch, logs=None): 97 | self.table.update_from_dict(logs) 98 | self.table.next_row() 99 | 100 | def on_train_end(self, logs=None): 101 | self.table.close() 102 | 103 | def on_train_batch_end(self, batch, logs=None): 104 | self.table.update_from_dict(logs) 105 | 106 | 107 | model.fit( 108 | x_train, 109 | y_train, 110 | validation_data=(x_train, y_train), 111 | callbacks=[TableCallback(table)], 112 | epochs=10, 113 | ) 114 | ``` 115 | -------------------------------------------------------------------------------- /docs/v3-notice.md: -------------------------------------------------------------------------------- 1 | # Version 3.0.0 2 | 3 | Version 3.0.0 of Progress Table is now available. 4 | 5 | It introduces: 6 | 7 | * Important internal changes to increase the stability. 8 | * Increased compatibility with some types of terminals. 9 | * Minor breaking changes in the API. 10 | 11 | ## BREAKING API CHANGES 12 | 13 | --- 14 | 15 | ``` 16 | ProgressTable(["c1", "c2", "c3"]) 17 | ``` 18 | 19 | ↑ becomes now ↓ 20 | 21 | ``` 22 | ProgressTable("c1", "c2", "c3") 23 | # or 24 | ProgressTable(columns=["c1", "c2", "c3"]) 25 | ``` 26 | 27 | --- 28 | 29 | ``` 30 | ProgressTable([], 1, 20) 31 | ``` 32 | 33 | ↑ will raise an error. Use named arguments instead ↓ 34 | 35 | ``` 36 | ProgressTable(interactive=1, refresh_rate=20) 37 | ``` 38 | 39 | --- 40 | 41 | ``` 42 | from progress_table import ProgressTableV1, ProgressTableV2 43 | ``` 44 | 45 | ↑ multiple versions of ProgressTable are not available anymore. Use ↓ 46 | 47 | ``` 48 | from progress_table import ProgressTable 49 | ``` 50 | 51 | --- 52 | 53 | -------------------------------------------------------------------------------- /examples/brown2d.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | import math 5 | import random 6 | import time 7 | 8 | from progress_table import ProgressTable 9 | 10 | 11 | def calc_distance(pos): 12 | return (pos[0] ** 2 + pos[1] ** 2) ** 0.5 13 | 14 | 15 | def get_color_by_distance(distance): 16 | if distance < 20: 17 | return "white" 18 | elif distance < 40: 19 | return "cyan" 20 | elif distance < 60: 21 | return "blue" 22 | elif distance < 80: 23 | return "magenta" 24 | elif distance < 100: 25 | return "red" 26 | else: 27 | return "yellow bold" 28 | 29 | 30 | def shift_rows_up(table, last_row_color): 31 | num_rows = table.num_rows() 32 | num_columns = table.num_columns() 33 | 34 | for row in range(num_rows - 1): 35 | for col in range(num_columns): 36 | table.at[row, col] = table.at[row + 1, col] 37 | table.at[row, col, "color"] = table.at[row + 1, col, "color"] 38 | for col in range(num_columns): 39 | table.at[-2, col, "color"] = last_row_color 40 | 41 | 42 | def main(random_seed=None, sleep_duration=0.001, **overrides): 43 | if random_seed is None: 44 | random_seed = random.randint(0, 100) 45 | random.seed(random_seed) 46 | 47 | table = ProgressTable( 48 | pbar_embedded=False, 49 | pbar_style="full clean blue", 50 | **overrides, 51 | ) 52 | 53 | TARGET_DISTANCE = 100 54 | 55 | PARTICLE_VELOCITY = 1 56 | PARTICLE_MOMENTUM = 0.999 57 | 58 | MAX_ROWS = 20 59 | STEP_SIZE = 100 60 | 61 | distance_pbar = table.pbar( 62 | TARGET_DISTANCE, 63 | description="Distance", 64 | show_throughput=False, 65 | show_progress=True, 66 | ) 67 | 68 | current_position = (0, 0) 69 | current_velocity = PARTICLE_VELOCITY 70 | 71 | print("Particle will move randomly in 2D space.") 72 | print(f"Simulation stops when it reaches distance of {TARGET_DISTANCE} from the center.") 73 | 74 | tick = 0 75 | pbar_momentum = 0 76 | while calc_distance(current_position) < TARGET_DISTANCE: 77 | random_direction = random.uniform(0, 2 * 3.1415) 78 | new_velocity = random.uniform(0, PARTICLE_VELOCITY * 2) 79 | current_velocity = current_velocity * PARTICLE_MOMENTUM + new_velocity * (1 - PARTICLE_MOMENTUM) 80 | move_vector = ( 81 | current_velocity * math.cos(random_direction), 82 | current_velocity * math.sin(random_direction), 83 | ) 84 | current_position = ( 85 | current_position[0] + move_vector[0], 86 | current_position[1] + move_vector[1], 87 | ) 88 | distance_from_center = calc_distance(current_position) 89 | 90 | tick += 1 91 | table["tick"] = tick 92 | table["velocity"] = current_velocity 93 | table["position X"] = current_position[0] 94 | table["position Y"] = current_position[1] 95 | table["distance from center"] = distance_from_center 96 | 97 | pbar_momentum = pbar_momentum * 0.95 + int(distance_from_center) * 0.05 98 | distance_pbar.set_step(round(pbar_momentum)) 99 | 100 | if tick % STEP_SIZE == 0: 101 | color = get_color_by_distance(distance_from_center) 102 | if table.num_rows() > MAX_ROWS: 103 | shift_rows_up(table, last_row_color=color) 104 | else: 105 | table.next_row(color=color) 106 | 107 | time.sleep(sleep_duration) 108 | 109 | color = get_color_by_distance(100) 110 | table.next_row(color=color) 111 | table.close() 112 | 113 | 114 | if __name__ == "__main__": 115 | main() 116 | -------------------------------------------------------------------------------- /examples/download_v1.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | import random 4 | import time 5 | from concurrent.futures import ThreadPoolExecutor 6 | 7 | import colorama 8 | 9 | from progress_table import ProgressTable 10 | 11 | 12 | def create_random_file_info(): 13 | info = {} 14 | index = random.randint(0, 1000) 15 | types = ["image_.jpg", "video_.mp4", "archive_.zip", "movie_.avi"] 16 | info["name"] = random.choice(types).replace("_", str(index)) 17 | info["size int"] = random.randint(1, 1000) 18 | info["size unit"] = random.choice(["KB", "MB", "GB"]) 19 | info["time"] = 2 + random.random() * 10 20 | return info 21 | 22 | 23 | NUM_FILES = 15 24 | files_to_download = [create_random_file_info() for _ in range(NUM_FILES)] 25 | 26 | 27 | table = ProgressTable( 28 | pbar_show_progress=False, 29 | pbar_show_throughput=False, 30 | pbar_show_eta=True, 31 | default_column_width=8, 32 | default_header_color="bold", 33 | ) 34 | 35 | main_pbar = table.pbar( 36 | NUM_FILES, 37 | position=1, 38 | show_progress=True, 39 | style="square clean blue", 40 | ) 41 | 42 | 43 | def fake_download(idx, file_info): 44 | # Modify pbar styling so that emebedded pbar shows color only 45 | pbar = table.pbar(1, position=idx, static=True, style_embed="hidden", color=colorama.Back.BLACK) 46 | 47 | # Update table values in a specifc row 48 | table.update("total size", str(file_info["size int"]) + " " + file_info["size unit"], row=idx) 49 | table.update("name", file_info["name"], row=idx) 50 | table.update("seeds", random.randint(1, 30), row=idx) 51 | table.update("peers", random.randint(0, 5), row=idx) 52 | 53 | number_of_errors = 0 54 | 55 | t0 = time.time() 56 | td = 0 57 | while True: 58 | pbar.set_step(td / file_info["time"]) # Set specific pbar progress 59 | 60 | # Maybe add an error to the error counter 61 | if random.random() < 0.004: 62 | number_of_errors += 1 63 | random_delay = random.random() 64 | time.sleep(random_delay) 65 | t0 += random_delay 66 | 67 | downloaded_units = int(td / file_info["time"] * file_info["size int"]) 68 | table.update("downloaded", str(downloaded_units) + " " + file_info["size unit"], row=idx) 69 | table.update("warnings", number_of_errors, row=idx) 70 | time.sleep(0.01) 71 | 72 | td = time.time() - t0 73 | if td > file_info["time"]: 74 | break 75 | pbar.close() 76 | main_pbar.update() 77 | 78 | 79 | table.add_column("name", alignment="right", width=25) 80 | table.add_columns("total size", "downloaded", "seeds", "peers", "warnings") 81 | table.add_rows(len(files_to_download), color="blue") 82 | 83 | threads = [] 84 | executor = ThreadPoolExecutor() 85 | 86 | for idx, pkg in enumerate(files_to_download): 87 | threads.append(executor.submit(fake_download, idx, pkg)) 88 | 89 | for thread in threads: 90 | thread.result() 91 | 92 | table.close() 93 | -------------------------------------------------------------------------------- /examples/download_v2.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | import random 4 | import time 5 | from concurrent.futures import ThreadPoolExecutor 6 | 7 | import colorama 8 | 9 | from progress_table import ProgressTable 10 | 11 | 12 | def create_random_file_info(): 13 | info = {} 14 | index = random.randint(0, 1000) 15 | types = ["image_.jpg", "video_.mp4", "archive_.zip", "movie_.avi"] 16 | info["name"] = random.choice(types).replace("_", str(index)) 17 | info["size int"] = random.randint(1, 1000) 18 | info["size unit"] = random.choice(["KB", "MB", "GB"]) 19 | info["time"] = 2 + random.random() * 10 20 | return info 21 | 22 | 23 | NUM_FILES = 40 24 | files_to_download = [create_random_file_info() for _ in range(NUM_FILES)] 25 | 26 | 27 | table = ProgressTable( 28 | pbar_show_progress=False, 29 | pbar_show_throughput=False, 30 | pbar_show_eta=True, 31 | default_column_width=8, 32 | default_header_color="bold", 33 | ) 34 | 35 | main_pbar = table.pbar( 36 | NUM_FILES, 37 | position=1, 38 | show_progress=True, 39 | style="square clean blue", 40 | ) 41 | 42 | 43 | def fake_download(file_info): 44 | # Modify pbar styling so that emebedded pbar shows color only 45 | idx = table.num_rows() - 1 46 | table.next_row() 47 | 48 | pbar = table.pbar(1, position=idx, static=True, style_embed="hidden", color=colorama.Back.BLACK) 49 | 50 | # Update table values in a specifc row 51 | table.update("total size", str(file_info["size int"]) + " " + file_info["size unit"], row=idx) 52 | table.update("name", file_info["name"], row=idx) 53 | table.update("seeds", random.randint(1, 30), row=idx) 54 | table.update("peers", random.randint(0, 5), row=idx) 55 | 56 | number_of_errors = 0 57 | 58 | t0 = time.time() 59 | td = 0 60 | while True: 61 | pbar.set_step(td / file_info["time"]) # Set specific pbar progress 62 | 63 | # Maybe add an error to the error counter 64 | if random.random() < 0.004: 65 | number_of_errors += 1 66 | random_delay = random.random() 67 | time.sleep(random_delay) 68 | t0 += random_delay 69 | 70 | downloaded_units = int(td / file_info["time"] * file_info["size int"]) 71 | table.update("downloaded", str(downloaded_units) + " " + file_info["size unit"], row=idx) 72 | table.update("warnings", number_of_errors, row=idx) 73 | time.sleep(0.01) 74 | 75 | td = time.time() - t0 76 | if td > file_info["time"]: 77 | break 78 | pbar.close() 79 | main_pbar.update() 80 | 81 | 82 | table.add_column("name", alignment="right", width=25) 83 | table.add_columns("total size", "downloaded", "seeds", "peers", "warnings") 84 | 85 | threads = [] 86 | executor = ThreadPoolExecutor() 87 | 88 | for _idx, pkg in enumerate(files_to_download): 89 | threads.append(executor.submit(fake_download, pkg)) 90 | random_wait_time = random.randint(1, 10) 91 | time.sleep(random_wait_time / 10) 92 | 93 | for thread in threads: 94 | thread.result() 95 | 96 | table.close() 97 | -------------------------------------------------------------------------------- /examples/tictactoe.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | import random 5 | import time 6 | 7 | from progress_table import ProgressTable 8 | 9 | BOARD_SIZE = 10 10 | STREAK_LENGTH = 5 11 | 12 | 13 | def main(random_seed=None, sleep_duration=0.05, **overrides): 14 | if random_seed is None: 15 | random_seed = random.randint(0, 100) 16 | random.seed(random_seed) 17 | 18 | table = ProgressTable( 19 | pbar_embedded=False, 20 | default_column_width=1, 21 | print_header_on_top=False, 22 | pbar_style="circle alt red", 23 | **overrides, 24 | ) 25 | 26 | print("Two players are playing tic-tac-toe.") 27 | print(f"The first to get a streak of {STREAK_LENGTH} wins.") 28 | 29 | # Use convenience method to add multiple columns at once 30 | table.add_columns(BOARD_SIZE) 31 | 32 | # Adding multiple new rows at once is also possible 33 | # We get the 1-st row automatically, so we add one less 34 | table.add_rows(BOARD_SIZE, split=True) 35 | 36 | sign = 0 37 | x = 0 38 | y = 0 39 | 40 | win_row_slice = None 41 | win_col_slice = None 42 | for _i in table(BOARD_SIZE * BOARD_SIZE, show_throughput=False, show_progress=True): 43 | current_symbol = True 44 | while current_symbol: 45 | x = random.randint(0, BOARD_SIZE - 1) 46 | y = random.randint(0, BOARD_SIZE - 1) 47 | current_symbol = table.at[y, x] 48 | sign = 1 - sign 49 | 50 | table.at[y, x] = "X" if sign else "O" 51 | table.at[y, x, "COLOR"] = "CYAN" if sign else "BLUE" 52 | 53 | finished = False 54 | for row_idx in range(BOARD_SIZE): 55 | row = table.at[row_idx, :] 56 | for j in range(BOARD_SIZE - STREAK_LENGTH): 57 | values = set(row[j : j + STREAK_LENGTH]) 58 | if values == {"X"} or values == {"O"}: 59 | win_row_slice = slice(row_idx, row_idx + 1) 60 | win_col_slice = slice(j, j + STREAK_LENGTH) 61 | finished = True 62 | for col_idx in range(BOARD_SIZE): 63 | col = table.at[:, col_idx] 64 | 65 | for j in range(BOARD_SIZE - STREAK_LENGTH): 66 | values = set(col[j : j + STREAK_LENGTH]) 67 | if values == {"X"} or values == {"O"}: 68 | win_row_slice = slice(j, j + STREAK_LENGTH) 69 | win_col_slice = slice(col_idx, col_idx + 1) 70 | finished = True 71 | 72 | if finished: 73 | break 74 | time.sleep(sleep_duration) 75 | 76 | # Flashing the winner 77 | for color in ["bold white", "bold red"] * 8: 78 | table.at[win_row_slice, win_col_slice, "COLOR"] = color 79 | time.sleep(0.1) 80 | 81 | table.close() 82 | 83 | 84 | if __name__ == "__main__": 85 | main() 86 | -------------------------------------------------------------------------------- /examples/training.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | import random 5 | import time 6 | 7 | try: 8 | import numpy as np 9 | except ImportError as ex: 10 | raise ImportError("Numpy is required to run the example!") from ex 11 | 12 | try: 13 | from sklearn.datasets import load_iris 14 | from sklearn.model_selection import train_test_split 15 | from sklearn.utils import shuffle 16 | except ImportError as ex: 17 | raise ImportError("Scikit-learn is required to run the example!") from ex 18 | 19 | from progress_table import ProgressTable 20 | 21 | # Training parameters 22 | SGD_LR = 0.01 23 | NUM_EPOCHS = 15 24 | SLEEP_DURATION = 0.04 25 | 26 | 27 | def softmax(x): 28 | exp = np.exp(x) 29 | return exp / np.sum(exp, axis=1, keepdims=True) 30 | 31 | 32 | def log_softmax(x): 33 | bot = np.sum(np.exp(x), axis=1, keepdims=True) 34 | return x - np.log(bot) 35 | 36 | 37 | def cross_entropy_loss(targets, logits): 38 | # Simulate heavy computation 39 | time.sleep(SLEEP_DURATION) 40 | 41 | assert len(targets) == len(logits) 42 | num_elements = len(targets) 43 | 44 | logits = log_softmax(logits) 45 | return -logits[np.arange(num_elements), targets] 46 | 47 | 48 | def cross_entropy_loss_grads(targets, logits): 49 | one_hot_targets = np.zeros_like(logits) 50 | one_hot_targets[np.arange(len(targets)), targets] = 1 51 | return one_hot_targets - softmax(logits) 52 | 53 | 54 | def model_grads(targets, logits, inputs): 55 | cross_entropy_grads = cross_entropy_loss_grads(targets, logits) 56 | return inputs.T @ cross_entropy_grads 57 | 58 | 59 | def main(random_seed=None, sleep_duration=SLEEP_DURATION, **overrides): 60 | if random_seed is None: 61 | random_seed = random.randint(0, 100) 62 | global SLEEP_DURATION 63 | SLEEP_DURATION = sleep_duration 64 | 65 | random.seed(random_seed) 66 | np.random.seed(random_seed) 67 | 68 | table = ProgressTable( 69 | pbar_embedded=False, # Do not use embedded pbar 70 | pbar_style="angled alt red blue", 71 | **overrides, 72 | ) 73 | print("Training a simple linear model on the Iris dataset.") 74 | 75 | # Loading dataset 76 | X, Y = load_iris(return_X_y=True) 77 | X_train, X_valid, Y_train, Y_valid = train_test_split(X, Y) 78 | weights = np.random.rand(4, 3) 79 | 80 | for epoch in table(NUM_EPOCHS, show_throughput=False, show_eta=True): 81 | table["epoch"] = epoch 82 | # Shuffling training dataset each epoch 83 | X_train, Y_train = shuffle(X_train, Y_train) # type: ignore 84 | 85 | NUM_BATCHES = 16 86 | X_batches = np.array_split(X_train, NUM_BATCHES) 87 | Y_batches = np.array_split(Y_train, NUM_BATCHES) 88 | 89 | for batch in table(zip(X_batches, Y_batches), total=NUM_BATCHES, description="train epoch"): 90 | x, y = batch 91 | logits = x @ weights 92 | 93 | # Computing and applying gradient update 94 | weights_updates = model_grads(y, logits, x) 95 | weights = weights + SGD_LR * weights_updates 96 | 97 | # Computing loss function for the logging 98 | accuracy = np.mean(np.argmax(logits, axis=1) == y) 99 | loss_value = np.mean(cross_entropy_loss(y, logits)) 100 | 101 | # We're using .update instead of __setitem__ so that we can specify column details 102 | table.update("train loss", loss_value, aggregate="mean", color="blue") 103 | table.update("train accuracy", accuracy, aggregate="mean", color="blue bold") 104 | 105 | run_validation = epoch % 5 == 4 or epoch == NUM_EPOCHS - 1 106 | if run_validation: 107 | NUM_BATCHES = 32 108 | X_batches = np.array_split(X_valid, NUM_BATCHES) 109 | Y_batches = np.array_split(Y_valid, NUM_BATCHES) 110 | 111 | for batch in table(zip(X_batches, Y_batches), total=NUM_BATCHES, description="valid epoch"): 112 | x, y = batch 113 | logits = x @ weights 114 | accuracy = np.mean(np.argmax(logits, axis=1) == y) 115 | loss_value = np.mean(cross_entropy_loss(y, logits)) 116 | 117 | # Use aggregation weight equal to batch size to get real mean over the validation dataset 118 | batch_size = x.shape[0] 119 | table.update( 120 | "valid loss", 121 | loss_value, 122 | weight=batch_size, 123 | aggregate="mean", 124 | color="red", 125 | ) 126 | table.update( 127 | "valid accuracy", 128 | accuracy, 129 | weight=batch_size, 130 | aggregate="mean", 131 | color="red bold", 132 | ) 133 | table.next_row(split=run_validation) 134 | table.close() 135 | 136 | 137 | if __name__ == "__main__": 138 | main() 139 | -------------------------------------------------------------------------------- /hooks.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | """Hooks for Progress Table. 5 | 6 | This hook modifies the README.md file to use 7 | direct GitHub URLs for images and links. This 8 | is useful for PyPI. Otherwise the README.md 9 | file would be rendered without the images. 10 | """ 11 | 12 | from pathlib import Path 13 | 14 | from hatchling.metadata.plugin.interface import MetadataHookInterface 15 | 16 | 17 | def with_direct_github_urls(text): 18 | images_github_link = "https://raw.githubusercontent.com/sjmikler/progress-table/main/images" 19 | text = text.replace("(images", "(" + images_github_link) 20 | 21 | docs_github_link = "https://github.com/sjmikler/progress-table/blob/main/docs/" 22 | return text.replace("(docs", "(" + docs_github_link) 23 | 24 | 25 | class ReadmeHook(MetadataHookInterface): 26 | def update(self, metadata: dict): 27 | readme_path = Path("README.md") 28 | original_text = readme_path.read_text(encoding="utf-8") 29 | updated_text = with_direct_github_urls(original_text) 30 | with open("README_pypi.md", "w", encoding="utf-8") as f: 31 | f.write(updated_text) 32 | 33 | # Tell Hatch to use this modified README 34 | print("Generated README for Pypi!") 35 | -------------------------------------------------------------------------------- /images/example-output4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjmikler/progress-table/bceedafe45f5f85b93dfbf90ee7a76693a7708b4/images/example-output4.gif -------------------------------------------------------------------------------- /images/examples-brown2d.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjmikler/progress-table/bceedafe45f5f85b93dfbf90ee7a76693a7708b4/images/examples-brown2d.gif -------------------------------------------------------------------------------- /images/examples-download.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjmikler/progress-table/bceedafe45f5f85b93dfbf90ee7a76693a7708b4/images/examples-download.gif -------------------------------------------------------------------------------- /images/examples-tictactoe.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjmikler/progress-table/bceedafe45f5f85b93dfbf90ee7a76693a7708b4/images/examples-tictactoe.gif -------------------------------------------------------------------------------- /images/examples-training.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjmikler/progress-table/bceedafe45f5f85b93dfbf90ee7a76693a7708b4/images/examples-training.gif -------------------------------------------------------------------------------- /images/progress-after3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjmikler/progress-table/bceedafe45f5f85b93dfbf90ee7a76693a7708b4/images/progress-after3.gif -------------------------------------------------------------------------------- /images/progress-after4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjmikler/progress-table/bceedafe45f5f85b93dfbf90ee7a76693a7708b4/images/progress-after4.gif -------------------------------------------------------------------------------- /images/progress-before3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjmikler/progress-table/bceedafe45f5f85b93dfbf90ee7a76693a7708b4/images/progress-before3.gif -------------------------------------------------------------------------------- /images/progress-table-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjmikler/progress-table/bceedafe45f5f85b93dfbf90ee7a76693a7708b4/images/progress-table-example.png -------------------------------------------------------------------------------- /progress_table/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | """Progress Table provides an easy and pretty way to track your process. 5 | 6 | Supported features: 7 | - Styling and coloring 8 | - Modifying existing cells 9 | - Progress bars integrated into the table 10 | """ 11 | 12 | __license__ = "MIT" 13 | __version__ = "3.1.2" 14 | __author__ = "Szymon Mikler" 15 | 16 | from progress_table.progress_table import ProgressTable, styles 17 | 18 | __all__ = ["ProgressTable", "styles"] 19 | -------------------------------------------------------------------------------- /progress_table/common.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | """Common utilities for progress_table.""" 5 | 6 | from typing import Union 7 | 8 | from colorama import Back, Fore, Style 9 | 10 | ALL_COLOR_NAME = [x for x in dir(Fore) if not x.startswith("__")] 11 | ALL_STYLE_NAME = [x for x in dir(Style) if not x.startswith("__")] 12 | ALL_COLOR_STYLE_NAME = ALL_COLOR_NAME + ALL_STYLE_NAME 13 | ALL_COLOR = [getattr(Fore, x) for x in ALL_COLOR_NAME] + [getattr(Back, x) for x in ALL_COLOR_NAME] 14 | ALL_STYLE = [getattr(Style, x) for x in ALL_STYLE_NAME] 15 | ALL_COLOR_STYLE = ALL_COLOR + ALL_STYLE 16 | 17 | COLORAMA_TRANSLATE = { 18 | "bold": "bright", 19 | } 20 | 21 | NoneType = type(None) 22 | ColorFormat = Union[str, tuple, list, NoneType] 23 | ColorFormatTuple = (str, tuple, list, NoneType) 24 | CURSOR_UP = "\033[A" 25 | 26 | 27 | def maybe_convert_to_colorama_str(color: str) -> str: 28 | """Convert color from string to colorama string.""" 29 | color = COLORAMA_TRANSLATE.get(color.lower(), color) 30 | 31 | if isinstance(color, str): 32 | if hasattr(Fore, color.upper()): 33 | return getattr(Fore, color.upper()) 34 | if hasattr(Style, color.upper()): 35 | return getattr(Style, color.upper()) 36 | 37 | assert color in ALL_COLOR_STYLE, f"Color {color!r} incorrect! Available: {' '.join(ALL_COLOR_STYLE_NAME)}" 38 | return color 39 | 40 | 41 | def maybe_convert_to_colorama(color: ColorFormat) -> str: 42 | """Fix unintuitive colorama names. 43 | 44 | Translation layer from user-passed to colorama-compatible names. 45 | """ 46 | if color is None or color == "": 47 | return "" 48 | if isinstance(color, str): 49 | color = color.split(" ") 50 | results = [maybe_convert_to_colorama_str(x) for x in color] 51 | return "".join(results) 52 | -------------------------------------------------------------------------------- /progress_table/progress_table.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | """Progress Table provides an easy and pretty way to track your process.""" 5 | 6 | from __future__ import annotations # for PEP 563 7 | 8 | import inspect 9 | import logging 10 | import math 11 | import os 12 | import shutil 13 | import sys 14 | import time 15 | from collections.abc import Callable, Iterable, Iterator, Sized 16 | from dataclasses import dataclass 17 | from threading import Thread 18 | from typing import Any, TextIO 19 | 20 | from colorama import Style 21 | 22 | from progress_table import styles 23 | from progress_table.common import ( 24 | CURSOR_UP, 25 | ColorFormat, 26 | ColorFormatTuple, 27 | maybe_convert_to_colorama, 28 | ) 29 | 30 | ###################### 31 | ## HELPER FUNCTIONS ## 32 | ###################### 33 | 34 | logger = logging.getLogger("progress_table") 35 | 36 | 37 | class TableClosedError(Exception): 38 | """Raised when trying to update a closed table.""" 39 | 40 | 41 | def aggregate_dont(value, *_): 42 | """Don't aggregate the values.""" 43 | return value 44 | 45 | 46 | def aggregate_mean(value, old_value, weight, old_weight): 47 | """Aggregate the values by keeping the mean.""" 48 | return (old_value * old_weight + value * weight) / (old_weight + weight) 49 | 50 | 51 | def aggregate_sum(value, old_value, *_): 52 | """Aggregate the values by keeping the sum.""" 53 | return old_value + value 54 | 55 | 56 | def aggregate_max(value, old_value, *_): 57 | """Aggregate the values by keeping the maximum.""" 58 | return max(old_value, value) 59 | 60 | 61 | def aggregate_min(value, old_value, *_): 62 | """Aggregate the values by keeping the minimum.""" 63 | return min(old_value, value) 64 | 65 | 66 | def get_aggregate_fn(aggregate: None | str | Callable) -> Callable: 67 | """Get the aggregate function from the provided value.""" 68 | if aggregate is None: 69 | return aggregate_dont 70 | 71 | if callable(aggregate): 72 | num_parameters = len(inspect.signature(aggregate).parameters) 73 | assert num_parameters == 4, f"Aggregate function has to take 4 arguments, not {num_parameters}!" 74 | return aggregate 75 | 76 | if isinstance(aggregate, str): 77 | if aggregate == "mean": 78 | return aggregate_mean 79 | if aggregate == "sum": 80 | return aggregate_sum 81 | if aggregate == "max": 82 | return aggregate_max 83 | if aggregate == "min": 84 | return aggregate_min 85 | msg = f"Unknown aggregate type string: {aggregate}" 86 | raise ValueError(msg) 87 | msg = f"Unknown aggregate type: {type(aggregate)}" 88 | raise ValueError(msg) 89 | 90 | 91 | def get_default_format_fn(decimal_places: int) -> Callable[[Any], str]: 92 | def fmt(x: Any) -> str: 93 | if isinstance(x, int): 94 | return str(x) 95 | try: 96 | return format(x, f".{decimal_places}f") 97 | except ValueError: 98 | return str(x) 99 | 100 | return fmt 101 | 102 | 103 | ################ 104 | ## MAIN CLASS ## 105 | ################ 106 | 107 | 108 | @dataclass 109 | class DataRow: 110 | """Basic unit of data storage for the table.""" 111 | 112 | values: dict[str, Any] 113 | weights: dict[str, float] 114 | colors: dict[str, str] 115 | user: bool = False 116 | 117 | def is_empty(self) -> bool: 118 | """Check if the row is empty.""" 119 | return not any(self.values) and not self.user 120 | 121 | 122 | class ProgressTable: 123 | """Provides an easy and pretty way to track your process.""" 124 | 125 | DEFAULT_COLUMN_WIDTH = 8 126 | DEFAULT_COLUMN_COLOR = None 127 | DEFAULT_COLUMN_ALIGNMENT = "center" 128 | DEFAULT_COLUMN_AGGREGATE = None 129 | DEFAULT_ROW_COLOR = None 130 | 131 | def __init__( 132 | self, 133 | *cols: str, 134 | columns: Iterable[str] = (), 135 | interactive: int = int(os.environ.get("PTABLE_INTERACTIVE", "2")), 136 | refresh_rate: int = 20, 137 | num_decimal_places: int = 4, 138 | default_column_width: int | None = None, 139 | default_column_color: ColorFormat = None, 140 | default_column_alignment: str | None = None, 141 | default_column_aggregate: str | None = None, 142 | default_header_color: ColorFormat = None, 143 | default_row_color: ColorFormat = None, 144 | pbar_show_throughput: bool = True, 145 | pbar_show_progress: bool = False, 146 | pbar_show_percents: bool = False, 147 | pbar_show_eta: bool = False, 148 | pbar_embedded: bool = True, 149 | pbar_style: str | styles.PbarStyleBase = "square", 150 | pbar_style_embed: str | styles.PbarStyleBase = "cdots", 151 | print_header_on_top: bool = True, 152 | print_header_every_n_rows: int = 30, 153 | custom_cell_format: Callable[[Any], str] | None = None, 154 | table_style: str | styles.TableStyleBase = "round", 155 | file: TextIO | list[TextIO] | tuple[TextIO] | None = None, 156 | # DEPRECATED ARGUMENTS 157 | custom_format: None = None, 158 | embedded_progress_bar: None = None, 159 | print_row_on_update: None = None, 160 | ) -> None: 161 | """Progress Table instance. 162 | 163 | Columns can be specified using `add_column` method, but they can be added on the fly as well. 164 | Use `next_row` method to display the current row and reset the values for the next row. 165 | 166 | Example: 167 | >>> table = ProgressTable() 168 | >>> table.add_column("b", width=10) 169 | >>> table["a"] = 1 170 | >>> table["b"] = "xyz" 171 | >>> table.next_row() 172 | 173 | Args: 174 | cols: Columns that will be displayed in the header. Columns can be provided directly in `__init__` or 175 | through methods `add_column` and `add_columns`. Columns added through `__init__` will have default 176 | settings like alignment, color and width, while columns added through methods can have those 177 | customized. 178 | columns: Alias for `cols`. 179 | interactive: Three interactivity levels are available: 2, 1 and 0. It's recommended to use 2, but some 180 | terminal environments might not all features. When using decreased interactivity, some features 181 | will be supressed. If something doesn't look right in your terminal, try to decrease the 182 | interactivity. 183 | On level 2 you can modify all rows, including the rows above the current one. 184 | You can also add columns on-the-fly or reorder them. You can use nested progress bars. 185 | On level 1 you can only operate on the current row, the old rows are frozen, but you still get 186 | to use a progress bar, albeit not nested. On level 0 there are no progress bars and rows are 187 | only printed after calling `next_row`. 188 | refresh_rate: The maximal number of times per second to render the updates in the table. 189 | num_decimal_places: This is only applicable when using the default formatting. This won't be used if 190 | `custom_cell_repr` is set. If applicable, for every displayed value except integers 191 | there will be an attempt to round it. 192 | default_column_color: Color of the header and the data in the column. 193 | This can be overwritten in columns by using an argument in `add_column` method. 194 | default_column_width: Width of the column excluding cell padding. 195 | This can be overwritten in columns by using an argument in `add_column` method. 196 | default_column_alignment: Alignment in the column. Can be aligned either to `left`, `right` or `center`. 197 | This can be overwritten by columns by using an argument in `add_column` method. 198 | default_column_aggregate: By default, there's no aggregation. But if this is for example 'mean', then after 199 | every update in the current row, the mean of the provided values will be 200 | displayed. Aggregated values is reset at every new row. This can be overwritten 201 | by columns by using an argument in `add_column` method. 202 | default_header_color: Color of the header. This can be overwritten by column-specific color. 203 | default_row_color: Color of the row. This can be overwritten by using an argument in `next_row` method. 204 | pbar_show_throughput: Show throughput in the progress bar, for example `3.55 it/s`. Defaults to True. 205 | pbar_show_progress: Show progress in the progress bar, for example 10/40. Defaults to True. 206 | pbar_show_percents: Show percents in the progress bar, for example 25%. Defaults to False. 207 | pbar_show_eta: Show estimated time of finishing in the progress bar, for example 10s. Defaults to False. 208 | pbar_embedded: If True, changes the way the first (non-nested) progress bar looks. 209 | Embedded version is more subtle, but does not prevent the current row from being displayed. 210 | If False, the progress bar covers the current row, preventing the user from seeing values 211 | that are being updated until the progress bar finishes. The default is True. 212 | pbar_style: Change the style of the progress bar. Either a string or 'PbarStyleBase' type class. 213 | pbar_style_embed: Change the style of the embedded progress bar. Same as pbar_style, but for embedded pbars. 214 | print_header_on_top: If True, header will be displayed as the first row in the table. 215 | print_header_every_n_rows: 30 by default. When table has a lot of rows, it can be useful to remind what the 216 | header is. If True, hedaer will be displayed periodically after the selected 217 | number of rows. 0 to supress. 218 | custom_cell_format: A function that defines how to get str value to display from a cell content. 219 | This function should be universal and work for all datatypes as inputs. 220 | It takes one value as an input and returns string as an output. 221 | table_style: Change the borders of the table. Either a string or 'TableStyleBase' type class. 222 | file: Redirect the output to another stream. There can be multiple streams at once passed as list or tuple. 223 | Defaults to None, which is interpreted as stdout. 224 | 225 | """ 226 | # Deprecation handling 227 | if custom_format is not None: 228 | logger.warning("Argument `custom_format` is deprecated. Use `custom_cell_format` instead!") 229 | if embedded_progress_bar is not None: 230 | logger.warning("Argument `embedded_progress_bar` is deprecated. Use `pbar_embedded` instead!") 231 | if print_row_on_update is not None: 232 | logger.warning("Argument `print_row_on_update` is deprecated. Specify `interactive` instead!") 233 | 234 | self.pbar_style = styles.parse_pbar_style(pbar_style) 235 | self.table_style = styles.parse_table_style(table_style) 236 | self.pbar_style_embed = styles.parse_pbar_style(pbar_style_embed) 237 | 238 | assert isinstance(default_row_color, ColorFormatTuple), "Row color has to be a color format!" 239 | assert isinstance(default_column_color, ColorFormatTuple), "Column color has to be a color format!" 240 | assert isinstance(default_header_color, ColorFormatTuple), "Header color has to be a color format!" 241 | 242 | # Default values for column and 243 | self.column_width = default_column_width 244 | self.column_color = default_column_color 245 | self.column_alignment = default_column_alignment 246 | self.column_aggregate = default_column_aggregate 247 | self.row_color = default_row_color 248 | self.header_color = default_header_color 249 | 250 | # We are storing column configs 251 | self.column_widths: dict[str, int] = {} 252 | self.column_colors: dict[str, str] = {} 253 | self.column_alignments: dict[str, str] = {} 254 | self.column_aggregates: dict[str, Callable] = {} 255 | self.column_names: list[str] = [] # Names serve as keys for column configs 256 | self._closed = False 257 | 258 | self.files = (file,) if not isinstance(file, (list, tuple)) else file 259 | 260 | assert print_header_every_n_rows >= 0, "Value must be non-negative!" 261 | self._print_header_on_top = print_header_on_top 262 | self._print_header_every_n_rows = print_header_every_n_rows 263 | self._previous_header_row_number = 0 264 | 265 | self._data_rows: list[DataRow] = [] 266 | self._display_rows: list[str | int] = [] 267 | self._pending_display_rows: list[int] = [] 268 | 269 | self._REFRESH_PENDING: bool = False 270 | self._RENDERER_RUNNING: bool = False 271 | 272 | self._active_pbars: list[TableProgressBar] = [] 273 | self._cleaning_pbar_instructions: list[tuple[int, str]] = [] 274 | 275 | self._latest_row_decorations: list[str] 276 | if self._print_header_on_top: 277 | self._latest_row_decorations = ["SPLIT TOP", "HEADER", "SPLIT MID"] 278 | else: 279 | self._latest_row_decorations = ["SPLIT TOP"] 280 | 281 | self.custom_cell_format = custom_cell_format or get_default_format_fn(num_decimal_places) 282 | self.pbar_show_throughput: bool = pbar_show_throughput 283 | self.pbar_show_progress: bool = pbar_show_progress 284 | self.pbar_show_percents: bool = pbar_show_percents 285 | self.pbar_show_eta: bool = pbar_show_eta 286 | self.pbar_embedded: bool = pbar_embedded 287 | 288 | self.refresh_rate: int = refresh_rate 289 | self._frame_time: float = 1 / self.refresh_rate if self.refresh_rate else 0.0 290 | 291 | self._CURSOR_ROW = 0 292 | 293 | # Interactivity settings 294 | self.interactive = interactive 295 | assert self.interactive in (2, 1, 0) 296 | 297 | self._printing_buffer: list[str] = [] 298 | self.add_columns(*cols, *columns) 299 | 300 | self._append_new_empty_data_row() 301 | self._at_indexer = TableAtIndexer(self) 302 | 303 | #################### 304 | ## PUBLIC METHODS ## 305 | #################### 306 | 307 | def add_column( 308 | self, 309 | name: str, 310 | *, 311 | width: int | None = None, 312 | color: ColorFormat = None, 313 | alignment: str | None = None, 314 | aggregate: None | str | Callable = None, 315 | ) -> None: 316 | """Add column to the table. 317 | 318 | You can re-add existing columns to modify their properties. 319 | 320 | Args: 321 | name: Name of the column. Will be displayed in the header. Must be unique. 322 | width: Width of the column. If width is smaller than the name, width will be automatically 323 | increased to the smallest possible value. This means setting `width=0` will set the column 324 | to the smallest possible width allowed by `name`. 325 | alignment: Alignment of the data in the column. 326 | color: Color of the header and the data in the column. 327 | aggregate: By default, there's no aggregation. But if this is for example 'mean', then 328 | after every update in the current row, the mean of the provided values will be 329 | displayed. Aggregated values is reset at every new row. 330 | 331 | """ 332 | assert isinstance(name, str), f"Column name has to be a string, not {type(name)}!" 333 | if name in self.column_names: 334 | logger.info("Column '%s' already exists!", name) 335 | else: 336 | self.column_names.append(name) 337 | 338 | resolved_width = width or self.column_width or self.DEFAULT_COLUMN_WIDTH 339 | if not width and resolved_width < len(str(name)): 340 | resolved_width = len(str(name)) 341 | self.column_widths[name] = resolved_width 342 | self.column_colors[name] = maybe_convert_to_colorama(color or self.column_color or self.DEFAULT_COLUMN_COLOR) 343 | self.column_alignments[name] = alignment or self.column_alignment or self.DEFAULT_COLUMN_ALIGNMENT 344 | self.column_aggregates[name] = get_aggregate_fn( 345 | aggregate or self.column_aggregate or self.DEFAULT_COLUMN_AGGREGATE, 346 | ) 347 | self._set_all_display_rows_as_pending() 348 | 349 | def add_columns(self, *columns, **kwds) -> None: 350 | """Add multiple columns to the table. 351 | 352 | This can be an integer - then the given number of columns will be created. 353 | In this case their names will be integers starting from 0. 354 | 355 | Args: 356 | columns: Names of the columns or a number of columns to create. 357 | kwds: Additional arguments for the columns. Column properties will be identical for all added columns. 358 | 359 | """ 360 | # Create the given number of columns 361 | if len(columns) == 1 and isinstance(columns[0], int): 362 | num_to_create = columns[0] 363 | col_idx = 0 364 | while num_to_create > 0: 365 | column_name = str(col_idx) 366 | if column_name not in self.column_names: 367 | self.add_column(column_name, **kwds) 368 | num_to_create -= 1 369 | col_idx += 1 370 | else: 371 | for column in columns: 372 | self.add_column(column, **kwds) 373 | 374 | def reorder_columns(self, *column_names) -> None: 375 | """Reorder columns in the table. 376 | 377 | Args: 378 | column_names: Names of the columns in the desired order. 379 | 380 | """ 381 | if all(x == y for x, y in zip(column_names, self.column_names)): 382 | return 383 | 384 | assert isinstance(column_names, (list, tuple)) 385 | assert all(x in self.column_names for x in column_names), f"Columns {column_names} not in {self.column_names}" 386 | self.column_names = list(column_names) 387 | self.column_widths = {k: self.column_widths[k] for k in column_names} 388 | self.column_colors = {k: self.column_colors[k] for k in column_names} 389 | self.column_alignments = {k: self.column_alignments[k] for k in column_names} 390 | self.column_aggregates = {k: self.column_aggregates[k] for k in column_names} 391 | self._set_all_display_rows_as_pending() 392 | 393 | def update( 394 | self, 395 | name: str, 396 | value: Any, 397 | *, 398 | row: int = -1, 399 | weight: float = 1.0, 400 | cell_color: ColorFormat = None, 401 | **column_kwds, 402 | ) -> None: 403 | """Update value in the current row. More powerful than __setitem__. 404 | 405 | Args: 406 | name: Name of the column. 407 | value: Value to be set. 408 | row: Index of the row. By default, it's the last row. 409 | weight: Weight of the value. This is used for aggregation. 410 | cell_color: Optionally override color for specific cell, independent of rows and columns. 411 | column_kwds: Additional arguments for the column. They will be only used for column creation. 412 | If column already exists, they will have no effect. 413 | 414 | """ 415 | if name not in self.column_names: 416 | self.add_column(name, **column_kwds) 417 | 418 | data_row_index = row if row >= 0 else len(self._data_rows) + row 419 | if data_row_index >= len(self._data_rows): 420 | msg = f"Row {data_row_index} out of range! Number of rows: {len(self._data_rows)}" 421 | raise IndexError(msg) 422 | 423 | # Set default values for rows without values in this column 424 | data_row = self._data_rows[row] 425 | data_row.values.setdefault(name, 0) 426 | data_row.weights.setdefault(name, 0) 427 | 428 | fn = self.column_aggregates[name] 429 | data_row.values[name] = fn(value, data_row.values[name], weight, data_row.weights[name]) 430 | data_row.weights[name] += weight 431 | 432 | if cell_color is not None: 433 | data_row.colors[name] = maybe_convert_to_colorama(cell_color) 434 | 435 | if self.interactive > 0: 436 | self._append_or_update_display_row(data_row_index) 437 | 438 | def __setitem__(self, key: str | tuple[str, int], value: Any) -> None: 439 | """Update value in the current row. Calls 'update'.""" 440 | if isinstance(key, slice): 441 | msg = "slicing not supported! Did you want to use 'table.at[:]' indexer?" 442 | raise IndexError(msg) 443 | 444 | if isinstance(key, tuple): 445 | name, row = key 446 | if isinstance(name, int) and isinstance(row, str): 447 | name, row = row, name 448 | else: 449 | name = key 450 | row = -1 451 | assert isinstance(row, int), f"Row {row} has to be an integer, not {type(row)}!" 452 | self.update(name, value, row=row, weight=1) 453 | 454 | def __getitem__(self, key: str | tuple[str, int]) -> Any: 455 | """Get the value from the current row in table.""" 456 | if isinstance(key, slice): 457 | msg = "slicing not supported! Did you want to use 'table.at[:]' indexer?" 458 | raise IndexError(msg) 459 | 460 | if isinstance(key, tuple): 461 | name, row = key 462 | if isinstance(name, int) and isinstance(row, int): 463 | name, row = row, name 464 | else: 465 | name = key 466 | row = -1 467 | assert name in self.column_names, f"Column {name} not in {self.column_names}" 468 | assert isinstance(row, int), f"Row {row} has to be an integer, not {type(row)}!" 469 | return self._data_rows[row].values.get(name, None) 470 | 471 | def update_from_dict(self, dictionary: dict[str, Any]) -> None: 472 | """Update multiple values in the current row.""" 473 | for key, value in dictionary.items(): 474 | self.update(key, value) 475 | 476 | @property 477 | def at(self) -> TableAtIndexer: 478 | """Advanced indexing and splicing for the table. 479 | 480 | Indexing with respect to data rows. 481 | Headers and decorations are ignored. 482 | 483 | Example: 484 | >>> table.at[:] = 0.0 # Initialize all values to 0.0 485 | >>> table.at[0, :] = 2.0 # Set all values in the first row to 2.0 486 | >>> table.at[:, 1] = 2.0 # Set all values in the second column to 2.0 487 | >>> table.at[-2, 0] = 3.0 # Set the first column in the second-to-last row to 3.0 488 | 489 | """ 490 | return self._at_indexer 491 | 492 | def next_row( 493 | self, 494 | color: ColorFormat | dict[str, ColorFormat] = None, 495 | split: bool | None = None, 496 | header: bool | None = None, 497 | ) -> None: 498 | """End the current row.""" 499 | # Force header if it wasn't printed for a long enough time 500 | if ( 501 | header is None 502 | and len(self._data_rows) - self._previous_header_row_number >= self._print_header_every_n_rows 503 | and self._print_header_every_n_rows > 0 504 | ): 505 | header = True 506 | header = header or False 507 | split = split or False 508 | 509 | row = self._data_rows[-1] 510 | data_row_index = len(self._data_rows) - 1 511 | 512 | # Color is applied to the existing row - not the new one! 513 | # Existing colors applied by `update` get the priority over row color 514 | row.colors = {**self._resolve_row_color_dict(color), **row.colors} 515 | 516 | # Refreshing the existing row is necessary to apply colors 517 | # Or - if row is new - this will add it to display rows 518 | self._append_or_update_display_row(data_row_index) 519 | 520 | self._append_new_empty_data_row() 521 | # Add decorations and a new row 522 | if header: 523 | self._previous_header_row_number = len(self._data_rows) - 1 524 | self._latest_row_decorations.extend(["SPLIT MID", "HEADER", "SPLIT MID"]) 525 | elif split: 526 | self._latest_row_decorations.append("SPLIT MID") 527 | 528 | def add_row(self, *values, **kwds) -> None: 529 | """Mimicking rich.table behavior for adding full rows in one call.""" 530 | if not self._data_rows[-1].is_empty(): 531 | self.next_row(**kwds) 532 | 533 | for key, value in zip(self.column_names, values): 534 | self.update(key, value) 535 | 536 | # The row was explicitly added, make sure it is displayed 537 | data_row_index = len(self._data_rows) - 1 538 | self._append_or_update_display_row(data_row_index) 539 | 540 | # Mark row as explicitly added by the user 541 | self._data_rows[data_row_index].user = True 542 | 543 | def add_rows(self, *rows, **kwds) -> None: 544 | """Like `add_row` but adds multiple rows at once. 545 | 546 | Optionally, it accepts an integer as the first argument, which will create that number of empty rows. 547 | """ 548 | if len(rows) == 1 and isinstance(rows[0], int): 549 | rows = [{} for _ in range(rows[0])] 550 | 551 | for row in rows: 552 | self.add_row(*row, **kwds) 553 | 554 | def num_rows(self) -> int: 555 | """Get the number of rows in the table.""" 556 | return len(self._data_rows) 557 | 558 | def num_columns(self) -> int: 559 | """Get the number of columns in the table.""" 560 | return len(self.column_names) 561 | 562 | def close(self) -> None: 563 | """Close the table gracefully. Closed table cannot be updated anymore.""" 564 | if self._closed: 565 | return 566 | for pbar in self._active_pbars: 567 | pbar.close() 568 | if "SPLIT TOP" in self._display_rows: 569 | self._append_or_update_display_row("SPLIT BOT") 570 | self._refresh() 571 | self._closed = True 572 | 573 | def write(self, *args, sep: str = " ") -> None: 574 | """Write a text message gracefully when the table is opened. 575 | 576 | Example: 577 | >>> ┌─────────┬─────────┐ 578 | >>> │ H1 │ H2 │ 579 | >>> ├─────────┼─────────┤ 580 | >>> │ V1 │ V2 │ 581 | >>> │ Your message here │ 582 | >>> │ V3 │ V4 │ 583 | >>> └─────────┴─────────┘ 584 | 585 | """ 586 | full_message = [str(arg) for arg in args] 587 | full_message_str = sep.join(full_message) 588 | message_lines = full_message_str.split("\n") 589 | 590 | tot_width = self._get_outer_inner_width() 591 | for raw_line in message_lines: 592 | line = self.table_style.vertical + raw_line 593 | 594 | if len(line) < tot_width: 595 | n_spaces = tot_width - len(line) - 1 596 | line += " " * n_spaces + self.table_style.vertical 597 | 598 | self._append_or_update_display_row("USER WRITE " + line) 599 | 600 | def to_list(self) -> list[list[Any]]: 601 | """Convert to Python nested list.""" 602 | values = [[row.values.get(col, None) for col in self.column_names] for row in self._data_rows] 603 | if self._data_rows[-1].is_empty(): 604 | values.pop(-1) 605 | return values 606 | 607 | def to_numpy(self) -> Any: 608 | """Convert to numpy array. 609 | 610 | Numpy library is required. 611 | """ 612 | import numpy as np 613 | 614 | return np.array(self.to_list()) 615 | 616 | def to_df(self) -> Any: 617 | """Convert to pandas DataFrame. 618 | 619 | Pandas library is required. 620 | """ 621 | import pandas as pd 622 | 623 | return pd.DataFrame(self.to_list(), columns=pd.Series(self.column_names)) 624 | 625 | def show(self) -> None: 626 | """Show the full table in the console.""" 627 | self._CURSOR_ROW = 0 628 | self._set_all_display_rows_as_pending() 629 | 630 | ##################### 631 | ## PRIVATE METHODS ## 632 | ##################### 633 | 634 | def _trigger_refresh(self) -> None: 635 | """Trigger refresh event. 636 | 637 | If fps>0 the refresh won't happen immediately. 638 | """ 639 | if self.refresh_rate == 0: 640 | return self._refresh() 641 | 642 | # Inform the renderer to refresh 643 | self._REFRESH_PENDING = True 644 | 645 | if not self._RENDERER_RUNNING: 646 | # Spawn the renderer thread 647 | self._RENDERER_RUNNING = True 648 | Thread(target=self._rendering_loop).start() 649 | return None 650 | return None 651 | 652 | def _rendering_loop(self) -> None: 653 | """Render in a loop. 654 | 655 | Renderer loop that runs as long as there's something to display. 656 | If no external events happen, the rendering will stop after a while. 657 | """ 658 | while True: 659 | time.sleep(self._frame_time) 660 | 661 | if not self._REFRESH_PENDING: 662 | # No triggers during wait time. 663 | # Waiting some more to be sure... 664 | 665 | time.sleep(self._frame_time) 666 | if not self._REFRESH_PENDING: 667 | self._RENDERER_RUNNING = False 668 | return # Kill the unused renderer 669 | 670 | self._REFRESH_PENDING = False 671 | self._refresh() 672 | 673 | def _refresh(self) -> None: 674 | """Immediate refresh of the table.""" 675 | if self._display_rows: 676 | self._print_pending_rows_to_buffer() 677 | self._flush_buffer() 678 | 679 | def _append_or_update_display_row(self, element: int | str) -> None: 680 | """Refresh or append a display row. 681 | 682 | For integer - this adds the corresponding existing data row as pending. 683 | For string - this appends a new row decoration. 684 | """ 685 | if self._closed: 686 | msg = "Table was closed! Updating closed tables is not supported." 687 | raise TableClosedError(msg) 688 | 689 | if isinstance(element, int): 690 | if element not in self._display_rows: 691 | for decoration in self._latest_row_decorations: 692 | self._append_or_update_display_row(decoration) 693 | self._latest_row_decorations.clear() 694 | self._display_rows.append(element) 695 | elif element != len(self._data_rows) - 1 and self.interactive < 2: 696 | # Won't refresh non-current rows for interactive<2 697 | return 698 | 699 | display_index = self._display_rows.index(element) 700 | if display_index not in self._pending_display_rows: 701 | # Check if the row isn't already pending 702 | self._pending_display_rows.append(display_index) 703 | else: 704 | self._display_rows.append(element) 705 | self._pending_display_rows.append(len(self._display_rows) - 1) 706 | 707 | self._trigger_refresh() 708 | 709 | def _set_all_display_rows_as_pending(self) -> None: 710 | """Set all display rows as pending. 711 | 712 | This will refresh all rows in the next tick. 713 | """ 714 | self._pending_display_rows = list(range(len(self._display_rows))) 715 | self._trigger_refresh() 716 | 717 | def _append_new_empty_data_row(self) -> None: 718 | # Add a new data row - but don't add it as display row yet 719 | row = DataRow(values={}, weights={}, colors={}) 720 | self._data_rows.append(row) 721 | 722 | def _get_cursor_offset(self, row_index: int) -> int: 723 | if row_index < 0: 724 | row_index = len(self._display_rows) + row_index 725 | return self._CURSOR_ROW - row_index 726 | 727 | def _move_cursor_in_buffer(self, row_index: int) -> None: 728 | if row_index < 0: 729 | row_index = len(self._display_rows) + row_index 730 | offset = self._CURSOR_ROW - row_index 731 | 732 | if offset > 0: 733 | self._print_to_buffer(CURSOR_UP * offset) 734 | elif offset < 0: 735 | self._print_to_buffer("\n" * (-offset)) 736 | self._CURSOR_ROW = row_index 737 | 738 | def _get_item_str(self, display_row_index: int) -> str: 739 | item: str | int = self._display_rows[display_row_index] 740 | if isinstance(item, int): 741 | row = self._data_rows[item] # item is the data row index 742 | row_str = self._get_row_str(row) # here we pass the row item, not index 743 | elif item == "HEADER": 744 | row_str = self._get_header() 745 | elif item == "SPLIT TOP": 746 | row_str = self._get_bar_top() 747 | elif item == "SPLIT BOT": 748 | row_str = self._get_bar_bot() + "\n" 749 | elif item == "SPLIT MID": 750 | row_str = self._get_bar_mid() 751 | elif item.startswith("USER WRITE"): 752 | row_str = item.split("USER WRITE", 1)[1].strip() 753 | else: 754 | msg = f"Unknown item: {item}" 755 | raise ValueError(msg) 756 | return row_str 757 | 758 | def _print_pending_rows_to_buffer(self) -> None: 759 | # Clearing progress bars below the table happens here 760 | for display_row_idx, cleaning_str in self._cleaning_pbar_instructions: 761 | assert self.interactive >= 2, "Should not need to clean pbars when interactive < 2!" 762 | self._move_cursor_in_buffer(display_row_idx) 763 | self._print_to_buffer(cleaning_str, prefix="\r") 764 | self._move_cursor_in_buffer(-1) 765 | self._cleaning_pbar_instructions.clear() 766 | 767 | # Remove duplicate pending and sort them 768 | self._pending_display_rows = sorted(set(self._pending_display_rows)) 769 | 770 | for display_row_index in self._pending_display_rows: 771 | offset = self._get_cursor_offset(display_row_index) 772 | 773 | # Cannot use CURSOR_UP when interactivity is less than 2 774 | # However, we can ALLOW going down with the cursor 775 | if self.interactive < 2 and offset > 0: 776 | continue 777 | 778 | self._move_cursor_in_buffer(display_row_index) 779 | 780 | # We don't need to print carriage return if cursor was moved DOWN. 781 | # After moving, cursor is in the right position to overwrite text. 782 | prefix = "" if offset < 0 else "\r" 783 | row_str = self._get_item_str(display_row_index) 784 | self._print_to_buffer(row_str, prefix=prefix) 785 | self._pending_display_rows.clear() 786 | 787 | # Printing progress bars happens here 788 | for pbar in self._active_pbars: 789 | if self.interactive == 0: 790 | break # No progress bars in non-interactive mode 791 | 792 | num_rows = len(self._display_rows) 793 | pbar_display_row_idx = ( 794 | self._display_rows.index(pbar.position) if pbar.static else num_rows + pbar.position - 1 795 | ) 796 | 797 | offset = self._get_cursor_offset(pbar_display_row_idx) 798 | # Cannot use CURSOR_UP when interactivity is less than 2 799 | # Here we DON'T ALLOW going down with the cursor 800 | if self.interactive < 2 and offset != 0: 801 | continue 802 | 803 | # We add the display row to pending if we were writing over a row 804 | # So in next tick the row will be printed again 805 | if pbar_display_row_idx < num_rows: 806 | self._pending_display_rows.append(pbar_display_row_idx) 807 | 808 | self._move_cursor_in_buffer(pbar_display_row_idx) 809 | 810 | row_str = None 811 | if self.pbar_embedded and pbar_display_row_idx < num_rows: 812 | data_row_idx = self._display_rows[pbar_display_row_idx] 813 | if isinstance(data_row_idx, int): 814 | data_row = self._data_rows[data_row_idx] 815 | row_str = self._get_row_str(data_row, colored=False) 816 | 817 | pbar_str = pbar.display(embed_str=row_str) 818 | 819 | # We need to take care of clearing pbars that are printed below the table 820 | if pbar_display_row_idx > num_rows: 821 | self._cleaning_pbar_instructions.append((pbar_display_row_idx, pbar._cleaning_str)) 822 | 823 | self._print_to_buffer(pbar_str, prefix="\r") 824 | self._move_cursor_in_buffer(-1) 825 | 826 | def _resolve_row_color_dict(self, color: ColorFormat | dict[str, ColorFormat] = None) -> dict[str, str]: 827 | color = color or self.row_color or {} 828 | if isinstance(color, ColorFormatTuple): 829 | color = dict.fromkeys(self.column_names, color) 830 | 831 | color = {column: color.get(column) or self.DEFAULT_ROW_COLOR for column in self.column_names} 832 | color_colorama = {column: maybe_convert_to_colorama(color) for column, color in color.items()} 833 | return {col: self.column_colors[col] + color_colorama[col] for col in color} 834 | 835 | def _apply_cell_formatting(self, value: Any, column_name: str, color: str) -> str: 836 | str_value = self.custom_cell_format(value) 837 | width = self.column_widths[column_name] 838 | alignment = self.column_alignments[column_name] 839 | 840 | if alignment == "center": 841 | str_value = str_value.center(width) 842 | elif alignment == "left": 843 | str_value = str_value.ljust(width) 844 | elif alignment == "right": 845 | str_value = str_value.rjust(width) 846 | else: 847 | allowed_alignments = ["center", "left", "right"] 848 | msg = f"Alignment '{alignment}' not in {allowed_alignments}!" 849 | raise KeyError(msg) 850 | 851 | clipped = len(str_value) > width 852 | str_value = "".join( 853 | [ 854 | " ", # space at the beginning of the row 855 | str_value[:width].center(width), 856 | self.table_style.cell_overflow if clipped else " ", 857 | ], 858 | ) 859 | reset = Style.RESET_ALL if color else "" 860 | return f"{color}{str_value}{reset}" 861 | 862 | def _get_outer_inner_width(self) -> int: 863 | return sum(self.column_widths.values()) + 3 * len(self.column_widths) + 1 864 | 865 | def _get_inner_table_width(self) -> int: 866 | return sum(self.column_widths.values()) + 3 * len(self.column_widths) - 1 867 | 868 | ##################### 869 | ## DISPLAY HELPERS ## 870 | ##################### 871 | 872 | def _print_to_buffer(self, msg: str = "", prefix: str = "", suffix: str = "") -> None: 873 | """Prints to table's buffer. 874 | 875 | Not displayed to stdout yet. 876 | """ 877 | self._printing_buffer.append(prefix + msg + suffix) 878 | 879 | def _flush_buffer(self) -> None: 880 | """This is where table prints to the stdout.""" 881 | output = "".join(self._printing_buffer) 882 | 883 | # Start by clearing the existing line 884 | self._printing_buffer = [] 885 | 886 | for file in self.files: 887 | print(output, file=file or sys.stdout, end="") 888 | 889 | def _get_row_str(self, row: DataRow, *, colored: bool = True) -> str: 890 | """Get the string representation of the data row.""" 891 | content = [] 892 | for column in self.column_names: 893 | value = row.values.get(column, "") 894 | color = row.colors.get(column, "") if colored else "" 895 | value = self._apply_cell_formatting(value=value, column_name=column, color=color) 896 | content.append(value) 897 | return "".join( 898 | [ 899 | self.table_style.vertical, 900 | self.table_style.vertical.join(content), 901 | self.table_style.vertical, 902 | ], 903 | ) 904 | 905 | def _get_bar(self, left: str, center: str, right: str) -> str: 906 | content_list: list[str] = [] 907 | for column_name in self.column_names: 908 | content_list.append(self.table_style.horizontal * (self.column_widths[column_name] + 2)) 909 | 910 | center = center.join(content_list) 911 | content = [ 912 | left, 913 | center, 914 | right, 915 | ] 916 | return "".join(content) 917 | 918 | def _get_bar_top(self) -> str: 919 | return self._get_bar( 920 | self.table_style.down_right, 921 | self.table_style.no_up, 922 | self.table_style.down_left, 923 | ) 924 | 925 | def _get_bar_bot(self) -> str: 926 | return self._get_bar( 927 | self.table_style.up_right, 928 | self.table_style.no_down, 929 | self.table_style.up_left, 930 | ) 931 | 932 | def _get_bar_mid(self) -> str: 933 | return self._get_bar(self.table_style.no_left, self.table_style.all, self.table_style.no_right) 934 | 935 | def _get_header(self) -> str: 936 | content = [] 937 | colors = self.column_colors if self.header_color is None else self._resolve_row_color_dict(self.header_color) 938 | 939 | for column in self.column_names: 940 | value = self._apply_cell_formatting(column, column, color=colors[column]) 941 | content.append(value) 942 | return "".join( 943 | [ 944 | self.table_style.vertical, 945 | self.table_style.vertical.join(content), 946 | self.table_style.vertical, 947 | ], 948 | ) 949 | 950 | ################## 951 | ## PROGRESS BAR ## 952 | ################## 953 | 954 | def pbar( 955 | self, 956 | iterable: Iterable | int, 957 | *range_args, 958 | position=None, 959 | static=False, 960 | total=None, 961 | description="", 962 | show_throughput: bool | None = None, 963 | show_progress: bool | None = None, 964 | show_percents: bool | None = None, 965 | show_eta: bool | None = None, 966 | style=None, 967 | style_embed=None, 968 | color=None, 969 | color_empty=None, 970 | ) -> TableProgressBar: 971 | """Create iterable progress bar object. 972 | 973 | Args: 974 | iterable: Iterable to iterate over. If None, it will be created from as range(iterable, *range_args). 975 | range_args: Optional arguments for range function. 976 | position: Level of the progress bar. If not provided, it will be set automatically. 977 | static: If True, the progress bar will stick to the row with index given by position. 978 | If False, the position will be interpreted as the offset from the last row. 979 | total: Total number of iterations. If not provided, it will be calculated from the length of the iterable. 980 | description: Custom description of the progress bar that will be shown as prefix. 981 | show_throughput: If True, the throughput will be displayed. 982 | show_progress: If True, the progress will be displayed. 983 | show_percents: If True, the percentage of the progress will be displayed. 984 | show_eta: If True, the estimated time of finishing will be displayed. 985 | style: Style of the progress bar. If None, the default style will be used. 986 | style_embed: Style of the embedded progress bar. If None, the default style will be used. 987 | color: Color of the progress bar. This overrides the default color. 988 | color_empty: Color of the empty progress bar. This overrides the default color. 989 | 990 | """ 991 | if isinstance(iterable, int): 992 | iterable = range(iterable, *range_args) 993 | 994 | if static is True and position is None: 995 | msg = "For static pbar position cannot be None!" 996 | raise ValueError(msg) 997 | if position is None: 998 | position = 0 if self.interactive < 2 else len(self._active_pbars) + 1 - self.pbar_embedded 999 | 1000 | total = total if total is not None else (len(iterable) if isinstance(iterable, Sized) else 0) 1001 | 1002 | style = styles.parse_pbar_style(style) if style else self.pbar_style 1003 | style_embed = styles.parse_pbar_style(style_embed) if style_embed else self.pbar_style_embed 1004 | 1005 | pbar = TableProgressBar( 1006 | iterable=iterable, 1007 | table=self, 1008 | total=total, 1009 | style=style, 1010 | style_embed=style_embed, 1011 | color=color, 1012 | color_empty=color_empty, 1013 | position=position, 1014 | static=static, 1015 | description=description, 1016 | show_throughput=(show_throughput if show_throughput is not None else self.pbar_show_throughput), 1017 | show_progress=(show_progress if show_progress is not None else self.pbar_show_progress), 1018 | show_percents=(show_percents if show_percents is not None else self.pbar_show_percents), 1019 | show_eta=show_eta if show_eta is not None else self.pbar_show_eta, 1020 | ) 1021 | self._active_pbars.append(pbar) 1022 | return pbar 1023 | 1024 | def __call__(self, *args, **kwds) -> TableProgressBar: 1025 | """Creates iterable progress bar object using .pbar method and returns it.""" 1026 | return self.pbar(*args, **kwds) 1027 | 1028 | 1029 | ################## 1030 | ## PROGRESS BAR ## 1031 | ################## 1032 | 1033 | 1034 | class TableProgressBar: 1035 | def __init__( 1036 | self, 1037 | iterable, 1038 | *, 1039 | table, 1040 | total, 1041 | style, 1042 | style_embed, 1043 | color, 1044 | color_empty, 1045 | position, 1046 | static, 1047 | description, 1048 | show_throughput: bool, 1049 | show_progress: bool, 1050 | show_percents: bool, 1051 | show_eta: bool, 1052 | ) -> None: 1053 | self.iterable: Iterable | None = iterable 1054 | 1055 | self._step: int = 0 1056 | self._total: int = total 1057 | self._creation_time: float = time.perf_counter() 1058 | self._last_refresh_time: float = -float("inf") 1059 | 1060 | self.style = style 1061 | self.style_embed = style_embed 1062 | 1063 | # Modyfing styles 1064 | if color: 1065 | color = maybe_convert_to_colorama(color) 1066 | self.style.color = color 1067 | self.style_embed.color = color 1068 | if color_empty: 1069 | color_empty = maybe_convert_to_colorama(color_empty) 1070 | self.style.color_empty = color_empty 1071 | self.style_embed.color_empty = color_empty 1072 | 1073 | self.table: ProgressTable = table 1074 | self.position: int = position 1075 | self.static: bool = static 1076 | self.description: str = description 1077 | self.show_throughput: bool = show_throughput 1078 | self.show_progress: bool = show_progress 1079 | self.show_percents: bool = show_percents 1080 | self.show_eta: bool = show_eta 1081 | self._is_active: bool = True 1082 | self._cleaning_str: str = "" 1083 | 1084 | self._modified_rows = [] 1085 | 1086 | def _get_infobar(self, step, total): 1087 | self._last_refresh_time = time.perf_counter() 1088 | time_passed = self._last_refresh_time - self._creation_time 1089 | throughput = self._step / time_passed if time_passed > 0 else 0.0 1090 | eta = (total - step) / throughput if throughput > 0 and total else None 1091 | 1092 | inside_infobar = [] 1093 | if self.description: 1094 | inside_infobar.append(self.description) 1095 | if self.show_progress: 1096 | if total: 1097 | str_total = str(total) 1098 | str_step = str(self._step).rjust(len(str(total - 1))) 1099 | inside_infobar.append(f"{str_step}/{str_total}") 1100 | else: 1101 | inside_infobar.append(f"{self._step}") 1102 | 1103 | if self.show_percents: 1104 | if total and total > 0: 1105 | if step / total < 0.1: 1106 | percents_str = f"{100 * step / total: <.2f}%" 1107 | elif step / total < 1: 1108 | percents_str = f"{100 * step / total: <.1f}%" 1109 | else: 1110 | percents_str = f"{100 * step / total: <.0f}%" 1111 | else: 1112 | percents_str = "?%" 1113 | inside_infobar.append(percents_str) 1114 | 1115 | if self.show_throughput: 1116 | if throughput < 10: 1117 | throughput_str = f"{throughput: <.2f} it/s" 1118 | elif throughput < 100: 1119 | throughput_str = f"{throughput: <.1f} it/s" 1120 | else: 1121 | throughput_str = f"{throughput: <.0f} it/s" 1122 | 1123 | inside_infobar.append(throughput_str) 1124 | 1125 | if self.show_eta: 1126 | if eta is None: 1127 | inside_infobar.append("ETA ?") 1128 | elif eta < 100: 1129 | eta_str = f"ETA {eta:>2.0f}s" 1130 | inside_infobar.append(eta_str) 1131 | elif round(eta / 60) < 100: 1132 | eta_str = f"ETA {eta / 60:>2.0f}m" 1133 | inside_infobar.append(eta_str) 1134 | else: 1135 | eta_str = f"ETA {eta / 3600:>2.0f}h" 1136 | inside_infobar.append(eta_str) 1137 | 1138 | return "[" + ", ".join(inside_infobar) + "] " if inside_infobar else "" 1139 | 1140 | def display(self, embed_str: str | None) -> str: 1141 | assert self._is_active, "Progress bar was closed!" 1142 | terminal_width = shutil.get_terminal_size(fallback=(0, 0)).columns or int(1e9) 1143 | 1144 | total = self._total 1145 | step = min(self._step, total) if total else self._step 1146 | infobar = self._get_infobar(step, total) 1147 | pbar = [] 1148 | 1149 | inner_width = self.table._get_inner_table_width() 1150 | if inner_width >= terminal_width - 1: 1151 | inner_width = terminal_width - 2 1152 | 1153 | if len(infobar) > inner_width: 1154 | infobar = "[…] " 1155 | 1156 | inner_width = inner_width - len(infobar) 1157 | if not total: 1158 | step = self._step % inner_width 1159 | total = inner_width 1160 | 1161 | num_filled = math.ceil(step / total * inner_width) 1162 | frac_missing = step / total * inner_width - num_filled 1163 | num_empty = inner_width - num_filled 1164 | 1165 | if embed_str is not None: 1166 | row_str = embed_str[1 + len(infobar) :] 1167 | 1168 | filled_part = row_str[:num_filled] 1169 | if len(filled_part) > 0 and filled_part[-1] == " ": 1170 | head = self.style_embed.head 1171 | if isinstance(head, (tuple, list)): 1172 | head = head[round(frac_missing * len(head))] 1173 | filled_part = filled_part[:-1] + head 1174 | filled_part = filled_part.replace(" ", self.style_embed.filled) 1175 | empty_part = row_str[num_filled:-1] 1176 | color_filled = self.style_embed.color 1177 | color_empty = self.style_embed.color_empty 1178 | else: 1179 | filled_part = self.style.filled * num_filled 1180 | if len(filled_part) > 0: 1181 | head = self.style.head 1182 | if isinstance(head, (tuple, list)): 1183 | head = head[round(frac_missing * len(head))] 1184 | filled_part = filled_part[:-1] + head 1185 | empty_part = self.style.empty * num_empty 1186 | color_filled = self.style.color 1187 | color_empty = self.style.color_empty 1188 | 1189 | pbar_body = "".join( 1190 | [ 1191 | self.table.table_style.vertical, 1192 | infobar, 1193 | color_filled, 1194 | filled_part, 1195 | Style.RESET_ALL if color_filled else "", 1196 | color_empty, 1197 | empty_part, 1198 | Style.RESET_ALL if color_empty else "", 1199 | self.table.table_style.vertical, 1200 | ], 1201 | ) 1202 | pbar.append(pbar_body) 1203 | self._cleaning_str = " " * len(pbar_body) 1204 | return "".join(pbar) 1205 | 1206 | def update(self, n: int = 1) -> None: 1207 | """Update the progress bar steps. 1208 | 1209 | Args: 1210 | n: Number of steps to update the progress bar. 1211 | 1212 | """ 1213 | self._step += n 1214 | self.table._trigger_refresh() 1215 | 1216 | def reset(self, total: int | None = None) -> None: 1217 | """Reset the progress bar. 1218 | 1219 | Args: 1220 | total: Modify the total number of iterations. Optional. 1221 | 1222 | """ 1223 | self._step = 0 1224 | 1225 | if total: 1226 | self._total = total 1227 | self.table._trigger_refresh() 1228 | 1229 | def set_step(self, step: int) -> None: 1230 | """Overwrite the current step. 1231 | 1232 | Args: 1233 | step: New value of the current step. 1234 | 1235 | """ 1236 | self._step = step 1237 | self.table._trigger_refresh() 1238 | 1239 | def set_total(self, total: int) -> None: 1240 | """Overwrite the total number of iterations. 1241 | 1242 | Args: 1243 | total: New value of the total number of iterations 1244 | 1245 | """ 1246 | self._total = total 1247 | self.table._trigger_refresh() 1248 | 1249 | def __iter__(self) -> Iterator: 1250 | """Iterate over iterable while updating the progress bar.""" 1251 | try: 1252 | assert self.iterable is not None, "No iterable provided!" 1253 | for element in self.iterable: 1254 | yield element 1255 | self.update() 1256 | finally: 1257 | self.close() 1258 | 1259 | def close(self) -> None: 1260 | """Close the progress bar.""" 1261 | self.table._active_pbars.remove(self) 1262 | self._is_active = False 1263 | 1264 | 1265 | ############# 1266 | ## INDEXER ## 1267 | ############# 1268 | 1269 | 1270 | class BadKeyError(Exception): 1271 | """Raised when the TableAtIndexer key is not valid.""" 1272 | 1273 | 1274 | class TableAtIndexer: 1275 | """Advanced indexing for the table.""" 1276 | 1277 | def __init__(self, table: ProgressTable) -> None: 1278 | """Initialize the indexer.""" 1279 | self.table = table 1280 | self.edit_mode_prefix_map: dict[str, str] = {} 1281 | for word in ("values", "weights", "colors"): 1282 | self.edit_mode_prefix_map.update({word[:i].lower(): word for i in range(1, len(word) + 1)}) 1283 | self.edit_mode_prefix_map.update({word[:i].upper(): word for i in range(1, len(word) + 1)}) 1284 | 1285 | def _parse_index(self, key: slice | tuple) -> tuple: 1286 | if isinstance(key, slice): 1287 | rows: slice | int = key 1288 | cols: slice | int = slice(None) 1289 | mode: str = "values" 1290 | elif len(key) == 2: 1291 | rows: slice | int = key[0] 1292 | cols: slice | int = key[1] 1293 | mode: str = "values" 1294 | elif len(key) >= 3: 1295 | rows: slice | int = key[0] 1296 | cols: slice | int = key[1] 1297 | mode: str = key[2] 1298 | assert mode in self.edit_mode_prefix_map, f"Unknown mode `{mode}`. Available: values, weights, colors" 1299 | mode = self.edit_mode_prefix_map[mode] 1300 | else: 1301 | msg = "Expected slice or tuple with 2 or 3 elements!" 1302 | raise BadKeyError(msg) 1303 | 1304 | assert isinstance(rows, (slice, int)), f"Rows have to be a slice or an integer, not {type(rows)}!" 1305 | assert isinstance(cols, (slice, int)), f"Columns have to be a slice or an integer, not {type(cols)}!" 1306 | row_indices = [rows] if isinstance(rows, int) else list(range(len(self.table._data_rows)))[rows] 1307 | column_names = self.table.column_names[cols] if isinstance(cols, slice) else [self.table.column_names[cols]] 1308 | return row_indices, column_names, mode 1309 | 1310 | def __setitem__(self, key: slice | tuple, value: Any) -> None: 1311 | """Set the values, colors, or weights of a slice in the table.""" 1312 | row_indices, column_names, edit_mode = self._parse_index(key) 1313 | if edit_mode == "colors": 1314 | assert isinstance(value, ColorFormatTuple), f"Color must be compatible with ColorFormat, not {type(value)}!" 1315 | value = maybe_convert_to_colorama(value) 1316 | 1317 | for row_idx in row_indices: 1318 | data_row = self.table._data_rows[row_idx] 1319 | 1320 | for column in column_names: 1321 | data_row.__getattribute__(edit_mode)[column] = value 1322 | 1323 | # Displaying the update 1324 | self.table._append_or_update_display_row(row_idx) 1325 | 1326 | def __getitem__(self, key: slice | tuple) -> list: 1327 | """Get the values of a slice and flatten before returning.""" 1328 | row_indices, column_names, edit_mode = self._parse_index(key) 1329 | gathered_values = [] 1330 | 1331 | for row_idx in row_indices: 1332 | row = self.table._data_rows[row_idx] 1333 | row_values = [row.__getattribute__(edit_mode).get(c, None) for c in column_names] 1334 | gathered_values.append(row_values) 1335 | 1336 | # Flattening outputs 1337 | if len(gathered_values) == 1 and len(gathered_values[0]) == 1: 1338 | return gathered_values[0][0] 1339 | if len(gathered_values) == 1: 1340 | return gathered_values[0] 1341 | if all(len(x) == 1 for x in gathered_values): 1342 | return [x[0] for x in gathered_values] 1343 | return gathered_values 1344 | -------------------------------------------------------------------------------- /progress_table/styles.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | """Module defining styles for progress bars and tables. 5 | 6 | Also includes functions to interpret style descriptions and converting them into style objects. 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from progress_table.common import ALL_COLOR_NAME, ColorFormat, maybe_convert_to_colorama 12 | 13 | 14 | def _contains_word(short: str, long: str) -> bool: 15 | return any(short == word.strip(" ") for word in long.split(" ")) 16 | 17 | 18 | def _parse_colors_from_description(description: str) -> tuple[str, str, str]: 19 | color = "" 20 | color_empty = "" 21 | for word in description.split(): 22 | for color_name in ALL_COLOR_NAME: 23 | if color_name.lower() == word.lower(): 24 | if not color: 25 | color = color_name 26 | else: 27 | color_empty = color_name 28 | description = description.replace(word, "") 29 | return color, color_empty, description 30 | 31 | 32 | class UnknownStyleError(ValueError): 33 | """Raised when style description is not recognized.""" 34 | 35 | 36 | def parse_pbar_style(description: str | PbarStyleBase) -> PbarStyleBase: 37 | """Parse progress bar style description and return a style object. 38 | 39 | Example: 40 | >>> parse_pbar_style("square alt clean") 41 | 42 | """ 43 | if isinstance(description, str): 44 | for obj in available_pbar_styles(): 45 | if _contains_word(obj.name, description): 46 | description = description.replace(obj.name, "") 47 | color, color_empty, description = _parse_colors_from_description(description) 48 | is_alt = "alt" in description 49 | is_clean = "clean" in description 50 | description = description.replace("alt", "").replace("clean", "").strip(" ") 51 | if description.strip(" "): 52 | msg = f"Name '{description}' is not recognized as a part of progress bar style" 53 | raise UnknownStyleError(msg) 54 | 55 | return obj(alt=is_alt, clean=is_clean, color=color, color_empty=color_empty) 56 | 57 | available_names = ", ".join([obj.name for obj in available_pbar_styles()]) 58 | msg = f"Progress bar style '{description}' not found. Available: {available_names}" 59 | raise UnknownStyleError(msg) 60 | return description 61 | 62 | 63 | def parse_table_style(description: str | TableStyleBase) -> TableStyleBase: 64 | """Parse table style description and return a style object. 65 | 66 | Example: 67 | >>> parse_table_style("modern") 68 | 69 | """ 70 | if isinstance(description, str): 71 | for obj in available_table_styles(): 72 | if _contains_word(obj.name, description): 73 | description = description.replace(obj.name, "").strip(" ") 74 | if description: 75 | msg = f"Name '{description}' is not recognized as a part of table style" 76 | raise UnknownStyleError(msg) 77 | 78 | return obj() 79 | available_names = ", ".join([obj.name for obj in available_table_styles()]) 80 | msg = f"Table style '{description}' not found. Available: {available_names}" 81 | raise UnknownStyleError(msg) 82 | return description 83 | 84 | 85 | def available_table_styles() -> list[type[TableStyleBase]]: 86 | """Return a list of available table styles.""" 87 | return [ 88 | obj 89 | for name, obj in globals().items() 90 | if isinstance(obj, type) and issubclass(obj, TableStyleBase) and hasattr(obj, "name") 91 | ] 92 | 93 | 94 | def available_pbar_styles() -> list[type[PbarStyleBase]]: 95 | """Return a list of available progress bar styles.""" 96 | return [ 97 | obj 98 | for name, obj in globals().items() 99 | if isinstance(obj, type) and issubclass(obj, PbarStyleBase) and hasattr(obj, "name") 100 | ] 101 | 102 | 103 | ################# 104 | ## PBAR STYLES ## 105 | ################# 106 | 107 | 108 | class PbarStyleBase: 109 | """Base class for progress bar styles.""" 110 | 111 | name: str 112 | filled: str 113 | empty: str 114 | head: str | tuple[str, ...] 115 | color: str = "" 116 | color_empty: str = "" 117 | 118 | def __init__( 119 | self, 120 | *, 121 | alt: bool = False, 122 | clean: bool = False, 123 | color: ColorFormat = None, 124 | color_empty: ColorFormat = None, 125 | ) -> None: 126 | """Initialize progress bar style. 127 | 128 | Args: 129 | alt: Use the same character for filled and empty parts. 130 | clean: Use space for empty parts. 131 | color: Color of the filled part. 132 | color_empty: Color of the empty part. 133 | 134 | """ 135 | if color is not None: 136 | self.color = maybe_convert_to_colorama(color) 137 | if color_empty is not None: 138 | self.color_empty = maybe_convert_to_colorama(color_empty) 139 | if alt: 140 | self.empty = self.filled 141 | if clean: 142 | self.empty = " " 143 | 144 | 145 | class PbarStyleSquare(PbarStyleBase): 146 | """Progress bar style. 147 | 148 | Example: 149 | >>> ■■■■■◩□□□□□ 150 | 151 | """ 152 | 153 | name = "square" 154 | filled = "■" 155 | empty = "□" 156 | head = "◩" 157 | 158 | 159 | class PbarStyleFull(PbarStyleBase): 160 | """Progress bar style. 161 | 162 | Example: 163 | >>> █████▌ 164 | 165 | """ 166 | 167 | name = "full" 168 | filled = "█" 169 | empty = " " 170 | head = ("▏", "▎", "▍", "▌", "▋", "▊", "▉") 171 | 172 | 173 | class PbarStyleDots(PbarStyleBase): 174 | """Progress bar style. 175 | 176 | Example: 177 | >>> ⣿⣿⣿⣿⣿⣦⣀⣀⣀⣀⣀ 178 | 179 | """ 180 | 181 | name = "dots" 182 | filled = "⣿" 183 | empty = "⣀" 184 | head = ("⣄", "⣤", "⣦", "⣶", "⣷") 185 | 186 | 187 | class PbarStyleShort(PbarStyleBase): 188 | """Progress bar style. 189 | 190 | Example: 191 | >>> ▬▬▬▬▬▬▭▭▭▭▭ 192 | 193 | """ 194 | 195 | name = "short" 196 | filled = "▬" 197 | empty = "▭" 198 | head = "▬" 199 | 200 | 201 | class PbarStyleCircle(PbarStyleBase): 202 | """Progress bar style. 203 | 204 | Example: 205 | >>> ●●●●●◉○○○○ 206 | 207 | """ 208 | 209 | name = "circle" 210 | filled = "●" 211 | empty = "○" 212 | head = "◉" 213 | 214 | 215 | class PbarStyleAngled(PbarStyleBase): 216 | """Progress bar style. 217 | 218 | Example: 219 | >>> ▰▰▰▰▰▰▱▱▱▱ 220 | 221 | """ 222 | 223 | name = "angled" 224 | filled = "▰" 225 | empty = "▱" 226 | head = "▰" 227 | 228 | 229 | class PbarStyleRich(PbarStyleBase): 230 | """Progress bar style. 231 | 232 | Example: 233 | >>> ━━━━━━━━ 234 | 235 | """ 236 | 237 | name = "rich" 238 | filled = "━" 239 | empty = " " 240 | head = "━" 241 | 242 | def __init__(self, *args, **kwds) -> None: 243 | """Similar to the default progress bar from rich.""" 244 | super().__init__(*args, **kwds) 245 | if not self.color and not self.color_empty: 246 | self.color = maybe_convert_to_colorama("red") 247 | self.color_empty = maybe_convert_to_colorama("black") 248 | self.empty = self.filled 249 | 250 | 251 | class PbarStyleCdots(PbarStyleBase): 252 | """Progress bar style. 253 | 254 | Example: 255 | >>> ꞏꞏꞏꞏꞏꞏꞏꞏ> 256 | 257 | """ 258 | 259 | name = "cdots" 260 | filled = "ꞏ" 261 | empty = " " 262 | head = ">" 263 | 264 | 265 | class PbarStyleDash(PbarStyleBase): 266 | """Progress bar style. 267 | 268 | Example: 269 | >>> -----> 270 | 271 | """ 272 | 273 | name = "dash" 274 | filled = "-" 275 | empty = " " 276 | head = ">" 277 | 278 | 279 | class PbarStyleUnder(PbarStyleBase): 280 | """Progress bar style. 281 | 282 | Example: 283 | >>> ________ 284 | 285 | """ 286 | 287 | name = "under" 288 | filled = "_" 289 | empty = " " 290 | head = "_" 291 | 292 | 293 | class PbarStyleDoubleDash(PbarStyleBase): 294 | """Progress bar style. 295 | 296 | Example: 297 | >>> ========> 298 | 299 | """ 300 | 301 | name = "doubledash" 302 | filled = "=" 303 | empty = " " 304 | head = ">" 305 | 306 | 307 | class PbarStyleNone(PbarStyleBase): 308 | """Progress bar style. 309 | 310 | Example: 311 | >>> 312 | 313 | """ 314 | 315 | name = "hidden" 316 | filled = " " 317 | empty = " " 318 | head = " " 319 | 320 | 321 | ################## 322 | ## TABLE STYLES ## 323 | ################## 324 | 325 | 326 | class TableStyleBase: 327 | """Base class for table styles.""" 328 | 329 | name: str 330 | cell_overflow: str 331 | horizontal: str 332 | vertical: str 333 | all: str 334 | up_left: str 335 | up_right: str 336 | down_left: str 337 | down_right: str 338 | no_left: str 339 | no_right: str 340 | no_up: str 341 | no_down: str 342 | 343 | 344 | class TableStyleModern(TableStyleBase): 345 | """Table style. 346 | 347 | Example: 348 | >>> ┌─────────┬─────────┐ 349 | >>> │ H1 │ H2 │ 350 | >>> ├─────────┼─────────┤ 351 | >>> │ V1 │ V2 │ 352 | >>> │ V3 │ V4 │ 353 | >>> └─────────┴─────────┘ 354 | 355 | """ 356 | 357 | name = "modern" 358 | cell_overflow = "…" 359 | horizontal = "─" 360 | vertical = "│" 361 | all = "┼" 362 | up_left = "┘" 363 | up_right = "└" 364 | down_left = "┐" 365 | down_right = "┌" 366 | no_left = "├" 367 | no_right = "┤" 368 | no_up = "┬" 369 | no_down = "┴" 370 | 371 | 372 | class TableStyleUnicodeBare(TableStyleBase): 373 | """Table style. 374 | 375 | Example: 376 | >>> ────────── ────────── 377 | >>> H1 H2 378 | >>> ────────── ────────── 379 | >>> V1 V2 380 | >>> V3 V4 381 | >>> ────────── ────────── 382 | 383 | """ 384 | 385 | name = "bare" 386 | cell_overflow = "…" 387 | horizontal = "─" 388 | vertical = " " 389 | all = "─" 390 | up_left = "─" 391 | up_right = "─" 392 | down_left = "─" 393 | down_right = "─" 394 | no_left = "─" 395 | no_right = "─" 396 | no_up = "─" 397 | no_down = "─" 398 | 399 | 400 | class TableStyleUnicodeRound(TableStyleBase): 401 | """Table style. 402 | 403 | Example: 404 | >>> ╭─────────┬─────────╮ 405 | >>> │ H1 │ H2 │ 406 | >>> ├─────────┼─────────┤ 407 | >>> │ V1 │ V2 │ 408 | >>> │ V3 │ V4 │ 409 | >>> ╰─────────┴─────────╯ 410 | 411 | """ 412 | 413 | name = "round" 414 | cell_overflow = "…" 415 | horizontal = "─" 416 | vertical = "│" 417 | all = "┼" 418 | up_left = "╯" 419 | up_right = "╰" 420 | down_left = "╮" 421 | down_right = "╭" 422 | no_left = "├" 423 | no_right = "┤" 424 | no_up = "┬" 425 | no_down = "┴" 426 | 427 | 428 | class TableStyleUnicodeDouble(TableStyleBase): 429 | """Table style. 430 | 431 | Example: 432 | >>> ╔═════════╦═════════╗ 433 | >>> ║ H1 ║ H2 ║ 434 | >>> ╠═════════╬═════════╣ 435 | >>> ║ V1 ║ V2 ║ 436 | >>> ║ V3 ║ V4 ║ 437 | >>> ╚═════════╩═════════╝ 438 | 439 | """ 440 | 441 | name = "double" 442 | cell_overflow = "…" 443 | horizontal = "═" 444 | vertical = "║" 445 | all = "╬" 446 | up_left = "╝" 447 | up_right = "╚" 448 | down_left = "╗" 449 | down_right = "╔" 450 | no_left = "╠" 451 | no_right = "╣" 452 | no_up = "╦" 453 | no_down = "╩" 454 | 455 | 456 | class TableStyleUnicodeBold(TableStyleBase): 457 | """Table style. 458 | 459 | Example: 460 | >>> ┏━━━━━━━━━┳━━━━━━━━━┓ 461 | >>> ┃ H1 ┃ H2 ┃ 462 | >>> ┣━━━━━━━━━╋━━━━━━━━━┫ 463 | >>> ┃ V1 ┃ V2 ┃ 464 | >>> ┃ V3 ┃ V4 ┃ 465 | >>> ┗━━━━━━━━━┻━━━━━━━━━┛ 466 | 467 | """ 468 | 469 | name = "bold" 470 | cell_overflow = "…" 471 | horizontal = "━" 472 | vertical = "┃" 473 | all = "╋" 474 | up_left = "┛" 475 | up_right = "┗" 476 | down_left = "┓" 477 | down_right = "┏" 478 | no_left = "┣" 479 | no_right = "┫" 480 | no_up = "┳" 481 | no_down = "┻" 482 | 483 | 484 | class TableStyleAscii(TableStyleBase): 485 | """Table style. 486 | 487 | Example: 488 | >>> +---------+---------+ 489 | >>> | H1 | H2 | 490 | >>> +---------+---------+ 491 | >>> | V1 | V2 | 492 | >>> | V3 | V4 | 493 | >>> +---------+---------+ 494 | 495 | """ 496 | 497 | name = "ascii" 498 | cell_overflow = "_" 499 | horizontal = "-" 500 | vertical = "|" 501 | all = "+" 502 | up_left = "+" 503 | up_right = "+" 504 | down_left = "+" 505 | down_right = "+" 506 | no_left = "+" 507 | no_right = "+" 508 | no_up = "+" 509 | no_down = "+" 510 | 511 | 512 | class TableStyleAsciiBare(TableStyleBase): 513 | """Table style. 514 | 515 | Example: 516 | >>> --------- --------- 517 | >>> H1 H2 518 | >>> --------- --------- 519 | >>> V1 V2 520 | >>> V3 V4 521 | >>> --------- --------- 522 | 523 | """ 524 | 525 | name = "asciib" 526 | cell_overflow = "_" 527 | horizontal = "-" 528 | vertical = " " 529 | all = "-" 530 | up_left = "-" 531 | up_right = "-" 532 | down_left = "-" 533 | down_right = "-" 534 | no_left = "-" 535 | no_right = "-" 536 | no_up = "-" 537 | no_down = "-" 538 | 539 | 540 | class TableStyleHidden(TableStyleBase): 541 | """Table style. 542 | 543 | Example: 544 | >>> 545 | >>> H1 H2 546 | >>> 547 | >>> V1 V2 548 | >>> V3 V4 549 | >>> 550 | 551 | """ 552 | 553 | name = "hidden" 554 | cell_overflow = " " 555 | horizontal = " " 556 | vertical = " " 557 | all = " " 558 | up_left = " " 559 | up_right = " " 560 | down_left = " " 561 | down_right = " " 562 | no_left = " " 563 | no_right = " " 564 | no_up = " " 565 | no_down = " " 566 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "progress-table" 7 | dynamic = ["version"] 8 | description = "Display progress as a pretty table in the command line." 9 | dependencies = ["colorama"] 10 | license = { text = "MIT" } 11 | requires-python = ">=3.7" 12 | authors = [ 13 | { name = "Szymon Mikler", email = "sjmikler@gmail.com" } 14 | ] 15 | classifiers = [ 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | ] 26 | readme = "README_pypi.md" 27 | 28 | [project.urls] 29 | Home = "https://github.com/gahaalt/progress-table" 30 | Documentation = "https://github.com/sjmikler/progress-table/blob/main/docs" 31 | 32 | [project.optional-dependencies] 33 | dev = ["ruff", "pyright", "pytest", "pytest-cov", "hatch", "twine"] 34 | 35 | [tool.hatch.version] 36 | path = "progress_table/__init__.py" 37 | 38 | [tool.hatch.build.targets.sdist] 39 | include = ["progress_table", "examples", "docs", "README.md"] 40 | 41 | [tool.hatch.build.targets.wheel] 42 | packages = ["progress_table"] 43 | 44 | [tool.hatch.metadata.hooks.custom] 45 | path = "hooks.py" 46 | 47 | # %% TOOLS 48 | 49 | [tool.pyright] 50 | typeCheckingMode = "standard" 51 | exclude = ["devel", "build", "dist"] 52 | 53 | [tool.ruff] 54 | line-length = 120 55 | target-version = "py37" 56 | 57 | [tool.ruff.lint] 58 | select = ["E", "F", "I", "B"] 59 | 60 | # %% 61 | 62 | [tool.pytest.ini_options] 63 | pythonpath = ["."] 64 | 65 | [tool.mypy] 66 | ignore_missing_imports = true 67 | exclude = ["devel", "build", "dist"] 68 | 69 | [tool.isort] 70 | profile = "black" 71 | line_length = 120 72 | 73 | [tool.black] 74 | line_length = 120 -------------------------------------------------------------------------------- /tests/test_auto_docs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | import logging 5 | import os 6 | import pathlib 7 | import re 8 | 9 | 10 | def get_header(msg): 11 | msg_len = len(msg) 12 | header = [ 13 | "#" * (msg_len + 6), 14 | "## " + msg + " ##", 15 | "#" * (msg_len + 6), 16 | ] 17 | return "\n".join(header) + "\n" 18 | 19 | 20 | def scan_for_code_blobs(text): 21 | # Capture code blocks starting with py or python 22 | blobs = re.findall(r"```(py|python)\n([\s\S]+?)\n```", text) 23 | 24 | new_blobs = [] 25 | for mode, blob in blobs: 26 | if "..." in blob: 27 | # ignore blobs that are not complete 28 | continue 29 | header = get_header(f"Generated ({mode})") 30 | blob = header + blob 31 | 32 | # For `py` code blocks, we append them to previous existing block 33 | # But `python` block starts a new scope and finishes the previous block 34 | # They usually need to include imports 35 | if mode == "py" and new_blobs: 36 | new_blobs[-1] = new_blobs[-1] + "\n" + blob 37 | else: 38 | new_blobs.append(blob) 39 | return new_blobs 40 | 41 | 42 | def test_all_code_blobs(): 43 | # Testing whether code blobs from the documentation run without errors 44 | all_code_blobs = [] 45 | 46 | for root, _dirs, files in os.walk("."): 47 | for file in files: 48 | path = pathlib.Path(os.path.join(root, file)) 49 | if path.suffix == ".md": 50 | code_blobs = scan_for_code_blobs(path.open("r", encoding="utf-8").read()) 51 | for blob in code_blobs: 52 | all_code_blobs.append(blob) 53 | 54 | logging.warning(f"Detected {len(all_code_blobs)} code examples!") 55 | 56 | for idx, blob in enumerate(all_code_blobs): 57 | try: 58 | globals_temp = {} 59 | exec(blob, globals_temp) 60 | except Exception as e: 61 | print(f"Exception during automated documentation testing {idx}/{len(all_code_blobs)}:") 62 | print(blob) 63 | raise e 64 | -------------------------------------------------------------------------------- /tests/test_auto_examples.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | import hashlib 5 | import importlib 6 | from glob import glob 7 | from io import StringIO 8 | 9 | EXPECTED_OUTPUTS = { 10 | "examples.brown2d": "e85fcc33e982cb783059c09c090fca4e", 11 | "examples.training": "91ca0321e3776d5f2ac45add37e0db27", 12 | "examples.tictactoe": "261c337ff04ae63e84857f9fcaf1d276", 13 | } 14 | 15 | 16 | def capture_example_stdout(main_fn): 17 | # To eliminate run to run variation of the example outputs we need to be independent from the execution speed 18 | # This includes removing the influence of: 19 | # * refresh rate 20 | # * throughput display 21 | 22 | # We will replace stdout with custom StringIO and check whether example stdout is as expected 23 | out_buffer = StringIO() 24 | override_kwds = dict( 25 | pbar_show_throughput=False, 26 | refresh_rate=0, 27 | file=out_buffer, 28 | ) 29 | main_fn(random_seed=42, sleep_duration=0, **override_kwds) 30 | return out_buffer.getvalue() 31 | 32 | 33 | def test_all_examples(): 34 | # Testing whether examples run exactly as intended 35 | 36 | outputs = {} 37 | for module in glob("examples/*"): 38 | module = module.replace(".py", "").replace("/", ".").replace("\\", ".") 39 | 40 | if module not in EXPECTED_OUTPUTS: 41 | print(f"Skipping example: {module}") 42 | continue 43 | 44 | print(f"Running example: {module}") 45 | main_fn = importlib.import_module(module).main 46 | out_str = capture_example_stdout(main_fn) 47 | 48 | md5hash = hashlib.md5(out_str.encode()).hexdigest() 49 | outputs[module] = md5hash 50 | 51 | err_msg = [] 52 | for key in EXPECTED_OUTPUTS: 53 | output = outputs.get(key, None) 54 | expected = EXPECTED_OUTPUTS[key] 55 | if output != expected: 56 | err_msg.append(f" {output} instead of {expected} in {key}") 57 | err_msg = "\n".join(err_msg) 58 | 59 | if err_msg: 60 | raise AssertionError(f"Errors in example outputs\n{err_msg}") 61 | -------------------------------------------------------------------------------- /tests/test_end_to_end.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | from io import StringIO 5 | 6 | 7 | def test_simple_example_1(): 8 | from progress_table import ProgressTable 9 | 10 | file = StringIO() 11 | table = ProgressTable(interactive=0, file=file) 12 | table.add_column("Value") 13 | table.add_rows(3) # Adding empty rows 14 | 15 | table.update(name="Value", value=1.0, row=1) 16 | table.update(name="Value", value=2.0, row=0) 17 | table.update(name="Value", value=3.0, row=2) 18 | table.close() 19 | 20 | results = file.getvalue() 21 | expected = """ 22 | ╭──────────╮ 23 | │ Value │ 24 | ├──────────┤ 25 | │ 2.0000 │ 26 | │ 1.0000 │ 27 | │ 3.0000 │ 28 | ╰──────────╯ 29 | """ 30 | assert results.strip() == expected.strip() 31 | 32 | 33 | def test_simple_example_2(): 34 | from progress_table import ProgressTable 35 | 36 | file = StringIO() 37 | table = ProgressTable(interactive=0, file=file) 38 | table.add_columns(4) # Add more columns with automatic names 39 | table.add_rows(4) # Adding empty rows 40 | 41 | table.at[:] = 0.0 # Initialize all values to 0.0 42 | table.at[0, :] = 2.0 # Set all values in the first row to 2.0 43 | table.at[:, 1] = 2.0 # Set all values in the second column to 2.0 44 | table.at[2, 0] = 3.0 # Set the first column in the second-to-last row to 3.0 45 | table.close() 46 | 47 | results = file.getvalue() 48 | expected = """ 49 | ╭──────────┬──────────┬──────────┬──────────╮ 50 | │ 0 │ 1 │ 2 │ 3 │ 51 | ├──────────┼──────────┼──────────┼──────────┤ 52 | │ 2.0000 │ 2.0000 │ 2.0000 │ 2.0000 │ 53 | │ 0.0000 │ 2.0000 │ 0.0000 │ 0.0000 │ 54 | │ 3.0000 │ 2.0000 │ 0.0000 │ 0.0000 │ 55 | │ 0.0000 │ 2.0000 │ 0.0000 │ 0.0000 │ 56 | ╰──────────┴──────────┴──────────┴──────────╯ 57 | """ 58 | assert results.strip() == expected.strip() 59 | 60 | 61 | def test_example_3(): 62 | import random 63 | 64 | from progress_table import ProgressTable 65 | 66 | random.seed(42) 67 | 68 | file = StringIO() 69 | table = ProgressTable(interactive=0, file=file) 70 | table.add_column("x") 71 | 72 | for _step in range(9): 73 | x = random.randint(0, 200) 74 | 75 | table["x"] = x 76 | table.update("x", value=x, weight=1.0) 77 | 78 | for _ in table(13): 79 | table["y"] = random.randint(0, 200) 80 | table["x-y"] = table["x"] - table["y"] 81 | table.update("average x-y", value=table["x-y"], weight=1.0, aggregate="mean") 82 | 83 | table.next_row() 84 | 85 | table.close() 86 | results = file.getvalue() 87 | expected = """ 88 | ╭──────────┬──────────┬──────────┬─────────────╮ 89 | │ x │ y │ x-y │ average x-y │ 90 | ├──────────┼──────────┼──────────┼─────────────┤ 91 | │ 163 │ 22 │ 141 │ 71.9231 │ 92 | │ 151 │ 166 │ -15 │ 67.0769 │ 93 | │ 179 │ 71 │ 108 │ 77.7692 │ 94 | │ 39 │ 186 │ -147 │ -45.8462 │ 95 | │ 117 │ 17 │ 100 │ 16.7692 │ 96 | │ 11 │ 41 │ -30 │ -79.9231 │ 97 | │ 94 │ 186 │ -92 │ -29.0769 │ 98 | │ 62 │ 14 │ 48 │ -55.5385 │ 99 | │ 58 │ 101 │ -43 │ -33.1538 │ 100 | ╰──────────┴──────────┴──────────┴─────────────╯ 101 | """ 102 | assert results.strip() == expected.strip() 103 | -------------------------------------------------------------------------------- /tests/test_unit.py: -------------------------------------------------------------------------------- 1 | from progress_table.progress_table import ProgressTable 2 | 3 | 4 | def test_aggregate_mean(): 5 | table = ProgressTable() 6 | table.add_column("value", aggregate="mean") 7 | assert table.column_aggregates["value"].__name__ == "aggregate_mean" 8 | 9 | for i in range(10): 10 | table["value"] = i 11 | 12 | assert table["value"] == 4.5 13 | 14 | 15 | def test_aggregate_sum(): 16 | table = ProgressTable() 17 | table.add_column("value", aggregate="sum") 18 | assert table.column_aggregates["value"].__name__ == "aggregate_sum" 19 | 20 | for i in range(10): 21 | table["value"] = i 22 | 23 | assert table["value"] == 45 24 | 25 | 26 | def test_aggregate_min(): 27 | table = ProgressTable() 28 | table.add_column("value", aggregate="min") 29 | assert table.column_aggregates["value"].__name__ == "aggregate_min" 30 | 31 | for i in range(10): 32 | table["value"] = i 33 | 34 | assert table["value"] == 0 35 | 36 | 37 | def test_aggregate_max(): 38 | table = ProgressTable() 39 | table.add_column("value", aggregate="max") 40 | assert table.column_aggregates["value"].__name__ == "aggregate_max" 41 | 42 | for i in range(10): 43 | table["value"] = i 44 | 45 | assert table["value"] == 9 46 | -------------------------------------------------------------------------------- /tests/test_unit_from_claude.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | import colorama 5 | import pytest 6 | 7 | from progress_table.styles import ( 8 | PbarStyleBase, 9 | PbarStyleRich, 10 | PbarStyleSquare, 11 | TableStyleBase, 12 | TableStyleModern, 13 | UnknownStyleError, 14 | _contains_word, 15 | _parse_colors_from_description, 16 | available_pbar_styles, 17 | available_table_styles, 18 | parse_pbar_style, 19 | parse_table_style, 20 | ) 21 | 22 | 23 | def test_word_contained_in_string(): 24 | assert _contains_word("foo", "foo bar baz") is True 25 | assert _contains_word("bar", "foo bar baz") is True 26 | 27 | 28 | def test_word_not_contained_in_string(): 29 | assert _contains_word("qux", "foo bar baz") is False 30 | assert _contains_word("fo", "foo bar baz") is False 31 | 32 | 33 | def test_words_with_spaces_properly_detected(): 34 | assert _contains_word("foo", " foo bar baz ") is True 35 | assert _contains_word(" foo ", "foo bar baz") is False 36 | 37 | 38 | def test_primary_color_extracted_from_description(): 39 | color, empty_color, rest = _parse_colors_from_description("red square") 40 | assert color == "RED" 41 | assert empty_color == "" 42 | assert "square" in rest 43 | 44 | 45 | def test_both_colors_extracted_from_description(): 46 | color, empty_color, rest = _parse_colors_from_description("red green square") 47 | assert color == "RED" 48 | assert empty_color == "GREEN" 49 | assert "square" in rest 50 | 51 | 52 | def test_no_colors_returns_empty_strings(): 53 | color, empty_color, rest = _parse_colors_from_description("square style") 54 | assert color == "" 55 | assert empty_color == "" 56 | assert rest == "square style" 57 | 58 | 59 | def test_pbar_style_created_from_valid_name(): 60 | style = parse_pbar_style("square") 61 | assert isinstance(style, PbarStyleSquare) 62 | assert style.name == "square" 63 | 64 | 65 | def test_pbar_style_alt_option_copies_filled_to_empty(): 66 | style = parse_pbar_style("square alt") 67 | assert style.empty == style.filled 68 | 69 | 70 | def test_pbar_style_clean_option_sets_empty_to_space(): 71 | style = parse_pbar_style("square clean") 72 | assert style.empty == " " 73 | 74 | 75 | def test_pbar_style_with_colors_sets_color_attributes(): 76 | style = parse_pbar_style("red green square") 77 | assert style.color == colorama.Fore.RED 78 | assert style.color_empty == colorama.Fore.GREEN 79 | 80 | 81 | def test_pbar_style_with_invalid_name_raises_error(): 82 | with pytest.raises(UnknownStyleError): 83 | parse_pbar_style("invalid_style") 84 | 85 | 86 | def test_pbar_style_with_unknown_word_raises_error(): 87 | with pytest.raises(UnknownStyleError): 88 | parse_pbar_style("square unknown_word") 89 | 90 | 91 | def test_pbar_style_returns_object_if_passed_directly(): 92 | style = PbarStyleSquare() 93 | result = parse_pbar_style(style) 94 | assert result is style 95 | 96 | 97 | def test_table_style_created_from_valid_name(): 98 | style = parse_table_style("modern") 99 | assert isinstance(style, TableStyleModern) 100 | assert style.name == "modern" 101 | 102 | 103 | def test_table_style_with_invalid_name_raises_error(): 104 | with pytest.raises(UnknownStyleError): 105 | parse_table_style("invalid_style") 106 | 107 | 108 | def test_table_style_returns_object_if_passed_directly(): 109 | style = TableStyleModern() 110 | result = parse_table_style(style) 111 | assert result is style 112 | 113 | 114 | def test_available_table_styles_returns_list_of_style_classes(): 115 | styles_list = available_table_styles() 116 | assert len(styles_list) > 0 117 | assert all(issubclass(style, TableStyleBase) for style in styles_list) 118 | 119 | 120 | def test_available_pbar_styles_returns_list_of_style_classes(): 121 | styles_list = available_pbar_styles() 122 | assert len(styles_list) > 0 123 | assert all(issubclass(style, PbarStyleBase) for style in styles_list) 124 | 125 | 126 | def test_pbar_style_rich_sets_default_colors(): 127 | style = PbarStyleRich() 128 | assert style.color != "" 129 | assert style.color_empty != "" 130 | assert style.empty == style.filled 131 | -------------------------------------------------------------------------------- /tests/test_unit_from_gemini.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | import pytest 5 | 6 | from progress_table import ProgressTable, progress_table, styles 7 | 8 | 9 | def test_add_column_with_custom_properties(): 10 | table = ProgressTable() 11 | table.add_column("col1", width=15, color="red", alignment="right", aggregate="sum") 12 | assert table.column_widths["col1"] == 15 13 | assert table.column_colors["col1"] == "\x1b[31m" 14 | assert table.column_alignments["col1"] == "right" 15 | assert callable(table.column_aggregates["col1"]) 16 | 17 | 18 | def test_add_columns_with_same_properties(): 19 | table = ProgressTable() 20 | table.add_columns("col1", "col2", width=10, color="blue", alignment="center", aggregate="mean") 21 | assert table.column_widths["col1"] == 10 22 | assert table.column_colors["col1"] == "\x1b[34m" 23 | assert table.column_alignments["col1"] == "center" 24 | assert callable(table.column_aggregates["col1"]) 25 | assert table.column_widths["col2"] == 10 26 | assert table.column_colors["col2"] == "\x1b[34m" 27 | assert table.column_alignments["col2"] == "center" 28 | assert callable(table.column_aggregates["col2"]) 29 | 30 | 31 | def test_add_columns_with_integer_argument(): 32 | table = ProgressTable() 33 | table.add_columns(3, width=5) 34 | assert "0" in table.column_names 35 | assert "1" in table.column_names 36 | assert "2" in table.column_names 37 | assert table.column_widths["0"] == 5 38 | assert table.column_widths["1"] == 5 39 | assert table.column_widths["2"] == 5 40 | 41 | 42 | def test_reorder_columns(): 43 | table = ProgressTable("col1", "col2", "col3") 44 | table.reorder_columns("col3", "col1", "col2") 45 | assert table.column_names == ["col3", "col1", "col2"] 46 | 47 | 48 | def test_reorder_columns_no_change(): 49 | table = ProgressTable("col1", "col2", "col3") 50 | table.reorder_columns("col1", "col2", "col3") 51 | assert table.column_names == ["col1", "col2", "col3"] 52 | 53 | 54 | def test_update_value_in_specific_row(): 55 | table = ProgressTable("col1", "col2") 56 | table["col1"] = 10 57 | table.next_row() 58 | table["col1"] = 20 59 | table.update("col1", 5, row=0) 60 | assert table[("col1", 0)] == 5 61 | assert table[("col1", 1)] == 20 62 | 63 | 64 | def test_update_with_column_kwargs(): 65 | table = ProgressTable() 66 | table.update("new_col", 10, width=20, color="green") 67 | assert "new_col" in table.column_names 68 | assert table.column_widths["new_col"] == 20 69 | assert table.column_colors["new_col"] == "\x1b[32m" 70 | 71 | 72 | def test_get_item_with_tuple_key(): 73 | table = ProgressTable("col1", "col2") 74 | table["col1"] = 10 75 | assert table[("col1", -1)] == 10 76 | 77 | 78 | def test_get_item_with_invalid_column(): 79 | table = ProgressTable("col1", "col2") 80 | with pytest.raises(AssertionError): 81 | table["invalid_col"] 82 | 83 | 84 | def test_update_from_dict(): 85 | table = ProgressTable("col1", "col2") 86 | data = {"col1": 10, "col2": "abc"} 87 | table.update_from_dict(data) 88 | assert table["col1"] == 10 89 | assert table["col2"] == "abc" 90 | 91 | 92 | def test_next_row_with_color_dict(): 93 | table = ProgressTable("col1", "col2") 94 | table["col1"] = 1 95 | table["col2"] = 2 96 | table.next_row(color={"col1": "red", "col2": "blue"}) 97 | assert table._data_rows[0].colors["col1"] == table.column_colors["col1"] + "\x1b[31m" 98 | assert table._data_rows[0].colors["col2"] == table.column_colors["col2"] + "\x1b[34m" 99 | 100 | 101 | def test_next_row_with_split_and_header(): 102 | table = ProgressTable("col1") 103 | table.next_row(split=True, header=True) 104 | assert "SPLIT MID" in table._latest_row_decorations 105 | assert "HEADER" in table._latest_row_decorations 106 | 107 | 108 | def test_add_row(): 109 | table = ProgressTable("col1", "col2") 110 | table.add_row(1, "abc") 111 | assert table[("col1", 0)] == 1 112 | assert table[("col2", 0)] == "abc" 113 | assert len(table._data_rows) == 1 114 | 115 | 116 | def test_add_rows_with_integer_argument(): 117 | table = ProgressTable("col1") 118 | table.add_rows(2) 119 | assert len(table._data_rows) == 2 120 | assert table.to_list() == [[None], [None]] 121 | 122 | 123 | def test_num_rows(): 124 | table = ProgressTable() 125 | table.add_rows(5) 126 | assert table.num_rows() == 5 127 | 128 | 129 | def test_num_columns(): 130 | table = ProgressTable("col1", "col2", "col3") 131 | assert table.num_columns() == 3 132 | 133 | 134 | def test_close(): 135 | table = ProgressTable("col1") 136 | table.close() 137 | assert table._closed 138 | 139 | 140 | def test_write_message(): 141 | table = ProgressTable("col1", "col2") 142 | table.add_row(1, 2) 143 | table.write("test message") 144 | assert isinstance(table._display_rows[-1], str) 145 | assert "USER WRITE" in table._display_rows[-1] 146 | 147 | 148 | def test_to_list(): 149 | table = ProgressTable("col1", "col2") 150 | table.add_row(1, "a") 151 | table.add_row(2, "b") 152 | expected_list = [[1, "a"], [2, "b"]] 153 | assert table.to_list() == expected_list 154 | 155 | 156 | def test_table_at_setitem_slice_rows_cols(): 157 | table = ProgressTable("col1", "col2") 158 | table.add_rows(2) 159 | table.at[:2, :] = 5 160 | assert table.at[:2, :2] == [[5, 5], [5, 5]] 161 | assert table.at[:] == [[5, 5], [5, 5]] 162 | 163 | 164 | def test_table_at_setitem_slice_rows_cols2(): 165 | table = ProgressTable("col1", "col2") 166 | table.add_rows(2) 167 | table.at[:] = 5 168 | assert table.at[:] == [[5, 5], [5, 5]] 169 | 170 | 171 | def test_table_at_setitem_slice_rows(): 172 | table = ProgressTable("col1", "col2") 173 | table.add_rows(2) 174 | table.at[0, :] = 5 175 | assert table.at[0, :] == [5, 5] 176 | assert table.at[1, :] == [None, None] 177 | assert table.at[:] == [[5, 5], [None, None]] 178 | 179 | 180 | def test_table_at_setitem_slice_cols(): 181 | table = ProgressTable("col1", "col2") 182 | table.add_rows(2) 183 | table.at[:2, 0] = 5 184 | assert table.at[:2, 0] == [5, 5] 185 | assert table.at[:2, 1] == [None, None] 186 | assert table.at[:] == [[5, None], [5, None]] 187 | 188 | 189 | def test_table_at_setitem_int_rows_cols(): 190 | table = ProgressTable("col1", "col2") 191 | table.add_rows(2) 192 | table.at[1, 0] = 5 193 | assert table.at[1, 0] == 5 194 | assert table.at[0, 0] is None 195 | 196 | 197 | def test_table_at_setitem_slice_rows_cols_mode(): 198 | table = ProgressTable("col1", "col2") 199 | table.add_rows(2) 200 | table.at[:, :, "weights"] = 1 201 | assert table.at[:, :, "weights"] == [[1, 1], [1, 1]] 202 | 203 | 204 | def test_table_at_setitem_slice_rows_cols_mode_colors(): 205 | table = ProgressTable("col1", "col2") 206 | table.add_rows(2) 207 | table.at[:, :, "colors"] = "red" 208 | assert table._data_rows[0].colors["col1"] == "\x1b[31m" 209 | assert table._data_rows[0].colors["col2"] == "\x1b[31m" 210 | assert table._data_rows[1].colors["col1"] == "\x1b[31m" 211 | assert table._data_rows[1].colors["col2"] == "\x1b[31m" 212 | 213 | 214 | def test_table_at_getitem_slice_rows_cols(): 215 | table = ProgressTable("col1", "col2") 216 | table.add_rows(2) 217 | table.at[0, 0] = 5 218 | table.at[1, 1] = 10 219 | assert table.at[:, :] == [[5, None], [None, 10]] 220 | assert table.at[:2, :] == [[5, None], [None, 10]] 221 | 222 | 223 | def test_table_at_getitem_slice_rows(): 224 | table = ProgressTable("col1", "col2") 225 | table.add_rows(2) 226 | table.at[0, 0] = 5 227 | table.at[-1, -1] = 10 228 | assert table.at[0, :] == [5, None] 229 | assert table.at[:] == [[5, None], [None, 10]] 230 | 231 | 232 | def test_table_at_getitem_slice_cols(): 233 | table = ProgressTable("col1", "col2") 234 | table.add_rows(2) 235 | table.at[0, 0] = 5 236 | table.at[1, 1] = 10 237 | assert table.at[:, 1] == [None, 10] 238 | assert table.at[:2, 1] == [None, 10] 239 | 240 | 241 | def test_table_at_getitem_int_rows_cols(): 242 | table = ProgressTable("col1", "col2") 243 | table.add_rows(2) 244 | table.at[0, 0] = 5 245 | table.at[1, 1] = 10 246 | assert table.at[1, 1] == 10 247 | 248 | 249 | def test_table_at_getitem_slice_rows_cols_mode(): 250 | table = ProgressTable("col1", "col2") 251 | table.add_rows(2) 252 | table.at[:2, :, "weights"] = 1 253 | assert table.at[:, :, "weights"] == [[1, 1], [1, 1]] 254 | assert table.at[:2, :, "weights"] == [[1, 1], [1, 1]] 255 | 256 | 257 | def test_table_at_getitem_slice_rows_cols_mode_colors(): 258 | table = ProgressTable("col1", "col2") 259 | table.add_rows(2) 260 | table.at[:2, :, "colors"] = "red" 261 | assert table.at[:2, :, "colors"] == [ 262 | ["\x1b[31m", "\x1b[31m"], 263 | ["\x1b[31m", "\x1b[31m"], 264 | ] 265 | 266 | 267 | def test_table_adding_rows(): 268 | table = ProgressTable("col1", "col2") 269 | for _i in range(3): 270 | table.add_row() 271 | table.at[:] = 0 272 | assert table.at[:] == [[0, 0], [0, 0], [0, 0]] 273 | 274 | 275 | def test_table_adding_rows2(): 276 | table = ProgressTable("col1", "col2") 277 | for i in range(3): 278 | table.add_row() 279 | table.at[-1, :] = i 280 | assert table.at[:] == [[0, 0], [1, 1], [2, 2]] 281 | 282 | 283 | def test_aggregate_dont(): 284 | assert progress_table.aggregate_dont(5, 10) == 5 285 | 286 | 287 | def test_aggregate_mean(): 288 | assert progress_table.aggregate_mean(5, 10, 1, 1) == 7.5 289 | 290 | 291 | def test_aggregate_sum(): 292 | assert progress_table.aggregate_sum(5, 10) == 15 293 | 294 | 295 | def test_aggregate_max(): 296 | assert progress_table.aggregate_max(5, 10) == 10 297 | 298 | 299 | def test_aggregate_min(): 300 | assert progress_table.aggregate_min(5, 10) == 5 301 | 302 | 303 | def test_get_aggregate_fn_none(): 304 | assert callable(progress_table.get_aggregate_fn(None)) 305 | 306 | 307 | def test_get_aggregate_fn_mean(): 308 | assert callable(progress_table.get_aggregate_fn("mean")) 309 | 310 | 311 | def test_get_aggregate_fn_sum(): 312 | assert callable(progress_table.get_aggregate_fn("sum")) 313 | 314 | 315 | def test_get_aggregate_fn_max(): 316 | assert callable(progress_table.get_aggregate_fn("max")) 317 | 318 | 319 | def test_get_aggregate_fn_min(): 320 | assert callable(progress_table.get_aggregate_fn("min")) 321 | 322 | 323 | def test_get_aggregate_fn_invalid_string(): 324 | with pytest.raises(ValueError): 325 | progress_table.get_aggregate_fn("invalid") 326 | 327 | 328 | def test_get_aggregate_fn_invalid_type(): 329 | with pytest.raises(ValueError): 330 | progress_table.get_aggregate_fn(123) # type: ignore 331 | 332 | 333 | def test_get_default_format_fn_int(): 334 | fmt = progress_table.get_default_format_fn(2) 335 | assert fmt(5) == "5" 336 | 337 | 338 | def test_get_default_format_fn_float(): 339 | fmt = progress_table.get_default_format_fn(2) 340 | assert fmt(5.123) == "5.12" 341 | 342 | 343 | def test_get_default_format_fn_invalid(): 344 | fmt = progress_table.get_default_format_fn(2) 345 | assert fmt("abc") == "abc" 346 | 347 | 348 | def test_pbar_iterable(): 349 | table = ProgressTable() 350 | iterable = range(5) 351 | pbar = table.pbar(iterable) 352 | assert pbar.iterable == iterable 353 | assert pbar._total == 5 354 | 355 | 356 | def test_pbar_int(): 357 | table = ProgressTable() 358 | pbar = table.pbar(5) 359 | assert isinstance(pbar.iterable, range) 360 | assert pbar._total == 5 361 | 362 | 363 | def test_pbar_static_position(): 364 | table = ProgressTable() 365 | pbar = table.pbar(5, position=1, static=True) 366 | assert pbar.position == 1 367 | assert pbar.static is True 368 | 369 | 370 | def test_pbar_dynamic_position(): 371 | table = ProgressTable() 372 | pbar = table.pbar(5, position=2, static=False) 373 | assert pbar.position == 2 374 | assert pbar.static is False 375 | 376 | 377 | def test_pbar_no_total_sized(): 378 | table = ProgressTable() 379 | iterable = [1, 2, 3] 380 | pbar = table.pbar(iterable) 381 | assert pbar._total == 3 382 | 383 | 384 | def test_pbar_no_total_unSized(): 385 | table = ProgressTable() 386 | iterable = iter([1, 2, 3]) 387 | pbar = table.pbar(iterable) 388 | assert pbar._total == 0 389 | 390 | 391 | def test_pbar_custom_styles(): 392 | table = ProgressTable() 393 | pbar = table.pbar(5, style="square", style_embed="cdots") 394 | assert isinstance(pbar.style, styles.PbarStyleSquare) 395 | assert isinstance(pbar.style_embed, styles.PbarStyleCdots) 396 | 397 | 398 | def test_pbar_custom_colors(): 399 | table = ProgressTable() 400 | pbar = table.pbar(5, color="red", color_empty="blue") 401 | assert pbar.style.color == "\x1b[31m" 402 | assert pbar.style.color_empty == "\x1b[34m" 403 | assert pbar.style_embed.color == "\x1b[31m" 404 | assert pbar.style_embed.color_empty == "\x1b[34m" 405 | 406 | 407 | def test_pbar_custom_description(): 408 | table = ProgressTable() 409 | pbar = table.pbar(5, description="Test Progress") 410 | assert pbar.description == "Test Progress" 411 | 412 | 413 | def test_pbar_custom_show_flags(): 414 | table = ProgressTable() 415 | pbar = table.pbar( 416 | 5, 417 | show_throughput=False, 418 | show_progress=False, 419 | show_percents=False, 420 | show_eta=False, 421 | ) 422 | assert pbar.show_throughput is False 423 | assert pbar.show_progress is False 424 | assert pbar.show_percents is False 425 | assert pbar.show_eta is False 426 | 427 | 428 | def test_pbar_call_method(): 429 | table = ProgressTable() 430 | pbar = table(5) 431 | assert isinstance(pbar, progress_table.TableProgressBar) 432 | 433 | 434 | def test_table_progress_bar_update(): 435 | table = ProgressTable() 436 | pbar = table.pbar(10) 437 | pbar.update(3) 438 | assert pbar._step == 3 439 | 440 | 441 | def test_table_progress_bar_reset(): 442 | table = ProgressTable() 443 | pbar = table.pbar(10) 444 | pbar.update(5) 445 | pbar.reset() 446 | assert pbar._step == 0 447 | assert pbar._total == 10 448 | -------------------------------------------------------------------------------- /tests/test_unit_from_gpt4o.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2025 Szymon Mikler 2 | # Licensed under the MIT License 3 | 4 | import colorama 5 | import pytest 6 | 7 | from progress_table.progress_table import ProgressTable, TableClosedError 8 | 9 | 10 | def test_add_column_with_default_settings(): 11 | table = ProgressTable() 12 | 13 | column_name = "test_column" 14 | table.add_column(column_name) 15 | assert column_name in table.column_names 16 | assert table.column_widths[column_name] == len(column_name) 17 | assert table.column_alignments[column_name] == table.DEFAULT_COLUMN_ALIGNMENT 18 | 19 | 20 | def test_add_column_with_default_settings2(): 21 | table = ProgressTable() 22 | 23 | column_name = "short" 24 | table.add_column(column_name) 25 | assert column_name in table.column_names 26 | assert table.column_widths[column_name] == table.DEFAULT_COLUMN_WIDTH 27 | assert table.column_alignments[column_name] == table.DEFAULT_COLUMN_ALIGNMENT 28 | 29 | 30 | def test_add_column_with_custom_settings(): 31 | table = ProgressTable() 32 | table.add_column("test_column", width=15, alignment="left", color="red", aggregate="sum") 33 | assert table.column_widths["test_column"] == 15 34 | assert table.column_alignments["test_column"] == "left" 35 | assert table.column_colors["test_column"] == colorama.Fore.RED 36 | assert table.column_aggregates["test_column"].__name__ == "aggregate_sum" 37 | 38 | 39 | def test_add_multiple_columns(): 40 | table = ProgressTable() 41 | table.add_columns("col1", "col2", "col3") 42 | assert "col1" in table.column_names 43 | assert "col2" in table.column_names 44 | assert "col3" in table.column_names 45 | 46 | 47 | def test_add_multiple_columns2(): 48 | table = ProgressTable("col1", "col2", "col3") 49 | assert "col1" in table.column_names 50 | assert "col2" in table.column_names 51 | assert "col3" in table.column_names 52 | 53 | 54 | def test_add_multiple_columns3(): 55 | table = ProgressTable("col1", "col2", columns=("col3", "col4")) 56 | assert "col1" in table.column_names 57 | assert "col2" in table.column_names 58 | assert "col3" in table.column_names 59 | assert "col4" in table.column_names 60 | 61 | 62 | def test_reorder_columns(): 63 | table = ProgressTable("col1", "col2", "col3") 64 | table.reorder_columns("col3", "col1", "col2") 65 | assert table.column_names == ["col3", "col1", "col2"] 66 | 67 | 68 | def test_update_value_in_existing_row(): 69 | table = ProgressTable("col1", "col2") 70 | table["col1"] = 10 71 | table.next_row() 72 | table["col1"] = 20 73 | assert table["col1", -1] == 20 74 | 75 | 76 | def test_update_value_in_non_existing_row(): 77 | table = ProgressTable("col1", "col2") 78 | 79 | with pytest.raises(IndexError): 80 | table.update("col1", 10, row=5) 81 | 82 | 83 | def test_close_table(): 84 | table = ProgressTable("col1", "col2") 85 | table.close() 86 | 87 | with pytest.raises(TableClosedError): 88 | table["col1"] = 10 89 | 90 | 91 | def test_add_row_with_values(): 92 | table = ProgressTable("col1", "col2") 93 | table.add_row(1, 2) 94 | assert table["col1", 0] == 1 95 | assert table["col2", 0] == 2 96 | 97 | 98 | def test_add_multiple_rows(): 99 | table = ProgressTable("col1", "col2") 100 | table.add_rows([1, 2], [3, 4]) 101 | assert table["col1", 0] == 1 102 | assert table["col2", 0] == 2 103 | assert table["col1", 1] == 3 104 | assert table["col2", 1] == 4 105 | 106 | 107 | def test_progress_bar_updates(): 108 | table = ProgressTable("col1", "col2") 109 | pbar = table.pbar(range(5)) 110 | for _ in pbar: 111 | pass 112 | assert pbar._step == 5 113 | --------------------------------------------------------------------------------