├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── tests.yml ├── .readthedocs.yaml ├── .sourcery.yaml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── asv.conf.json ├── benchmarks ├── __init__.py └── benchmarks.py ├── doc ├── Makefile ├── _static │ └── demo.gif ├── api.rst ├── conf.py ├── docutils.conf ├── examples.rst ├── faq.rst ├── index.rst ├── install.rst ├── patterns.rst └── spelling_wordlist.txt ├── enlighten ├── __init__.py ├── _basecounter.py ├── _basemanager.py ├── _counter.py ├── _manager.py ├── _notebook_manager.py ├── _statusbar.py ├── _util.py ├── counter.py └── manager.py ├── examples ├── __init__.py ├── basic.py ├── context_manager.py ├── demo.py ├── floats.py ├── ftp_downloader.py ├── multicolored.py ├── multiple_logging.py ├── multiprocessing_queues.py └── prefixes.py ├── pylintrc ├── requirements.txt ├── requirements_docs.txt ├── setup.cfg ├── setup.py ├── setup_helpers.py ├── tests ├── __init__.py ├── test_basecounter.py ├── test_counter.py ├── test_manager.py ├── test_notebook_manager.ipynb ├── test_notebook_manager.py ├── test_statusbar.py ├── test_subcounter.py └── test_util.py └── tox.ini /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | It's best to provide a generic code sample that illustrates the problem. Examples from the documentation are a good starting point. 15 | 16 | **Environment (please complete the following information):** 17 | - Enlighten Version: 18 | - OS and version: 19 | - Console application: [e.g. xterm, cmd, VS Code Terminal] 20 | - Special Conditions: [e.g. Running under pyinstaller] 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: Feature Request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | schedule: 8 | # Every Thursday at 1 AM 9 | - cron: '0 1 * * 4' 10 | 11 | jobs: 12 | 13 | Tests: 14 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 15 | container: ${{ matrix.container || format('python:{0}', matrix.python-version) }} 16 | name: ${{ (matrix.toxenv && !startsWith(matrix.toxenv, 'py')) && format('{0} ({1})', matrix.toxenv, matrix.python-version) || matrix.python-version }} ${{ matrix.optional && '[OPTIONAL]' }} 17 | continue-on-error: ${{ matrix.optional || false }} 18 | 19 | strategy: 20 | fail-fast: false 21 | 22 | matrix: 23 | python-version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] 24 | include: 25 | 26 | - python-version: '3.13' 27 | toxenv: lint 28 | os-deps: 29 | - enchant-2 30 | 31 | - python-version: '3.13' 32 | toxenv: docs 33 | os-deps: 34 | - enchant-2 35 | 36 | - python-version: '3.13' 37 | toxenv: coverage 38 | 39 | - python-version: '3.14' 40 | container: 'python:3.14-rc' 41 | optional: true 42 | 43 | - python-version: pypy-2.7 44 | toxenv: pypy27 45 | container: pypy:2.7 46 | 47 | - python-version: pypy-3 48 | toxenv: pypy3 49 | container: pypy:3 50 | 51 | env: 52 | TOXENV: ${{ matrix.toxenv || format('py{0}', matrix.python-version) }} 53 | 54 | steps: 55 | # This is only needed for Python 3.6 and earlier because Tox 4 requires 3.7+ 56 | - name: Fix TOXENV 57 | run: echo "TOXENV=$(echo $TOXENV | sed 's/\.//g')" >> $GITHUB_ENV 58 | if: ${{ contains(fromJson('["2.7", "3.5", "3.6"]'), matrix.python-version) }} 59 | 60 | - name: Install OS Dependencies 61 | run: apt update && apt -y install ${{ join(matrix.os-deps, ' ') }} 62 | if: ${{ matrix.os-deps }} 63 | 64 | - uses: actions/checkout@v4 65 | 66 | # https://github.com/actions/checkout/issues/1048 67 | - name: Workaround for git ownership issue 68 | run: git config --global --add safe.directory $GITHUB_WORKSPACE 69 | 70 | - name: Install tox 71 | run: pip install tox 72 | 73 | - name: Run tox 74 | run: tox -- --verbose 75 | 76 | - name: Upload coverage to Codecov 77 | uses: codecov/codecov-action@v4 78 | with: 79 | verbose: true 80 | fail_ci_if_error: true 81 | token: ${{ secrets.CODECOV_TOKEN }} 82 | if: ${{ matrix.toxenv == 'coverage' }} 83 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs Configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Version is required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-lts-latest 10 | tools: 11 | python: '3' 12 | 13 | # Build documentation in the docs/ directory with Sphinx 14 | sphinx: 15 | configuration: doc/conf.py 16 | 17 | python: 18 | install: 19 | - requirements: requirements.txt 20 | - requirements: requirements_docs.txt 21 | -------------------------------------------------------------------------------- /.sourcery.yaml: -------------------------------------------------------------------------------- 1 | refactor: 2 | skip: 3 | - replace-interpolation-with-fstring # Python 3.6+ 4 | - use-contextlib-suppress # Python 3.4+ (Possibly less efficient) 5 | - use-fstring-for-concatenation # Python 3.6+ 6 | - use-named-expression # Python 3.8+ 7 | 8 | clone_detection: 9 | min_lines: 4 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE setup_helpers.py README.* 2 | graft tests 3 | prune tests/.ipynb_checkpoints 4 | graft examples 5 | global-exclude *.pyc __pycache__ 6 | -------------------------------------------------------------------------------- /asv.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | // The version of the config file format. Do not change, unless 3 | // you know what you are doing. 4 | "version": 1, 5 | 6 | // The name of the project being benchmarked 7 | "project": "Enlighten", 8 | 9 | // The project's homepage 10 | "project_url": "https://pypi.org/project/enlighten", 11 | 12 | // The URL or local path of the source code repository for the 13 | // project being benchmarked 14 | "repo": ".", 15 | 16 | // The Python project's subdirectory in your repo. If missing or 17 | // the empty string, the project is assumed to be located at the root 18 | // of the repository. 19 | // "repo_subdir": "", 20 | 21 | // Customizable commands for building the project. 22 | // See asv.conf.json documentation. 23 | // To build the package using pyproject.toml (PEP518), uncomment the following lines 24 | // "build_command": [ 25 | // "python -m pip install build", 26 | // "python -m build", 27 | // "PIP_NO_BUILD_ISOLATION=false python -mpip wheel --no-deps --no-index -w {build_cache_dir} {build_dir}" 28 | // ], 29 | // To build the package using setuptools and a setup.py file, uncomment the following lines 30 | // "build_command": [ 31 | // "python setup.py build", 32 | // "PIP_NO_BUILD_ISOLATION=false python -mpip wheel --no-deps --no-index -w {build_cache_dir} {build_dir}" 33 | // ], 34 | 35 | // Customizable commands for installing and uninstalling the project. 36 | // See asv.conf.json documentation. 37 | // "install_command": ["in-dir={env_dir} python -mpip install {wheel_file}"], 38 | // "uninstall_command": ["return-code=any python -mpip uninstall -y {project}"], 39 | 40 | // List of branches to benchmark. If not provided, defaults to "master" 41 | // (for git) or "default" (for mercurial). 42 | // "branches": ["master"], // for git 43 | // "branches": ["default"], // for mercurial 44 | 45 | // The DVCS being used. If not set, it will be automatically 46 | // determined from "repo" by looking at the protocol in the URL 47 | // (if remote), or by looking for special directories, such as 48 | // ".git" (if local). 49 | // "dvcs": "git", 50 | 51 | // The tool to use to create environments. May be "conda", 52 | // "virtualenv", "mamba" (above 3.8) 53 | // or other value depending on the plugins in use. 54 | // If missing or the empty string, the tool will be automatically 55 | // determined by looking for tools on the PATH environment 56 | // variable. 57 | "environment_type": "virtualenv", 58 | 59 | // timeout in seconds for installing any dependencies in environment 60 | // defaults to 10 min 61 | //"install_timeout": 600, 62 | 63 | // the base URL to show a commit for the project. 64 | // "show_commit_url": "http://github.com/owner/project/commit/", 65 | 66 | // The Pythons you'd like to test against. If not provided, defaults 67 | // to the current version of Python used to run `asv`. 68 | // "pythons": ["2.7", "3.8"], 69 | 70 | // The list of conda channel names to be searched for benchmark 71 | // dependency packages in the specified order 72 | // "conda_channels": ["conda-forge", "defaults"], 73 | 74 | // A conda environment file that is used for environment creation. 75 | // "conda_environment_file": "environment.yml", 76 | 77 | // The matrix of dependencies to test. Each key of the "req" 78 | // requirements dictionary is the name of a package (in PyPI) and 79 | // the values are version numbers. An empty list or empty string 80 | // indicates to just test against the default (latest) 81 | // version. null indicates that the package is to not be 82 | // installed. If the package to be tested is only available from 83 | // PyPi, and the 'environment_type' is conda, then you can preface 84 | // the package name by 'pip+', and the package will be installed 85 | // via pip (with all the conda available packages installed first, 86 | // followed by the pip installed packages). 87 | // 88 | // The ``@env`` and ``@env_nobuild`` keys contain the matrix of 89 | // environment variables to pass to build and benchmark commands. 90 | // An environment will be created for every combination of the 91 | // cartesian product of the "@env" variables in this matrix. 92 | // Variables in "@env_nobuild" will be passed to every environment 93 | // during the benchmark phase, but will not trigger creation of 94 | // new environments. A value of ``null`` means that the variable 95 | // will not be set for the current combination. 96 | // 97 | // "matrix": { 98 | // "req": { 99 | // "numpy": ["1.6", "1.7"], 100 | // "six": ["", null], // test with and without six installed 101 | // "pip+emcee": [""] // emcee is only available for install with pip. 102 | // }, 103 | // "env": {"ENV_VAR_1": ["val1", "val2"]}, 104 | // "env_nobuild": {"ENV_VAR_2": ["val3", null]}, 105 | // }, 106 | 107 | // Combinations of libraries/python versions can be excluded/included 108 | // from the set to test. Each entry is a dictionary containing additional 109 | // key-value pairs to include/exclude. 110 | // 111 | // An exclude entry excludes entries where all values match. The 112 | // values are regexps that should match the whole string. 113 | // 114 | // An include entry adds an environment. Only the packages listed 115 | // are installed. The 'python' key is required. The exclude rules 116 | // do not apply to includes. 117 | // 118 | // In addition to package names, the following keys are available: 119 | // 120 | // - python 121 | // Python version, as in the *pythons* variable above. 122 | // - environment_type 123 | // Environment type, as above. 124 | // - sys_platform 125 | // Platform, as in sys.platform. Possible values for the common 126 | // cases: 'linux2', 'win32', 'cygwin', 'darwin'. 127 | // - req 128 | // Required packages 129 | // - env 130 | // Environment variables 131 | // - env_nobuild 132 | // Non-build environment variables 133 | // 134 | // "exclude": [ 135 | // {"python": "3.2", "sys_platform": "win32"}, // skip py3.2 on windows 136 | // {"environment_type": "conda", "req": {"six": null}}, // don't run without six on conda 137 | // {"env": {"ENV_VAR_1": "val2"}}, // skip val2 for ENV_VAR_1 138 | // ], 139 | // 140 | // "include": [ 141 | // // additional env for python2.7 142 | // {"python": "2.7", "req": {"numpy": "1.8"}, "env_nobuild": {"FOO": "123"}}, 143 | // // additional env if run on windows+conda 144 | // {"platform": "win32", "environment_type": "conda", "python": "2.7", "req": {"libpython": ""}}, 145 | // ], 146 | 147 | // The directory (relative to the current directory) that benchmarks are 148 | // stored in. If not provided, defaults to "benchmarks" 149 | // "benchmark_dir": "benchmarks", 150 | 151 | // The directory (relative to the current directory) to cache the Python 152 | // environments in. If not provided, defaults to "env" 153 | "env_dir": ".asv/env", 154 | 155 | // The directory (relative to the current directory) that raw benchmark 156 | // results are stored in. If not provided, defaults to "results". 157 | "results_dir": ".asv/results", 158 | 159 | // The directory (relative to the current directory) that the html tree 160 | // should be written to. If not provided, defaults to "html". 161 | "html_dir": ".asv/html", 162 | 163 | // The number of characters to retain in the commit hashes. 164 | // "hash_length": 8, 165 | 166 | // `asv` will cache results of the recent builds in each 167 | // environment, making them faster to install next time. This is 168 | // the number of builds to keep, per environment. 169 | // "build_cache_size": 2, 170 | 171 | // The commits after which the regression search in `asv publish` 172 | // should start looking for regressions. Dictionary whose keys are 173 | // regexps matching to benchmark names, and values corresponding to 174 | // the commit (exclusive) after which to start looking for 175 | // regressions. The default is to start from the first commit 176 | // with results. If the commit is `null`, regression detection is 177 | // skipped for the matching benchmark. 178 | // 179 | // "regressions_first_commits": { 180 | // "some_benchmark": "352cdf", // Consider regressions only after this commit 181 | // "another_benchmark": null, // Skip regression detection altogether 182 | // }, 183 | 184 | // The thresholds for relative change in results, after which `asv 185 | // publish` starts reporting regressions. Dictionary of the same 186 | // form as in ``regressions_first_commits``, with values 187 | // indicating the thresholds. If multiple entries match, the 188 | // maximum is taken. If no entry matches, the default is 5%. 189 | // 190 | // "regressions_thresholds": { 191 | // "some_benchmark": 0.01, // Threshold of 1% 192 | // "another_benchmark": 0.5, // Threshold of 50% 193 | // }, 194 | } 195 | -------------------------------------------------------------------------------- /benchmarks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rockhopper-Technologies/enlighten/d71eccac66683f50dd864e3303da34c823d13333/benchmarks/__init__.py -------------------------------------------------------------------------------- /benchmarks/benchmarks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2023 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | Benchmark for base operations in order to compare between versions 10 | 11 | For a basic comparison 12 | 13 | asv run main^! 14 | asv run branch^! 15 | asv compare main branch 16 | 17 | For more information see https://asv.readthedocs.io/en/stable/index.html 18 | 19 | """ 20 | 21 | from enlighten import get_manager 22 | 23 | 24 | class TimeFormat: 25 | """ 26 | Time-based benchmarks for format operations 27 | These are emphasized because they will have the greatest impact on end users 28 | """ 29 | def setup(self): 30 | """ 31 | General setup functions 32 | """ 33 | 34 | # pylint: disable=attribute-defined-outside-init 35 | manager = get_manager(disable=True) 36 | self.pbar = manager.counter(total=1000) 37 | self.counter = manager.counter() 38 | self.sbar = manager.status_bar(status_format='Current Count: {num}', num=0) 39 | 40 | def time_format_bar(self): 41 | """ 42 | Time Counter.format() for progress bar 43 | Count does not exceed total 44 | """ 45 | 46 | pbar = self.pbar 47 | 48 | for _ in range(1000): 49 | pbar.update() 50 | pbar.format() 51 | 52 | def time_format_counter(self): 53 | """ 54 | Time Counter.format() for counter 55 | Count exceeds total 56 | """ 57 | 58 | pbar = self.pbar 59 | 60 | for _ in range(1000): 61 | pbar.update() 62 | pbar.format() 63 | 64 | def time_format_status_bar(self): 65 | """ 66 | Time Counter.format() for status bar 67 | Uses dynamic variable 68 | """ 69 | 70 | sbar = self.sbar 71 | 72 | for num in range(1000): 73 | sbar.update(num=num) 74 | sbar.format() 75 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = -Ea 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = Enlighten 8 | SOURCEDIR = . 9 | BUILDDIR = ../build/doc 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/_static/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rockhopper-Technologies/enlighten/d71eccac66683f50dd864e3303da34c823d13333/doc/_static/demo.gif -------------------------------------------------------------------------------- /doc/api.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2017 - 2024 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/enlighten 9 | 10 | API Reference 11 | ============= 12 | 13 | Classes 14 | ------- 15 | 16 | .. py:module:: enlighten 17 | 18 | .. autoclass:: Manager(stream=None, counter_class=Counter, **kwargs) 19 | :inherited-members: 20 | :exclude-members: write, remove 21 | 22 | .. autoclass:: NotebookManager(stream=None, counter_class=Counter, **kwargs) 23 | :inherited-members: 24 | :exclude-members: write, remove 25 | 26 | .. autoclass:: Counter 27 | :members: 28 | :inherited-members: 29 | :exclude-members: count, elapsed, position 30 | 31 | .. autoclass:: StatusBar 32 | :members: 33 | :inherited-members: 34 | :exclude-members: count, elapsed, position 35 | 36 | .. autoclass:: SubCounter 37 | :members: 38 | 39 | Functions 40 | --------- 41 | 42 | .. autofunction:: enlighten.get_manager(stream=None, counter_class=Counter, **kwargs) 43 | .. autofunction:: format_time 44 | 45 | Constants 46 | --------- 47 | 48 | .. autoclass:: enlighten.Justify -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Enlighten documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Sep 22 12:06:03 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | from setup_helpers import get_version # noqa: E402 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = ['sphinx.ext.autodoc', 36 | 'sphinx.ext.intersphinx', 37 | 'sphinx.ext.napoleon'] 38 | 39 | if os.environ.get('READTHEDOCS') != 'True': 40 | extensions.append('sphinxcontrib.spelling') 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # 48 | # source_suffix = ['.rst', '.md'] 49 | source_suffix = '.rst' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # General information about the project. 55 | project = 'Enlighten' 56 | copyright = '2017 - 2025, Avram Lubkin' 57 | author = 'Avram Lubkin' 58 | 59 | # The version info for the project you're documenting, acts as replacement for 60 | # |version| and |release|, also used in various other places throughout the 61 | # built documents. 62 | # 63 | # The short X.Y version. 64 | version = get_version('../enlighten/__init__.py') 65 | # The full version, including alpha/beta/rc tags. 66 | release = version 67 | 68 | # The language for content autogenerated by Sphinx. Refer to documentation 69 | # for a list of supported languages. 70 | # 71 | # This is also used if you do content translation via gettext catalogs. 72 | # Usually you set "language" from the command line for these cases. 73 | language = 'en' 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | # This patterns also effect to html_static_path and html_extra_path 78 | exclude_patterns = [] 79 | 80 | # The name of the Pygments (syntax highlighting) style to use. 81 | pygments_style = 'sphinx' 82 | 83 | # If true, `todo` and `todoList` produce output, else they produce nothing. 84 | todo_include_todos = False 85 | 86 | autodoc_mock_imports = ["IPython"] 87 | 88 | # -- Options for HTML output ---------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = 'sphinx_rtd_theme' 94 | # if os.environ.get('READTHEDOCS') == 'True': 95 | # html_theme = 'default' 96 | # else: 97 | # try: 98 | # import sphinx_rtd_theme 99 | # html_theme = 'sphinx_rtd_theme' 100 | # except ImportError: 101 | # pass 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | # 107 | # html_theme_options = {} 108 | 109 | # Add any paths that contain custom static files (such as style sheets) here, 110 | # relative to this directory. They are copied after the builtin static files, 111 | # so a file named "default.css" will overwrite the builtin "default.css". 112 | html_static_path = ['_static'] 113 | 114 | # Custom sidebar templates, must be a dictionary that maps document names 115 | # to template names. 116 | # 117 | # This is required for the alabaster theme 118 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 119 | # html_sidebars = { 120 | # '**': [ 121 | # 'about.html', 122 | # 'navigation.html', 123 | # 'relations.html', # needs 'show_related': True theme option to display 124 | # 'searchbox.html', 125 | # 'donate.html', 126 | # ] 127 | # } 128 | 129 | 130 | # -- Options for HTMLHelp output ------------------------------------------ 131 | 132 | # Output file base name for HTML help builder. 133 | htmlhelp_basename = 'Enlightendoc' 134 | 135 | 136 | # -- Options for LaTeX output --------------------------------------------- 137 | 138 | latex_elements = { 139 | # The paper size ('letterpaper' or 'a4paper'). 140 | # 141 | # 'papersize': 'letterpaper', 142 | 143 | # The font size ('10pt', '11pt' or '12pt'). 144 | # 145 | # 'pointsize': '10pt', 146 | 147 | # Additional stuff for the LaTeX preamble. 148 | # 149 | # 'preamble': '', 150 | 151 | # Latex figure (float) alignment 152 | # 153 | # 'figure_align': 'htbp', 154 | } 155 | 156 | # Grouping the document tree into LaTeX files. List of tuples 157 | # (source start file, target name, title, 158 | # author, documentclass [howto, manual, or own class]). 159 | latex_documents = [ 160 | (master_doc, 'Enlighten.tex', 'Enlighten Documentation', 161 | 'Avram Lubkin', 'manual'), 162 | ] 163 | 164 | 165 | # -- Options for manual page output --------------------------------------- 166 | 167 | # One entry per manual page. List of tuples 168 | # (source start file, name, description, authors, manual section). 169 | man_pages = [ 170 | (master_doc, 'enlighten', 'Enlighten Documentation', 171 | [author], 1) 172 | ] 173 | 174 | 175 | # -- Options for Texinfo output ------------------------------------------- 176 | 177 | # Grouping the document tree into Texinfo files. List of tuples 178 | # (source start file, target name, title, author, 179 | # dir menu entry, description, category) 180 | texinfo_documents = [ 181 | (master_doc, 'Enlighten', 'Enlighten Documentation', 182 | author, 'Enlighten', 'One line description of project.', 183 | 'Miscellaneous'), 184 | ] 185 | 186 | # Example configuration for intersphinx: refer to the Python standard library. 187 | intersphinx_mapping = { 188 | 'python': ('https://docs.python.org/3', None), 189 | 'prefixed': ('https://prefixed.readthedocs.io/en/stable', None) 190 | } 191 | -------------------------------------------------------------------------------- /doc/docutils.conf: -------------------------------------------------------------------------------- 1 | [parsers] 2 | smart_quotes: no -------------------------------------------------------------------------------- /doc/examples.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2017 - 2023 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/enlighten 9 | 10 | 11 | How to Use 12 | ========== 13 | 14 | Managers 15 | -------- 16 | 17 | The first step is to create a manager. Managers handle output to the terminal and allow multiple 18 | progress bars to be displayed at the same time. 19 | 20 | :py:func:`~enlighten.get_manager` can be used to get a :py:class:`~enlighten.Manager` instance. 21 | Managers will only display output when the output stream, :py:data:`sys.__stdout__` by default, 22 | is attached to a TTY. If the stream is not attached to a TTY, the manager instance returned will be 23 | disabled. 24 | 25 | In most cases, a manager can be created like this. 26 | 27 | .. code-block:: python 28 | 29 | import enlighten 30 | manager = enlighten.get_manager() 31 | 32 | If you need to use a different output stream, or override the defaults, see the documentation for 33 | :py:func:`~enlighten.get_manager` 34 | 35 | 36 | Progress Bars 37 | ------------- 38 | 39 | For a basic progress bar, invoke the :py:meth:`Manager.counter ` method. 40 | 41 | .. code-block:: python 42 | 43 | import time 44 | import enlighten 45 | 46 | manager = enlighten.get_manager() 47 | pbar = manager.counter(total=100, desc='Basic', unit='ticks') 48 | 49 | for num in range(100): 50 | time.sleep(0.1) # Simulate work 51 | pbar.update() 52 | 53 | Additional progress bars can be created with additional calls to the 54 | :py:meth:`Manager.counter ` method. 55 | 56 | .. code-block:: python 57 | 58 | import time 59 | import enlighten 60 | 61 | manager = enlighten.get_manager() 62 | ticks = manager.counter(total=100, desc='Ticks', unit='ticks') 63 | tocks = manager.counter(total=20, desc='Tocks', unit='tocks') 64 | 65 | for num in range(100): 66 | time.sleep(0.1) # Simulate work 67 | print(num) 68 | ticks.update() 69 | if not num % 5: 70 | tocks.update() 71 | 72 | manager.stop() 73 | 74 | Counters 75 | -------- 76 | 77 | The :py:class:`~enlighten.Counter` class has two output formats, progress bar and counter. 78 | 79 | The progress bar format is used when a total is not :py:data:`None` and the count is less than the 80 | total. If neither of these conditions are met, the counter format is used: 81 | 82 | .. code-block:: python 83 | 84 | import time 85 | import enlighten 86 | 87 | manager = enlighten.get_manager() 88 | counter = manager.counter(desc='Basic', unit='ticks') 89 | 90 | for num in range(100): 91 | time.sleep(0.1) # Simulate work 92 | counter.update() 93 | 94 | Status Bars 95 | ----------- 96 | Status bars are bars that work similarly to progress bars and counters, but present relatively 97 | static information. Status bars are created with 98 | :py:meth:`Manager.status_bar `. 99 | 100 | .. code-block:: python 101 | 102 | import enlighten 103 | import time 104 | 105 | manager = enlighten.get_manager() 106 | status_bar = manager.status_bar('Static Message', 107 | color='white_on_red', 108 | justify=enlighten.Justify.CENTER) 109 | time.sleep(1) 110 | status_bar.update('Updated static message') 111 | time.sleep(1) 112 | 113 | Status bars can also use formatting with dynamic variables. 114 | 115 | .. code-block:: python 116 | 117 | import enlighten 118 | import time 119 | 120 | manager = enlighten.get_manager() 121 | status_format = '{program}{fill}Stage: {stage}{fill} Status {status}' 122 | status_bar = manager.status_bar(status_format=status_format, 123 | color='bold_slategray', 124 | program='Demo', 125 | stage='Loading', 126 | status='OKAY') 127 | time.sleep(1) 128 | status_bar.update(stage='Initializing', status='OKAY') 129 | time.sleep(1) 130 | status_bar.update(status='FAIL') 131 | 132 | Status bars, like other bars can be pinned. To pin a status bar to the top of all other bars, 133 | initialize it before any other bars. To pin a bar to the bottom of the screen, use 134 | ``position=1`` when initializing. 135 | 136 | See :py:class:`~enlighten.StatusBar` for more details. 137 | 138 | Color 139 | ----- 140 | 141 | Status bars and the bar component of a progress bar can be colored by setting the 142 | ``color`` keyword argument. See :ref:`Series Color ` for more information 143 | about valid colors. 144 | 145 | .. code-block:: python 146 | 147 | import time 148 | import enlighten 149 | 150 | manager = enlighten.get_manager() 151 | counter = manager.counter(total=100, desc='Colorized', unit='ticks', color='red') 152 | 153 | for num in range(100): 154 | time.sleep(0.1) # Simulate work 155 | counter.update() 156 | 157 | Additionally, any part of the progress bar can be colored using counter 158 | :ref:`formatting ` and the 159 | `color capabilities `_ 160 | of the underlying `Blessed `_ 161 | `Terminal `_. 162 | 163 | .. code-block:: python 164 | 165 | import enlighten 166 | 167 | manager = enlighten.get_manager() 168 | 169 | # Standard bar format 170 | std_bar_format = u'{desc}{desc_pad}{percentage:3.0f}%|{bar}| ' + \ 171 | u'{count:{len_total}d}/{total:d} ' + \ 172 | u'[{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]' 173 | 174 | # Red text 175 | bar_format = manager.term.red(std_bar_format) 176 | 177 | # Red on white background 178 | bar_format = manager.term.red_on_white(std_bar_format) 179 | 180 | # X11 colors 181 | bar_format = manager.term.peru_on_seagreen(std_bar_format) 182 | 183 | # RBG text 184 | bar_format = manager.term.color_rgb(2, 5, 128)(std_bar_format) 185 | 186 | # RBG background 187 | bar_format = manager.term.on_color_rgb(255, 190, 195)(std_bar_format) 188 | 189 | # RGB text and background 190 | bar_format = manager.term.on_color_rgb(255, 190, 195)(std_bar_format) 191 | bar_format = manager.term.color_rgb(2, 5, 128)(bar_format) 192 | 193 | # Apply color to select parts 194 | bar_format = manager.term.red(u'{desc}') + u'{desc_pad}' + \ 195 | manager.term.blue(u'{percentage:3.0f}%') + u'|{bar}|' 196 | 197 | # Apply to counter 198 | ticks = manager.counter(total=100, desc='Ticks', unit='ticks', bar_format=bar_format) 199 | 200 | If the ``color`` option is applied to a :py:class:`~enlighten.Counter`, 201 | it will override any foreground color applied. 202 | 203 | 204 | 205 | Multicolored 206 | ------------ 207 | 208 | The bar component of a progress bar can be multicolored to track multiple categories in a single 209 | progress bar. 210 | 211 | The colors are drawn from right to left in the order they were added. 212 | 213 | By default, when multicolored progress bars are used, additional fields are available for 214 | ``bar_format``: 215 | 216 | - count_n (:py:class:`int`) - Current value of ``count`` 217 | - count_0(:py:class:`int`) - Remaining count after deducting counts for all subcounters 218 | - count_00 (:py:class:`int`) - Sum of counts from all subcounters 219 | - percentage_n (:py:class:`float`) - Percentage complete 220 | - percentage_0(:py:class:`float`) - Remaining percentage after deducting percentages 221 | for all subcounters 222 | - percentage_00 (:py:class:`float`) - Total of percentages from all subcounters 223 | 224 | When :py:meth:`add_subcounter` is called with ``all_fields`` set to :py:data:`True`, 225 | the subcounter will have the additional fields: 226 | 227 | - eta_n (:py:class:`str`) - Estimated time to completion 228 | - rate_n (:py:class:`float`) - Average increments per second since parent was created 229 | 230 | More information about ``bar_format`` can be found in the 231 | :ref:`Format ` section of the API. 232 | 233 | One use case for multicolored progress bars is recording the status of a series of tests. 234 | In this example, Failures are red, errors are white, and successes are green. The count of each is 235 | listed in the progress bar. 236 | 237 | .. code-block:: python 238 | 239 | import random 240 | import time 241 | import enlighten 242 | 243 | bar_format = u'{desc}{desc_pad}{percentage:3.0f}%|{bar}| ' + \ 244 | u'S:{count_0:{len_total}d} ' + \ 245 | u'F:{count_2:{len_total}d} ' + \ 246 | u'E:{count_1:{len_total}d} ' + \ 247 | u'[{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]' 248 | 249 | manager = enlighten.get_manager() 250 | success = manager.counter(total=100, desc='Testing', unit='tests', 251 | color='green', bar_format=bar_format) 252 | errors = success.add_subcounter('white') 253 | failures = success.add_subcounter('red') 254 | 255 | while success.count < 100: 256 | time.sleep(random.uniform(0.1, 0.3)) # Random processing time 257 | result = random.randint(0, 10) 258 | 259 | if result == 7: 260 | errors.update() 261 | if result in (5, 6): 262 | failures.update() 263 | else: 264 | success.update() 265 | 266 | A more complicated example is recording process start-up. In this case, all items will start red, 267 | transition to yellow, and eventually all will be green. The count, percentage, rate, and eta fields 268 | are all derived from the second subcounter added. 269 | 270 | .. code-block:: python 271 | 272 | import random 273 | import time 274 | import enlighten 275 | 276 | services = 100 277 | bar_format = u'{desc}{desc_pad}{percentage_2:3.0f}%|{bar}|' + \ 278 | u' {count_2:{len_total}d}/{total:d} ' + \ 279 | u'[{elapsed}<{eta_2}, {rate_2:.2f}{unit_pad}{unit}/s]' 280 | 281 | manager = enlighten.get_manager() 282 | initializing = manager.counter(total=services, desc='Starting', unit='services', 283 | color='red', bar_format=bar_format) 284 | starting = initializing.add_subcounter('yellow') 285 | started = initializing.add_subcounter('green', all_fields=True) 286 | 287 | while started.count < services: 288 | remaining = services - initializing.count 289 | if remaining: 290 | num = random.randint(0, min(4, remaining)) 291 | initializing.update(num) 292 | 293 | ready = initializing.count - initializing.subcount 294 | if ready: 295 | num = random.randint(0, min(3, ready)) 296 | starting.update_from(initializing, num) 297 | 298 | if starting.count: 299 | num = random.randint(0, min(2, starting.count)) 300 | started.update_from(starting, num) 301 | 302 | time.sleep(random.uniform(0.1, 0.5)) # Random processing time 303 | 304 | 305 | Additional Examples 306 | ------------------- 307 | 308 | * :download:`Basic <../examples/basic.py>` - Basic progress bar 309 | * :download:`Binary prefixes <../examples/prefixes.py>` - Automatic binary prefixes 310 | * :download:`Context manager <../examples/context_manager.py>` - Managers and counters as context managers 311 | * :download:`FTP downloader <../examples/ftp_downloader.py>` - Show progress downloading files from FTP 312 | * :download:`Floats <../examples/floats.py>` - Support totals and counts that are :py:class:`floats` 313 | * :download:`Multicolored <../examples/multicolored.py>` - Multicolored progress bars 314 | * :download:`Multiple with logging <../examples/multiple_logging.py>` - Nested progress bars and logging 315 | * :download:`Multiprocessing queues <../examples/multiprocessing_queues.py>` - Progress bars with queues for IPC 316 | 317 | 318 | Customization 319 | ------------- 320 | 321 | Enlighten is highly configurable. For information on modifying the output, see the 322 | :ref:`Series ` and :ref:`Format ` 323 | sections of the :py:class:`~enlighten.Counter` documentation. 324 | -------------------------------------------------------------------------------- /doc/faq.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2017 - 2024 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/enlighten 9 | 10 | Frequently Asked Questions 11 | ========================== 12 | 13 | Why is Enlighten called Enlighten? 14 | ---------------------------------- 15 | 16 | A progress bar's purpose is to inform the user about an ongoing process. 17 | Enlighten, meaning "to inform", seems a fitting name. 18 | (Plus any names related to progress were already taken) 19 | 20 | 21 | Is Windows supported? 22 | --------------------- 23 | 24 | Enlighten has supported Windows since version 1.3.0. 25 | 26 | Windows does not currently support resizing. 27 | 28 | Enlighten also works in Linux-like subsystems for Windows such as 29 | `Cygwin `_ or 30 | `Windows Subsystem for Linux `_. 31 | 32 | Is Jupyter Notebooks Supported? 33 | ------------------------------- 34 | 35 | Support for Jupyter notebooks was added in version 1.10.0. 36 | 37 | Jupyter Notebook support is provide by the :py:class:`~enlighten.NotebookManager` class. 38 | If running inside a Jupyter Notebook, :py:func:`~enlighten.get_manager` will return a 39 | :py:class:`~enlighten.NotebookManager` instance. 40 | 41 | There is currently no support for detecting the width of a Jupyter notebook so output width has been 42 | set statically to 100 characters. This can be overridden by passing the ``width`` keyword argument 43 | to :py:func:`~enlighten.get_manager`. 44 | 45 | Is PyCharm supported? 46 | --------------------- 47 | 48 | PyCharm uses multiple consoles and the behavior differs depending on how the code is called. 49 | 50 | Enlighten works natively in the PyCharm command terminal. 51 | 52 | To use Enlighten with Run or Debug, terminal emulation must be enabled. 53 | Navigate to `Run -> Edit Configurations -> Templates -> Python` 54 | and select `Emulate terminal in output console`. 55 | 56 | The PyCharm Python console is currently not supported because :py:data:`sys.stdout` 57 | does not reference a valid TTY. 58 | 59 | We are also tracking an `issue with CSR `_ 60 | in the PyCharm terminal. 61 | 62 | .. spelling:word-list:: 63 | csr 64 | eos 65 | eol 66 | 67 | Can you add support for _______ terminal? 68 | ----------------------------------------- 69 | 70 | We are happy to add support for as many terminals as we can. 71 | However, not all terminals can be supported. There a few requirements. 72 | 73 | 1. The terminal must be detectable programmatically 74 | 75 | We need to be able to identify the terminal in some reasonable way 76 | and differentiate it from other terminals. This could be through environment variables, 77 | the :py:mod:`platform` module, or some other method. 78 | 79 | 2. A subset of terminal codes must be supported 80 | 81 | While these codes may vary among terminals, the capability must be 82 | provided and activated by printing a terminal sequence. 83 | The required codes are listed below. 84 | 85 | * move / CUP - Cursor Position 86 | * hide_cursor / DECTCEM - Text Cursor Enable Mode 87 | * show_cursor / DECTCEM - Text Cursor Enable Mode 88 | * csr / DECSTBM - Set Top and Bottom Margins 89 | * clear_eos / ED - Erase in Display 90 | * clear_eol / EL - Erase in Line 91 | * feed / CUD - Cursor Down (Or scroll with linefeed) 92 | 93 | 3. Terminal dimensions must be detectable 94 | 95 | The height and width of the terminal must be available to the running process. 96 | 97 | Why does ``RuntimeError: reentrant call`` get raised sometimes during a resize? 98 | ------------------------------------------------------------------------------- 99 | 100 | This is caused when another thread or process is writing to a standard stream (STDOUT, STDERR) 101 | at the same time the resize signal handler is writing to the stream. 102 | 103 | Enlighten tries to detect when a program is threaded or running multiple processes and defer 104 | resize handling until the next normal write event. However, this condition is evaluated when 105 | the scroll area is set, typically when the first counter is added. If no threads or processes 106 | are detected at that time, and the value of threaded was not set explicitly, resize events will not 107 | be deferred. 108 | 109 | In order to guarantee resize handling is deferred, it is best to pass ``threaded=True`` when 110 | creating a manager instance. 111 | 112 | Why isn't my progress bar displayed until :py:meth:`~enlighten.Counter.update` is called? 113 | ----------------------------------------------------------------------------------------- 114 | 115 | Progress bars and counters are not automatically drawn when created because some fields may be 116 | missing if subcounters are used. To force the counter to draw before updating, call 117 | :py:meth:`~enlighten.Counter.refresh` 118 | 119 | Why does the output get scrambled when the number of progress bars exceeds the terminal height? 120 | ----------------------------------------------------------------------------------------------- 121 | 122 | Enlighten draws progress bars in a non-scrolling region at the bottom of the terminal. This 123 | areas is limited to the size of the terminal. In some terminals, the output 124 | is cut off to the size of the terminal. In others, lines will be overwritten and appear scrambled. 125 | 126 | We advise you to close progress bars when they are complete and do not add additional value for the 127 | user. However, if you have a need to create a lot of progress bars, you may want to check the size 128 | of the terminal and resize it if needed. How this is accomplished will depend on the platform and 129 | terminal you are using. 130 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2017 - 2021 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/enlighten 9 | 10 | .. toctree:: 11 | :hidden: 12 | 13 | self 14 | install.rst 15 | examples.rst 16 | patterns.rst 17 | faq.rst 18 | api.rst 19 | 20 | Overview 21 | ======== 22 | 23 | Enlighten Progress Bar is a console progress bar library for Python. 24 | 25 | The main advantage of Enlighten is it allows writing to stdout and stderr without any 26 | redirection or additional code. Just print or log as you normally would. 27 | 28 | Enlighten also includes experimental support for Jupyter Notebooks. 29 | 30 | .. image:: _static/demo.gif 31 | :target: examples.html 32 | 33 | The code for this animation can be found in 34 | `demo.py `__ 35 | in 36 | `examples `__. -------------------------------------------------------------------------------- /doc/install.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2017 - 2023 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/enlighten 9 | 10 | 11 | Installation 12 | ============ 13 | 14 | PIP 15 | --- 16 | 17 | .. code-block:: console 18 | 19 | $ pip install enlighten 20 | 21 | 22 | RPM 23 | --- 24 | 25 | Fedora and EL8 (RHEL/CentOS) 26 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 27 | 28 | (For EPEL_ repositories must be configured_) 29 | 30 | .. code-block:: console 31 | 32 | $ dnf install python3-enlighten 33 | 34 | 35 | DEB 36 | --- 37 | 38 | Debian and Ubuntu 39 | ^^^^^^^^^^^^^^^^^ 40 | .. code-block:: console 41 | 42 | $ apt-get install python3-enlighten 43 | 44 | 45 | Conda 46 | ----- 47 | 48 | .. code-block:: console 49 | 50 | $ conda install -c conda-forge enlighten 51 | 52 | 53 | .. _EPEL: https://fedoraproject.org/wiki/EPEL 54 | .. _configured: https://fedoraproject.org/wiki/EPEL#How_can_I_use_these_extra_packages.3F 55 | -------------------------------------------------------------------------------- /doc/patterns.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright 2017 - 2023 Avram Lubkin, All Rights Reserved 3 | 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | :github_url: https://github.com/Rockhopper-Technologies/enlighten 9 | 10 | Common Patterns 11 | =============== 12 | 13 | Enable / Disable 14 | ---------------- 15 | 16 | A program may want to disable progress bars based on a configuration setting as well as if 17 | output redirection occurs. 18 | 19 | .. code-block:: python 20 | 21 | import sys 22 | import enlighten 23 | 24 | # Example configuration object 25 | config = {'stream': sys.stdout, 26 | 'useCounter': False} 27 | 28 | enableCounter = config['useCounter'] and stream.isatty() 29 | manager = enlighten.Manager(stream=config['stream'], enabled=enableCounter) 30 | 31 | The :py:func:`~enlighten.get_manager` function slightly simplifies this 32 | 33 | .. code-block:: python 34 | 35 | import enlighten 36 | 37 | # Example configuration object 38 | config = {'stream': None, # Defaults to sys.__stdout__ 39 | 'useCounter': False} 40 | 41 | manager = enlighten.get_manager(stream=config['stream'], enabled=config['useCounter']) 42 | 43 | 44 | Context Managers 45 | ---------------- 46 | 47 | Both :py:class:`~enlighten.Counter` and :py:class:`~enlighten.Manager` 48 | can be used as context managers. 49 | 50 | .. code-block:: python 51 | 52 | import enlighten 53 | 54 | SPLINES = 100 55 | 56 | with enlighten.Manager() as manager: 57 | with manager.counter(total=SPLINES, desc='Reticulating:', unit='splines') as retic: 58 | for num in range(1, SPLINES + 1): 59 | time.sleep(.1) 60 | retic.update() 61 | 62 | 63 | Automatic Updating 64 | ------------------ 65 | 66 | Both :py:class:`~enlighten.Counter` and :py:class:`~enlighten.SubCounter` instances can be 67 | called as functions on one or more iterators. A generator is returned which yields each element of 68 | the iterables and then updates the count by 1. 69 | 70 | .. note:: 71 | When a :py:class:`~enlighten.Counter` instance is called as a function, type checking is lazy 72 | and won't validate an iterable was passed until iteration begins. 73 | 74 | .. code-block:: python 75 | 76 | import time 77 | import enlighten 78 | 79 | flock1 = ['Harry', 'Sally', 'Randy', 'Mandy', 'Danny', 'Joe'] 80 | flock2 = ['Punchy', 'Kicky', 'Spotty', 'Touchy', 'Brenda'] 81 | total = len(flock1) + len(flock2) 82 | 83 | manager = enlighten.Manager() 84 | pbar = manager.counter(total=total, desc='Counting Sheep', unit='sheep') 85 | 86 | for sheep in pbar(flock1, flock2): 87 | time.sleep(0.2) 88 | print('%s: Baaa' % sheep) 89 | 90 | 91 | User-defined fields 92 | ------------------- 93 | 94 | Both :py:class:`~enlighten.Counter` and :py:class:`~enlighten.StatusBar` accept 95 | user defined fields as keyword arguments at initialization and during an update. 96 | These fields are persistent and only need to be specified when they change. 97 | 98 | In the following example, ``source`` is a user-defined field that is periodically updated. 99 | 100 | .. code-block:: python 101 | 102 | import enlighten 103 | import random 104 | import time 105 | 106 | bar_format = u'{desc}{desc_pad}{source} {percentage:3.0f}%|{bar}| ' + \ 107 | u'{count:{len_total}d}/{total:d} ' + \ 108 | u'[{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]' 109 | manager = enlighten.get_manager(bar_format=bar_format) 110 | 111 | bar = manager.counter(total=100, desc='Loading', unit='files', source='server.a') 112 | for num in range(100): 113 | time.sleep(0.1) # Simulate work 114 | if not num % 5: 115 | bar.update(source=random.choice(['server.a', 'server.b', 'server.c'])) 116 | else: 117 | bar.update() 118 | 119 | For more information, see the :ref:`Counter Format ` and 120 | :ref:`StatusBar Format ` sections. 121 | 122 | 123 | Human-readable numeric prefixes 124 | ------------------------------- 125 | 126 | Enlighten supports automatic `SI (metric)`_ and `IEC (binary)`_ prefixes using the Prefixed_ 127 | library. 128 | 129 | All ``rate`` and ``interval`` formatting fields are of the type :py:class:`prefixed.Float`. 130 | ``total`` and all ``count`` fields default to :py:class:`int`. 131 | If :py:attr:`~Counter.total` or or :py:attr:`~Counter.count` are set to a :py:class:`float`, 132 | or a :py:class:`float` is provided to :py:meth:`~Counter.update`, 133 | these fields will be :py:class:`prefixed.Float` instead. 134 | 135 | .. code-block:: python 136 | 137 | import time 138 | import random 139 | import enlighten 140 | 141 | size = random.uniform(1.0, 10.0) * 2 ** 20 # 1-10 MiB (float) 142 | chunk_size = 64 * 1024 # 64 KiB 143 | 144 | bar_format = '{desc}{desc_pad}{percentage:3.0f}%|{bar}| ' \ 145 | '{count:!.2j}{unit} / {total:!.2j}{unit} ' \ 146 | '[{elapsed}<{eta}, {rate:!.2j}{unit}/s]' 147 | 148 | manager = enlighten.get_manager() 149 | pbar = manager.counter(total=size, desc='Downloading', unit='B', bar_format=bar_format) 150 | 151 | bytes_left = size 152 | while bytes_left: 153 | time.sleep(random.uniform(0.05, 0.15)) 154 | next_chunk = min(chunk_size, bytes_left) 155 | pbar.update(next_chunk) 156 | bytes_left -= next_chunk 157 | 158 | 159 | .. code-block:: python 160 | 161 | import enlighten 162 | 163 | counter_format = 'Trying to get to sleep: {count:.2h} sheep' 164 | 165 | manager = enlighten.get_manager() 166 | counter = manager.counter(counter_format=counter_format) 167 | counter.count = 0.0 168 | for num in range(10000000): 169 | counter.update() 170 | 171 | 172 | For more information, see the :ref:`Counter Format ` 173 | and the `Prefixed`_ documentation. 174 | 175 | .. _SI (metric): https://en.wikipedia.org/wiki/Metric_prefix 176 | .. _IEC (binary): https://en.wikipedia.org/wiki/Binary_prefix 177 | .. _Prefixed: https://prefixed.readthedocs.io/en/stable/index.html 178 | 179 | Manually Printing 180 | ----------------- 181 | 182 | By default, if the manager's stream is connected to a TTY, bars and counters are automatically 183 | printed and updated. There may, however be cases where manual output is desired in addition to or 184 | instead of the automatic output. For example, to send to other streams or print to a file. 185 | 186 | The output for an individual bar can be retrieved from the :py:meth:`~Counter.format` method. This 187 | supports optional arguments to specify width and elapsed time. 188 | 189 | .. code-block:: python 190 | 191 | import enlighten 192 | 193 | manager = enlighten.get_manager(enabled=False) 194 | pbar = manager.counter(desc='Progress', total=10) 195 | 196 | pbar.update() 197 | print(pbar.format(width=100)) 198 | 199 | 200 | As a shortcut, the counter object will call the :py:meth:`~Counter.format` method with the default 201 | arguments when coerced to a string. 202 | 203 | .. code-block:: python 204 | 205 | import enlighten 206 | 207 | manager = enlighten.get_manager(enabled=False) 208 | pbar = manager.counter(desc='Progress', total=10) 209 | 210 | pbar.update() 211 | print(pbar) 212 | 213 | 214 | While Enlighten's default output provides more advanced capability, A basic refreshing progress bar 215 | can be created like so. 216 | 217 | .. code-block:: python 218 | 219 | import enlighten 220 | import time 221 | 222 | manager = enlighten.get_manager(enabled=False) 223 | pbar = manager.counter(desc='Progress', total=10) 224 | 225 | print() 226 | 227 | for num in range(10): 228 | time.sleep(0.2) 229 | pbar.update() 230 | print(f'\r{pbar}', end='', flush=True) 231 | 232 | print() 233 | -------------------------------------------------------------------------------- /doc/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | Conda 2 | downconverted 3 | iterable 4 | iterables 5 | Jupyter 6 | natively 7 | programmatically 8 | resize 9 | resizing 10 | stdout 11 | stderr 12 | subcounter 13 | subcounters 14 | -------------------------------------------------------------------------------- /enlighten/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2025 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | **Enlighten Progress Bar** 10 | 11 | Provides progress bars and counters which play nice in a TTY console 12 | """ 13 | 14 | from enlighten.counter import Counter, StatusBar, SubCounter 15 | from enlighten.manager import Manager, get_manager 16 | from enlighten._util import EnlightenWarning, Justify, format_time 17 | 18 | 19 | __version__ = '1.14.1' 20 | __all__ = ['Counter', 'EnlightenWarning', 'format_time', 'get_manager', 'Justify', 21 | 'Manager', 'NotebookManager', 'StatusBar', 'SubCounter'] 22 | 23 | try: 24 | from enlighten.manager import NotebookManager # noqa: F401 25 | except ImportError: 26 | __all__.remove('NotebookManager') 27 | -------------------------------------------------------------------------------- /enlighten/_basecounter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2025 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | **Enlighten base counter submodule** 10 | 11 | Provides BaseCounter and PrintableCounter classes 12 | """ 13 | 14 | import time 15 | 16 | from enlighten._util import BASESTRING, EnlightenWarning, lru_cache, warn_best_level 17 | 18 | try: 19 | from collections.abc import Iterable 20 | except ImportError: # pragma: no cover(Python 2) 21 | from collections import Iterable # pylint: disable=deprecated-class 22 | 23 | 24 | class BaseCounter(object): 25 | """ 26 | Args: 27 | manager(:py:class:`Manager`): Manager instance. Required. 28 | color(str): Color as a string or RGB tuple (Default: None) 29 | 30 | Base class for counters 31 | """ 32 | 33 | __slots__ = ('_color', '_count', 'manager', 'start_count') 34 | _repr_attrs = ('count', 'color') 35 | _placeholder_ = u'___ENLIGHTEN_PLACEHOLDER___' 36 | _placeholder_len_ = len(_placeholder_) 37 | 38 | def __repr__(self): 39 | 40 | params = [] 41 | for attr in self._repr_attrs: 42 | value = getattr(self, attr) 43 | if value is not None: 44 | params.append('%s=%r' % (attr, value)) 45 | 46 | return '%s(%s)' % (self.__class__.__name__, ', '.join(params)) 47 | 48 | def __init__(self, keywords=None, **kwargs): 49 | 50 | if keywords is not None: 51 | kwargs = keywords 52 | 53 | self._count = self.start_count = kwargs.pop('count', 0) 54 | self._color = None 55 | 56 | self.manager = kwargs.pop('manager', None) 57 | if self.manager is None: 58 | raise TypeError('manager must be specified') 59 | 60 | self.color = kwargs.pop('color', None) 61 | 62 | @property 63 | def count(self): 64 | """ 65 | Running count 66 | A property so additional logic can be added in children 67 | """ 68 | 69 | return self._count 70 | 71 | @count.setter 72 | def count(self, value): 73 | 74 | self._count = value 75 | 76 | @property 77 | def color(self): 78 | """ 79 | Color property 80 | 81 | Preferred to be a string or iterable of three integers for RGB. 82 | Single integer supported for backwards compatibility 83 | """ 84 | 85 | color = self._color 86 | return color if color is None else color[0] 87 | 88 | @color.setter 89 | def color(self, value): 90 | 91 | if value is None: 92 | self._color = None 93 | 94 | elif isinstance(value, list): 95 | self._color = (value, self._resolve_color(tuple(value))) 96 | 97 | else: 98 | self._color = (value, self._resolve_color(value)) 99 | 100 | @lru_cache(maxsize=512) 101 | def _resolve_color(self, value): 102 | """ 103 | Caching method to resolve a color to terminal code 104 | """ 105 | 106 | # Color provided as an int form 0 to 255 107 | if isinstance(value, int) and 0 <= value <= 255: 108 | return self.manager.term.color(value) 109 | 110 | # Color provided as a string 111 | if isinstance(value, BASESTRING): 112 | term = self.manager.term 113 | color_cap = self.manager.term.formatter(value) 114 | if not color_cap and term.does_styling and term.number_of_colors: 115 | raise AttributeError('Invalid color specified: %s' % value) 116 | return color_cap 117 | 118 | # Color provided as an RGB iterable 119 | if isinstance(value, Iterable) and \ 120 | len(value) == 3 and \ 121 | all(isinstance(_, int) and 0 <= _ <= 255 for _ in value): 122 | return self.manager.term.color_rgb(*value) 123 | 124 | # Invalid format given 125 | raise AttributeError('Invalid color specified: %s' % repr(value)) 126 | 127 | def _colorize(self, content): 128 | """ 129 | Args: 130 | content(str): Color as a string or number 0 - 255 (Default: None) 131 | 132 | Returns: 133 | :py:class:`str`: content formatted with color 134 | 135 | Format ``content`` with the color specified for this progress bar 136 | 137 | If no color is specified for this instance, the content is returned unmodified 138 | """ 139 | 140 | # Used spec cached by color.setter if available 141 | return content if self._color is None else self._color[1](content) 142 | 143 | def update(self, *args, **kwargs): 144 | """ 145 | Placeholder for update method 146 | """ 147 | 148 | raise NotImplementedError 149 | 150 | def __call__(self, *args): 151 | 152 | for iterable in args: 153 | if not isinstance(iterable, Iterable): 154 | raise TypeError('Argument type %s is not iterable' % type(iterable).__name__) 155 | 156 | for element in iterable: 157 | yield element 158 | self.update() 159 | 160 | 161 | class PrintableCounter(BaseCounter): # pylint: disable=too-many-instance-attributes 162 | """ 163 | Base class for printable counters 164 | """ 165 | 166 | __slots__ = ('_closed', '_count_updated', 'enabled', '_fill', 'last_update', 167 | 'leave', 'min_delta', '_pinned', 'start') 168 | 169 | def __init__(self, keywords=None, **kwargs): 170 | 171 | if keywords is not None: # pragma: no branch 172 | kwargs = keywords 173 | super(PrintableCounter, self).__init__(keywords=kwargs) 174 | 175 | self._closed = 0.0 # Time when closed, 0 indicates it's open 176 | self.enabled = kwargs.pop('enabled', True) 177 | self._fill = u' ' 178 | self.fill = kwargs.pop('fill', u' ') 179 | self.leave = kwargs.pop('leave', True) 180 | self.min_delta = kwargs.pop('min_delta', 0.1) 181 | self._pinned = False 182 | self.last_update = self.start = self._count_updated = time.time() 183 | 184 | def __str__(self): 185 | 186 | # format() returns Unicode so encode if Python 2 187 | return self.format() if BASESTRING is str else self.format().encode('utf-8') 188 | 189 | def __unicode__(self): # pragma: no cover(Python 2) 190 | return self.format() 191 | 192 | def __enter__(self): 193 | return self 194 | 195 | def __exit__(self, *args): 196 | self.close() 197 | 198 | @property 199 | def count(self): 200 | """ 201 | Running count 202 | """ 203 | 204 | return self._count 205 | 206 | @count.setter 207 | def count(self, value): 208 | 209 | self._count = value 210 | self._count_updated = time.time() 211 | 212 | @property 213 | def elapsed(self): 214 | """ 215 | Get elapsed time is seconds (float) 216 | """ 217 | 218 | return (self._closed or time.time()) - self.start 219 | 220 | @property 221 | def fill(self): 222 | """ 223 | Fill character used in formatting 224 | """ 225 | return self._fill 226 | 227 | @fill.setter 228 | def fill(self, value): 229 | 230 | char_len = self.manager.term.length(value) 231 | if char_len != 1: 232 | raise ValueError('fill character must be a length of 1 ' 233 | 'when printed. Length: %d, Value given: %r' % (char_len, value)) 234 | 235 | self._fill = value 236 | 237 | @property 238 | def position(self): 239 | """ 240 | Fetch position from the manager 241 | """ 242 | 243 | return self.manager.counters.get(self, 0) 244 | 245 | def clear(self, flush=True): 246 | """ 247 | Args: 248 | flush(bool): Flush stream after clearing bar (Default:True) 249 | 250 | Clear bar 251 | """ 252 | 253 | if self.enabled: 254 | self.manager.write(flush=flush, counter=self) 255 | self.last_update = 0 256 | 257 | def close(self, clear=False): 258 | """ 259 | Do final refresh and remove from manager 260 | 261 | If ``leave`` is True, the default, the effect is the same as :py:meth:`refresh`. 262 | 263 | When closed, elapsed time will stop even when refreshed 264 | """ 265 | 266 | # Warn if counter is already closed 267 | if self._closed: 268 | warn_best_level('Closing already closed counter: %r' % self, EnlightenWarning) 269 | else: 270 | self._closed = time.time() 271 | 272 | if clear and not self.leave: 273 | self.clear() 274 | 275 | # If counter was already closed we may not know the position 276 | elif self in self.manager.counters: 277 | self.refresh() 278 | 279 | self.manager.remove(self) 280 | 281 | def format(self, width=None, elapsed=None): 282 | """ 283 | Format counter for printing 284 | """ 285 | 286 | raise NotImplementedError 287 | 288 | def refresh(self, flush=True, elapsed=None): 289 | """ 290 | Args: 291 | flush(bool): Flush stream after writing bar (Default:True) 292 | elapsed(float): Time since started. Automatically determined if :py:data:`None` 293 | 294 | Redraw bar 295 | """ 296 | 297 | if self.enabled: 298 | self.last_update = time.time() 299 | self.manager.write(output=self.format, flush=flush, counter=self, elapsed=elapsed) 300 | 301 | def _fill_text(self, text, width, offset=None): 302 | """ 303 | Args: 304 | text (str): String to modify 305 | width (int): Width in columns to make progress bar 306 | offset(int): Number of non-printable characters to account for when formatting 307 | 308 | Returns: 309 | :py:class:`str`: String with ``self._placeholder_`` replaced with fill characters 310 | 311 | Replace ``self._placeholder_`` in string with appropriate number of fill characters 312 | """ 313 | 314 | fill_count = text.count(self._placeholder_) 315 | if not fill_count: 316 | return text 317 | 318 | if offset is None: 319 | remaining = width - self.manager.term.length(text) + self._placeholder_len_ * fill_count 320 | else: 321 | remaining = width - len(text) + offset + self._placeholder_len_ * fill_count 322 | 323 | # If only one substitution is required, make it 324 | if fill_count == 1: 325 | return text.replace(self._placeholder_, self.fill * remaining) 326 | 327 | # Determine even fill size and number of extra characters to fill 328 | fill_size, extra = divmod(remaining, fill_count) 329 | 330 | # Add extra fill is needed, add extra fill evenly starting from the end 331 | if extra: 332 | text = text.replace(self._placeholder_, self.fill * fill_size, fill_count - extra) 333 | return text.replace(self._placeholder_, self.fill * (fill_size + 1)) 334 | 335 | # If fill is even, replace evenly 336 | return text.replace(self._placeholder_, self.fill * fill_size) 337 | 338 | def reset(self): 339 | """ 340 | Reset to initial state 341 | """ 342 | 343 | self.last_update = self.start = self._count_updated = time.time() 344 | self._count = self.start_count 345 | self._closed = 0.0 346 | -------------------------------------------------------------------------------- /enlighten/_basemanager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2023 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | **Enlighten base manager submodule** 10 | 11 | Provides BaseManager class 12 | """ 13 | 14 | import sys 15 | import time 16 | from collections import OrderedDict 17 | 18 | from blessed import Terminal 19 | 20 | from enlighten._counter import Counter 21 | from enlighten._statusbar import StatusBar 22 | 23 | 24 | class BaseManager(object): 25 | """ 26 | 27 | Args: 28 | stream(:py:term:`file object`): Output stream. If :py:data:`None`, 29 | defaults to :py:data:`sys.__stdout__` 30 | status_bar_class(:py:term:`class`): Status bar class (Default: :py:class:`StatusBar`) 31 | counter_class(:py:term:`class`): Progress bar class (Default: :py:class:`Counter`) 32 | set_scroll(bool): Enable scroll area redefinition (Default: :py:data:`True`) 33 | companion_stream(:py:term:`file object`): See :ref:`companion_stream ` 34 | below. (Default: :py:data:`None`) 35 | enabled(bool): Status (Default: True) 36 | no_resize(bool): Disable resizing support 37 | term(str): Terminal type passed to Blessed 38 | threaded(bool): When True resize handling is deferred until next write (Default: False 39 | unless multiple threads or multiple processes are detected) 40 | width(int): Static output width. If unset, width is determined dynamically 41 | kwargs(Dict[str, Any]): Any additional :py:term:`keyword arguments` 42 | will be used as default values when :py:meth:`counter` is called. 43 | 44 | Base manager class 45 | """ 46 | 47 | # pylint: disable=too-many-instance-attributes 48 | def __init__(self, **kwargs): 49 | 50 | self.enabled = kwargs.get('enabled', True) # Double duty for counters 51 | 52 | self.companion_stream = kwargs.pop('companion_stream', None) 53 | self.counter_class = kwargs.pop('counter_class', Counter) 54 | self.no_resize = kwargs.pop('no_resize', False) 55 | self.set_scroll = kwargs.pop('set_scroll', True) 56 | self.status_bar_class = kwargs.pop('status_bar_class', StatusBar) 57 | self.stream = kwargs.pop('stream', sys.__stdout__) 58 | self.threaded = kwargs.pop('threaded', None) 59 | self._width = kwargs.pop('width', None) 60 | 61 | self.counters = OrderedDict() 62 | 63 | self.autorefresh = [] 64 | self._buffer = [] 65 | self._companion_buffer = [] 66 | self.process_exit = False 67 | self.refresh_lock = False 68 | self._resize = False 69 | self.resize_lock = False 70 | self.scroll_offset = 1 71 | 72 | # If terminal is kind is given, force styling 73 | kind = kwargs.pop('term', None) 74 | self.term = Terminal(stream=self.stream, kind=kind, force_styling=bool(kind)) 75 | 76 | self.height = self.term.height 77 | self.width = self._width or self.term.width 78 | 79 | self.defaults = kwargs # Counter defaults 80 | 81 | def write(self, output='', flush=True, counter=None, **kwargs): 82 | """ 83 | Args: 84 | output(str): Output string or callable 85 | flush(bool): Flush the output stream after writing 86 | counter(:py:class:`Counter`): Bar being written (for position and auto-refresh) 87 | kwargs(dict): Additional arguments passed when output is callable 88 | 89 | Write to the stream. 90 | 91 | The position is determined by the counter or defaults to the bottom of the terminal 92 | 93 | If ``output`` is callable, it will be called with any additional keyword arguments 94 | to produce the output string 95 | """ 96 | 97 | raise NotImplementedError() 98 | 99 | def stop(self): 100 | """ 101 | Clean up and reset terminal 102 | 103 | This method should be called when the manager and counters will no longer be needed. 104 | 105 | Any progress bars that have ``leave`` set to :py:data:`True` or have not been closed 106 | will remain on the console. All others will be cleared. 107 | 108 | Manager and all counters will be disabled. 109 | """ 110 | 111 | raise NotImplementedError() 112 | 113 | def _flush_streams(self): 114 | """ 115 | Flush output buffers 116 | """ 117 | 118 | raise NotImplementedError() 119 | 120 | def __enter__(self): 121 | return self 122 | 123 | def __exit__(self, *args): 124 | self.stop() 125 | 126 | def counter(self, position=None, **kwargs): 127 | """ 128 | Args: 129 | position(int): Line number counting from the bottom of the screen 130 | autorefresh(bool): Refresh this counter when other bars are drawn 131 | replace(:py:class:`PrintableCounter`): Replace given counter with new. Position ignored. 132 | kwargs(Dict[str, Any]): Any additional :py:term:`keyword arguments` 133 | are passed to :py:class:`Counter` 134 | 135 | Returns: 136 | :py:class:`Counter`: Instance of counter class 137 | 138 | Get a new progress bar instance 139 | 140 | If ``position`` is specified, the counter's position will be pinned. 141 | A :py:exc:`ValueError` will be raised if ``position`` exceeds the screen height or 142 | has already been pinned by another counter. 143 | 144 | If ``autorefresh`` is :py:data:`True`, this bar will be redrawn whenever another bar is 145 | drawn assuming it had been ``min_delta`` seconds since the last update. This is usually 146 | unnecessary. 147 | 148 | .. note:: Counters are not automatically drawn when created because fields may be missing 149 | if subcounters are used. To force the counter to draw before updating, 150 | call :py:meth:`~Counter.refresh`. 151 | 152 | """ 153 | 154 | return self._add_counter(self.counter_class, position=position, **kwargs) 155 | 156 | def status_bar(self, *args, **kwargs): 157 | """ 158 | Args: 159 | position(int): Line number counting from the bottom of the screen 160 | autorefresh(bool): Refresh this counter when other bars are drawn 161 | replace(:py:class:`PrintableCounter`): Replace given counter with new. Position ignored. 162 | kwargs(Dict[str, Any]): Any additional :py:term:`keyword arguments` 163 | are passed to :py:class:`StatusBar` 164 | 165 | Returns: 166 | :py:class:`StatusBar`: Instance of status bar class 167 | 168 | Get a new status bar instance 169 | 170 | If ``position`` is specified, the counter's position can change dynamically if 171 | additional counters are called without a ``position`` argument. 172 | 173 | If ``autorefresh`` is :py:data:`True`, this bar will be redrawn whenever another bar is 174 | drawn assuming it had been ``min_delta`` seconds since the last update. Generally, 175 | only need when ``elapsed`` is used in :ref:`status_format `. 176 | 177 | """ 178 | 179 | position = kwargs.pop('position', None) 180 | 181 | return self._add_counter(self.status_bar_class, *args, position=position, **kwargs) 182 | 183 | def _add_counter(self, counter_class, *args, **kwargs): # pylint: disable=too-many-branches 184 | """ 185 | Args: 186 | counter_class(:py:class:`PrintableCounter`): Class to instantiate 187 | position(int): Line number counting from the bottom of the screen 188 | autorefresh(bool): Refresh this counter when other bars are drawn 189 | replace(:py:class:`PrintableCounter`): Replace given counter with new. Position ignored. 190 | kwargs(Dict[str, Any]): Any additional :py:term:`keyword arguments` 191 | are passed to :py:class:`Counter` 192 | 193 | Returns: 194 | :py:class:`Counter`: Instance of counter class 195 | 196 | Get a new instance of the given class and add it to the manager 197 | 198 | If ``position`` is specified, the counter's position can change dynamically if 199 | additional counters are called without a ``position`` argument. 200 | 201 | """ 202 | 203 | position = kwargs.pop('position', None) 204 | autorefresh = kwargs.pop('autorefresh', False) 205 | replace = kwargs.pop('replace', None) 206 | 207 | # List of counters to refresh due to new position 208 | toRefresh = [] 209 | 210 | # Add default values to kwargs 211 | for key, val in self.defaults.items(): 212 | if key not in kwargs: 213 | kwargs[key] = val 214 | kwargs['manager'] = self 215 | 216 | # Create counter 217 | new = counter_class(*args, **kwargs) 218 | if autorefresh: 219 | self.autorefresh.append(new) 220 | 221 | # Get pinned counters 222 | # pylint: disable=protected-access 223 | pinned = {pos: ctr for ctr, pos in self.counters.items() if ctr._pinned} 224 | 225 | # Manage replacement 226 | if replace is not None: 227 | if replace not in self.counters: 228 | raise ValueError('Counter to replace is not currently managed: %r' % replace) 229 | 230 | # Remove old counter 231 | position = self.counters[replace] 232 | replace.leave = False 233 | replace.close() 234 | 235 | # Replace old counter with new counter 236 | self.counters[new] = position 237 | if replace._pinned: 238 | new._pinned = True 239 | pinned[position] = new 240 | 241 | # Position specified 242 | elif position is not None: 243 | if position < 1: 244 | raise ValueError('Counter position %d is less than 1.' % position) 245 | if position in pinned: 246 | raise ValueError('Counter position %d is already occupied.' % position) 247 | if position > self.height: 248 | raise ValueError('Counter position %d is greater than terminal height.' % position) 249 | new._pinned = True # pylint: disable=protected-access 250 | self.counters[new] = position 251 | pinned[position] = new 252 | 253 | # Dynamic placement 254 | else: 255 | # Set for now, but will change 256 | self.counters[new] = 0 257 | 258 | # Refresh status bars only, counters may have subcounters 259 | if counter_class is self.status_bar_class: 260 | toRefresh.append(new) 261 | 262 | # Iterate through all counters in reverse order 263 | pos = 1 264 | for ctr in reversed(self.counters): 265 | 266 | if ctr in pinned.values(): 267 | continue 268 | 269 | old_pos = self.counters[ctr] 270 | 271 | while pos in pinned: 272 | pos += 1 273 | 274 | if pos != old_pos: 275 | 276 | # Don't refresh new counter, already accounted for 277 | if ctr is not new: 278 | ctr.clear(flush=False) 279 | toRefresh.append(ctr) 280 | 281 | self.counters[ctr] = pos 282 | 283 | pos += 1 284 | 285 | self._set_scroll_area() 286 | for ctr in reversed(toRefresh): 287 | ctr.refresh(flush=False) 288 | self._flush_streams() 289 | 290 | return new 291 | 292 | def _set_scroll_area(self, force=False): 293 | """ 294 | In the base class this is a no-op 295 | It is called when adding counters for managers which manage scrollable regions 296 | """ 297 | 298 | def remove(self, counter): 299 | """ 300 | Args: 301 | counter(:py:class:`Counter`): Progress bar or status bar instance 302 | 303 | Remove bar instance from manager 304 | 305 | Does not error if instance is not managed by this manager 306 | 307 | Generally this method should not be called directly, 308 | instead used :py:meth:`Counter.close`. 309 | """ 310 | 311 | # If leave is set, there is nothing to do 312 | if counter.leave: 313 | return 314 | 315 | # Remove counter from manager 316 | try: 317 | del self.counters[counter] 318 | self.autorefresh.remove(counter) 319 | except (KeyError, ValueError): 320 | pass 321 | 322 | # Get pinned counters # pylint: disable=protected-access 323 | pinned = {pos: ctr for ctr, pos in self.counters.items() if ctr._pinned} 324 | 325 | # Iterate through all counters in reverse order to determine new positions 326 | pos = 1 327 | to_refresh = [] 328 | for ctr in reversed(self.counters): 329 | 330 | if ctr in pinned.values(): 331 | continue 332 | 333 | old_pos = self.counters[ctr] 334 | 335 | while pos in pinned: 336 | pos += 1 337 | 338 | if pos != old_pos: 339 | ctr.clear(flush=False) 340 | to_refresh.append(ctr) 341 | 342 | self.counters[ctr] = pos 343 | 344 | pos += 1 345 | 346 | # Refresh counters 347 | self._set_scroll_area() 348 | for ctr in reversed(to_refresh): 349 | ctr.refresh(flush=False) 350 | self._flush_streams() 351 | 352 | def _autorefresh(self, exclude): 353 | """ 354 | Args: 355 | exclude(list): Iterable of bars to ignore when auto-refreshing 356 | 357 | Refresh any bars specified for auto-refresh 358 | """ 359 | 360 | self.refresh_lock = True 361 | current_time = time.time() 362 | 363 | for counter in self.autorefresh: 364 | 365 | if counter in exclude or counter.min_delta > current_time - counter.last_update: 366 | continue 367 | 368 | counter.refresh() 369 | 370 | self.refresh_lock = False 371 | -------------------------------------------------------------------------------- /enlighten/_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2025 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | **Enlighten manager submodule** 10 | 11 | Provides Manager class 12 | """ 13 | 14 | import atexit 15 | import multiprocessing 16 | import signal 17 | import sys 18 | import threading 19 | 20 | from blessed import Terminal 21 | 22 | from enlighten._basemanager import BaseManager 23 | 24 | 25 | RESIZE_SUPPORTED = hasattr(signal, 'SIGWINCH') 26 | 27 | 28 | class Manager(BaseManager): 29 | """ 30 | 31 | Args: 32 | stream(:py:term:`file object`): Output stream. If :py:data:`None`, 33 | defaults to :py:data:`sys.__stdout__` 34 | status_bar_class(:py:term:`class`): Status bar class (Default: :py:class:`StatusBar`) 35 | counter_class(:py:term:`class`): Progress bar class (Default: :py:class:`Counter`) 36 | set_scroll(bool): Enable scroll area redefinition (Default: :py:data:`True`) 37 | companion_stream(:py:term:`file object`): See :ref:`companion_stream ` 38 | below. (Default: :py:data:`None`) 39 | enabled(bool): Status (Default: True) 40 | no_resize(bool): Disable resizing support 41 | threaded(bool): When True resize handling is deferred until next write (Default: False 42 | unless multiple threads or multiple processes are detected) 43 | width(int): Static output width. If unset, terminal width is determined dynamically 44 | kwargs(Dict[str, Any]): Any additional :py:term:`keyword arguments` 45 | will be used as default values when :py:meth:`counter` is called. 46 | 47 | Manager class for outputting progress bars to streams attached to TTYs 48 | 49 | Progress bars are displayed at the bottom of the screen 50 | with standard output displayed above. 51 | 52 | .. _companion_stream: 53 | 54 | **companion_stream** 55 | 56 | A companion stream is a :py:term:`file object` that shares a TTY with 57 | the primary output stream. The cursor position in the companion stream will be 58 | moved in coordination with the primary stream. 59 | 60 | If the value is :py:data:`None`, the companion stream will be dynamically determined. 61 | Unless explicitly specified, a stream which is not attached to a TTY (the case when 62 | redirected to a file), will not be used as a companion stream. 63 | 64 | """ 65 | 66 | # pylint: disable=too-many-instance-attributes 67 | def __init__(self, **kwargs): 68 | 69 | super(Manager, self).__init__(**kwargs) 70 | 71 | # Set up companion stream 72 | if self.companion_stream is None: 73 | 74 | # Account for calls with original output 75 | if self.stream is sys.__stdout__ and sys.__stderr__.isatty(): 76 | self.companion_stream = sys.__stderr__ 77 | elif self.stream is sys.__stderr__ and sys.__stdout__.isatty(): 78 | self.companion_stream = sys.__stdout__ 79 | 80 | # Account for output redirection 81 | elif self.stream is sys.stdout and sys.stderr.isatty(): 82 | self.companion_stream = sys.stderr 83 | elif self.stream is sys.stderr and sys.stdout.isatty(): 84 | self.companion_stream = sys.stdout 85 | 86 | # Set up companion terminal 87 | if self.companion_stream: 88 | self.companion_term = Terminal(stream=self.companion_stream) 89 | else: 90 | self.companion_term = None 91 | 92 | if not self.no_resize and RESIZE_SUPPORTED: 93 | self.sigwinch_orig = signal.getsignal(signal.SIGWINCH) 94 | 95 | def __repr__(self): 96 | return '%s(stream=%r)' % (self.__class__.__name__, self.stream) 97 | 98 | def _stage_resize(self, *args, **kwarg): # pylint: disable=unused-argument 99 | """ 100 | Called when a window resize signal is detected 101 | """ 102 | 103 | # Set semaphore to trigger resize on next write 104 | self._resize = True 105 | 106 | if self.threaded: 107 | # Reset update time to avoid any delay in resize 108 | for counter in self.counters: 109 | counter.last_update = 0 110 | 111 | else: 112 | # If not threaded, handle resize now 113 | self._resize_handler() 114 | 115 | def _resize_handler(self): 116 | """ 117 | Called when a window resize has been detected 118 | 119 | Resets the scroll window 120 | """ 121 | 122 | # Make sure only one resize handler is running 123 | if self.resize_lock: 124 | return 125 | 126 | self.resize_lock = True 127 | buffer = self._buffer 128 | term = self.term 129 | 130 | oldHeight = self.height 131 | newHeight = self.height = term.height 132 | 133 | if newHeight < oldHeight: 134 | buffer.append(term.move(max(0, newHeight - self.scroll_offset), 0)) 135 | if self.counters: 136 | buffer.append(u'\n' * (2 * max(self.counters.values()))) 137 | else: 138 | buffer.append(u'\n\n') 139 | elif newHeight > oldHeight: 140 | buffer.append(term.move(newHeight, 0)) 141 | buffer.append(u'\n' * (self.scroll_offset - 1)) 142 | 143 | buffer.append(term.move(max(0, newHeight - self.scroll_offset), 0)) 144 | buffer.append(term.clear_eos) 145 | 146 | self.width = self._width or term.width 147 | self._set_scroll_area(force=True) 148 | 149 | for counter in self.counters: 150 | counter.refresh(flush=False) 151 | self._flush_streams() 152 | 153 | self.resize_lock = False 154 | 155 | def _set_scroll_area(self, force=False): 156 | """ 157 | Args: 158 | force(bool): Set the scroll area even if no change in height and position is detected 159 | 160 | Sets the scroll window based on the counter positions 161 | """ 162 | 163 | # Save scroll offset for resizing 164 | oldOffset = self.scroll_offset 165 | newOffset = max(self.counters.values()) + 1 if self.counters else 1 166 | if newOffset > oldOffset: 167 | self.scroll_offset = newOffset 168 | use_new = True 169 | else: 170 | use_new = False 171 | 172 | if not self.enabled: 173 | return 174 | 175 | # Set exit handling only once 176 | if not self.process_exit: 177 | atexit.register(self._at_exit) 178 | if not self.no_resize and RESIZE_SUPPORTED: 179 | if self.threaded is None: 180 | self.threaded = ( 181 | threading.active_count() > 1 # Multiple threads 182 | or multiprocessing.active_children() # Main process with children 183 | or multiprocessing.current_process().name != 'MainProcess' # Child process 184 | ) 185 | signal.signal(signal.SIGWINCH, self._stage_resize) 186 | self.process_exit = True 187 | 188 | if self.set_scroll: 189 | 190 | buffer = self._buffer 191 | term = self.term 192 | scrollPosition = max(0, self.height - self.scroll_offset) 193 | 194 | if force or use_new: 195 | 196 | # Add line feeds so we don't overwrite existing output 197 | if use_new: 198 | buffer.append(term.move(max(0, self.height - oldOffset), 0)) 199 | buffer.append(u'\n' * (newOffset - oldOffset)) 200 | 201 | # Reset scroll area 202 | buffer.append(term.hide_cursor) 203 | buffer.append(term.csr(0, scrollPosition)) 204 | 205 | # Get current horizontal position. If we can't get it, it's -1, so use 0 instead 206 | if self.resize_lock: 207 | column = 0 208 | else: 209 | self.stream.flush() 210 | column = max(self.term.get_location(timeout=1)[1], 0) 211 | 212 | # Always reset position 213 | buffer.append(term.move(scrollPosition, column)) 214 | if self.companion_term is not None: 215 | self._companion_buffer.append(term.move(scrollPosition, column)) 216 | 217 | def _flush_streams(self): 218 | """ 219 | Flush stream and companion buffers 220 | """ 221 | 222 | buffer = self._buffer 223 | companion_buffer = self._companion_buffer 224 | 225 | if buffer: 226 | self.stream.write(u''.join(buffer)) 227 | 228 | self.stream.flush() 229 | 230 | if self.companion_stream is not None: 231 | if companion_buffer: 232 | self.companion_stream.write(u''.join(companion_buffer)) 233 | self.companion_stream.flush() 234 | 235 | del buffer[:] # Python 2.7 does not support list.clear() 236 | del companion_buffer[:] 237 | 238 | def _at_exit(self): 239 | """ 240 | Resets terminal to normal configuration 241 | """ 242 | 243 | if not self.process_exit: 244 | return 245 | 246 | try: 247 | term = self.term 248 | buffer = self._buffer 249 | 250 | if self.set_scroll: 251 | buffer.append(self.term.normal_cursor) 252 | buffer.append(self.term.csr(0, self.height - 1)) 253 | 254 | buffer.append(term.move(term.height, 0)) 255 | buffer.append(term.cud1 or u'\n') 256 | 257 | self._flush_streams() 258 | 259 | except ValueError: # Possibly closed file handles 260 | pass 261 | 262 | def stop(self): 263 | # See parent class for docstring 264 | 265 | if not self.enabled: 266 | return 267 | 268 | buffer = self._buffer 269 | term = self.term 270 | height = term.height 271 | positions = self.counters.values() 272 | 273 | if not self.no_resize and RESIZE_SUPPORTED: 274 | signal.signal(signal.SIGWINCH, self.sigwinch_orig) 275 | 276 | try: 277 | for num in range(self.scroll_offset - 1, 0, -1): 278 | if num not in positions: 279 | buffer.append(term.move(height - num, 0)) 280 | buffer.append(term.clear_eol) 281 | 282 | finally: 283 | 284 | # Reset terminal 285 | if self.set_scroll: 286 | buffer.append(term.normal_cursor) 287 | buffer.append(term.csr(0, self.height - 1)) 288 | if self.companion_term: 289 | self._companion_buffer.extend((term.normal_cursor, 290 | term.csr(0, self.height - 1), 291 | term.move(height, 0))) 292 | 293 | # Re-home cursor 294 | buffer.append(term.move(height, 0)) 295 | 296 | self.process_exit = False 297 | self.enabled = False 298 | for counter in self.counters: 299 | counter.enabled = False 300 | 301 | # Feed terminal if lowest position isn't cleared 302 | if 1 in positions: 303 | buffer.append(term.cud1 or '\n') 304 | 305 | self._flush_streams() 306 | 307 | def write(self, output='', flush=True, counter=None, **kwargs): 308 | # See parent class for docstring 309 | 310 | if not self.enabled: 311 | return 312 | 313 | # If resize signal was caught, handle resize 314 | if self._resize and not self.resize_lock: 315 | try: 316 | self._resize_handler() 317 | finally: 318 | self._resize = False 319 | 320 | return 321 | 322 | position = self.counters[counter] if counter else 0 323 | term = self.term 324 | 325 | # If output is callable, call it with supplied arguments 326 | if callable(output): 327 | output = output(**kwargs) 328 | 329 | try: 330 | self._buffer.extend((term.move(self.height - position, 0), 331 | u'\r', 332 | term.clear_eol, 333 | output)) 334 | 335 | finally: 336 | # Reset position and scrolling 337 | if not self.refresh_lock: 338 | if self.autorefresh: 339 | self._autorefresh(exclude=(counter,)) 340 | self._set_scroll_area() 341 | if flush: 342 | self._flush_streams() 343 | -------------------------------------------------------------------------------- /enlighten/_notebook_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2023 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | **Enlighten manager submodule** 10 | 11 | Provides Manager class 12 | """ 13 | 14 | from IPython.display import DisplayHandle, HTML 15 | 16 | from enlighten._basemanager import BaseManager 17 | from enlighten._util import HTMLConverter 18 | 19 | 20 | class NotebookManager(BaseManager): 21 | """ 22 | Args: 23 | counter_class(:py:term:`class`): Progress bar class (Default: :py:class:`Counter`) 24 | status_bar_class(:py:term:`class`): Status bar class (Default: :py:class:`StatusBar`) 25 | enabled(bool): Status (Default: True) 26 | width(int): Static output width (Default: 100) 27 | kwargs(Dict[str, Any]): Any additional :py:term:`keyword arguments` 28 | will be used as default values when :py:meth:`counter` is called. 29 | 30 | Manager class for outputting progress bars to Jupyter notebooks 31 | 32 | The following keyword arguments are set if provided, but ignored: 33 | 34 | * *stream* 35 | * *set_scroll* 36 | * *companion_stream* 37 | * *no_resize* 38 | * *threaded* 39 | 40 | """ 41 | 42 | def __init__(self, **kwargs): 43 | 44 | # Force terminal to xterm-256color because it should have broad support 45 | kwargs['term'] = 'xterm-256color' 46 | 47 | super(NotebookManager, self).__init__(**kwargs) 48 | 49 | # Force 24-bit color 50 | self.term.number_of_colors = 1 << 24 51 | 52 | self._converter = HTMLConverter(self.term) 53 | self._output = [] 54 | self._display = DisplayHandle() 55 | self._html = HTML('') 56 | self._primed = False 57 | 58 | # Default width to 100 unless specified 59 | self.width = self._width or 100 60 | 61 | def __repr__(self): 62 | return '%s()' % self.__class__.__name__ 63 | 64 | def _flush_streams(self): 65 | """ 66 | Display buffered output 67 | """ 68 | 69 | if not self.enabled: 70 | return 71 | 72 | self._html.data = '%s
\n%s\n
\n' % ( 73 | self._converter.style, '\n'.join(reversed(self._output))) 74 | 75 | if self._primed: 76 | self._display.update(self._html) 77 | else: 78 | self._primed = True 79 | self._display.display(self._html) 80 | 81 | def stop(self): 82 | # See parent class for docstring 83 | 84 | if not self.enabled: 85 | return 86 | 87 | positions = self.counters.values() 88 | 89 | if positions: 90 | for num in range(max(positions), 0, -1): 91 | if num not in positions: 92 | self._output[num - 1] = '
' 93 | 94 | for counter in self.counters: 95 | counter.enabled = False 96 | 97 | self._flush_streams() 98 | 99 | def write(self, output='', flush=True, counter=None, **kwargs): 100 | # See parent class for docstring 101 | 102 | if not self.enabled: 103 | return 104 | 105 | position = self.counters[counter] if counter else 1 106 | 107 | # If output is callable, call it with supplied arguments 108 | if callable(output): 109 | output = output(**kwargs) 110 | 111 | # If there is space between this bar and the last, fill with blank lines 112 | for _ in range(position - len(self._output)): 113 | self._output.append('
') 114 | 115 | # Set output 116 | self._output[position - 1] = ( 117 | '
\n %s\n
' % self._converter.to_html(output) 118 | ) 119 | 120 | if flush: 121 | self._flush_streams() 122 | -------------------------------------------------------------------------------- /enlighten/_statusbar.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2025 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | **Enlighten status bar submodule** 10 | 11 | Provides StatusBar class 12 | """ 13 | 14 | import time 15 | 16 | from enlighten._basecounter import PrintableCounter 17 | from enlighten._util import (EnlightenWarning, FORMAT_MAP_SUPPORT, format_time, 18 | Justify, raise_from_none, warn_best_level) 19 | 20 | 21 | STATUS_FIELDS = {'elapsed', 'fill'} 22 | 23 | 24 | class StatusBar(PrintableCounter): 25 | """ 26 | Args: 27 | enabled(bool): Status (Default: :py:data:`True`) 28 | color(str): Color as a string or RGB tuple see :ref:`Status Color ` 29 | fields(dict): Additional fields used for :ref:`formatting ` 30 | fill(str): Fill character used in formatting and justifying text (Default: ' ') 31 | justify(str): 32 | One of :py:attr:`Justify.CENTER`, :py:attr:`Justify.LEFT`, :py:attr:`Justify.RIGHT` 33 | leave(True): Leave status bar after closing (Default: :py:data:`True`) 34 | min_delta(float): Minimum time, in seconds, between refreshes (Default: 0.1) 35 | status_format(str): Status bar format, see :ref:`Format ` 36 | 37 | Status bar class 38 | 39 | A :py:class:`StatusBar` instance should be created with the :py:meth:`Manager.status_bar` 40 | method. 41 | 42 | .. _status_color: 43 | 44 | **Status Color** 45 | 46 | Color works similarly to color on :py:class:`Counter`, except it affects the entire status bar. 47 | See :ref:`Series Color ` for more information. 48 | 49 | .. _status_format: 50 | 51 | **Format** 52 | 53 | There are two ways to populate the status bar, direct and formatted. Direct takes 54 | precedence over formatted. 55 | 56 | .. _status_format_direct: 57 | 58 | **Direct Status** 59 | 60 | Direct status is used when arguments are passed to :py:meth:`Manager.status_bar` or 61 | :py:meth:`StatusBar.update`. Any arguments are coerced to strings and joined with a space. 62 | For example: 63 | 64 | .. code-block:: python 65 | 66 | 67 | status_bar.update('Hello', 'World!') 68 | # Example output: Hello World! 69 | 70 | status_bar.update('Hello World!') 71 | # Example output: Hello World! 72 | 73 | count = [1, 2, 3, 4] 74 | status_bar.update(*count) 75 | # Example output: 1 2 3 4 76 | 77 | .. _status_format_formatted: 78 | 79 | **Formatted Status** 80 | 81 | Formatted status uses the format specified in the ``status_format`` parameter to populate 82 | the status bar. 83 | 84 | .. code-block:: python 85 | 86 | 'Current Stage: {stage}' 87 | 88 | # Example output 89 | 'Current Stage: Testing' 90 | 91 | Available fields: 92 | 93 | - elapsed(:py:class:`str`) - Time elapsed since instance was created 94 | - fill(:py:class:`str`) - Filled with :py:attr:`fill` until line is width of terminal. 95 | May be used multiple times. Minimum width is 3. 96 | 97 | .. note:: 98 | 99 | The status bar is only updated when :py:meth:`StatusBar.update` or 100 | :py:meth:`StatusBar.refresh` is called, so fields like ``elapsed`` 101 | will need additional calls to appear dynamic. 102 | 103 | User-defined fields: 104 | 105 | Users can define fields in two ways, the ``fields`` parameter and by passing keyword 106 | arguments to :py:meth:`Manager.status_bar` or :py:meth:`StatusBar.update` 107 | 108 | The ``fields`` parameter can be used to pass a dictionary of additional 109 | user-defined fields. The dictionary values can be updated after initialization to allow 110 | for dynamic fields. Any fields that share names with available fields are ignored. 111 | 112 | If fields are passed as keyword arguments to :py:meth:`Manager.status_bar` or 113 | :py:meth:`StatusBar.update`, they take precedent over the ``fields`` parameter. 114 | 115 | 116 | **Instance Attributes** 117 | 118 | .. py:attribute:: elapsed 119 | 120 | :py:class:`float` - Time since start 121 | 122 | .. py:attribute:: enabled 123 | 124 | :py:class:`bool` - Current status 125 | 126 | .. py:attribute:: manager 127 | 128 | :py:class:`Manager` - Manager Instance 129 | 130 | .. py:attribute:: position 131 | 132 | :py:class:`int` - Current position 133 | 134 | """ 135 | 136 | __slots__ = ('fields', '_justify', 'status_format', '_static', '_fields') 137 | 138 | def __init__(self, *args, **kwargs): 139 | 140 | super(StatusBar, self).__init__(keywords=kwargs) 141 | 142 | self.fields = kwargs.pop('fields', {}) 143 | self._justify = None 144 | self.justify = kwargs.pop('justify', Justify.LEFT) 145 | self.status_format = kwargs.pop('status_format', None) 146 | self._fields = kwargs 147 | self._static = ' '.join(str(arg) for arg in args) if args else None 148 | 149 | @property 150 | def justify(self): 151 | """ 152 | Maps to justify method determined by ``justify`` parameter 153 | """ 154 | return self._justify 155 | 156 | @justify.setter 157 | def justify(self, value): 158 | 159 | if value in (Justify.LEFT, Justify.CENTER, Justify.RIGHT): 160 | self._justify = getattr(self.manager.term, value) 161 | 162 | else: 163 | raise ValueError("justify must be one of Justify.LEFT, Justify.CENTER, ", 164 | "Justify.RIGHT, not: '%r'" % value) 165 | 166 | def format(self, width=None, elapsed=None): 167 | """ 168 | Args: 169 | width (int): Width in columns to make progress bar 170 | elapsed(float): Time since started. Automatically determined if :py:data:`None` 171 | 172 | Returns: 173 | :py:class:`str`: Formatted status bar 174 | 175 | Format status bar 176 | """ 177 | 178 | width = width or self.manager.width 179 | justify = self.justify 180 | 181 | # If static message was given, just return it 182 | if self._static is not None: 183 | rtn = self._static 184 | 185 | # If there is no format, return empty 186 | elif self.status_format is None: 187 | rtn = '' 188 | 189 | # Generate from format 190 | else: 191 | fields = self.fields.copy() 192 | fields.update(self._fields) 193 | 194 | # Warn on reserved fields 195 | reserved_fields = set(fields) & STATUS_FIELDS 196 | if reserved_fields: 197 | warn_best_level('Ignoring reserved fields specified as user-defined fields: %s' % 198 | ', '.join(reserved_fields), 199 | EnlightenWarning) 200 | 201 | elapsed = elapsed if elapsed is not None else self.elapsed 202 | fields['elapsed'] = format_time(elapsed) 203 | fields['fill'] = self._placeholder_ 204 | 205 | # Format 206 | try: 207 | if FORMAT_MAP_SUPPORT: 208 | rtn = self.status_format.format_map(fields) 209 | else: # pragma: no cover 210 | rtn = self.status_format.format(**fields) 211 | except KeyError as e: 212 | raise_from_none(ValueError('%r specified in format, but not provided' % e.args[0])) 213 | 214 | rtn = self._fill_text(rtn, width) 215 | 216 | return self._colorize(justify(rtn, width=width, fillchar=self.fill)) 217 | 218 | def update(self, *objects, **fields): # pylint: disable=arguments-differ 219 | """ 220 | Args: 221 | objects(list): Values for :ref:`Direct Status ` 222 | force(bool): Force refresh even if ``min_delta`` has not been reached 223 | fields(dict): Fields for for :ref:`Formatted Status ` 224 | 225 | Update status and redraw 226 | 227 | Status bar is only redrawn if ``min_delta`` seconds past since the last update 228 | """ 229 | 230 | force = fields.pop('force', False) 231 | 232 | self._static = ' '.join(str(obj) for obj in objects) if objects else None 233 | self._fields.update(fields) 234 | 235 | if self.enabled: 236 | currentTime = time.time() 237 | if force or currentTime - self.last_update >= self.min_delta: 238 | self.refresh(elapsed=currentTime - self.start) 239 | -------------------------------------------------------------------------------- /enlighten/_util.py: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2017 - 2024 Avram Lubkin, All Rights Reserved 4 | 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ 10 | **Enlighten utility submodule** 11 | 12 | Provides utility functions and objects 13 | """ 14 | 15 | from collections import OrderedDict 16 | import inspect 17 | import os 18 | import re 19 | import sys 20 | import warnings 21 | 22 | from blessed.colorspace import RGB_256TABLE, X11_COLORNAMES_TO_RGB 23 | from blessed.sequences import iter_parse 24 | 25 | 26 | try: 27 | from functools import lru_cache 28 | except ImportError: # pragma: no cover(Python 2) 29 | # lru_cache was added in Python 3.2 30 | from backports.functools_lru_cache import lru_cache 31 | 32 | 33 | try: 34 | BASESTRING = basestring 35 | except NameError: 36 | BASESTRING = str 37 | 38 | BASE_DIR = os.path.basename(os.path.dirname(__file__)) 39 | FORMAT_MAP_SUPPORT = sys.version_info[:2] >= (3, 2) 40 | RE_COLOR_RGB = re.compile(r'\x1b\[38;2;(\d+);(\d+);(\d+)m') 41 | RE_ON_COLOR_RGB = re.compile(r'\x1b\[48;2;(\d+);(\d+);(\d+)m') 42 | RE_COLOR_256 = re.compile(r'\x1b\[38;5;(\d+)m') 43 | RE_ON_COLOR_256 = re.compile(r'\x1b\[48;5;(\d+)m') 44 | RE_SET_A = re.compile(r'\x1b\[(\d+)m') 45 | RE_LINK = re.compile(r'\x1b]8;.*;(.*)\x1b\\') 46 | 47 | CGA_COLORS = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white') 48 | HTML_ESCAPE = {'&': '&', '<': '<', '>': '>', '?': '?'} 49 | 50 | 51 | class EnlightenWarning(Warning): 52 | """ 53 | Generic warning class for Enlighten 54 | """ 55 | 56 | 57 | def warn_best_level(message, category): 58 | """ 59 | Helper function to warn at first frame stack outside of library 60 | """ 61 | 62 | level = 5 # Unused default 63 | for level, frame in enumerate(inspect.stack(), 1): # pragma: no cover 64 | if os.path.basename(os.path.dirname(frame[1])) != BASE_DIR: 65 | break 66 | 67 | warnings.warn(message, category=category, stacklevel=level) 68 | 69 | 70 | def format_time(seconds): 71 | """ 72 | Args: 73 | seconds (float): A period of time expressed in seconds 74 | 75 | Returns: 76 | :py:class:`str`: Time formatted in seconds, minutes, hours, and days 77 | 78 | 79 | Format time as displayed in the ``elapsed`` and ``eta`` fields 80 | 81 | The format will take one of the following forms depending on the length of time: 82 | 83 | - 01:56 84 | - 12h 01:56 85 | - 1d 0h 01:56 86 | """ 87 | 88 | # Always do minutes and seconds in mm:ss format 89 | minutes, seconds = divmod(round(seconds), 60) 90 | hours, minutes = divmod(minutes, 60) 91 | rtn = u'%02d:%02d' % (minutes, seconds) 92 | 93 | # Add hours if there are any 94 | if hours: 95 | days, hours = divmod(hours, 24) 96 | rtn = u'%dh %s' % (hours, rtn) 97 | 98 | # Add days if there are any 99 | if days: 100 | rtn = u'%dd %s' % (days, rtn) 101 | 102 | return rtn 103 | 104 | 105 | def raise_from_none(exc): # pragma: no cover 106 | """ 107 | Convenience function to raise from None in a Python 2/3 compatible manner 108 | """ 109 | raise exc 110 | 111 | 112 | if sys.version_info[0] >= 3: # pragma: no branch 113 | exec('def raise_from_none(exc):\n raise exc from None') # pylint: disable=exec-used 114 | 115 | 116 | class Justify(object): 117 | """ 118 | Enumerated type for justification options 119 | 120 | .. py:attribute:: CENTER 121 | 122 | Justify center 123 | 124 | .. py:attribute:: LEFT 125 | 126 | Justify left 127 | 128 | .. py:attribute:: RIGHT 129 | 130 | Justify right 131 | 132 | """ 133 | 134 | CENTER = 'center' 135 | LEFT = 'ljust' 136 | RIGHT = 'rjust' 137 | 138 | 139 | class Lookahead: 140 | """ 141 | Args: 142 | iterator(:py:term:`iterator`): Instance of an iterator 143 | 144 | Wrapper for an iterator supporting look ahead 145 | """ 146 | 147 | def __init__(self, iterator): 148 | self.iterator = iterator 149 | self.buffer = [] 150 | 151 | def __iter__(self): 152 | return self 153 | 154 | def __next__(self): 155 | return self.buffer.pop(0) if self.buffer else next(self.iterator) 156 | 157 | # Python 2 158 | next = __next__ 159 | 160 | def __getitem__(self, key): 161 | 162 | if isinstance(key, int): 163 | first = last = key 164 | elif isinstance(key, slice): 165 | first = key.start or 0 166 | last = max(first, (key.stop or 0) - 1) 167 | else: 168 | raise TypeError('Index or slice notation is required') 169 | 170 | if first < 0: 171 | raise ValueError('Negative indexes are not supported') 172 | 173 | while last >= len(self.buffer): 174 | try: 175 | self.buffer.append(next(self.iterator)) 176 | except StopIteration: 177 | break 178 | 179 | return self.buffer.__getitem__(key) 180 | 181 | 182 | class Span(list): 183 | """ 184 | Container for span classes 185 | 186 | A list is used to preserve order 187 | """ 188 | def __str__(self): 189 | return '' % ' '.join(self) 190 | 191 | def append_unique(self, item): 192 | """ 193 | Append only if value is unique 194 | """ 195 | 196 | if item not in self: 197 | self.append(item) 198 | 199 | 200 | class HTMLConverter(object): 201 | """ 202 | Args: 203 | term(:py:class:`blessed.Terminal`): Blessed terminal instance 204 | 205 | Blessed-based ANSI terminal code to HTML converter 206 | """ 207 | 208 | def __init__(self, term): 209 | 210 | self.term = term 211 | self.caps = self.term.caps 212 | self.normal = [elem[0] for elem in iter_parse(term, term.normal)] 213 | self.normal_rem = len(self.normal) - 1 214 | self._styles = OrderedDict() 215 | self._additional_styles = set() 216 | 217 | @property 218 | def style(self): 219 | """ 220 | Formatted style section for an HTML document 221 | 222 | Styles are cumulative for the life of the instance 223 | """ 224 | 225 | out = '\n' 233 | 234 | return out 235 | 236 | def to_html(self, text): 237 | """ 238 | Args: 239 | text(str): String formatted with ANSI escape codes 240 | 241 | Convert text to HTML 242 | 243 | Formatted text is enclosed in an HTML span and classes are available in HTMLConverter.style 244 | 245 | Supported formatting: 246 | - Blink 247 | - Bold 248 | - Color (8, 16, 256, and RGB) 249 | - Italic 250 | - Links 251 | - Underline 252 | """ 253 | 254 | out = '
'
255 |         open_spans = 0
256 |         to_out = []
257 |         parsed = Lookahead(iter_parse(self.term, text))
258 |         normal = self.normal
259 | 
260 |         # Iterate through parsed text
261 |         for value, cap in parsed:
262 | 
263 |             # If there's no capability, it's just regular text
264 |             if cap is None:
265 | 
266 |                 # Add in any previous spans
267 |                 out += ''.join(str(item) for item in to_out)
268 |                 del to_out[:]  # Python 2 compatible .clear()
269 | 
270 |                 # Append character and continue
271 |                 out += HTML_ESCAPE.get(value, value)
272 |                 continue
273 | 
274 |             # Parse links
275 |             if cap is self.caps['link']:
276 |                 url = RE_LINK.match(value).group(1).strip()
277 |                 out += '' % url if url else ''
278 |                 continue
279 | 
280 |             last_added = to_out[-1] if to_out else None
281 | 
282 |             # Look for normal to close span
283 |             if value == normal[0] and normal[1:] == [val[0] for val in parsed[: self.normal_rem]]:
284 | 
285 |                 # Clear rest of normal
286 |                 for _ in range(self.normal_rem):
287 |                     next(parsed)
288 | 
289 |                 # Ignore empty spans
290 |                 if isinstance(last_added, Span):
291 |                     to_out.pop()
292 |                     open_spans -= 1
293 | 
294 |                 # Only add if there are open spans
295 |                 elif open_spans:
296 |                     to_out.append('')
297 |                     open_spans -= 1
298 | 
299 |                 continue  # pragma: no cover  # To be fixed in PEP 626 (3.10)
300 | 
301 |             # Parse styles
302 |             key, value = self._parse_style(value, cap)
303 | 
304 |             # If not parsed, ignore
305 |             if not key:
306 |                 continue
307 | 
308 |             # Update style sheet
309 |             self._styles[key] = value
310 | 
311 |             # Update span classes
312 |             if isinstance(last_added, Span):
313 |                 last_added.append_unique(key)
314 |             else:
315 |                 to_out.append(Span([key]))
316 |                 open_spans += 1
317 | 
318 |         # Process any remaining caps
319 |         out += ''.join(str(item) for item in to_out)
320 | 
321 |         # Close any spans that didn't get closed
322 |         out += '' * open_spans
323 | 
324 |         out += '
' 325 | 326 | return out 327 | 328 | set_a_codes = { 329 | 1: ('enlighten-bold', {'font-weight': 'bold'}), 330 | 3: ('enlighten-italic', {'font-style': 'italic'}), 331 | 5: ('enlighten-blink', 332 | {'animation': 'enlighten-blink-animation 1s steps(5, start) infinite'}), 333 | 4: ('enlighten-underline', {'text-decoration': 'underline'}), 334 | } 335 | 336 | @property 337 | @lru_cache() 338 | def rgb_to_colors(self): 339 | """ 340 | Dictionary for translating known RGB values into X11 names 341 | """ 342 | 343 | rtn = {} 344 | for key, val in sorted(X11_COLORNAMES_TO_RGB.items()): 345 | val = '#%02x%02x%02x' % val 346 | if val not in rtn: 347 | rtn[val] = key 348 | 349 | return rtn 350 | 351 | def _color256_lookup(self, idx): 352 | """ 353 | Look up RGB values and attempt to get names in the 256 color space 354 | """ 355 | 356 | rgb = str(RGB_256TABLE[idx]) 357 | 358 | # Some terminals use 256 color syntax for basic colors 359 | if 0 <= idx <= 7: # pragma: no cover(Non-standard Terminal) 360 | name = CGA_COLORS[idx] 361 | elif 8 <= idx <= 15: # pragma: no cover(Non-standard Terminal) 362 | name = 'bright-%s' % CGA_COLORS[idx - 8] 363 | else: 364 | name = self.rgb_to_colors.get((rgb[1:3], rgb[3:5], rgb[5:7]), rgb[1:]) 365 | return name, rgb 366 | 367 | @lru_cache(maxsize=256) 368 | def _parse_style(self, value, cap): # pylint: disable=too-many-return-statements 369 | r""" 370 | Args: 371 | value (str): VT100 terminal code 372 | cap(term(:py:class:`~blessed.sequences.Termcap`): Blessed terminal capability 373 | 374 | Parse text attributes of the form '\x1b\[\d+m' into CSS styles 375 | """ 376 | 377 | caps = self.caps 378 | 379 | # Parse RGB color foreground 380 | if cap is caps['color_rgb']: 381 | rgb = '#%02x%02x%02x' % tuple(int(num) for num in RE_COLOR_RGB.match(value).groups()) 382 | name = self.rgb_to_colors.get(rgb, rgb[1:]) 383 | return 'enlighten-fg-%s' % name, {'color': rgb} 384 | 385 | # Parse RGB color background 386 | if cap is caps['on_color_rgb']: 387 | rgb = '#%02x%02x%02x' % tuple(int(num) for num in RE_ON_COLOR_RGB.match(value).groups()) 388 | name = self.rgb_to_colors.get(rgb, rgb[1:]) 389 | return 'enlighten-bg-%s' % name, {'background-color': rgb} 390 | 391 | # Weird and inconsistent bug that seems to affect Python <= 3.5 392 | # Matches set_a_attributes3 instead of more specific color 256 patterns 393 | if cap is caps['set_a_attributes3']: # pragma: no cover 394 | if caps['color256'].re_compiled.match(value): 395 | cap = caps['color256'] 396 | elif caps['on_color256'].re_compiled.match(value): 397 | cap = caps['on_color256'] 398 | 399 | # Parse 256 color foreground 400 | if cap is caps['color256']: 401 | name, rgb = self._color256_lookup(int(RE_COLOR_256.match(value).group(1))) 402 | return 'enlighten-fg-%s' % name, {'color': rgb} 403 | 404 | # Parse 256 color background 405 | if cap is caps['on_color256']: 406 | name, rgb = self._color256_lookup(int(RE_ON_COLOR_256.match(value).group(1))) 407 | return 'enlighten-bg-%s' % name, {'background-color': rgb} 408 | 409 | # Parse text attributes 410 | if cap is caps['set_a_attributes1']: 411 | code = int(RE_SET_A.match(value).group(1)) 412 | else: 413 | return None, None 414 | 415 | # Blink needs additional styling 416 | if code == 5: 417 | self._additional_styles.add( 418 | '@keyframes enlighten-blink-animation {\n to {\n visibility: hidden;\n }\n}' 419 | ) 420 | 421 | if code in self.set_a_codes: 422 | return self.set_a_codes[code] 423 | 424 | if 30 <= code <= 37: 425 | idx = code - 30 426 | return 'enlighten-fg-%s' % CGA_COLORS[idx], {'color': str(RGB_256TABLE[idx])} 427 | 428 | if 40 <= code <= 47: 429 | idx = code - 40 430 | return 'enlighten-bg-%s' % CGA_COLORS[idx], {'background-color': str(RGB_256TABLE[idx])} 431 | 432 | if 90 <= code <= 97: 433 | idx = code - 90 434 | return 'enlighten-fg-bright-%s' % CGA_COLORS[idx], {'color': str(RGB_256TABLE[idx + 8])} 435 | 436 | if 100 <= code <= 107: 437 | idx = code - 100 438 | return ( 439 | 'enlighten-bg-bright-%s' % CGA_COLORS[idx], 440 | {'background-color': str(RGB_256TABLE[idx + 8])} 441 | ) 442 | 443 | return None, None 444 | -------------------------------------------------------------------------------- /enlighten/counter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2023 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | **Enlighten counter submodule** 10 | 11 | Provides Counter class 12 | """ 13 | 14 | from enlighten._counter import Counter as _Counter 15 | from enlighten._counter import SubCounter # pylint: disable=unused-import # noqa: F401 16 | from enlighten._statusbar import StatusBar # pylint: disable=unused-import # noqa: F401 17 | from enlighten.manager import get_manager 18 | 19 | 20 | # Counter is defined here to avoid circular dependencies 21 | class Counter(_Counter): # pylint: disable=missing-docstring 22 | # pylint: disable=too-many-instance-attributes 23 | 24 | __doc__ = _Counter.__doc__ 25 | 26 | def __new__(cls, **kwargs): 27 | 28 | manager = kwargs.pop('manager', None) 29 | stream = kwargs.pop('stream', None) 30 | 31 | if manager is None: 32 | manager = get_manager(stream=stream) 33 | 34 | return manager.counter(**kwargs) 35 | -------------------------------------------------------------------------------- /enlighten/manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2022 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | **Enlighten manager submodule** 10 | 11 | Provides Manager classes and utilities 12 | """ 13 | 14 | import sys 15 | 16 | from enlighten._counter import Counter 17 | from enlighten._manager import Manager 18 | 19 | 20 | try: 21 | from IPython import get_ipython 22 | IN_NOTEBOOK = 'IPKernelApp' in get_ipython().config 23 | from enlighten._notebook_manager import NotebookManager # pylint: disable=ungrouped-imports 24 | except (ImportError, AttributeError): 25 | IN_NOTEBOOK = False 26 | 27 | 28 | def get_manager(stream=None, counter_class=Counter, **kwargs): 29 | """ 30 | Args: 31 | stream(:py:term:`file object`): Output stream. If :py:data:`None`, 32 | defaults to :py:data:`sys.__stdout__` 33 | counter_class(:py:term:`class`): Progress bar class (Default: :py:class:`Counter`) 34 | kwargs(Dict[str, Any]): Any additional :py:term:`keyword arguments` 35 | will passed to the manager class. 36 | 37 | Returns: 38 | :py:class:`Manager`: Manager instance 39 | 40 | Convenience function to get a manager instance 41 | 42 | If running inside a notebook, a :py:class:`NotebookManager` 43 | instance is returned. otherwise a standard :py:class:`Manager` instance is returned. 44 | 45 | If a a standard :py:class:`Manager` instance is used and ``stream`` is not attached 46 | to a TTY, the :py:class:`Manager` instance is disabled. 47 | """ 48 | 49 | if IN_NOTEBOOK: 50 | return NotebookManager(stream=stream, counter_class=counter_class, **kwargs) 51 | 52 | stream = sys.__stdout__ if stream is None else stream 53 | isatty = hasattr(stream, 'isatty') and stream.isatty() 54 | kwargs['enabled'] = isatty and kwargs.get('enabled', True) 55 | return Manager(stream=stream, counter_class=counter_class, **kwargs) 56 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rockhopper-Technologies/enlighten/d71eccac66683f50dd864e3303da34c823d13333/examples/__init__.py -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 - 2023 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Simple progress bar example 9 | """ 10 | 11 | import time 12 | 13 | from enlighten import get_manager 14 | 15 | 16 | def process_files(): 17 | """ 18 | Process files with a single progress bar 19 | """ 20 | 21 | with get_manager() as manager: 22 | with manager.counter(total=100, desc='Simple', unit='ticks') as pbar: 23 | for _ in range(100): 24 | time.sleep(0.05) 25 | pbar.update() 26 | 27 | 28 | if __name__ == '__main__': 29 | 30 | process_files() 31 | -------------------------------------------------------------------------------- /examples/context_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Progress bar example with context managers 9 | """ 10 | import random 11 | import time 12 | 13 | import enlighten 14 | 15 | SPLINES = 15 16 | LLAMAS = 20 17 | 18 | 19 | def process_files(): 20 | """ 21 | Use Manager and Counter as context managers 22 | """ 23 | 24 | with enlighten.Manager() as manager: 25 | with manager.counter(total=SPLINES, desc='Reticulating:', unit='splines') as retic: 26 | for _ in range(SPLINES): 27 | time.sleep(random.uniform(0.1, 0.5)) # Random processing time 28 | retic.update() 29 | 30 | with manager.counter(total=LLAMAS, desc='Herding:', unit='llamas') as herd: 31 | for _ in range(SPLINES): 32 | time.sleep(random.uniform(0.1, 0.5)) # Random processing time 33 | herd.update() 34 | 35 | 36 | if __name__ == '__main__': 37 | 38 | process_files() 39 | -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 - 2021 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Demo of Enlighten's features 9 | """ 10 | 11 | import os 12 | import platform 13 | import random 14 | import time 15 | import sys 16 | 17 | import enlighten 18 | 19 | # Hack so imports work regardless of how this gets called 20 | # We do it this way so any enlighten path can be used 21 | sys.path.insert(1, os.path.dirname(__file__)) 22 | 23 | # pylint: disable=wrong-import-order,import-error,wrong-import-position 24 | from multicolored import run_tests, load # noqa: E402 25 | from multiple_logging import process_files, win_time_granularity # noqa: E402 26 | from prefixes import download # noqa: E402 27 | 28 | 29 | def initialize(manager, initials=15): 30 | """ 31 | Simple progress bar example 32 | """ 33 | 34 | # Simulated preparation 35 | pbar = manager.counter(total=initials, desc='Initializing:', unit='initials') 36 | for _ in range(initials): 37 | time.sleep(random.uniform(0.05, 0.25)) # Random processing time 38 | pbar.update() 39 | pbar.close() 40 | 41 | 42 | def main(): 43 | """ 44 | Main function 45 | """ 46 | 47 | with enlighten.get_manager() as manager: 48 | status = manager.status_bar(status_format=u'Enlighten{fill}Stage: {demo}{fill}{elapsed}', 49 | color='bold_underline_bright_white_on_lightslategray', 50 | justify=enlighten.Justify.CENTER, demo='Initializing', 51 | autorefresh=True, min_delta=0.5) 52 | docs = manager.term.link('https://python-enlighten.readthedocs.io/en/stable/examples.html', 53 | 'Read the Docs') 54 | manager.status_bar(' More examples on %s! ' % docs, position=1, fill='-', 55 | justify=enlighten.Justify.CENTER) 56 | 57 | initialize(manager, 15) 58 | status.update(demo='Loading') 59 | load(manager, 40) 60 | status.update(demo='Testing') 61 | run_tests(manager, 20) 62 | status.update(demo='Downloading') 63 | download(manager, 2.0 * 2 ** 20) 64 | status.update(demo='File Processing') 65 | process_files(manager) 66 | 67 | 68 | if __name__ == '__main__': 69 | 70 | if platform.system() == 'Windows': 71 | with win_time_granularity(1): 72 | main() 73 | else: 74 | main() 75 | -------------------------------------------------------------------------------- /examples/floats.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 - 2023 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Progress bar example that uses floats for count and total 9 | """ 10 | 11 | from __future__ import print_function 12 | 13 | import time 14 | 15 | from enlighten import get_manager 16 | 17 | # Use float formatting for count and total in bar_format 18 | BAR_FMT = u'{desc}{desc_pad}{percentage:3.0f}%|{bar}| {count:{len_total}.1f}/{total:.1f} ' + \ 19 | u'[{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]' 20 | 21 | COUNTER_FMT = u'{desc}{desc_pad}{count:.1f} {unit}{unit_pad}' + \ 22 | u'[{elapsed}, {rate:.2f}{unit_pad}{unit}/s]{fill}' 23 | 24 | 25 | def process_files(count=None): 26 | """ 27 | Process files with a single progress bar 28 | """ 29 | 30 | manager = get_manager() 31 | pbar = manager.counter(total=count, desc='Floats', unit='ticks', 32 | bar_format=BAR_FMT, counter_format=COUNTER_FMT) 33 | 34 | for _ in range(100): 35 | time.sleep(0.05) 36 | pbar.update(1.1) 37 | 38 | 39 | if __name__ == '__main__': 40 | 41 | # Progress bar 42 | process_files(110.0) 43 | 44 | print() 45 | 46 | # No total, so we just get a counter 47 | process_files() 48 | -------------------------------------------------------------------------------- /examples/ftp_downloader.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 - 2021 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Example FTP downloader 9 | """ 10 | 11 | import ftplib 12 | import os 13 | 14 | import enlighten 15 | 16 | 17 | SITE = 'test.rebex.net' 18 | USER = 'demo' 19 | PASSWD = 'password' 20 | DIR = 'pub/example' 21 | DEST = '/tmp' 22 | DEBUG = 0 # 0, 1, 2 are valid 23 | MANAGER = enlighten.get_manager() 24 | 25 | 26 | class Writer(object): 27 | """ 28 | Context manager for handling download writes 29 | """ 30 | 31 | def __init__(self, filename, size, directory=None): 32 | self.filename = filename 33 | self.size = size 34 | self.dest = os.path.join(directory, filename) if directory else filename 35 | self.status = self.fileobj = None 36 | 37 | def __enter__(self): 38 | self.status = MANAGER.counter(total=self.size, desc=self.filename, 39 | unit='bytes', leave=False) 40 | self.fileobj = open(self.dest, 'wb') # pylint: disable=consider-using-with 41 | return self 42 | 43 | def __exit__(self, *args): 44 | self.fileobj.close() 45 | self.status.close() 46 | 47 | def write(self, block): 48 | """ 49 | Write to local file and update progress bar 50 | """ 51 | self.fileobj.write(block) 52 | self.status.update(len(block)) 53 | 54 | 55 | def download(): 56 | """ 57 | Download all files from an FTP share 58 | """ 59 | 60 | ftp = ftplib.FTP(SITE) 61 | ftp.set_debuglevel(DEBUG) 62 | ftp.login(USER, PASSWD) 63 | ftp.cwd(DIR) 64 | filelist = ftp.nlst() 65 | filecounter = MANAGER.counter(total=len(filelist), desc='Downloading', 66 | unit='files') 67 | 68 | for filename in filelist: 69 | 70 | with Writer(filename, ftp.size(filename), DEST) as writer: 71 | ftp.retrbinary('RETR %s' % filename, writer.write) 72 | print(filename) 73 | filecounter.update() 74 | 75 | ftp.close() 76 | 77 | 78 | if __name__ == '__main__': 79 | download() 80 | -------------------------------------------------------------------------------- /examples/multicolored.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 - 2022 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Multicolored progress bar example 9 | """ 10 | 11 | import logging 12 | import random 13 | import time 14 | 15 | import enlighten 16 | 17 | logging.basicConfig(level=logging.INFO) 18 | LOGGER = logging.getLogger("enlighten") 19 | 20 | BAR_FMT = u'{desc}{desc_pad}{percentage_2:3.0f}%|{bar}| {count_2:{len_total}d}/{total:d} ' + \ 21 | u'[{elapsed}<{eta_2}, {rate_2:.2f}{unit_pad}{unit}/s]' 22 | 23 | 24 | class Node(object): 25 | """ 26 | Simulated service node 27 | """ 28 | 29 | def __init__(self, iden): 30 | self.iden = iden 31 | self._connected = None 32 | self._loaded = None 33 | 34 | def connect(self): 35 | """ 36 | Connect to node 37 | """ 38 | 39 | self._connected = False 40 | 41 | def load(self): 42 | """ 43 | Load service 44 | """ 45 | 46 | self._loaded = False 47 | 48 | @property 49 | def connected(self): 50 | """ 51 | Connected state 52 | """ 53 | 54 | return self._state('_connected', 3) 55 | 56 | @property 57 | def loaded(self): 58 | """ 59 | Loaded state 60 | """ 61 | 62 | return self._state('_loaded', 5) 63 | 64 | def _state(self, variable, num): 65 | """ 66 | Generic method to randomly determine if state is reached 67 | """ 68 | 69 | value = getattr(self, variable) 70 | 71 | if value is None: 72 | return False 73 | 74 | if value is True: 75 | return True 76 | 77 | if random.randint(1, num) == num: 78 | setattr(self, variable, True) 79 | return True 80 | 81 | return False 82 | 83 | 84 | def run_tests(manager, tests=100): 85 | """ 86 | Simulate a test program 87 | Tests will error (yellow), fail (red), or succeed (green) 88 | """ 89 | 90 | terminal = manager.term 91 | bar_format = u'{desc}{desc_pad}{percentage:3.0f}%|{bar}| ' + \ 92 | u'S:' + terminal.green3(u'{count_0:{len_total}d}') + u' ' + \ 93 | u'F:' + terminal.red2(u'{count_2:{len_total}d}') + u' ' + \ 94 | u'E:' + terminal.yellow2(u'{count_1:{len_total}d}') + u' ' + \ 95 | u'[{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]' 96 | 97 | with manager.counter(total=tests, desc='Testing', unit='tests', color='green3', 98 | bar_format=bar_format) as success: 99 | errors = success.add_subcounter('yellow2') 100 | failures = success.add_subcounter('red2') 101 | 102 | for num in range(tests): 103 | time.sleep(random.uniform(0.1, 0.3)) # Random processing time 104 | result = random.randint(0, 10) 105 | if result == 7: 106 | LOGGER.error("Test %d did not complete", num) 107 | errors.update() 108 | elif result in {5, 6}: 109 | LOGGER.error("Test %d failed", num) 110 | failures.update() 111 | else: 112 | LOGGER.info("Test %d passed", num) 113 | success.update() 114 | 115 | 116 | def load(manager, units=80): 117 | """ 118 | Simulate loading services from a remote node 119 | States are connecting (red), loading (yellow), and loaded (green) 120 | """ 121 | 122 | pb_connecting = manager.counter(total=units, desc='Loading', unit='services', 123 | color='red2', bar_format=BAR_FMT) 124 | pb_loading = pb_connecting.add_subcounter('yellow2') 125 | pb_loaded = pb_connecting.add_subcounter('green3', all_fields=True) 126 | 127 | connecting = [] 128 | loading = [] 129 | loaded = [] 130 | count = 0 131 | 132 | while pb_loaded.count < units: 133 | time.sleep(random.uniform(0.05, 0.15)) # Random processing time 134 | 135 | for idx, node in enumerate(loading): 136 | if node.loaded: 137 | loading.pop(idx) 138 | loaded.append(node) 139 | LOGGER.info('Service %d loaded', node.iden) 140 | pb_loaded.update_from(pb_loading) 141 | 142 | for idx, node in enumerate(connecting): 143 | if node.connected: 144 | connecting.pop(idx) 145 | node.load() 146 | loading.append(node) 147 | LOGGER.info('Service %d connected', node.iden) 148 | pb_loading.update_from(pb_connecting) 149 | 150 | # Connect to up to 5 units at a time 151 | for _ in range(min(units - count, 5 - len(connecting))): 152 | node = Node(count) 153 | node.connect() 154 | connecting.append(node) 155 | LOGGER.info('Connection to service %d', node.iden) 156 | pb_connecting.update() 157 | count += 1 158 | 159 | 160 | def main(): 161 | """ 162 | Main function 163 | """ 164 | 165 | manager = enlighten.get_manager() 166 | run_tests(manager, 100) 167 | load(manager, 80) 168 | 169 | 170 | if __name__ == "__main__": 171 | main() 172 | -------------------------------------------------------------------------------- /examples/multiple_logging.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Multiple progress bars example 9 | """ 10 | 11 | from contextlib import contextmanager 12 | import logging 13 | import platform 14 | import random 15 | import time 16 | 17 | import enlighten 18 | 19 | 20 | logging.basicConfig(level=logging.INFO) 21 | LOGGER = logging.getLogger('enlighten') 22 | 23 | DATACENTERS = 5 24 | SYSTEMS = (5, 10) # Range 25 | FILES = (10, 100) # Range 26 | 27 | 28 | @contextmanager 29 | def win_time_granularity(milliseconds): 30 | """ 31 | time.sleep() on Windows may not have high precision with older versions of Python 32 | This will temporarily change the timing resolution 33 | 34 | 35 | # https://docs.microsoft.com/en-us/windows/desktop/api/timeapi/nf-timeapi-timebeginperiod 36 | """ 37 | 38 | from ctypes import windll # pylint: disable=import-outside-toplevel 39 | try: 40 | windll.winmm.timeBeginPeriod(milliseconds) 41 | yield 42 | finally: 43 | windll.winmm.timeEndPeriod(milliseconds) 44 | 45 | 46 | def process_files(manager): 47 | """ 48 | Process a random number of files on a random number of systems across multiple data centers 49 | """ 50 | 51 | # Get a top level progress bar 52 | enterprise = manager.counter(total=DATACENTERS, desc='Processing:', unit='datacenters') 53 | 54 | # Iterate through data centers 55 | for d_num in range(1, DATACENTERS + 1): 56 | systems = random.randint(*SYSTEMS) # Random number of systems 57 | # Get a child progress bar. leave is False so it can be replaced 58 | datacenter = manager.counter(total=systems, desc=' Datacenter %d:' % d_num, 59 | unit='systems', leave=False) 60 | 61 | # Iterate through systems 62 | for s_num in range(1, systems + 1): 63 | 64 | # Has no total, so will act as counter. Leave is False 65 | system = manager.counter(desc=' System %d:' % s_num, unit='files', leave=False) 66 | files = random.randint(*FILES) # Random file count 67 | 68 | # Iterate through files 69 | for _ in range(files): 70 | system.update() # Update count 71 | time.sleep(random.uniform(0.001, 0.005)) # Random processing time 72 | 73 | system.close() # Close counter so it gets removed 74 | # Log status 75 | LOGGER.info('Updated %d files on System %d in Datacenter %d', files, s_num, d_num) 76 | datacenter.update() # Update count 77 | 78 | datacenter.close() # Close counter so it gets removed 79 | 80 | enterprise.update() # Update count 81 | 82 | enterprise.close() # Close counter, won't be removed but does a refresh 83 | 84 | 85 | def main(): 86 | """ 87 | Main function 88 | """ 89 | 90 | with enlighten.get_manager() as manager: 91 | process_files(manager) 92 | 93 | 94 | if __name__ == '__main__': 95 | 96 | if platform.system() == 'Windows': 97 | with win_time_granularity(1): 98 | main() 99 | else: 100 | main() 101 | -------------------------------------------------------------------------------- /examples/multiprocessing_queues.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 - 2023 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Example of using Enlighten with multiprocessing 9 | 10 | This example uses queues for inter-process communication (IPC) 11 | """ 12 | 13 | from multiprocessing import Process, Queue 14 | import random 15 | import time 16 | 17 | import enlighten 18 | 19 | 20 | WORKERS = 4 21 | SYSTEMS = (10, 20) 22 | FILES = (100, 200) 23 | FILE_TIME = (0.01, 0.05) 24 | ERROR_VALUES = (4, ) # 1 - 10, each number is ~10% chance of error 25 | 26 | 27 | def process_files(queue, count): 28 | """ 29 | Simple child processor 30 | 31 | Sleeps for a random interval and pushes the current count onto the queue 32 | """ 33 | 34 | for num in range(1, count + 1): 35 | time.sleep(random.uniform(*FILE_TIME)) # Random processing time 36 | queue.put(num) 37 | 38 | if random.randint(1, 10) in ERROR_VALUES: 39 | raise RuntimeError("Simulated Error: I don't think we're in Kansas anymore") 40 | 41 | 42 | def multiprocess_systems(manager, systems): 43 | """ 44 | Process a random number of virtual files in subprocesses for the given number of systems 45 | """ 46 | 47 | started = 0 48 | active = {} 49 | bar_format = u'{desc}{desc_pad}{percentage:3.0f}%|{bar}| ' + \ 50 | u'S:' + manager.term.yellow2(u'{count_0:{len_total}d}') + u' ' + \ 51 | u'F:' + manager.term.green3(u'{count_1:{len_total}d}') + u' ' + \ 52 | u'E:' + manager.term.red2(u'{count_2:{len_total}d}') + u' ' + \ 53 | u'[{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]' 54 | 55 | pb_started = manager.counter( 56 | total=systems, desc='Systems:', unit='systems', color='yellow2', bar_format=bar_format, 57 | ) 58 | pb_finished = pb_started.add_subcounter('green3', all_fields=True) 59 | pb_error = pb_started.add_subcounter('red2', all_fields=True) 60 | 61 | # Loop until all systems finish 62 | while systems > started or active: 63 | 64 | # If there are free workers and tasks left to run, start them 65 | if systems > started and len(active) < WORKERS: 66 | queue = Queue() 67 | files = random.randint(*FILES) 68 | started += 1 69 | process = Process(target=process_files, name='System %d' % started, args=(queue, files)) 70 | counter = manager.counter(total=files, desc=' System %d:' % started, 71 | unit='files', leave=False) 72 | process.start() 73 | pb_started.update() 74 | active[started] = (process, queue, counter) 75 | 76 | # Iterate through active subprocesses 77 | for system in tuple(active.keys()): 78 | process, queue, counter = active[system] 79 | alive = process.is_alive() 80 | 81 | # Latest count is the last one on the queue 82 | count = None 83 | while not queue.empty(): 84 | count = queue.get() 85 | 86 | # Update counter 87 | if count is not None: 88 | counter.update(count - counter.count) 89 | 90 | # Remove any finished subprocesses and update main progress bar 91 | if not alive: 92 | counter.close() 93 | print('Processed %d files on System %d' % (counter.total, system)) 94 | del active[system] 95 | 96 | # Check for failures 97 | if process.exitcode != 0: 98 | print('ERROR: Receive exitcode %d while processing System %d' 99 | % (process.exitcode, system)) 100 | pb_error.update_from(pb_started) 101 | else: 102 | pb_finished.update_from(pb_started) 103 | 104 | # Sleep for 1/10 of a second to reduce load 105 | time.sleep(0.1) 106 | 107 | 108 | def main(): 109 | """ 110 | Main function 111 | """ 112 | 113 | with enlighten.get_manager() as manager: 114 | multiprocess_systems(manager, random.randint(*SYSTEMS)) 115 | 116 | 117 | if __name__ == '__main__': 118 | main() 119 | -------------------------------------------------------------------------------- /examples/prefixes.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 - 2021 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Progress bar using binary prefixes 9 | """ 10 | 11 | import time 12 | import random 13 | 14 | import enlighten 15 | 16 | # 64k chunk size 17 | CHUNK_SIZE = 64 * 1024 18 | 19 | BAR_FORMAT = '{desc}{desc_pad}{percentage:3.0f}%|{bar}| {count:!.2j}{unit} / {total:!.2j}{unit} ' \ 20 | '[{elapsed}<{eta}, {rate:!.2j}{unit}/s]' 21 | 22 | 23 | def download(manager, size): 24 | """ 25 | Simulate a download 26 | """ 27 | 28 | pbar = manager.counter(total=size, desc='Downloading', 29 | unit='B', bar_format=BAR_FORMAT, color='purple') 30 | 31 | bytes_left = size 32 | while bytes_left: 33 | time.sleep(random.uniform(0.05, 0.15)) 34 | next_chunk = min(CHUNK_SIZE, bytes_left) 35 | pbar.update(next_chunk) 36 | bytes_left -= next_chunk 37 | 38 | 39 | if __name__ == '__main__': 40 | 41 | with enlighten.get_manager() as mgr: 42 | 43 | # 1 - 10 MB file size 44 | total = random.uniform(1.0, 10.0) * 2 ** 20 45 | download(mgr, total) 46 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | 2 | [MAIN] 3 | 4 | # Load and enable all available extensions. Use --list-extensions to see a list 5 | # all available extensions. 6 | enable-all-extensions=yes 7 | 8 | [REPORTS] 9 | 10 | # Set the output format. Available formats are text, parseable, colorized, json 11 | # and msvs (visual studio). You can also give a reporter class, e.g. 12 | # mypackage.mymodule.MyReporterClass. 13 | output-format=colorized 14 | 15 | 16 | [FORMAT] 17 | 18 | # Use max line length of 100 19 | max-line-length=100 20 | 21 | # Regexp for a line that is allowed to be longer than the limit. 22 | # URLs and pure strings 23 | ignore-long-lines=^\s*(# )??$|^\s*[f|r|u]?b?[\"\'\`].+[\"\'\`]$ 24 | 25 | 26 | [DESIGN] 27 | 28 | # Maximum number of branch for function / method body. 29 | max-branches=15 30 | 31 | # Maximum number of positional arguments for function / method. 32 | max-positional-arguments=6 33 | 34 | [BASIC] 35 | 36 | # As far as I can tell, PEP-8 (Nov 1, 2013) does not specify 37 | # a specific naming convention for variables and arguments 38 | # prefer mixedcase, starting with a lowercase or underscore 39 | variable-rgx=[a-z_][A-Za-z0-9_]{1,29}[A-Za-z0-9_]$ 40 | 41 | # Good variable names which should always be accepted, separated by a comma. 42 | good-names=e,_ 43 | 44 | # Regular expression which should only match function or class names that do 45 | # not require a docstring. 46 | no-docstring-rgx=__.*__ 47 | 48 | 49 | [MESSAGES CONTROL] 50 | 51 | # Disable the message, report, category or checker with the given id(s). You 52 | # can either give multiple identifiers separated by comma (,) or put this 53 | # option multiple times (only on the command line, not in the configuration 54 | # file where it should appear only once). You can also use "--disable=all" to 55 | # disable everything first and then re-enable specific checks. For example, if 56 | # you want to run only the similarities checker, you can use "--disable=all 57 | # --enable=similarities". If you want to run only the classes checker, but have 58 | # no Warning level messages displayed, use "--disable=all --enable=classes 59 | # --disable=W". 60 | disable= 61 | consider-using-f-string, # Python 2 62 | redundant-u-string-prefix, # Python 2 63 | super-with-arguments, # Python 2 64 | too-few-public-methods, 65 | useless-object-inheritance, # Python 2 66 | 67 | 68 | [SIMILARITIES] 69 | 70 | # Minimum lines number of a similarity. 71 | min-similarity-lines=8 72 | 73 | 74 | [SPELLING] 75 | 76 | # Spelling dictionary name. Available dictionaries: en_US (myspell). 77 | spelling-dict=en_US 78 | 79 | # List of comma separated words that should not be checked. 80 | spelling-ignore-words= 81 | Avram, ansicon, Args, assertRaisesRegexp, assertRegexpMatches, assertNotRegexpMatches, asv, 82 | attr, AttributeError, autorefresh, 83 | BaseManager, bool, 84 | CGA, CSS, 85 | desc, docstring, downconverted, downloader, 86 | Enlighten's, exc, 87 | html, 88 | IEC, incr, IPC, isatty, iterable, 89 | kwargs, 90 | Jupyter, 91 | len, lookahead, Lubkin, 92 | meth, Mozilla, MPL, 93 | noqa, 94 | peru, pragma, PrintableCounter, py, 95 | redirector, resize, resizing, RGB, 96 | seagreen, setscroll, scrollable, sphinxcontrib, ss, StatusBar, stdout, 97 | stderr, str, subcounter, subcounters, submodule, 98 | subprocesses, sys, 99 | Termcap, TestCase, tty, TTY, tuple, 100 | unicode, unittest, unmanaged, 101 | ValueError, 102 | 103 | # For nbqa 104 | NBQA, SEP -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | blessed 2 | prefixed -------------------------------------------------------------------------------- /requirements_docs.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx_rtd_theme 3 | sphinxcontrib-spelling 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [metadata] 5 | description_file = README.rst 6 | license_files = LICENSE 7 | 8 | [tool:pytest] 9 | python_files = test_*.py 10 | testpaths = tests 11 | 12 | [flake8] 13 | max-line-length = 110 14 | exclude = .asv,.svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg,build,notes 15 | 16 | [pycodestyle] 17 | max-line-length = 110 18 | 19 | [coverage:run] 20 | branch = True 21 | source = 22 | enlighten 23 | omit = 24 | enlighten/_win_terminal.py 25 | 26 | [coverage:report] 27 | show_missing: True 28 | fail_under: 100 29 | exclude_lines = 30 | pragma: no cover 31 | raise NotImplementedError 32 | if __name__ == "__main__": 33 | 34 | [build_sphinx] 35 | source-dir = doc 36 | build-dir = build/doc 37 | all_files = True 38 | fresh-env = True 39 | warning-is-error = True 40 | 41 | [aliases] 42 | spelling=build_sphinx --builder spelling 43 | html=build_sphinx --builder html 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2017 - 2024 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | Enlighten Progress Bar is console progress bar module for Python. (Yes, another one.) 10 | The main advantage of Enlighten is it allows writing to stdout and stderr without any 11 | redirection. 12 | """ 13 | 14 | import os 15 | 16 | from setuptools import setup, find_packages 17 | 18 | from setup_helpers import get_version, readme 19 | 20 | INSTALL_REQUIRES = [ 21 | 'blessed>=1.17.7', 22 | 'prefixed>=0.3.2', 23 | 'backports.functools-lru-cache; python_version < "3.2"' 24 | ] 25 | TESTS_REQUIRE = ['mock; python_version < "3.3"'] 26 | 27 | # Additional requirements 28 | # html requires sphinx, sphinx_rtd_theme 29 | # spelling requires sphinxcontrib-spelling 30 | 31 | setup( 32 | name='enlighten', 33 | version=get_version(os.path.join('enlighten', '__init__.py')), 34 | description='Enlighten Progress Bar', 35 | long_description=readme('README.rst'), 36 | author='Avram Lubkin', 37 | author_email='avylove@rockhopper.net', 38 | maintainer='Avram Lubkin', 39 | maintainer_email='avylove@rockhopper.net', 40 | url='https://github.com/Rockhopper-Technologies/enlighten', 41 | project_urls={'Documentation': 'https://python-enlighten.readthedocs.io'}, 42 | license='MPLv2.0', 43 | zip_safe=False, 44 | install_requires=INSTALL_REQUIRES, 45 | tests_require=TESTS_REQUIRE, 46 | packages=find_packages(exclude=['tests', 'tests.*', 'examples']), 47 | test_suite='tests', 48 | 49 | classifiers=[ 50 | 'Development Status :: 5 - Production/Stable', 51 | 'Environment :: Console', 52 | 'Intended Audience :: Developers', 53 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', 54 | 'Operating System :: POSIX', 55 | 'Operating System :: Microsoft :: Windows', 56 | 'Programming Language :: Python', 57 | 'Programming Language :: Python :: 2.7', 58 | 'Programming Language :: Python :: 3.5', 59 | 'Programming Language :: Python :: 3.6', 60 | 'Programming Language :: Python :: 3.7', 61 | 'Programming Language :: Python :: 3.8', 62 | 'Programming Language :: Python :: 3.9', 63 | 'Programming Language :: Python :: 3.10', 64 | 'Programming Language :: Python :: 3.11', 65 | 'Programming Language :: Python :: 3.12', 66 | 'Programming Language :: Python :: 3.13', 67 | 'Programming Language :: Python :: Implementation :: CPython', 68 | 'Programming Language :: Python :: Implementation :: PyPy', 69 | 'Topic :: Utilities', 70 | ], 71 | keywords='progress, bar, progressbar, counter, status, statusbar', 72 | ) 73 | -------------------------------------------------------------------------------- /setup_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 - 2025 Avram Lubkin, All Rights Reserved 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | """ 8 | Functions to help with build and setup 9 | """ 10 | 11 | import contextlib 12 | import datetime 13 | import io 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | 19 | 20 | RE_VERSION = re.compile(r'__version__\s*=\s*[\'\"](.+)[\'\"]$') 21 | DIR_SPELLING = 'build/doc/spelling/' 22 | 23 | 24 | def get_version(filename, encoding='utf8'): 25 | """ 26 | Get __version__ definition out of a source file 27 | """ 28 | 29 | with io.open(filename, encoding=encoding) as sourcecode: 30 | for line in sourcecode: 31 | version = RE_VERSION.match(line) 32 | if version: 33 | return version.group(1) 34 | 35 | return None 36 | 37 | 38 | def readme(filename, encoding='utf8'): 39 | """ 40 | Read the contents of a file 41 | """ 42 | 43 | with io.open(filename, encoding=encoding) as source: 44 | return source.read() 45 | 46 | 47 | def print_spelling_errors(filename, encoding='utf8'): 48 | """ 49 | Print misspelled words returned by sphinxcontrib-spelling 50 | """ 51 | try: 52 | filesize = os.stat(filename).st_size 53 | except FileNotFoundError: 54 | filesize = 0 55 | 56 | if filesize: 57 | sys.stdout.write('Misspelled Words:\n') 58 | with io.open(filename, encoding=encoding) as wordlist: 59 | for line in wordlist: 60 | sys.stdout.write(' ' + line) 61 | 62 | return 1 if filesize else 0 63 | 64 | 65 | def print_all_spelling_errors(path): 66 | """ 67 | Print all spelling errors in the path 68 | """ 69 | 70 | rtn = 0 71 | if not os.path.isdir(path): 72 | return rtn 73 | 74 | for filename in os.listdir(path): 75 | if print_spelling_errors(os.path.join(path, filename)): 76 | rtn = 1 77 | 78 | return rtn 79 | 80 | 81 | def spelling_clean_dir(path): 82 | """ 83 | Remove spelling files from path 84 | """ 85 | if not os.path.isdir(path): 86 | return 87 | for filename in os.listdir(path): 88 | os.unlink(os.path.join(path, filename)) 89 | 90 | 91 | def check_rst2html(path): 92 | """ 93 | Checks for warnings when doing ReST to HTML conversion 94 | """ 95 | 96 | from docutils.core import publish_file # pylint: disable=import-error,import-outside-toplevel 97 | 98 | stderr = io.StringIO() 99 | 100 | # This will exit with status if there is a bad enough error 101 | with contextlib.redirect_stderr(stderr): 102 | output = publish_file(source_path=path, writer_name='html', 103 | enable_exit_status=True, destination_path='/dev/null') 104 | 105 | warning_text = stderr.getvalue() 106 | 107 | if warning_text or not output: 108 | print(warning_text) 109 | return 1 110 | 111 | return 0 112 | 113 | 114 | def _get_changed_files(): 115 | """ 116 | Get files in current repository that have been changed 117 | Ignore changes to copyright lines 118 | """ 119 | 120 | changed = [] 121 | 122 | # Get list of changed files 123 | process = subprocess.run( 124 | ('git', 'status', '--porcelain=1'), stdout=subprocess.PIPE, check=True, text=True 125 | ) 126 | for entry in process.stdout.splitlines(): 127 | 128 | # Ignore deleted files 129 | if entry[1] == 'D': 130 | continue 131 | 132 | # Construct diff command 133 | filename = entry[3:].strip() 134 | diff_cmd = ['git', 'diff', filename] 135 | if entry[0].strip(): 136 | diff_cmd.insert(-1, '--cached') 137 | 138 | # Find files with changes that aren't only for copyright 139 | process = subprocess.run(diff_cmd, stdout=subprocess.PIPE, check=True, text=True) 140 | for line in process.stdout.splitlines(): 141 | if line[0] != '+' or line[:3] == '+++': # Ignore everything but the new contents 142 | continue 143 | 144 | if re.search(r'copyright.*20\d\d', line, re.IGNORECASE): # Ignore copyright line 145 | continue 146 | 147 | changed.append(filename) 148 | break 149 | 150 | return changed 151 | 152 | 153 | def check_copyrights(): 154 | """ 155 | Check files recursively to ensure year of last change is in copyright line 156 | """ 157 | 158 | this_year = str(datetime.date.today().year) 159 | changed_now = _get_changed_files() 160 | 161 | # Look for copyright lines 162 | process = subprocess.run( 163 | ('git', 'grep', '-i', 'copyright'), stdout=subprocess.PIPE, check=True, text=True 164 | ) 165 | 166 | rtn = 0 167 | for entry in process.stdout.splitlines(): 168 | 169 | modified = None 170 | 171 | # Get the year in the copyright line 172 | filename, text = entry.split(':', 1) 173 | match = re.match(r'.*(20\d\d)', text) 174 | if match: 175 | year = match.group(1) 176 | 177 | # If file is in current changes, use this year 178 | if filename in changed_now: 179 | modified = this_year 180 | 181 | # Otherwise, try to get the year of last commit that wasn't only updating copyright 182 | else: 183 | git_log = subprocess.run( 184 | ('git', '--no-pager', 'log', '-U0', filename), 185 | stdout=subprocess.PIPE, check=True, text=True 186 | ) 187 | 188 | for line in git_log.stdout.splitlines(): 189 | 190 | # Get year 191 | if line.startswith('Date: '): 192 | modified = line.split()[5] 193 | 194 | # Skip blank line and lines that aren't changes 195 | if not line.strip() or line[0] != '+' or line[:3] == '+++': 196 | continue 197 | 198 | # Stop looking on the first line we hit that isn't a copyright 199 | if re.search(r'copyright.*20\d\d', line, re.IGNORECASE) is None: 200 | break 201 | 202 | # Special case for Sphinx configuration 203 | if filename == 'doc/conf.py' and modified != this_year: 204 | 205 | # Get the latest change date for docs 206 | process = subprocess.run( 207 | ('git', 'log', '-1', '--pretty=format:%cs', 'doc/*.rst', 'enlighten/*.py'), 208 | stdout=subprocess.PIPE, check=True, text=True 209 | ) 210 | modified = process.stdout[:4] 211 | 212 | # Compare modified date to copyright year 213 | if modified and modified != year: 214 | rtn = 1 215 | print('%s: %s [%s]' % (filename, text, modified)) 216 | 217 | return rtn 218 | 219 | 220 | if __name__ == '__main__': 221 | 222 | # Do nothing if no arguments were given 223 | if len(sys.argv) < 2: 224 | sys.exit(0) 225 | 226 | # Print misspelled word list 227 | if sys.argv[1] == 'spelling-clean': 228 | spelling_clean_dir(DIR_SPELLING) 229 | sys.exit(0) 230 | 231 | # Print misspelled word list 232 | if sys.argv[1] == 'spelling': 233 | if len(sys.argv) > 2: 234 | sys.exit(print_spelling_errors(sys.argv[2])) 235 | else: 236 | sys.exit(print_all_spelling_errors(DIR_SPELLING)) 237 | 238 | # Check file for Rest to HTML conversion 239 | if sys.argv[1] == 'rst2html': 240 | if len(sys.argv) < 3: 241 | sys.exit('Missing filename for ReST to HTML check') 242 | sys.exit(check_rst2html(sys.argv[2])) 243 | 244 | # Check copyrights 245 | if sys.argv[1] == 'copyright': 246 | sys.exit(check_copyrights()) 247 | 248 | # Unknown option 249 | else: 250 | sys.stderr.write('Unknown option: %s' % sys.argv[1]) 251 | sys.exit(1) 252 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2022 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | Test module for Enlighten 10 | """ 11 | 12 | from contextlib import contextmanager 13 | import fcntl 14 | import io 15 | import os 16 | import pty 17 | import struct 18 | import sys 19 | import termios 20 | import unittest 21 | 22 | from enlighten import Manager 23 | from enlighten._basecounter import BaseCounter 24 | from enlighten._counter import Counter 25 | from enlighten._statusbar import StatusBar 26 | 27 | 28 | if sys.version_info[:2] < (3, 3): 29 | import mock 30 | else: 31 | from unittest import mock # noqa: F401 # pylint: disable=no-name-in-module 32 | 33 | 34 | PY2 = sys.version_info[0] < 3 35 | OUTPUT = io.StringIO() 36 | os.environ['TERM'] = 'xterm-256color' # Default to xterm-256color 37 | 38 | 39 | # pylint: disable=missing-docstring 40 | 41 | class TestCase(unittest.TestCase): 42 | """ 43 | Subclass of :py:class:`unittest.TestCase` for customization 44 | """ 45 | 46 | 47 | # Fix deprecated methods for 2.7 48 | def assert_regex(self, text, regex, msg=None): 49 | """ 50 | Wrapper for assertRegexpMatches 51 | """ 52 | 53 | return self.assertRegexpMatches(text, regex, msg) 54 | 55 | 56 | def assert_not_regex(self, text, regex, msg=None): 57 | """ 58 | Wrapper for assertNotRegexpMatches 59 | """ 60 | 61 | return self.assertNotRegexpMatches(text, regex, msg) 62 | 63 | 64 | def assert_raises_regex(self, exception, regex, *args, **kwargs): 65 | """ 66 | Wrapper for assertRaisesRegexp 67 | """ 68 | 69 | return self.assertRaisesRegexp(exception, regex, *args, **kwargs) 70 | 71 | 72 | if not hasattr(TestCase, 'assertRegex'): 73 | TestCase.assertRegex = assert_regex 74 | 75 | if not hasattr(TestCase, 'assertNotRegex'): 76 | TestCase.assertNotRegex = assert_not_regex 77 | 78 | if not hasattr(TestCase, 'assertRaisesRegex'): 79 | TestCase.assertRaisesRegex = assert_raises_regex 80 | 81 | 82 | # Some tests fail if "real" stdout is does not have a file descriptor 83 | try: 84 | sys.__stdout__.fileno() 85 | except ValueError: 86 | STDOUT_NO_FD = True 87 | else: 88 | STDOUT_NO_FD = False 89 | 90 | 91 | @contextmanager 92 | def redirect_output(stream, target): 93 | """ 94 | Temporary redirector for stdout and stderr 95 | """ 96 | 97 | original = getattr(sys, stream) 98 | try: 99 | setattr(sys, stream, target) 100 | yield 101 | finally: 102 | setattr(sys, stream, original) 103 | 104 | 105 | class MockTTY(object): 106 | 107 | def __init__(self, height=25, width=80): 108 | 109 | self.master, self.slave = pty.openpty() 110 | # pylint: disable=consider-using-with 111 | self.stdout = io.open(self.slave, 'w', 1, encoding='UTF-8', newline='') 112 | self.stdread = io.open(self.master, 'r', encoding='UTF-8', newline='\n') 113 | 114 | # Make sure linefeed behavior is consistent between Python 2 and Python 3 115 | termattrs = termios.tcgetattr(self.slave) 116 | termattrs[1] = termattrs[1] & ~termios.ONLCR & ~termios.OCRNL 117 | termattrs[0] = termattrs[0] & ~termios.ICRNL 118 | termios.tcsetattr(self.slave, termios.TCSADRAIN, termattrs) 119 | 120 | self.resize(height, width) 121 | 122 | def flush(self): 123 | self.stdout.flush() 124 | 125 | def close(self): 126 | self.stdout.flush() 127 | self.stdout.close() 128 | self.stdread.close() 129 | 130 | def clear(self): 131 | # Using TCIOFLUSH here instead of TCIFLUSH to support MacOS 132 | termios.tcflush(self.stdread, termios.TCIOFLUSH) 133 | 134 | def resize(self, height, width): 135 | fcntl.ioctl(self.slave, termios.TIOCSWINSZ, struct.pack('hhhh', height, width, 0, 0)) 136 | 137 | 138 | class MockBaseCounter(BaseCounter): 139 | """ 140 | Mock version of base counter for testing 141 | """ 142 | 143 | def update(self, *args, **kwargs): 144 | """ 145 | Simple update that updates the count. We know it's called based on the count. 146 | """ 147 | 148 | self.count += 1 149 | 150 | 151 | class MockCounter(Counter): 152 | 153 | __slots__ = ('output', 'calls') 154 | 155 | def __init__(self, *args, **kwargs): 156 | super(MockCounter, self).__init__(*args, **kwargs) 157 | self.output = [] 158 | self.calls = [] 159 | 160 | def refresh(self, flush=True, elapsed=None): 161 | self.output.append(self.count) 162 | self.calls.append('refresh(flush=%s, elapsed=%s)' % (flush, elapsed)) 163 | 164 | def clear(self, flush=True): 165 | self.calls.append('clear(flush=%s)' % flush) 166 | 167 | 168 | class MockStatusBar(StatusBar): 169 | 170 | __slots__ = ('called', 'calls') 171 | 172 | def __init__(self, *args, **kwargs): 173 | super(MockStatusBar, self).__init__(*args, **kwargs) 174 | self.called = 0 175 | self.calls = [] 176 | 177 | def refresh(self, flush=True, elapsed=None): 178 | self.called += 1 179 | self.calls.append('refresh(flush=%s, elapsed=%s)' % (flush, elapsed)) 180 | 181 | 182 | class MockManager(Manager): 183 | # pylint: disable=super-init-not-called 184 | def __init__(self, counter_class=Counter, **kwargs): 185 | super(MockManager, self).__init__(counter_class=counter_class, **kwargs) 186 | self.width = 80 187 | self.output = [] 188 | self.remove_calls = 0 189 | 190 | def write(self, output='', flush=True, counter=None, **kwargs): 191 | 192 | if callable(output): 193 | output = output(**kwargs) 194 | 195 | self.output.append('write(output=%s, flush=%s, position=%s)' % 196 | (output, flush, counter.position)) 197 | 198 | def remove(self, counter): 199 | self.remove_calls += 1 200 | super(MockManager, self).remove(counter) 201 | -------------------------------------------------------------------------------- /tests/test_basecounter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2023 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | Test module for enlighten._basecounter 10 | """ 11 | 12 | from types import GeneratorType 13 | 14 | from enlighten._basecounter import BaseCounter 15 | 16 | from tests import TestCase, MockManager, MockTTY, MockBaseCounter 17 | 18 | 19 | # pylint: disable=protected-access 20 | class TestBaseCounter(TestCase): 21 | """ 22 | Test the BaseCounter class 23 | """ 24 | 25 | def setUp(self): 26 | self.tty = MockTTY() 27 | self.manager = MockManager(stream=self.tty.stdout) 28 | 29 | def tearDown(self): 30 | self.tty.close() 31 | 32 | def test_init_default(self): 33 | """Ensure default values are set""" 34 | counter = BaseCounter(manager=self.manager) 35 | self.assertIsNone(counter.color) 36 | self.assertIsNone(counter.color) 37 | self.assertIs(counter.manager, self.manager) 38 | self.assertEqual(counter.count, 0) 39 | self.assertEqual(counter.start_count, 0) 40 | 41 | def test_no_manager(self): 42 | """Raise an error if there is no manager specified""" 43 | with self.assertRaisesRegex(TypeError, 'manager must be specified'): 44 | BaseCounter() 45 | 46 | def test_color_invalid(self): 47 | """Color must be a valid string, RGB, or int 0 - 255""" 48 | # Unsupported type 49 | with self.assertRaisesRegex(AttributeError, 'Invalid color specified: 1.0'): 50 | BaseCounter(manager=self.manager, color=1.0) 51 | 52 | # Invalid String 53 | with self.assertRaisesRegex(AttributeError, 'Invalid color specified: buggersnot'): 54 | BaseCounter(manager=self.manager, color='buggersnot') 55 | 56 | # Invalid integer 57 | with self.assertRaisesRegex(AttributeError, 'Invalid color specified: -1'): 58 | BaseCounter(manager=self.manager, color=-1) 59 | with self.assertRaisesRegex(AttributeError, 'Invalid color specified: 256'): 60 | BaseCounter(manager=self.manager, color=256) 61 | 62 | # Invalid iterable 63 | with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \(\)'): 64 | BaseCounter(manager=self.manager, color=[]) 65 | with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \(1,\)'): 66 | BaseCounter(manager=self.manager, color=[1]) 67 | with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \(1, 2\)'): 68 | BaseCounter(manager=self.manager, color=(1, 2)) 69 | with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \(1, 2, 3, 4\)'): 70 | BaseCounter(manager=self.manager, color=(1, 2, 3, 4)) 71 | 72 | def test_colorize_none(self): 73 | """If color is None, return content unchanged""" 74 | counter = BaseCounter(manager=self.manager) 75 | self.assertEqual(counter._colorize('test'), 'test') 76 | 77 | def test_colorize_string(self): 78 | """Return string formatted with color (string)""" 79 | counter = BaseCounter(manager=self.manager, color='red') 80 | self.assertEqual(counter.color, 'red') 81 | self.assertEqual(counter._color, ('red', self.manager.term.red)) 82 | self.assertNotEqual(counter._colorize('test'), 'test') 83 | self.assertEqual(counter._colorize('test'), self.manager.term.red('test')) 84 | 85 | def test_colorize_string_compound(self): 86 | """Return string formatted with compound color (string)""" 87 | counter = BaseCounter(manager=self.manager, color='bold_red_on_blue') 88 | self.assertEqual(counter.color, 'bold_red_on_blue') 89 | self.assertEqual(counter._color, ('bold_red_on_blue', self.manager.term.bold_red_on_blue)) 90 | self.assertNotEqual(counter._colorize('test'), 'test') 91 | self.assertEqual(counter._colorize('test'), self.manager.term.bold_red_on_blue('test')) 92 | 93 | def test_colorize_int(self): 94 | """Return string formatted with color (int)""" 95 | counter = BaseCounter(manager=self.manager, color=40) 96 | self.assertEqual(counter.color, 40) 97 | self.assertEqual(counter._color, (40, self.manager.term.color(40))) 98 | self.assertNotEqual(counter._colorize('test'), 'test') 99 | self.assertEqual(counter._colorize('test'), self.manager.term.color(40)('test')) 100 | 101 | def test_colorize_rgb(self): 102 | """Return string formatted with color (RGB)""" 103 | counter = BaseCounter(manager=self.manager, color=(50, 40, 60)) 104 | self.assertEqual(counter.color, (50, 40, 60)) 105 | self.assertEqual(counter._color, ((50, 40, 60), self.manager.term.color_rgb(50, 40, 60))) 106 | self.assertNotEqual(counter._colorize('test'), 'test') 107 | self.assertEqual(counter._colorize('test'), self.manager.term.color_rgb(50, 40, 60)('test')) 108 | 109 | def test_call(self): 110 | """Returns generator when used as a function""" 111 | 112 | # Bad arguments 113 | counter = MockBaseCounter(manager=self.manager) 114 | with self.assertRaisesRegex(TypeError, 'Argument type int is not iterable'): 115 | list(counter(1)) 116 | with self.assertRaisesRegex(TypeError, 'Argument type bool is not iterable'): 117 | list(counter([1, 2, 3], True)) 118 | 119 | # Expected 120 | counter = MockBaseCounter(manager=self.manager) 121 | rtn = counter([1, 2, 3]) 122 | self.assertIsInstance(rtn, GeneratorType) 123 | self.assertEqual(list(rtn), [1, 2, 3]) 124 | self.assertEqual(counter.count, 3) 125 | 126 | # Multiple arguments 127 | counter = MockBaseCounter(manager=self.manager) 128 | rtn = counter([1, 2, 3], (3, 2, 1)) 129 | self.assertIsInstance(rtn, GeneratorType) 130 | self.assertEqual(tuple(rtn), (1, 2, 3, 3, 2, 1)) 131 | self.assertEqual(counter.count, 6) 132 | -------------------------------------------------------------------------------- /tests/test_notebook_manager.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "\"\"\"\n", 10 | "Notebook for testing NotebookManager\n", 11 | "\"\"\"\n", 12 | "\n", 13 | "# Setup\n", 14 | "import sys\n", 15 | "import os\n", 16 | "\n", 17 | "import coverage\n", 18 | "\n", 19 | "# Set path so this works for running live\n", 20 | "cwd = os.getcwd()\n", 21 | "if os.path.basename(cwd) == 'tests':\n", 22 | " project_dir = os.path.dirname(cwd)\n", 23 | " sys.path.insert(1, project_dir)\n", 24 | "else:\n", 25 | " project_dir = cwd\n", 26 | "\n", 27 | "# Start coverage, should be before imports\n", 28 | "cov = coverage.Coverage(\n", 29 | " data_file=os.path.join(project_dir, '.coverage.notebook'),\n", 30 | " config_file=os.path.join(project_dir, 'setup.cfg')\n", 31 | ")\n", 32 | "cov.start()\n", 33 | "\n", 34 | "# pylint: disable=wrong-import-position\n", 35 | "from enlighten import get_manager, NotebookManager # noqa: E402" 36 | ] 37 | }, 38 | { 39 | "cell_type": "code", 40 | "execution_count": null, 41 | "metadata": {}, 42 | "outputs": [], 43 | "source": [ 44 | "# test_get_manager\n", 45 | "# Test we get the right manager when running in a notebook\n", 46 | "manager = get_manager()\n", 47 | "assert isinstance(manager, NotebookManager)\n", 48 | "assert repr(manager) == 'NotebookManager()'" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": null, 54 | "metadata": {}, 55 | "outputs": [], 56 | "source": [ 57 | "# test_standard\n", 58 | "# Test standard manager\n", 59 | "manager = NotebookManager()\n", 60 | "ctr = manager.counter(total=100)\n", 61 | "ctr.update(force=True)\n", 62 | "manager.stop()" 63 | ] 64 | }, 65 | { 66 | "cell_type": "code", 67 | "execution_count": null, 68 | "metadata": {}, 69 | "outputs": [], 70 | "source": [ 71 | "# test_disabled\n", 72 | "# Test manager disabled\n", 73 | "manager = NotebookManager(enabled=False)\n", 74 | "ctr = manager.counter(total=100)\n", 75 | "ctr.update(force=True)\n", 76 | "manager.write('We should never see this')\n", 77 | "manager.stop()" 78 | ] 79 | }, 80 | { 81 | "cell_type": "code", 82 | "execution_count": null, 83 | "metadata": { 84 | "scrolled": true 85 | }, 86 | "outputs": [], 87 | "source": [ 88 | "# test_bare_no_flush\n", 89 | "# Test write bare message, no flush\n", 90 | "\n", 91 | "manager = NotebookManager()\n", 92 | "manager.write('test message', flush=False)\n", 93 | "# pylint: disable=protected-access\n", 94 | "assert manager._output[0] == '
\\n
test message
\\n
'" 95 | ] 96 | }, 97 | { 98 | "cell_type": "code", 99 | "execution_count": null, 100 | "metadata": {}, 101 | "outputs": [], 102 | "source": [ 103 | "# test_advanced\n", 104 | "# More advanced use case\n", 105 | "manager = NotebookManager()\n", 106 | "\n", 107 | "ticks = manager.counter(total=10, desc='Ticks', unit='ticks', color='red', min_delta=0)\n", 108 | "tocks = manager.counter(total=5, desc='Tocks', unit='tocks', color='blue', position=3, min_delta=0)\n", 109 | "\n", 110 | "for num in range(10):\n", 111 | " ticks.update()\n", 112 | " if not num % 2:\n", 113 | " tocks.update()\n", 114 | "manager.stop()" 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": null, 120 | "metadata": {}, 121 | "outputs": [], 122 | "source": [ 123 | "# test_styles\n", 124 | "# Styles converted to HTML\n", 125 | "manager = NotebookManager()\n", 126 | "term = manager.term\n", 127 | "\n", 128 | "status = manager.status_bar(' '.join((\n", 129 | " 'normal',\n", 130 | " term.blue_on_aquamarine('blue_on_aquamarine'),\n", 131 | " term.aquamarine_on_blue('aquamarine_on_blue'),\n", 132 | " term.color(90)('color_90'),\n", 133 | " term.on_color(90)('on_color_90'),\n", 134 | " term.italic_bright_red('italics_red'),\n", 135 | " term.on_bright_blue('on_bright_blue'),\n", 136 | " term.blink('blink'),\n", 137 | " term.bold('bold'),\n", 138 | " term.bold(''), # Empty span will be ignored\n", 139 | " term.underline('underline'),\n", 140 | " term.reverse('unsupported_reverse'),\n", 141 | " term.move(5, 6) + 'unsupported_move',\n", 142 | " term.normal + 'ignore_unmatched_normal',\n", 143 | " term.link('https://pypi.org/project/enlighten/', 'enlighten'),\n", 144 | ")))" 145 | ] 146 | }, 147 | { 148 | "cell_type": "code", 149 | "execution_count": null, 150 | "metadata": {}, 151 | "outputs": [], 152 | "source": [ 153 | "# test_stop_no_counters\n", 154 | "# Test stop succeeds when there are no counters\n", 155 | "manager = NotebookManager()\n", 156 | "manager.stop()" 157 | ] 158 | }, 159 | { 160 | "cell_type": "code", 161 | "execution_count": null, 162 | "metadata": {}, 163 | "outputs": [], 164 | "source": [ 165 | "# Cleanup\n", 166 | "cov.stop()\n", 167 | "cov.save()" 168 | ] 169 | } 170 | ], 171 | "metadata": { 172 | "kernelspec": { 173 | "display_name": "Python 3", 174 | "language": "python", 175 | "name": "python3" 176 | }, 177 | "language_info": { 178 | "codemirror_mode": { 179 | "name": "ipython", 180 | "version": 3 181 | }, 182 | "file_extension": ".py", 183 | "mimetype": "text/x-python", 184 | "name": "python", 185 | "nbconvert_exporter": "python", 186 | "pygments_lexer": "ipython3", 187 | "version": "3.13.0" 188 | }, 189 | "vscode": { 190 | "interpreter": { 191 | "hash": "e7370f93d1d0cde622a1f8e1c04877d8463912d04d973331ad4851f04de6915a" 192 | } 193 | } 194 | }, 195 | "nbformat": 4, 196 | "nbformat_minor": 4 197 | } 198 | -------------------------------------------------------------------------------- /tests/test_notebook_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2021 - 2023 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | Test module for enlighten._notebook_manager 10 | """ 11 | 12 | import unittest 13 | 14 | try: 15 | from nbconvert.preprocessors import ExecutePreprocessor 16 | import nbformat 17 | RUN_NOTEBOOK = True 18 | except ImportError: 19 | RUN_NOTEBOOK = False 20 | 21 | from tests import TestCase 22 | 23 | 24 | def run_notebook(path): 25 | """ 26 | Run a notebook at the given path 27 | The notebook's path is set to the current working directory 28 | """ 29 | 30 | with open(path, encoding='utf-8') as notebook_file: 31 | notebook = nbformat.read(notebook_file, as_version=nbformat.NO_CONVERT) 32 | 33 | process = ExecutePreprocessor(timeout=60, allow_errors=True) 34 | process.preprocess(notebook, {}) 35 | 36 | return notebook 37 | 38 | 39 | def has_html_output(cell): 40 | """ 41 | Check if cell has HTML output 42 | """ 43 | 44 | for output in cell.get('outputs', []): 45 | if output.output_type == 'display_data': 46 | return 'text/html' in output['data'] 47 | 48 | return False 49 | 50 | 51 | @unittest.skipUnless(RUN_NOTEBOOK, 'Notebook testing packages not installed') 52 | class TestNotebookManager(TestCase): 53 | """ 54 | Tests for NotebookManager 55 | """ 56 | maxDiff = None 57 | 58 | def test_notebook(self): 59 | """ 60 | All the tests run in the notebook. This just runs it and checks for errors. 61 | """ 62 | 63 | notebook = run_notebook('tests/test_notebook_manager.ipynb') 64 | 65 | # Make sure there are no errors 66 | for cell in notebook.cells: 67 | for output in cell.get('outputs', []): 68 | if output.output_type == 'stream' and output.name == 'stderr': 69 | errors = ''.join(output.text) 70 | print(errors) 71 | 72 | if output.output_type == 'error': 73 | raise AssertionError( 74 | '%s: %s\n%s' % (output.ename, output.evalue, '\n'.join(output.traceback)) 75 | ) 76 | 77 | self.assertNotEqual(output.output_type, 'error') 78 | 79 | # Setup: should have no output 80 | self.assertFalse(notebook.cells[0].outputs) 81 | 82 | # test_get_manager: should have no output 83 | self.assertFalse(notebook.cells[1].outputs) 84 | 85 | # test_standard: should have output 86 | self.assertTrue(has_html_output(notebook.cells[2]), 'display_data not found in outputs') 87 | 88 | # # test_disabled: should have no output 89 | self.assertFalse(notebook.cells[3].outputs) 90 | 91 | # test_bare_no_flush: should have no output 92 | self.assertFalse(notebook.cells[4].outputs) 93 | 94 | # test_advanced: should have output 95 | self.assertTrue(has_html_output(notebook.cells[5]), 'display_data not found in outputs') 96 | 97 | # test_styles: should have output 98 | self.assertTrue(has_html_output(notebook.cells[6]), 'display_data not found in outputs') 99 | 100 | # test_stop_no_counters: should have output 101 | self.assertTrue(has_html_output(notebook.cells[7]), 'display_data not found in outputs') 102 | 103 | # Cleanup: should have no output 104 | self.assertFalse(notebook.cells[8].outputs) 105 | -------------------------------------------------------------------------------- /tests/test_statusbar.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2025 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | Test module for enlighten._statusbar 10 | """ 11 | 12 | import time 13 | 14 | from enlighten import EnlightenWarning, Justify 15 | 16 | import tests 17 | from tests import TestCase, MockManager, MockTTY, MockStatusBar, PY2, unittest 18 | 19 | 20 | class TestStatusBar(TestCase): 21 | """ 22 | Test the StatusBar class 23 | """ 24 | 25 | def setUp(self): 26 | self.tty = MockTTY() 27 | self.manager = MockManager(stream=self.tty.stdout) 28 | 29 | def tearDown(self): 30 | self.tty.close() 31 | 32 | def test_static(self): 33 | """ 34 | Basic static status bar 35 | """ 36 | 37 | sbar = self.manager.status_bar('Hello', 'World!') 38 | self.assertEqual(sbar.format(), 'Hello World!' + ' ' * 68) 39 | 40 | sbar.update('Goodbye, World!') 41 | self.assertEqual(sbar.format(), 'Goodbye, World!' + ' ' * 65) 42 | 43 | def test_static_justify(self): 44 | """ 45 | Justified static status bar 46 | """ 47 | 48 | sbar = self.manager.status_bar('Hello', 'World!', justify=Justify.LEFT) 49 | self.assertEqual(sbar.format(), 'Hello World!' + ' ' * 68) 50 | 51 | sbar = self.manager.status_bar('Hello', 'World!', justify=Justify.RIGHT) 52 | self.assertEqual(sbar.format(), ' ' * 68 + 'Hello World!') 53 | 54 | sbar = self.manager.status_bar('Hello', 'World!', justify=Justify.CENTER) 55 | self.assertEqual(sbar.format(), ' ' * 34 + 'Hello World!' + ' ' * 34) 56 | 57 | def test_formatted(self): 58 | """ 59 | Basic formatted status bar 60 | """ 61 | 62 | sbar = self.manager.status_bar(status_format=u'Stage: {stage}, Status: {status}', stage=1, 63 | fields={'status': 'All good!'}) 64 | self.assertEqual(sbar.format(), 'Stage: 1, Status: All good!' + ' ' * 53) 65 | sbar.update(stage=2) 66 | self.assertEqual(sbar.format(), 'Stage: 2, Status: All good!' + ' ' * 53) 67 | sbar.update(stage=3, status='Meh') 68 | self.assertEqual(sbar.format(), 'Stage: 3, Status: Meh' + ' ' * 59) 69 | 70 | def test_formatted_justify(self): 71 | """ 72 | Justified formatted status bar 73 | """ 74 | 75 | sbar = self.manager.status_bar(status_format=u'Stage: {stage}, Status: {status}', stage=1, 76 | fields={'status': 'All good!'}, justify=Justify.LEFT) 77 | self.assertEqual(sbar.format(), 'Stage: 1, Status: All good!' + ' ' * 53) 78 | 79 | sbar = self.manager.status_bar(status_format=u'Stage: {stage}, Status: {status}', stage=1, 80 | fields={'status': 'All good!'}, justify=Justify.RIGHT) 81 | self.assertEqual(sbar.format(), ' ' * 53 + 'Stage: 1, Status: All good!') 82 | 83 | sbar = self.manager.status_bar(status_format=u'Stage: {stage}, Status: {status}', stage=1, 84 | fields={'status': 'All good'}, justify=Justify.CENTER) 85 | self.assertEqual(sbar.format(), ' ' * 27 + 'Stage: 1, Status: All good' + ' ' * 27) 86 | 87 | def test_formatted_missing_field(self): 88 | """ 89 | ValueError raised when a field is missing when updating status bar 90 | """ 91 | 92 | fields = {'status': 'All good!'} 93 | sbar = self.manager.status_bar(status_format=u'Stage: {stage}, Status: {status}', stage=1, 94 | fields=fields) 95 | del fields['status'] 96 | 97 | sbar.last_update = sbar.start - 5.0 98 | with self.assertRaisesRegex(ValueError, "'status' specified in format, but not provided"): 99 | sbar.update() 100 | 101 | def test_bad_justify(self): 102 | """ 103 | ValueError raised when justify is given an invalid value 104 | """ 105 | 106 | with self.assertRaisesRegex(ValueError, 'justify must be one of Justify.LEFT, '): 107 | self.manager.status_bar('Hello', 'World!', justify='justice') 108 | 109 | def test_update(self): 110 | """ 111 | update() does not refresh is bar is disabled or min_delta hasn't passed 112 | """ 113 | 114 | self.manager.status_bar_class = MockStatusBar 115 | sbar = self.manager.status_bar('Hello', 'World!') 116 | 117 | self.assertEqual(sbar.called, 1) 118 | sbar.last_update = sbar.start - 1.0 119 | sbar.update() 120 | self.assertEqual(sbar.called, 2) 121 | 122 | sbar.last_update = sbar.start + 5.0 123 | sbar.update() 124 | self.assertEqual(sbar.called, 2) 125 | 126 | sbar.last_update = sbar.last_update - 10.0 127 | sbar.enabled = False 128 | sbar.update() 129 | self.assertEqual(sbar.called, 2) 130 | 131 | sbar.enabled = True 132 | sbar.update() 133 | self.assertEqual(sbar.called, 3) 134 | 135 | def test_fill(self): 136 | """ 137 | Fill uses remaining space 138 | """ 139 | 140 | sbar = self.manager.status_bar(status_format=u'{fill}HI', fill='-') 141 | self.assertEqual(sbar.format(), u'-' * 78 + 'HI') 142 | 143 | sbar = self.manager.status_bar(status_format=u'{fill}HI{fill}', fill='-') 144 | self.assertEqual(sbar.format(), u'-' * 39 + 'HI' + u'-' * 39) 145 | 146 | def test_fill_uneven(self): 147 | """ 148 | Extra fill should be equal 149 | """ 150 | 151 | sbar = self.manager.status_bar( 152 | status_format=u'{fill}Helloooo!{fill}Woooorld!{fill}', fill='-' 153 | ) 154 | self.assertEqual(sbar.format(), 155 | u'-' * 20 + 'Helloooo!' + u'-' * 21 + 'Woooorld!' + u'-' * 21) 156 | 157 | @unittest.skipIf(PY2, 'Skip warnings tests in Python 2') 158 | def test_reserve_fields(self): 159 | """ 160 | When reserved fields are used, a warning is raised 161 | """ 162 | 163 | with self.assertWarnsRegex(EnlightenWarning, 'Ignoring reserved fields') as warn: 164 | self.manager.status_bar(status_format=u'Stage: {stage}, Fill: {fill}', stage=1, 165 | fields={'fill': 'Reserved field'}) 166 | self.assertRegex(tests.__file__, warn.filename) 167 | 168 | with self.assertWarnsRegex(EnlightenWarning, 'Ignoring reserved fields') as warn: 169 | self.manager.status_bar(status_format=u'Stage: {stage}, elapsed: {elapsed}', stage=1, 170 | elapsed='Reserved field') 171 | self.assertRegex(tests.__file__, warn.filename) 172 | 173 | def test_elapsed(self): 174 | """ 175 | Elapsed property only counts to closed time 176 | """ 177 | 178 | self.manager.status_bar_class = MockStatusBar 179 | sbar = self.manager.status_bar('Hello', 'World!') 180 | now = time.time() 181 | sbar.start = now - 5.0 182 | 183 | self.assertEqual(round(sbar.elapsed), 5) 184 | 185 | sbar.close() 186 | self.assertEqual(round(sbar._closed), round(now)) # pylint: disable=protected-access 187 | self.assertEqual(round(sbar.elapsed), 5) 188 | 189 | sbar._closed -= 1.0 190 | self.assertEqual(round(sbar.elapsed), 4) 191 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 - 2023 Avram Lubkin, All Rights Reserved 3 | 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ 9 | Test module for enlighten._util 10 | """ 11 | 12 | from textwrap import dedent 13 | 14 | import blessed 15 | 16 | from enlighten._util import format_time, Lookahead, HTMLConverter 17 | 18 | from tests import TestCase, MockTTY 19 | 20 | 21 | class TestFormatTime(TestCase): 22 | """ 23 | Test cases for :py:func:`_format_time` 24 | """ 25 | 26 | def test_seconds(self): 27 | """Verify seconds formatting""" 28 | 29 | self.assertEqual(format_time(0), '00:00') 30 | self.assertEqual(format_time(6), '00:06') 31 | self.assertEqual(format_time(42), '00:42') 32 | self.assertEqual(format_time(48.2), '00:48') 33 | self.assertEqual(format_time(52.6), '00:53') 34 | 35 | def test_minutes(self): 36 | """Verify minutes formatting""" 37 | 38 | self.assertEqual(format_time(59.9), '01:00') 39 | self.assertEqual(format_time(60), '01:00') 40 | self.assertEqual(format_time(128), '02:08') 41 | self.assertEqual(format_time(1684), '28:04') 42 | 43 | def test_hours(self): 44 | """Verify hours formatting""" 45 | 46 | self.assertEqual(format_time(3600), '1h 00:00') 47 | self.assertEqual(format_time(43980), '12h 13:00') 48 | self.assertEqual(format_time(43998), '12h 13:18') 49 | 50 | def test_days(self): 51 | """Verify days formatting""" 52 | 53 | self.assertEqual(format_time(86400), '1d 0h 00:00') 54 | self.assertEqual(format_time(1447597), '16d 18h 06:37') 55 | 56 | 57 | class TestLookahead(TestCase): 58 | """ 59 | Test cases for Lookahead 60 | """ 61 | 62 | def test_iteration(self): 63 | """Verify normal iteration""" 64 | 65 | wrapped = Lookahead(iter(range(10))) 66 | 67 | self.assertEqual([next(wrapped) for _ in range(10)], list(range(10))) 68 | 69 | def test_getitem(self): 70 | """Verify __getitem__ behavior""" 71 | 72 | wrapped = Lookahead(iter(range(10))) 73 | 74 | self.assertEqual(wrapped[0], 0) 75 | self.assertEqual(wrapped[4], 4) 76 | self.assertEqual(wrapped[2: 4], [2, 3]) 77 | self.assertEqual(wrapped[8: 12], [8, 9]) 78 | 79 | with self.assertRaisesRegex(TypeError, 'Index or slice notation is required'): 80 | wrapped['named_key'] # pylint: disable=pointless-statement 81 | 82 | with self.assertRaisesRegex(ValueError, 'Negative indexes are not supported'): 83 | wrapped[-1] # pylint: disable=pointless-statement 84 | 85 | def test_buffer(self): 86 | """Output changes as iteration proceeds""" 87 | 88 | wrapped = Lookahead(iter(range(10))) 89 | 90 | self.assertEqual(next(wrapped), 0) 91 | self.assertEqual(wrapped[0], 1) 92 | self.assertEqual(next(wrapped), 1) 93 | self.assertEqual(wrapped[4], 6) 94 | self.assertEqual(next(wrapped), 2) 95 | self.assertEqual(wrapped[2: 4], [5, 6]) 96 | self.assertEqual(next(wrapped), 3) 97 | self.assertEqual(wrapped[8: 12], []) 98 | 99 | def test_step_notation(self): 100 | """Slice notation is supported""" 101 | 102 | wrapped = Lookahead(iter(range(10))) 103 | 104 | self.assertEqual(wrapped[: 6: 2], [0, 2, 4]) 105 | 106 | 107 | class TestHTMLConverter(TestCase): 108 | """ 109 | Test cases for HTMLConverter 110 | """ 111 | 112 | # pylint: disable=protected-access 113 | 114 | @classmethod 115 | def setUpClass(cls): 116 | cls.tty = MockTTY() 117 | cls.term = blessed.Terminal( 118 | stream=cls.tty.stdout, force_styling=True 119 | ) 120 | cls.term.number_of_colors = 1 << 24 121 | 122 | @classmethod 123 | def tearDownClass(cls): 124 | cls.tty.close() 125 | 126 | def setUp(self): 127 | self.converter = HTMLConverter(term=self.term) 128 | 129 | def test_color(self): 130 | """Verify color conversion""" 131 | 132 | # CGA color on RGB color 133 | out = self.converter.to_html(self.term.blue_on_aquamarine('blue_on_aquam')) 134 | self.assertEqual( 135 | out, 136 | u'
blue_on_aquam
' 137 | ) 138 | 139 | self.assertEqual(self.converter._styles['enlighten-fg-blue'], {'color': '#0000ee'}) 140 | self.assertEqual( 141 | self.converter._styles['enlighten-bg-aquamarine'], {'background-color': '#7fffd4'} 142 | ) 143 | 144 | # RGB color on CGA color 145 | out = self.converter.to_html(self.term.aquamarine_on_blue('aquam_on_blue')) 146 | self.assertEqual( 147 | out, 148 | u'
aquam_on_blue
' 149 | ) 150 | 151 | self.assertEqual(self.converter._styles['enlighten-fg-aquamarine'], {'color': '#7fffd4'}) 152 | self.assertEqual( 153 | self.converter._styles['enlighten-bg-blue'], {'background-color': '#0000ee'} 154 | ) 155 | 156 | # On RGB color 157 | out = self.converter.to_html(self.term.on_color_rgb(80, 4, 13)('on_color_rgb')) 158 | self.assertEqual(out, '
on_color_rgb
') 159 | 160 | self.assertEqual( 161 | self.converter._styles['enlighten-bg-50040d'], {'background-color': '#50040d'} 162 | ) 163 | 164 | # 256 Color 165 | out = self.converter.to_html(self.term.color(90)('color_90')) 166 | self.assertEqual(out, '
color_90
') 167 | 168 | self.assertEqual(self.converter._styles['enlighten-fg-870087'], {'color': '#870087'}) 169 | 170 | # On 256 Color 171 | out = self.converter.to_html(self.term.on_color(90)('on_color_90')) 172 | self.assertEqual(out, '
on_color_90
') 173 | 174 | self.assertEqual( 175 | self.converter._styles['enlighten-bg-870087'], {'background-color': '#870087'} 176 | ) 177 | 178 | # CGA Bright Color 179 | out = self.converter.to_html(self.term.bright_red('bright_red')) 180 | self.assertEqual(out, '
bright_red
') 181 | 182 | self.assertEqual(self.converter._styles['enlighten-fg-bright-red'], {'color': '#ff0000'}) 183 | 184 | # On CGA Bright Color 185 | out = self.converter.to_html(self.term.on_bright_red('on_bright_red')) 186 | self.assertEqual( 187 | out, 188 | '
on_bright_red
' 189 | ) 190 | 191 | self.assertEqual( 192 | self.converter._styles['enlighten-bg-bright-red'], {'background-color': '#ff0000'} 193 | ) 194 | 195 | def test_style(self): 196 | """Verify style conversion""" 197 | 198 | # Italics 199 | out = self.converter.to_html(self.term.italic('italic')) 200 | self.assertEqual(out, '
italic
') 201 | 202 | self.assertEqual(self.converter._styles['enlighten-italic'], {'font-style': 'italic'}) 203 | 204 | # Bold 205 | out = self.converter.to_html(self.term.bold('bold')) 206 | self.assertEqual(out, '
bold
') 207 | 208 | self.assertEqual(self.converter._styles['enlighten-bold'], {'font-weight': 'bold'}) 209 | 210 | # Underline 211 | out = self.converter.to_html(self.term.underline('underline')) 212 | self.assertEqual(out, '
underline
') 213 | 214 | self.assertEqual( 215 | self.converter._styles['enlighten-underline'], {'text-decoration': 'underline'} 216 | ) 217 | 218 | def test_unsupported(self): 219 | """Verify unsupported does not produce classes""" 220 | 221 | # Unsupported capability 222 | out = self.converter.to_html(self.term.move(5, 6) + 'unsupported_move') 223 | self.assertEqual(out, '
unsupported_move
') 224 | 225 | # Unsupported text attribute 226 | out = self.converter.to_html(self.term.reverse('unsupported_reverse')) 227 | self.assertEqual(out, '
unsupported_reverse
') 228 | 229 | def test_link(self): 230 | """Verify link creates hyperlink""" 231 | 232 | out = self.converter.to_html( 233 | self.term.link('https://pypi.org/project/enlighten/', 'enlighten') 234 | ) 235 | self.assertEqual( 236 | out, 237 | '
enlighten
' 238 | ) 239 | 240 | def test_empty_span(self): 241 | """Empty Spans are ignored""" 242 | 243 | out = self.converter.to_html(self.term.underline('') + 'empty') 244 | self.assertEqual(out, '
empty
') 245 | 246 | def test_class_not_unique(self): 247 | """Repeated classes are dropped within the same span""" 248 | 249 | out = self.converter.to_html(self.term.blue_on_aquamarine(self.term.blue('blue_on_aquam'))) 250 | self.assertEqual( 251 | out, 252 | u'
blue_on_aquam
' 253 | ) 254 | 255 | def test_style_output(self): 256 | """Verify style section output""" 257 | 258 | out = self.converter.to_html(self.term.red_on_slategrey('red_on_slategrey')) 259 | 260 | self.assertEqual( 261 | out, 262 | u'
red_on_slategrey
' 263 | ) 264 | 265 | style = '''\ 266 | 274 | ''' 275 | 276 | self.assertEqual(self.converter.style, dedent(style)) 277 | 278 | def test_blink(self): 279 | """Blink requires an additional style section""" 280 | 281 | if not self.term.blink: 282 | self.skipTest('blink is not supported by this terminal') 283 | 284 | out = self.converter.to_html(self.term.blink('blink')) 285 | self.assertEqual(out, '
blink
') 286 | 287 | self.assertEqual( 288 | self.converter._additional_styles, 289 | {'@keyframes enlighten-blink-animation {\n to {\n visibility: hidden;\n }\n}'} 290 | ) 291 | 292 | self.assertEqual( 293 | self.converter._styles['enlighten-blink'], 294 | {'animation': 'enlighten-blink-animation 1s steps(5, start) infinite'} 295 | ) 296 | 297 | style = '''\ 298 | 308 | ''' 309 | 310 | self.assertEqual(self.converter.style, dedent(style)) 311 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | # Pin virtualenv to the last version supporting 2.7 and 3.6 4 | virtualenv<=20.21.1 5 | pip<23.2 6 | ignore_basepython_conflict = True 7 | envlist = 8 | lint 9 | copyright 10 | coverage 11 | docs 12 | py27 13 | py3{12,11,10,9,8,7,6,5} 14 | pypy{27,310} 15 | 16 | [base] 17 | deps = 18 | blessed 19 | prefixed 20 | py{27,py27}: backports.functools-lru-cache 21 | 22 | [ipython] 23 | deps = 24 | ipykernel 25 | ipython 26 | nbconvert 27 | nbformat 28 | 29 | [testenv] 30 | basepython = python3.13 31 | usedevelop = True 32 | download=True 33 | ignore_errors = True 34 | 35 | deps = 36 | {[base]deps} 37 | py{27,py27}: mock 38 | 39 | commands = 40 | {envpython} -m unittest discover -s {toxinidir}/tests {posargs} 41 | 42 | [testenv:flake8] 43 | skip_install = True 44 | deps = 45 | flake8 46 | 47 | commands = 48 | flake8 49 | 50 | [testenv:pylint] 51 | skip_install = True 52 | ignore_errors = True 53 | deps = 54 | {[base]deps} 55 | {[ipython]deps} 56 | pylint 57 | pyenchant 58 | 59 | commands = 60 | pylint enlighten setup setup_helpers tests examples benchmarks 61 | 62 | [testenv:nbqa] 63 | skip_install = True 64 | ignore_errors = True 65 | deps = 66 | {[testenv:flake8]deps} 67 | {[testenv:pylint]deps} 68 | coverage 69 | nbqa 70 | 71 | commands = 72 | nbqa flake8 tests 73 | nbqa pylint tests 74 | 75 | [testenv:copyright] 76 | skip_install = True 77 | ignore_errors = True 78 | 79 | commands = 80 | {envpython} setup_helpers.py copyright 81 | 82 | [testenv:lint] 83 | skip_install = True 84 | ignore_errors = True 85 | deps = 86 | {[testenv:flake8]deps} 87 | {[testenv:pylint]deps} 88 | {[testenv:nbqa]deps} 89 | 90 | commands = 91 | {[testenv:flake8]commands} 92 | {[testenv:pylint]commands} 93 | {[testenv:nbqa]commands} 94 | 95 | [testenv:specialist] 96 | basepython = python3.11 97 | skip_install = True 98 | ignore_errors=True 99 | deps = 100 | {[base]deps} 101 | specialist >= 0.2.1 102 | 103 | commands = 104 | {envpython} -m specialist --output {toxinidir}/.specialist --targets enlighten/*.py -m unittest discover -s {toxinidir}/tests {posargs} 105 | 106 | [testenv:coverage] 107 | passenv = 108 | CI 109 | CODECOV_* 110 | GITHUB_* 111 | deps = 112 | {[base]deps} 113 | {[ipython]deps} 114 | coverage 115 | 116 | commands = 117 | coverage erase 118 | coverage run -p -m unittest discover -s {toxinidir}/tests {posargs} 119 | coverage combine 120 | coverage xml 121 | coverage report 122 | 123 | [testenv:docs] 124 | deps = 125 | sphinx 126 | sphinxcontrib-spelling 127 | sphinx_rtd_theme 128 | 129 | commands= 130 | {envpython} setup_helpers.py spelling-clean 131 | sphinx-build -vWEa --keep-going -b spelling doc build/doc 132 | {envpython} setup_helpers.py spelling 133 | sphinx-build -vWEa --keep-going -b html doc build/doc 134 | {envpython} setup_helpers.py rst2html README.rst 135 | --------------------------------------------------------------------------------