├── .coveragerc ├── .editorconfig ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── main.yml ├── .gitignore ├── .pylintrc ├── DEVELOPMENT.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── art ├── doge_spin.svg ├── halo.png └── persist_spin.svg ├── examples ├── __init__.py ├── colored_text_spin.py ├── context_manager.py ├── custom_spins.py ├── doge_spin.py ├── loader_spin.py ├── long_text.py ├── notebook.ipynb ├── persist_spin.py └── stream_change.py ├── halo ├── __init__.py ├── _utils.py ├── cursor.py ├── halo.py └── halo_notebook.py ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── _utils.py ├── test_halo.py └── test_halo_notebook.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | show_missing = True 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [*.py] 10 | indent_size = 4 11 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute? 2 | 3 | ## Development Guidelines 4 | 5 | Please read [development guidelines](https://github.com/manrajgrover/halo/blob/master/DEVELOPMENT.md) inorder to setup dev environment and run tests. 6 | 7 | ## Steps to contribute 8 | 9 | * Look for a issue or open a new one in [project issues](https://github.com/manrajgrover/halo/issues) 10 | * Fork the project 11 | * Clone to your machine based on your forked project 12 | * Create a new branch with an intuitive name. Take a look in concept of [feature branch](https://martinfowler.com/bliki/FeatureBranch.html) 13 | * Code your change / fix / new feature 14 | * Run tests 15 | * When the tests pass you are free to commit and push 16 | * Open a Pull Request with a description and the issue reference 17 | 18 | ## Best Practices 19 | 20 | Let's try to keep the code as simple and clean as we can. Some good pratices to follow during the contributing process: 21 | 22 | - **Respect the PEP8**: don't forget to check the [PEP8](https://www.python.org/dev/peps/pep-0008/) complains; 23 | - **Write Tests**: **always** write tests for your code 24 | - **Keep the Cordiality**: be polite and kind; words like please and thank you are welcome :) 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | ## Description 13 | 14 | 15 | ### System settings 16 | 17 | - Operating System: 18 | - Terminal in use: 19 | - Python version: 20 | - Halo version: 21 | - `pip freeze` output: 22 | 23 | ### Error 24 | 25 | 26 | ### Expected behaviour 27 | 28 | 29 | ## Steps to recreate 30 | 31 | 32 | ## People to notify 33 | 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ## Description of new feature, or changes 10 | 11 | 12 | ## Checklist 13 | 14 | - [ ] Your branch is up-to-date with the base branch 15 | - [ ] You've included at least one test if this is a new feature 16 | - [ ] All tests are passing 17 | 18 | ## Related Issues and Discussions 19 | 20 | 21 | 22 | ## People to notify 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: halo 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | test: 11 | name: Test with ${{ matrix.python-version }} on ${{ matrix.os }} 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: 17 | - "3.8" 18 | - "3.9" 19 | - "3.10" 20 | - "3.11" 21 | os: 22 | - ubuntu-latest 23 | - macos-latest 24 | - windows-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Setup python for test ${{ matrix.python-version }} 29 | uses: actions/setup-python@v3 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - name: Install dependencies 33 | run: | 34 | python --version 35 | python -m pip install --upgrade pip setuptools wheel 36 | pip --version 37 | python -m pip install --upgrade tox tox-gh>=1.2 38 | tox --version 39 | - name: Run tox targets for ${{ matrix.python-version }} 40 | run: | 41 | python -m tox 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # Coverage 104 | cover/ 105 | 106 | # tests 107 | tests/*.txt 108 | .pytest_cache 109 | 110 | # OS-specific files 111 | .DS_Store 112 | 113 | # IDE settings 114 | .vscode/ 115 | 116 | # Idea 117 | .idea -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Python code to execute, usually for sys.path manipulation such as 4 | # pygtk.require(). 5 | #init-hook= 6 | 7 | # Files or directories to be skipped. They should be base names, not 8 | # paths. 9 | ignore=CVS 10 | 11 | # Add files or directories matching the regex patterns to the ignore-list. The 12 | # regex matches against paths and can be in Posix or Windows format. 13 | ignore-paths= 14 | 15 | # Files or directories matching the regex patterns are skipped. The regex 16 | # matches against base names, not paths. 17 | ignore-patterns=^\.# 18 | 19 | # Pickle collected data for later comparisons. 20 | persistent=yes 21 | 22 | # List of plugins (as comma separated values of python modules names) to load, 23 | # usually to register additional checkers. 24 | load-plugins= 25 | pylint.extensions.check_elif, 26 | pylint.extensions.bad_builtin, 27 | pylint.extensions.docparams, 28 | pylint.extensions.for_any_all, 29 | pylint.extensions.set_membership, 30 | pylint.extensions.code_style, 31 | pylint.extensions.overlapping_exceptions, 32 | pylint.extensions.typing, 33 | pylint.extensions.redefined_variable_type, 34 | pylint.extensions.comparison_placement, 35 | pylint.extensions.broad_try_clause, 36 | pylint.extensions.dict_init_mutate, 37 | pylint.extensions.consider_refactoring_into_while_condition, 38 | 39 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 40 | # number of processors available to use. 41 | jobs=1 42 | 43 | # When enabled, pylint would attempt to guess common misconfiguration and emit 44 | # user-friendly hints instead of false-positive error messages. 45 | suggestion-mode=yes 46 | 47 | # Allow loading of arbitrary C extensions. Extensions are imported into the 48 | # active Python interpreter and may run arbitrary code. 49 | unsafe-load-any-extension=no 50 | 51 | # A comma-separated list of package or module names from where C extensions may 52 | # be loaded. Extensions are loading into the active Python interpreter and may 53 | # run arbitrary code 54 | extension-pkg-allow-list= 55 | 56 | # Minimum supported python version 57 | py-version = 3.8.0 58 | 59 | # Control the amount of potential inferred values when inferring a single 60 | # object. This can help the performance when dealing with large functions or 61 | # complex, nested conditions. 62 | limit-inference-results=100 63 | 64 | # Specify a score threshold under which the program will exit with error. 65 | fail-under=10.0 66 | 67 | # Return non-zero exit code if any of these messages/categories are detected, 68 | # even if score is above --fail-under value. Syntax same as enable. Messages 69 | # specified are enabled, while categories only check already-enabled messages. 70 | fail-on= 71 | 72 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint in 73 | # a server-like mode. 74 | clear-cache-post-run=no 75 | 76 | 77 | [MESSAGES CONTROL] 78 | 79 | # Only show warnings with the listed confidence levels. Leave empty to show 80 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 81 | # confidence= 82 | 83 | # Enable the message, report, category or checker with the given id(s). You can 84 | # either give multiple identifier separated by comma (,) or put this option 85 | # multiple time (only on the command line, not in the configuration file where 86 | # it should appear only once). See also the "--disable" option for examples. 87 | enable= 88 | use-symbolic-message-instead, 89 | useless-suppression, 90 | 91 | # Disable the message, report, category or checker with the given id(s). You 92 | # can either give multiple identifiers separated by comma (,) or put this 93 | # option multiple times (only on the command line, not in the configuration 94 | # file where it should appear only once).You can also use "--disable=all" to 95 | # disable everything first and then re-enable specific checks. For example, if 96 | # you want to run only the similarities checker, you can use "--disable=all 97 | # --enable=similarities". If you want to run only the classes checker, but have 98 | # no Warning level messages displayed, use"--disable=all --enable=classes 99 | # --disable=W" 100 | 101 | disable= 102 | attribute-defined-outside-init, 103 | invalid-name, 104 | missing-docstring, 105 | protected-access, 106 | too-few-public-methods, 107 | # handled by black 108 | format, 109 | # We anticipate #3512 where it will become optional 110 | fixme, 111 | consider-using-assignment-expr, 112 | 113 | 114 | [REPORTS] 115 | 116 | # Set the output format. Available formats are text, parseable, colorized, msvs 117 | # (visual studio) and html. You can also give a reporter class, eg 118 | # mypackage.mymodule.MyReporterClass. 119 | output-format=text 120 | 121 | # Tells whether to display a full report or only the messages 122 | reports=no 123 | 124 | # Python expression which should return a note less than 10 (10 is the highest 125 | # note). You have access to the variables 'fatal', 'error', 'warning', 'refactor', 'convention' 126 | # and 'info', which contain the number of messages in each category, as 127 | # well as 'statement', which is the total number of statements analyzed. This 128 | # score is used by the global evaluation report (RP0004). 129 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 130 | 131 | # Template used to display messages. This is a python new-style format string 132 | # used to format the message information. See doc for all details 133 | #msg-template= 134 | 135 | # Activate the evaluation score. 136 | score=yes 137 | 138 | 139 | [LOGGING] 140 | 141 | # Logging modules to check that the string format arguments are in logging 142 | # function parameter format 143 | logging-modules=logging 144 | 145 | # The type of string formatting that logging methods do. `old` means using % 146 | # formatting, `new` is for `{}` formatting. 147 | logging-format-style=old 148 | 149 | 150 | [MISCELLANEOUS] 151 | 152 | # List of note tags to take in consideration, separated by a comma. 153 | notes=FIXME,XXX,TODO 154 | 155 | # Regular expression of note tags to take in consideration. 156 | #notes-rgx= 157 | 158 | 159 | [SIMILARITIES] 160 | 161 | # Minimum lines number of a similarity. 162 | min-similarity-lines=6 163 | 164 | # Ignore comments when computing similarities. 165 | ignore-comments=yes 166 | 167 | # Ignore docstrings when computing similarities. 168 | ignore-docstrings=yes 169 | 170 | # Ignore imports when computing similarities. 171 | ignore-imports=yes 172 | 173 | # Signatures are removed from the similarity computation 174 | ignore-signatures=yes 175 | 176 | 177 | [VARIABLES] 178 | 179 | # Tells whether we should check for unused import in __init__ files. 180 | init-import=no 181 | 182 | # List of additional names supposed to be defined in builtins. Remember that 183 | # you should avoid defining new builtins when possible. 184 | additional-builtins= 185 | 186 | # List of strings which can identify a callback function by name. A callback 187 | # name must start or end with one of those strings. 188 | callbacks=cb_,_cb 189 | 190 | # Tells whether unused global variables should be treated as a violation. 191 | allow-global-unused-variables=yes 192 | 193 | # List of names allowed to shadow builtins 194 | allowed-redefined-builtins= 195 | 196 | # List of qualified module names which can have objects that can redefine 197 | # builtins. 198 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 199 | 200 | 201 | [FORMAT] 202 | 203 | # Maximum number of characters on a single line. 204 | max-line-length=100 205 | 206 | # Regexp for a line that is allowed to be longer than the limit. 207 | ignore-long-lines=^\s*(# )??$ 208 | 209 | # Allow the body of an if to be on the same line as the test if there is no 210 | # else. 211 | single-line-if-stmt=no 212 | 213 | # Allow the body of a class to be on the same line as the declaration if body 214 | # contains single statement. 215 | single-line-class-stmt=no 216 | 217 | # Maximum number of lines in a module 218 | max-module-lines=2000 219 | 220 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 221 | # tab). 222 | indent-string=' ' 223 | 224 | # Number of spaces of indent required inside a hanging or continued line. 225 | indent-after-paren=4 226 | 227 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 228 | expected-line-ending-format= 229 | 230 | 231 | [BASIC] 232 | 233 | # Good variable names which should always be accepted, separated by a comma 234 | good-names=i,j,k,ex,Run,_ 235 | 236 | # Good variable names regexes, separated by a comma. If names match any regex, 237 | # they will always be accepted 238 | good-names-rgxs= 239 | 240 | # Bad variable names which should always be refused, separated by a comma 241 | bad-names=foo,bar,baz,toto,tutu,tata 242 | 243 | # Bad variable names regexes, separated by a comma. If names match any regex, 244 | # they will always be refused 245 | bad-names-rgxs= 246 | 247 | # Colon-delimited sets of names that determine each other's naming style when 248 | # the name regexes allow several styles. 249 | name-group= 250 | 251 | # Include a hint for the correct naming format with invalid-name 252 | include-naming-hint=no 253 | 254 | # Naming style matching correct function names. 255 | function-naming-style=snake_case 256 | 257 | # Regular expression matching correct function names 258 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 259 | 260 | # Naming style matching correct variable names. 261 | variable-naming-style=snake_case 262 | 263 | # Regular expression matching correct variable names 264 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 265 | 266 | # Naming style matching correct constant names. 267 | const-naming-style=UPPER_CASE 268 | 269 | # Regular expression matching correct constant names 270 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 271 | 272 | # Naming style matching correct attribute names. 273 | attr-naming-style=snake_case 274 | 275 | # Regular expression matching correct attribute names 276 | attr-rgx=[a-z_][a-z0-9_]{2,}$ 277 | 278 | # Naming style matching correct argument names. 279 | argument-naming-style=snake_case 280 | 281 | # Regular expression matching correct argument names 282 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 283 | 284 | # Naming style matching correct class attribute names. 285 | class-attribute-naming-style=any 286 | 287 | # Regular expression matching correct class attribute names 288 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 289 | 290 | # Naming style matching correct class constant names. 291 | class-const-naming-style=UPPER_CASE 292 | 293 | # Regular expression matching correct class constant names. Overrides class- 294 | # const-naming-style. 295 | #class-const-rgx= 296 | 297 | # Naming style matching correct inline iteration names. 298 | inlinevar-naming-style=any 299 | 300 | # Regular expression matching correct inline iteration names 301 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 302 | 303 | # Naming style matching correct class names. 304 | class-naming-style=PascalCase 305 | 306 | # Regular expression matching correct class names 307 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 308 | 309 | 310 | # Naming style matching correct module names. 311 | module-naming-style=snake_case 312 | 313 | # Regular expression matching correct module names 314 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 315 | 316 | 317 | # Naming style matching correct method names. 318 | method-naming-style=snake_case 319 | 320 | # Regular expression matching correct method names 321 | method-rgx=[a-z_][a-z0-9_]{2,}$ 322 | 323 | # Regular expression matching correct type variable names 324 | #typevar-rgx= 325 | 326 | # Regular expression which should only match function or class names that do 327 | # not require a docstring. Use ^(?!__init__$)_ to also check __init__. 328 | no-docstring-rgx=__.*__ 329 | 330 | # Minimum line length for functions/classes that require docstrings, shorter 331 | # ones are exempt. 332 | docstring-min-length=-1 333 | 334 | # List of decorators that define properties, such as abc.abstractproperty. 335 | property-classes=abc.abstractproperty 336 | 337 | 338 | [TYPECHECK] 339 | 340 | # Regex pattern to define which classes are considered mixins if ignore-mixin- 341 | # members is set to 'yes' 342 | mixin-class-rgx=.*MixIn 343 | 344 | # List of module names for which member attributes should not be checked and 345 | # will not be imported (useful for modules/projects where namespaces are 346 | # manipulated during runtime and thus existing member attributes cannot be 347 | # deduced by static analysis). It supports qualified module names, as well 348 | # as Unix pattern matching. 349 | ignored-modules= 350 | 351 | # List of class names for which member attributes should not be checked (useful 352 | # for classes with dynamically set attributes). This supports the use of 353 | # qualified names. 354 | ignored-classes=SQLObject, optparse.Values, thread._local, _thread._local 355 | 356 | # List of members which are set dynamically and missed by pylint inference 357 | # system, and so shouldn't trigger E1101 when accessed. Python regular 358 | # expressions are accepted. 359 | generated-members=REQUEST,acl_users,aq_parent,argparse.Namespace 360 | 361 | # List of decorators that create context managers from functions, such as 362 | # contextlib.contextmanager. 363 | contextmanager-decorators=contextlib.contextmanager 364 | 365 | # Tells whether to warn about missing members when the owner of the attribute 366 | # is inferred to be None. 367 | ignore-none=yes 368 | 369 | # This flag controls whether pylint should warn about no-member and similar 370 | # checks whenever an opaque object is returned when inferring. The inference 371 | # can return multiple potential results while evaluating a Python object, but 372 | # some branches might not be evaluated, which results in partial inference. In 373 | # that case, it might be useful to still emit no-member and other checks for 374 | # the rest of the inferred objects. 375 | ignore-on-opaque-inference=yes 376 | 377 | # Show a hint with possible names when a member name was not found. The aspect 378 | # of finding the hint is based on edit distance. 379 | missing-member-hint=yes 380 | 381 | # The minimum edit distance a name should have in order to be considered a 382 | # similar match for a missing member name. 383 | missing-member-hint-distance=1 384 | 385 | # The total number of similar names that should be taken in consideration when 386 | # showing a hint for a missing member. 387 | missing-member-max-choices=1 388 | 389 | [SPELLING] 390 | 391 | # Spelling dictionary name. Available dictionaries: none. To make it working 392 | # install python-enchant package. 393 | spelling-dict= 394 | 395 | # List of comma separated words that should not be checked. 396 | spelling-ignore-words= 397 | 398 | # List of comma separated words that should be considered directives if they 399 | # appear and the beginning of a comment and should not be checked. 400 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:,pragma:,# noinspection 401 | 402 | # A path to a file that contains private dictionary; one word per line. 403 | spelling-private-dict-file=.pyenchant_pylint_custom_dict.txt 404 | 405 | # Tells whether to store unknown words to indicated private dictionary in 406 | # --spelling-private-dict-file option instead of raising a message. 407 | spelling-store-unknown-words=no 408 | 409 | # Limits count of emitted suggestions for spelling mistakes. 410 | max-spelling-suggestions=2 411 | 412 | 413 | [DESIGN] 414 | 415 | # Maximum number of arguments for function / method 416 | max-args = 9 417 | 418 | # Maximum number of locals for function / method body 419 | max-locals = 19 420 | 421 | # Maximum number of return / yield for function / method body 422 | max-returns=11 423 | 424 | # Maximum number of branch for function / method body 425 | max-branches = 20 426 | 427 | # Maximum number of statements in function / method body 428 | max-statements = 50 429 | 430 | # Maximum number of attributes for a class (see R0902). 431 | max-attributes=11 432 | 433 | # Maximum number of statements in a try-block 434 | max-try-statements = 7 435 | 436 | [CLASSES] 437 | 438 | # List of method names used to declare (i.e. assign) instance attributes. 439 | defining-attr-methods=__init__,__new__,setUp,__post_init__ 440 | 441 | # List of valid names for the first argument in a class method. 442 | valid-classmethod-first-arg=cls 443 | 444 | # List of valid names for the first argument in a metaclass class method. 445 | valid-metaclass-classmethod-first-arg=mcs 446 | 447 | # List of member names, which should be excluded from the protected access 448 | # warning. 449 | exclude-protected=_asdict,_fields,_replace,_source,_make 450 | 451 | # Warn about protected attribute access inside special methods 452 | check-protected-access-in-special-methods=no 453 | 454 | [IMPORTS] 455 | 456 | # List of modules that can be imported at any level, not just the top level 457 | # one. 458 | allow-any-import-level= 459 | 460 | # Allow wildcard imports from modules that define __all__. 461 | allow-wildcard-with-all=no 462 | 463 | # Allow explicit reexports by alias from a package __init__. 464 | allow-reexport-from-package=no 465 | 466 | # Analyse import fallback blocks. This can be used to support both Python 2 and 467 | # 3 compatible code, which means that the block might have code that exists 468 | # only in one or another interpreter, leading to false positives when analysed. 469 | analyse-fallback-blocks=no 470 | 471 | # Deprecated modules which should not be used, separated by a comma 472 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 473 | 474 | # Create a graph of every (i.e. internal and external) dependencies in the 475 | # given file (report RP0402 must not be disabled) 476 | import-graph= 477 | 478 | # Create a graph of external dependencies in the given file (report RP0402 must 479 | # not be disabled) 480 | ext-import-graph= 481 | 482 | # Create a graph of internal dependencies in the given file (report RP0402 must 483 | # not be disabled) 484 | int-import-graph= 485 | 486 | # Force import order to recognize a module as part of the standard 487 | # compatibility libraries. 488 | known-standard-library=_string 489 | 490 | # Force import order to recognize a module as part of a third party library. 491 | known-third-party=enchant 492 | 493 | # Couples of modules and preferred modules, separated by a comma. 494 | preferred-modules= 495 | 496 | 497 | [EXCEPTIONS] 498 | 499 | # Exceptions that will emit a warning when being caught. Defaults to 500 | # "Exception" 501 | overgeneral-exceptions=builtins.Exception 502 | 503 | 504 | [TYPING] 505 | 506 | # Set to ``no`` if the app / library does **NOT** need to support runtime 507 | # introspection of type annotations. If you use type annotations 508 | # **exclusively** for type checking of an application, you're probably fine. 509 | # For libraries, evaluate if some users what to access the type hints at 510 | # runtime first, e.g., through ``typing.get_type_hints``. Applies to Python 511 | # versions 3.7 - 3.9 512 | runtime-typing = no 513 | 514 | 515 | [DEPRECATED_BUILTINS] 516 | 517 | # List of builtins function names that should not be used, separated by a comma 518 | bad-functions=map,input 519 | 520 | 521 | [REFACTORING] 522 | 523 | # Maximum number of nested blocks for function / method body 524 | max-nested-blocks=5 525 | 526 | # Complete name of functions that never returns. When checking for 527 | # inconsistent-return-statements if a never returning function is called then 528 | # it will be considered as an explicit return statement and no message will be 529 | # printed. 530 | never-returning-functions=sys.exit,argparse.parse_error 531 | 532 | 533 | [STRING] 534 | 535 | # This flag controls whether inconsistent-quotes generates a warning when the 536 | # character used as a quote delimiter is used inconsistently within a module. 537 | check-quote-consistency=no 538 | 539 | # This flag controls whether the implicit-str-concat should generate a warning 540 | # on implicit string concatenation in sequences defined over several lines. 541 | check-str-concat-over-line-jumps=no 542 | 543 | 544 | [CODE_STYLE] 545 | 546 | # Max line length for which to sill emit suggestions. Used to prevent optional 547 | # suggestions which would get split by a code formatter (e.g., black). Will 548 | # default to the setting for ``max-line-length``. 549 | #max-line-length-suggestions= -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | ## Development 2 | 3 | We need to clone the project and prepare the dev environment: 4 | 5 | ```bash 6 | $ git clone https://github.com/manrajgrover/halo.git // or using ssh: git@github.com:manrajgrover/halo.git 7 | $ cd halo 8 | $ pip install -e . 9 | ``` 10 | 11 | This will install all requirements to use `halo`. You may want to create a virtual environment specifically for this. 12 | 13 | To install development dependencies, run: 14 | 15 | ```bash 16 | $ pip install -r requirements-dev.txt 17 | ``` 18 | 19 | #### Testing 20 | Before submitting a pull request, make sure the code passes all the tests and is clean of lint errors: 21 | 22 | ```bash 23 | $ tox 24 | ``` 25 | 26 | To run tests for specific environment, run: 27 | 28 | For Python 3.6: 29 | 30 | ```bash 31 | $ tox -e py36 32 | ``` 33 | 34 | For checking lint issues: 35 | 36 | ```bash 37 | $ tox -e lint 38 | ``` 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Manraj Singh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include requirements-dev.txt 3 | include LICENSE 4 | include README.md 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | halo 5 |

6 | 7 | [![Build Status](https://travis-ci.com/manrajgrover/halo.svg?branch=master)](https://travis-ci.com/manrajgrover/halo) [![Build status](https://ci.appveyor.com/api/projects/status/wa6t414gltr403ff?svg=true)](https://ci.appveyor.com/project/manrajgrover/halo) [![Coverage Status](https://coveralls.io/repos/github/manrajgrover/halo/badge.svg?branch=master)](https://coveralls.io/github/manrajgrover/halo?branch=master) 8 | [![PyPI](https://img.shields.io/pypi/v/halo.svg)](https://github.com/manrajgrover/halo) ![awesome](https://img.shields.io/badge/awesome-yes-green.svg) [![Downloads](https://pepy.tech/badge/halo)](https://pepy.tech/project/halo) [![Downloads](https://pepy.tech/badge/halo/month)](https://pepy.tech/project/halo/month) 9 | > Beautiful spinners for terminal, IPython and Jupyter 10 | 11 | ![halo](https://raw.github.com/manrajgrover/halo/master/art/doge_spin.svg?sanitize=true) 12 | 13 | ## Install 14 | 15 | ```shell 16 | $ pip install halo 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```py 22 | from halo import Halo 23 | 24 | spinner = Halo(text='Loading', spinner='dots') 25 | spinner.start() 26 | 27 | # Run time consuming work here 28 | # You can also change properties for spinner as and when you want 29 | 30 | spinner.stop() 31 | ``` 32 | 33 | Alternatively, you can use halo with Python's `with` statement: 34 | 35 | ```py 36 | from halo import Halo 37 | 38 | with Halo(text='Loading', spinner='dots'): 39 | # Run time consuming work here 40 | ``` 41 | 42 | Finally, you can use halo as a decorator: 43 | 44 | ```py 45 | from halo import Halo 46 | 47 | @Halo(text='Loading', spinner='dots') 48 | def long_running_function(): 49 | # Run time consuming work here 50 | pass 51 | 52 | long_running_function() 53 | ``` 54 | 55 | ## API 56 | 57 | #### `Halo([text|text_color|spinner|animation|placement|color|interval|stream|enabled])` 58 | 59 | ##### `text` 60 | *Type*: `str` 61 | 62 | Text shown along with spinner. 63 | 64 | ##### `text_color` 65 | *Type*: `str` 66 | *Values*: `grey`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` 67 | 68 | Color of the spinner text. Defaults to `None`. 69 | 70 | ##### `spinner` 71 | *Type*: `str|dict` 72 | 73 | If string, it should be one of the spinners listed in the given [json](https://github.com/sindresorhus/cli-spinners/blob/dac4fc6571059bb9e9bc204711e9dfe8f72e5c6f/spinners.json) file. If a dict is passed, it should define `interval` and `frames`. Something like: 74 | 75 | ```py 76 | { 77 | 'interval': 100, 78 | 'frames': ['-', '+', '*', '+', '-'] 79 | } 80 | ``` 81 | 82 | Defaults to `dots` spinner. For Windows users, it defaults to `line` spinner. 83 | 84 | ##### `animation` 85 | *Type*: `str` 86 | *Values*: `bounce`, `marquee` 87 | 88 | Animation to apply to the text if it's too large and doesn't fit in the terminal. If no animation is defined, the text will be ellipsed. 89 | 90 | ##### `placement` 91 | *Type*: `str` 92 | *Values*: `left`, `right` 93 | 94 | Which side of the text the spinner should be displayed. Defaults to `left` 95 | 96 | ##### `color` 97 | *Type*: `str` 98 | *Values*: `grey`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` 99 | 100 | Color of the spinner. Defaults to `cyan`. 101 | 102 | ##### `interval` 103 | *Type*: `float` 104 | 105 | Interval between each frame. Defaults to spinner interval (recommended). 106 | 107 | ##### `stream` 108 | *Type*: `file` 109 | 110 | Stream to write the output. Defaults to `sys.stdout`. 111 | 112 | ##### `enabled` 113 | *Type*: `bool` 114 | 115 | Enable or disable the spinner. Defaults to `True`. 116 | 117 | ### Methods 118 | 119 | Following are the methods available: 120 | 121 | #### `spinner.start([text])` 122 | 123 | Starts the spinner. If `text` is passed, it is set as spinner text. Returns the instance. 124 | 125 | #### `spinner.stop()` 126 | 127 | Stops and clears the spinner. Returns the instance. 128 | 129 | #### `spinner.clear()` 130 | 131 | Clears the spinner. Returns the instance. 132 | 133 | #### `spinner.render()` 134 | 135 | Manually renders a new frame. Returns the instance. 136 | 137 | #### `spinner.frame()` 138 | 139 | Returns next frame to be rendered. 140 | 141 | #### `spinner.succeed([text])` 142 | ##### `text`: *Type*: `str` 143 | 144 | Stops the spinner and changes symbol to `✔`. If text is provided, it is persisted else current text is persisted. Returns the instance. 145 | 146 | #### `spinner.fail([text])` 147 | ##### `text`: *Type*: `str` 148 | 149 | Stops the spinner and changes symbol to `✖`. If text is provided, it is persisted else current text is persisted. Returns the instance. 150 | 151 | #### `spinner.warn([text])` 152 | ##### `text`: *Type*: `str` 153 | 154 | Stops the spinner and changes symbol to `⚠`. If text is provided, it is persisted else current text is persisted. Returns the instance. 155 | 156 | #### `spinner.info([text])` 157 | ##### `text`: *Type*: `str` 158 | 159 | Stops the spinner and changes symbol to `ℹ`. If text is provided, it is persisted else current text is persisted. Returns the instance. 160 | 161 | #### `spinner.stop_and_persist([symbol|text])` 162 | Stops the spinner and changes symbol and text. Returns the instance. 163 | 164 | ##### `symbol` 165 | *Type*: `str` 166 | 167 | Symbol to replace the spinner with. Defaults to `' '`. 168 | 169 | ##### `text` 170 | *Type*: `str` 171 | 172 | Text to be persisted. Defaults to instance text. 173 | 174 | ![Persist spin](https://raw.github.com/manrajgrover/halo/master/art/persist_spin.svg?sanitize=true) 175 | 176 | #### `spinner.text` 177 | Change the text of spinner. 178 | 179 | #### `spinner.color` 180 | Change the color of spinner 181 | 182 | #### `spinner.spinner` 183 | Change the spinner itself. 184 | 185 | #### `spinner.enabled` 186 | Enable or disable the spinner. 187 | 188 | ## How to contribute? 189 | 190 | Please see [Contributing guidelines](https://github.com/manrajgrover/halo/blob/master/.github/CONTRIBUTING.md) for more information. 191 | 192 | ## Like it? 193 | 194 | 🌟 this repo to show support. Let me know you liked it on [Twitter](https://twitter.com/manrajsgrover). 195 | Also, share the [project](https://twitter.com/intent/tweet?url=https%3A%2F%2Fgithub.com%2Fmanrajgrover%2Fhalo&via=manrajsgrover&text=Checkout%20%23halo%20-%20a%20beautiful%20%23terminal%20%23spinners%20library%20for%20%23python&hashtags=github%2C%20pypi). 196 | 197 | ## Related 198 | 199 | * [py-spinners](https://github.com/manrajgrover/py-spinners) - Spinners in Python 200 | * [py-log-symbols](https://github.com/manrajgrover/py-log-symbols) - Log Symbols in Python 201 | * [ora](https://github.com/sindresorhus/ora) - Elegant terminal spinners in JavaScript (inspiration behind this project) 202 | 203 | ## License 204 | [MIT](https://github.com/manrajgrover/halo/blob/master/LICENSE) © Manraj Singh 205 | -------------------------------------------------------------------------------- /art/doge_spin.svg: -------------------------------------------------------------------------------- 1 | $pythonexamples/doge_spin.pySuchSpinsSuchSpinsSuchSpinsSuchSpinsSuchSpinsSuchSpinsSuchSpinsSuchSpinsSuchSpinsSuchSpinsMuchColorsMuchColorsMuchColorsMuchColorsMuchColorsMuchColorsMuchColorsMuchColorsMuchColorsMuchColors💛Veryemojis💙Veryemojis💜Veryemojis💚VeryemojisVeryemojis🦄Wow! 2 | -------------------------------------------------------------------------------- /art/halo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manrajgrover/halo/b76fa94abae4505d0d5b14dcf1d77521c02482e4/art/halo.png -------------------------------------------------------------------------------- /art/persist_spin.svg: -------------------------------------------------------------------------------- 1 | $pythonexamples/persist_spin.pyLoadingsuccessLoadingsuccessLoadingsuccessLoadingsuccessLoadingsuccessLoadingsuccessLoadingsuccessLoadingsuccessLoadingfailedLoadingfailedLoadingfailedLoadingfailedLoadingfailedLoadingfailedLoadingfailedLoadingunicornLoadingunicornLoadingunicornLoadingunicornLoadingunicornLoadingunicorn🦄LoadingunicornLoadingsuccessLoadingsuccessLoadingsuccessLoadingfailedLoadingfailedLoadingfailedLoadingfailedLoadingunicornLoadingunicornLoadingunicornLoadingunicorn 2 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manrajgrover/halo/b76fa94abae4505d0d5b14dcf1d77521c02482e4/examples/__init__.py -------------------------------------------------------------------------------- /examples/colored_text_spin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Example for doge spinner ;) 3 | """ 4 | from __future__ import unicode_literals 5 | import os 6 | import sys 7 | import time 8 | 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | from halo import Halo 12 | 13 | spinner = Halo(text='Such Spins', text_color= 'cyan', color='green', spinner='dots') 14 | 15 | try: 16 | spinner.start() 17 | time.sleep(2) 18 | spinner.text = 'Much Colors' 19 | spinner.color = 'magenta' 20 | spinner.text_color = 'green' 21 | time.sleep(2) 22 | spinner.text = 'Very emojis' 23 | spinner.spinner = 'hearts' 24 | spinner.text_color = 'magenta' 25 | time.sleep(2) 26 | spinner.stop_and_persist(symbol='🦄 '.encode('utf-8'), text='Wow!') 27 | except (KeyboardInterrupt, SystemExit): 28 | spinner.stop() 29 | -------------------------------------------------------------------------------- /examples/context_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Example for context manager 3 | """ 4 | import os 5 | import sys 6 | import time 7 | 8 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 9 | 10 | from halo import Halo 11 | 12 | with Halo(text='Loading', spinner='dots'): 13 | # Run time consuming work here 14 | time.sleep(4) 15 | 16 | with Halo(text='Loading 2', spinner='dots'): 17 | # Run time consuming work here 18 | time.sleep(4) 19 | -------------------------------------------------------------------------------- /examples/custom_spins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Example for custom spinner 3 | """ 4 | from __future__ import unicode_literals 5 | import os 6 | import sys 7 | import time 8 | 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | from halo import Halo 12 | 13 | spinner = Halo( 14 | text='Custom Spins', 15 | spinner={ 16 | 'interval': 100, 17 | 'frames': ['-', '+', '*', '+', '-'] 18 | } 19 | ) 20 | 21 | try: 22 | spinner.start() 23 | time.sleep(2) 24 | spinner.succeed('It works!') 25 | except (KeyboardInterrupt, SystemExit): 26 | spinner.stop() 27 | -------------------------------------------------------------------------------- /examples/doge_spin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Example for doge spinner ;) 3 | """ 4 | from __future__ import unicode_literals 5 | import os 6 | import sys 7 | import time 8 | 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | from halo import Halo 12 | 13 | spinner = Halo(text='Such Spins', spinner='dots') 14 | 15 | try: 16 | spinner.start() 17 | time.sleep(2) 18 | spinner.text = 'Much Colors' 19 | spinner.color = 'magenta' 20 | time.sleep(2) 21 | spinner.text = 'Very emojis' 22 | spinner.spinner = 'hearts' 23 | time.sleep(2) 24 | spinner.stop_and_persist(symbol='🦄'.encode('utf-8'), text='Wow!') 25 | except (KeyboardInterrupt, SystemExit): 26 | spinner.stop() 27 | -------------------------------------------------------------------------------- /examples/loader_spin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Example for spinner that looks like loader 3 | """ 4 | from __future__ import unicode_literals 5 | import os 6 | import time 7 | import sys 8 | import random 9 | 10 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 11 | 12 | from halo import Halo 13 | 14 | spinner = Halo(text='Downloading dataset.zip', spinner='dots') 15 | 16 | try: 17 | spinner.start() 18 | for i in range(100): 19 | spinner.text = '{}% Downloaded dataset.zip'.format(i) 20 | time.sleep(random.random()) 21 | spinner.succeed('Downloaded dataset.zip') 22 | except (KeyboardInterrupt, SystemExit): 23 | spinner.stop() 24 | -------------------------------------------------------------------------------- /examples/long_text.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Example for text wrapping animation 3 | """ 4 | from __future__ import unicode_literals 5 | import os 6 | import sys 7 | import time 8 | 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | from halo import Halo 12 | 13 | spinner = Halo(text='This is a text that it is too long. In fact, it exceeds the eighty column standard ' 14 | 'terminal width, which forces the text frame renderer to add an ellipse at the end of the ' 15 | 'text. This should definitely make it more than 180!', spinner='dots', animation='marquee') 16 | 17 | try: 18 | spinner.start() 19 | time.sleep(15) 20 | spinner.succeed('End!') 21 | except (KeyboardInterrupt, SystemExit): 22 | spinner.stop() 23 | -------------------------------------------------------------------------------- /examples/notebook.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import os\n", 10 | "import time\n", 11 | "\n", 12 | "os.sys.path.append(os.path.dirname(os.path.abspath('./')))" 13 | ] 14 | }, 15 | { 16 | "cell_type": "markdown", 17 | "metadata": {}, 18 | "source": [ 19 | "# HaloNotebook" 20 | ] 21 | }, 22 | { 23 | "cell_type": "code", 24 | "execution_count": null, 25 | "metadata": {}, 26 | "outputs": [], 27 | "source": [ 28 | "from halo import HaloNotebook as Halo" 29 | ] 30 | }, 31 | { 32 | "cell_type": "markdown", 33 | "metadata": {}, 34 | "source": [ 35 | "## Test example codes\n", 36 | "\n", 37 | "This frontend (for example, a static rendering on GitHub or NBViewer) doesn't currently support widgets. If you wonder results, run notebook manually.\n" 38 | ] 39 | }, 40 | { 41 | "cell_type": "markdown", 42 | "metadata": {}, 43 | "source": [ 44 | "### `persist_spin.py`" 45 | ] 46 | }, 47 | { 48 | "cell_type": "code", 49 | "execution_count": null, 50 | "metadata": {}, 51 | "outputs": [], 52 | "source": [ 53 | "success_message = 'Loading success'\n", 54 | "failed_message = 'Loading failed'\n", 55 | "unicorn_message = 'Loading unicorn'\n", 56 | "\n", 57 | "spinner = Halo(text=success_message, spinner='dots')\n", 58 | "\n", 59 | "try:\n", 60 | " spinner.start()\n", 61 | " time.sleep(1)\n", 62 | " spinner.succeed()\n", 63 | " spinner.start(failed_message)\n", 64 | " time.sleep(1)\n", 65 | " spinner.fail()\n", 66 | " spinner.start(unicorn_message)\n", 67 | " time.sleep(1)\n", 68 | " spinner.stop_and_persist(symbol='🦄'.encode('utf-8'), text=unicorn_message)\n", 69 | "except (KeyboardInterrupt, SystemExit):\n", 70 | " spinner.stop()" 71 | ] 72 | }, 73 | { 74 | "cell_type": "markdown", 75 | "metadata": {}, 76 | "source": [ 77 | "### `long_text.py`" 78 | ] 79 | }, 80 | { 81 | "cell_type": "code", 82 | "execution_count": null, 83 | "metadata": {}, 84 | "outputs": [], 85 | "source": [ 86 | "spinner = Halo(text='This is a text that it is too long. In fact, it exceeds the eighty column standard '\n", 87 | " 'terminal width, which forces the text frame renderer to add an ellipse at the end of the '\n", 88 | " 'text. This should definitely make it more than 180!', spinner='dots', animation='marquee')\n", 89 | "\n", 90 | "try:\n", 91 | " spinner.start()\n", 92 | " time.sleep(5)\n", 93 | " spinner.succeed('End!')\n", 94 | "except (KeyboardInterrupt, SystemExit):\n", 95 | " spinner.stop()" 96 | ] 97 | }, 98 | { 99 | "cell_type": "markdown", 100 | "metadata": {}, 101 | "source": [ 102 | "### `loader_spin.py`" 103 | ] 104 | }, 105 | { 106 | "cell_type": "code", 107 | "execution_count": null, 108 | "metadata": { 109 | "scrolled": true 110 | }, 111 | "outputs": [], 112 | "source": [ 113 | "spinner = Halo(text='Downloading dataset.zip', spinner='dots')\n", 114 | "\n", 115 | "try:\n", 116 | " spinner.start()\n", 117 | " for i in range(100):\n", 118 | " spinner.text = '{}% Downloaded dataset.zip'.format(i)\n", 119 | " time.sleep(0.05)\n", 120 | " spinner.succeed('Downloaded dataset.zip')\n", 121 | "except (KeyboardInterrupt, SystemExit):\n", 122 | " spinner.stop()" 123 | ] 124 | }, 125 | { 126 | "cell_type": "markdown", 127 | "metadata": {}, 128 | "source": [ 129 | "### `doge_spin.py`" 130 | ] 131 | }, 132 | { 133 | "cell_type": "code", 134 | "execution_count": null, 135 | "metadata": {}, 136 | "outputs": [], 137 | "source": [ 138 | "spinner = Halo(text='Such Spins', spinner='dots')\n", 139 | "\n", 140 | "try:\n", 141 | " spinner.start()\n", 142 | " time.sleep(1)\n", 143 | " spinner.text = 'Much Colors'\n", 144 | " spinner.color = 'magenta'\n", 145 | " time.sleep(1)\n", 146 | " spinner.text = 'Very emojis'\n", 147 | " spinner.spinner = 'hearts'\n", 148 | " time.sleep(1)\n", 149 | " spinner.stop_and_persist(symbol='🦄 '.encode('utf-8'), text='Wow!')\n", 150 | "except (KeyboardInterrupt, SystemExit):\n", 151 | " spinner.stop()" 152 | ] 153 | }, 154 | { 155 | "cell_type": "markdown", 156 | "metadata": {}, 157 | "source": [ 158 | "### `custom_spins.py`" 159 | ] 160 | }, 161 | { 162 | "cell_type": "code", 163 | "execution_count": null, 164 | "metadata": {}, 165 | "outputs": [], 166 | "source": [ 167 | "spinner = Halo(\n", 168 | " text='Custom Spins',\n", 169 | " spinner={\n", 170 | " 'interval': 100,\n", 171 | " 'frames': ['-', '+', '*', '+', '-']\n", 172 | " }\n", 173 | ")\n", 174 | "\n", 175 | "try:\n", 176 | " spinner.start()\n", 177 | " time.sleep(2)\n", 178 | " spinner.succeed('It works!')\n", 179 | "except (KeyboardInterrupt, SystemExit):\n", 180 | " spinner.stop()" 181 | ] 182 | }, 183 | { 184 | "cell_type": "markdown", 185 | "metadata": {}, 186 | "source": [ 187 | "### `context_manager.py`" 188 | ] 189 | }, 190 | { 191 | "cell_type": "code", 192 | "execution_count": null, 193 | "metadata": {}, 194 | "outputs": [], 195 | "source": [ 196 | "with Halo(text='Loading', spinner='dots'):\n", 197 | " # Run time consuming work here\n", 198 | " time.sleep(2)\n", 199 | "\n", 200 | "with Halo(text='Loading 2', spinner='dots') as spinner:\n", 201 | " # Run time consuming work here\n", 202 | " time.sleep(2)\n", 203 | " spinner.succeed('Done!')" 204 | ] 205 | } 206 | ], 207 | "metadata": { 208 | "kernelspec": { 209 | "display_name": "Python 3", 210 | "language": "python", 211 | "name": "python3" 212 | }, 213 | "language_info": { 214 | "codemirror_mode": { 215 | "name": "ipython", 216 | "version": 3 217 | }, 218 | "file_extension": ".py", 219 | "mimetype": "text/x-python", 220 | "name": "python", 221 | "nbconvert_exporter": "python", 222 | "pygments_lexer": "ipython3", 223 | "version": "3.6.3" 224 | } 225 | }, 226 | "nbformat": 4, 227 | "nbformat_minor": 2 228 | } 229 | -------------------------------------------------------------------------------- /examples/persist_spin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Example for persisting spinner 3 | """ 4 | from __future__ import unicode_literals 5 | import os 6 | import sys 7 | import time 8 | 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | from halo import Halo 12 | 13 | success_message = 'Loading success' 14 | failed_message = 'Loading failed' 15 | unicorn_message = 'Loading unicorn' 16 | 17 | spinner = Halo(text=success_message, spinner='dots') 18 | 19 | try: 20 | spinner.start() 21 | time.sleep(1) 22 | spinner.succeed() 23 | spinner.start(failed_message) 24 | time.sleep(1) 25 | spinner.fail() 26 | spinner.start(unicorn_message) 27 | time.sleep(1) 28 | spinner.stop_and_persist(symbol='🦄'.encode('utf-8'), text=unicorn_message) 29 | except (KeyboardInterrupt, SystemExit): 30 | spinner.stop() 31 | -------------------------------------------------------------------------------- /examples/stream_change.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Example for changing stream 3 | """ 4 | from __future__ import unicode_literals, absolute_import 5 | import os 6 | import sys 7 | import time 8 | 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | from halo import Halo 12 | 13 | import sys 14 | 15 | spinner = Halo(stream=sys.stderr) 16 | 17 | spinner.start('Loading') 18 | time.sleep(1) 19 | spinner.stop() 20 | -------------------------------------------------------------------------------- /halo/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Manraj Singh' 3 | __email__ = 'manrajsinghgrover@gmail.com' 4 | 5 | import logging 6 | 7 | from .halo import Halo 8 | from .halo_notebook import HaloNotebook 9 | 10 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 11 | -------------------------------------------------------------------------------- /halo/_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Utilities for Halo library. 3 | """ 4 | import codecs 5 | import platform 6 | import six 7 | 8 | from colorama import init 9 | from shutil import get_terminal_size 10 | from termcolor import colored 11 | 12 | init(autoreset=True) 13 | 14 | 15 | def is_supported(): 16 | """Check whether operating system supports main symbols or not. 17 | 18 | Returns 19 | ------- 20 | boolean 21 | Whether operating system supports main symbols or not 22 | """ 23 | 24 | os_arch = platform.system() 25 | 26 | if os_arch != 'Windows': 27 | return True 28 | 29 | return False 30 | 31 | 32 | def get_environment(): 33 | """Get the environment in which halo is running 34 | 35 | Returns 36 | ------- 37 | str 38 | Environment name 39 | """ 40 | try: 41 | from IPython import get_ipython 42 | except ImportError: 43 | return 'terminal' 44 | 45 | try: 46 | shell = get_ipython().__class__.__name__ 47 | 48 | if shell == 'ZMQInteractiveShell': # Jupyter notebook or qtconsole 49 | return 'jupyter' 50 | elif shell == 'TerminalInteractiveShell': # Terminal running IPython 51 | return 'ipython' 52 | else: 53 | return 'terminal' # Other type (?) 54 | 55 | except NameError: 56 | return 'terminal' 57 | 58 | 59 | def colored_frame(frame, color): 60 | """Color the frame with given color and returns. 61 | 62 | Parameters 63 | ---------- 64 | frame : str 65 | Frame to be colored 66 | color : str 67 | Color to be applied 68 | 69 | Returns 70 | ------- 71 | str 72 | Colored frame 73 | """ 74 | return colored(frame, color, attrs=['bold']) 75 | 76 | 77 | def is_text_type(text): 78 | """Check if given parameter is a string or not 79 | 80 | Parameters 81 | ---------- 82 | text : * 83 | Parameter to be checked for text type 84 | 85 | Returns 86 | ------- 87 | bool 88 | Whether parameter is a string or not 89 | """ 90 | if isinstance(text, six.text_type) or isinstance(text, six.string_types): 91 | return True 92 | 93 | return False 94 | 95 | 96 | def decode_utf_8_text(text): 97 | """Decode the text from utf-8 format 98 | 99 | Parameters 100 | ---------- 101 | text : str 102 | String to be decoded 103 | 104 | Returns 105 | ------- 106 | str 107 | Decoded string 108 | """ 109 | try: 110 | return codecs.decode(text, 'utf-8') 111 | except (TypeError, ValueError): 112 | return text 113 | 114 | 115 | def encode_utf_8_text(text): 116 | """Encodes the text to utf-8 format 117 | 118 | Parameters 119 | ---------- 120 | text : str 121 | String to be encoded 122 | 123 | Returns 124 | ------- 125 | str 126 | Encoded string 127 | """ 128 | try: 129 | return codecs.encode(text, 'utf-8', 'ignore') 130 | except (TypeError, ValueError): 131 | return text 132 | 133 | 134 | def get_terminal_columns(): 135 | """Determine the amount of available columns in the terminal 136 | 137 | Returns 138 | ------- 139 | int 140 | Terminal width 141 | """ 142 | terminal_size = get_terminal_size() 143 | 144 | # If column size is 0 either we are not connected 145 | # to a terminal or something else went wrong. Fallback to 80. 146 | if terminal_size.columns == 0: 147 | return 80 148 | else: 149 | return terminal_size.columns 150 | -------------------------------------------------------------------------------- /halo/cursor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Source: https://stackoverflow.com/a/10455937/2692667 3 | """ 4 | 5 | import sys 6 | import os 7 | 8 | if os.name == "nt": 9 | import ctypes 10 | 11 | class _CursorInfo(ctypes.Structure): 12 | _fields_ = [("size", ctypes.c_int), ("visible", ctypes.c_byte)] 13 | 14 | 15 | def hide(stream=sys.stdout): 16 | """Hide cursor. 17 | Parameters 18 | ---------- 19 | stream: sys.stdout, Optional 20 | Defines stream to write output to. 21 | """ 22 | if os.name == "nt": 23 | ci = _CursorInfo() 24 | handle = ctypes.windll.kernel32.GetStdHandle(-11) 25 | ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(ci)) 26 | ci.visible = False 27 | ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(ci)) 28 | elif os.name == "posix": 29 | stream.write("\033[?25l") 30 | stream.flush() 31 | 32 | 33 | def show(stream=sys.stdout): 34 | """Show cursor. 35 | Parameters 36 | ---------- 37 | stream: sys.stdout, Optional 38 | Defines stream to write output to. 39 | """ 40 | if os.name == "nt": 41 | ci = _CursorInfo() 42 | handle = ctypes.windll.kernel32.GetStdHandle(-11) 43 | ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(ci)) 44 | ci.visible = True 45 | ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(ci)) 46 | elif os.name == "posix": 47 | stream.write("\033[?25h") 48 | stream.flush() 49 | -------------------------------------------------------------------------------- /halo/halo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=unsubscriptable-object 3 | """Beautiful terminal spinners in Python. 4 | """ 5 | from __future__ import absolute_import, unicode_literals 6 | 7 | import atexit 8 | import functools 9 | import sys 10 | import threading 11 | import time 12 | 13 | import halo.cursor as cursor 14 | 15 | from log_symbols.symbols import LogSymbols 16 | from spinners.spinners import Spinners 17 | 18 | from halo._utils import ( 19 | colored_frame, 20 | decode_utf_8_text, 21 | get_environment, 22 | get_terminal_columns, 23 | is_supported, 24 | is_text_type, 25 | encode_utf_8_text, 26 | ) 27 | 28 | 29 | class Halo(object): 30 | """Halo library. 31 | Attributes 32 | ---------- 33 | CLEAR_LINE : str 34 | Code to clear the line 35 | """ 36 | 37 | CLEAR_LINE = "\033[K" 38 | SPINNER_PLACEMENTS = ( 39 | "left", 40 | "right", 41 | ) 42 | 43 | def __init__( 44 | self, 45 | text="", 46 | color="cyan", 47 | text_color=None, 48 | spinner=None, 49 | animation=None, 50 | placement="left", 51 | interval=-1, 52 | enabled=True, 53 | stream=sys.stdout, 54 | ): 55 | """Constructs the Halo object. 56 | Parameters 57 | ---------- 58 | text : str, optional 59 | Text to display. 60 | text_color : str, optional 61 | Color of the text. 62 | color : str, optional 63 | Color of the text to display. 64 | spinner : str|dict, optional 65 | String or dictionary representing spinner. String can be one of 60+ spinners 66 | supported. 67 | animation: str, optional 68 | Animation to apply if text is too large. Can be one of `bounce`, `marquee`. 69 | Defaults to ellipses. 70 | placement: str, optional 71 | Side of the text to place the spinner on. Can be `left` or `right`. 72 | Defaults to `left`. 73 | interval : integer, optional 74 | Interval between each frame of the spinner in milliseconds. 75 | enabled : boolean, optional 76 | Spinner enabled or not. 77 | stream : io, optional 78 | Output. 79 | """ 80 | self._color = color 81 | self._animation = animation 82 | 83 | self.spinner = spinner 84 | self.text = text 85 | self._text_color = text_color 86 | 87 | self._interval = ( 88 | int(interval) if int(interval) > 0 else self._spinner["interval"] 89 | ) 90 | self._stream = stream 91 | 92 | self.placement = placement 93 | self._frame_index = 0 94 | self._text_index = 0 95 | self._spinner_thread = None 96 | self._stop_spinner = None 97 | self._spinner_id = None 98 | self.enabled = enabled 99 | 100 | environment = get_environment() 101 | 102 | def clean_up(args): 103 | """Handle cell execution""" 104 | self.stop() 105 | 106 | if environment in ("ipython", "jupyter"): 107 | from IPython import get_ipython 108 | 109 | ip = get_ipython() 110 | ip.events.register("post_run_cell", clean_up) 111 | else: # default terminal 112 | atexit.register(clean_up) 113 | 114 | def __enter__(self): 115 | """Starts the spinner on a separate thread. For use in context managers. 116 | Returns 117 | ------- 118 | self 119 | """ 120 | return self.start() 121 | 122 | def __exit__(self, type, value, traceback): 123 | """Stops the spinner. For use in context managers.""" 124 | self.stop() 125 | 126 | def __call__(self, f): 127 | """Allow the Halo object to be used as a regular function decorator.""" 128 | 129 | @functools.wraps(f) 130 | def wrapped(*args, **kwargs): 131 | with self: 132 | return f(*args, **kwargs) 133 | 134 | return wrapped 135 | 136 | @property 137 | def spinner(self): 138 | """Getter for spinner property. 139 | Returns 140 | ------- 141 | dict 142 | spinner value 143 | """ 144 | return self._spinner 145 | 146 | @spinner.setter 147 | def spinner(self, spinner=None): 148 | """Setter for spinner property. 149 | Parameters 150 | ---------- 151 | spinner : dict, str 152 | Defines the spinner value with frame and interval 153 | """ 154 | 155 | self._spinner = self._get_spinner(spinner) 156 | self._frame_index = 0 157 | self._text_index = 0 158 | 159 | @property 160 | def text(self): 161 | """Getter for text property. 162 | Returns 163 | ------- 164 | str 165 | text value 166 | """ 167 | return self._text["original"] 168 | 169 | @text.setter 170 | def text(self, text): 171 | """Setter for text property. 172 | Parameters 173 | ---------- 174 | text : str 175 | Defines the text value for spinner 176 | """ 177 | self._text = self._get_text(text) 178 | 179 | @property 180 | def text_color(self): 181 | """Getter for text color property. 182 | Returns 183 | ------- 184 | str 185 | text color value 186 | """ 187 | return self._text_color 188 | 189 | @text_color.setter 190 | def text_color(self, text_color): 191 | """Setter for text color property. 192 | Parameters 193 | ---------- 194 | text_color : str 195 | Defines the text color value for spinner 196 | """ 197 | self._text_color = text_color 198 | 199 | @property 200 | def color(self): 201 | """Getter for color property. 202 | Returns 203 | ------- 204 | str 205 | color value 206 | """ 207 | return self._color 208 | 209 | @color.setter 210 | def color(self, color): 211 | """Setter for color property. 212 | Parameters 213 | ---------- 214 | color : str 215 | Defines the color value for spinner 216 | """ 217 | self._color = color 218 | 219 | @property 220 | def placement(self): 221 | """Getter for placement property. 222 | Returns 223 | ------- 224 | str 225 | spinner placement 226 | """ 227 | return self._placement 228 | 229 | @placement.setter 230 | def placement(self, placement): 231 | """Setter for placement property. 232 | Parameters 233 | ---------- 234 | placement: str 235 | Defines the placement of the spinner 236 | """ 237 | if placement not in self.SPINNER_PLACEMENTS: 238 | raise ValueError( 239 | "Unknown spinner placement '{0}', available are {1}".format( 240 | placement, self.SPINNER_PLACEMENTS 241 | ) 242 | ) 243 | self._placement = placement 244 | 245 | @property 246 | def spinner_id(self): 247 | """Getter for spinner id 248 | Returns 249 | ------- 250 | str 251 | Spinner id value 252 | """ 253 | return self._spinner_id 254 | 255 | @property 256 | def animation(self): 257 | """Getter for animation property. 258 | Returns 259 | ------- 260 | str 261 | Spinner animation 262 | """ 263 | return self._animation 264 | 265 | @animation.setter 266 | def animation(self, animation): 267 | """Setter for animation property. 268 | Parameters 269 | ---------- 270 | animation: str 271 | Defines the animation of the spinner 272 | """ 273 | self._animation = animation 274 | self._text = self._get_text(self._text["original"]) 275 | 276 | def _check_stream(self): 277 | """Returns whether the stream is open, and if applicable, writable 278 | Returns 279 | ------- 280 | bool 281 | Whether the stream is open 282 | """ 283 | if self._stream.closed: 284 | return False 285 | 286 | try: 287 | # Attribute access kept separate from invocation, to avoid 288 | # swallowing AttributeErrors from the call which should bubble up. 289 | check_stream_writable = self._stream.writable 290 | except AttributeError: 291 | pass 292 | else: 293 | return check_stream_writable() 294 | 295 | return True 296 | 297 | def _write(self, s): 298 | """Write to the stream, if writable 299 | Parameters 300 | ---------- 301 | s : str 302 | Characters to write to the stream 303 | """ 304 | if self._check_stream(): 305 | self._stream.write(s) 306 | 307 | def _hide_cursor(self): 308 | """Disable the user's blinking cursor 309 | """ 310 | if self._check_stream() and self._stream.isatty(): 311 | cursor.hide(stream=self._stream) 312 | 313 | def _show_cursor(self): 314 | """Re-enable the user's blinking cursor 315 | """ 316 | if self._check_stream() and self._stream.isatty(): 317 | cursor.show(stream=self._stream) 318 | 319 | def _get_spinner(self, spinner): 320 | """Extracts spinner value from options and returns value 321 | containing spinner frames and interval, defaults to 'dots' spinner. 322 | Parameters 323 | ---------- 324 | spinner : dict, str 325 | Contains spinner value or type of spinner to be used 326 | Returns 327 | ------- 328 | dict 329 | Contains frames and interval defining spinner 330 | """ 331 | default_spinner = Spinners["dots"].value 332 | 333 | if spinner and type(spinner) == dict: 334 | return spinner 335 | 336 | if is_supported(): 337 | if all([is_text_type(spinner), spinner in Spinners.__members__]): 338 | return Spinners[spinner].value 339 | else: 340 | return default_spinner 341 | else: 342 | return Spinners["line"].value 343 | 344 | def _get_text(self, text): 345 | """Creates frames based on the selected animation 346 | Returns 347 | ------- 348 | self 349 | """ 350 | animation = self._animation 351 | stripped_text = text.strip() 352 | 353 | # Check which frame of the animation is the widest 354 | max_spinner_length = max([len(i) for i in self._spinner["frames"]]) 355 | 356 | # Subtract to the current terminal size the max spinner length 357 | # (-1 to leave room for the extra space between spinner and text) 358 | terminal_width = get_terminal_columns() - max_spinner_length - 1 359 | text_length = len(stripped_text) 360 | 361 | frames = [] 362 | 363 | if terminal_width < text_length and animation: 364 | if animation == "bounce": 365 | """ 366 | Make the text bounce back and forth 367 | """ 368 | for x in range(0, text_length - terminal_width + 1): 369 | frames.append(stripped_text[x : terminal_width + x]) 370 | frames.extend(list(reversed(frames))) 371 | elif "marquee": 372 | """ 373 | Make the text scroll like a marquee 374 | """ 375 | stripped_text = stripped_text + " " + stripped_text[:terminal_width] 376 | for x in range(0, text_length + 1): 377 | frames.append(stripped_text[x : terminal_width + x]) 378 | elif terminal_width < text_length and not animation: 379 | # Add ellipsis if text is larger than terminal width and no animation was specified 380 | frames = [stripped_text[: terminal_width - 6] + " (...)"] 381 | else: 382 | frames = [stripped_text] 383 | 384 | return {"original": text, "frames": frames} 385 | 386 | def clear(self): 387 | """Clears the line and returns cursor to the start. 388 | of line 389 | Returns 390 | ------- 391 | self 392 | """ 393 | self._write("\r") 394 | self._write(self.CLEAR_LINE) 395 | return self 396 | 397 | def _render_frame(self): 398 | """Renders the frame on the line after clearing it. 399 | """ 400 | if not self.enabled: 401 | # in case we're disabled or stream is closed while still rendering, 402 | # we render the frame and increment the frame index, so the proper 403 | # frame is rendered if we're reenabled or the stream opens again. 404 | return 405 | 406 | self.clear() 407 | frame = self.frame() 408 | output = "\r{}".format(frame) 409 | try: 410 | self._write(output) 411 | except UnicodeEncodeError: 412 | self._write(encode_utf_8_text(output)) 413 | 414 | def render(self): 415 | """Runs the render until thread flag is set. 416 | Returns 417 | ------- 418 | self 419 | """ 420 | while not self._stop_spinner.is_set(): 421 | self._render_frame() 422 | time.sleep(0.001 * self._interval) 423 | 424 | return self 425 | 426 | def frame(self): 427 | """Builds and returns the frame to be rendered 428 | Returns 429 | ------- 430 | self 431 | """ 432 | frames = self._spinner["frames"] 433 | frame = frames[self._frame_index] 434 | 435 | if self._color: 436 | frame = colored_frame(frame, self._color) 437 | 438 | self._frame_index += 1 439 | self._frame_index = self._frame_index % len(frames) 440 | 441 | text_frame = self.text_frame() 442 | return "{0} {1}".format( 443 | *[ 444 | (text_frame, frame) 445 | if self._placement == "right" 446 | else (frame, text_frame) 447 | ][0] 448 | ) 449 | 450 | def text_frame(self): 451 | """Builds and returns the text frame to be rendered 452 | Returns 453 | ------- 454 | self 455 | """ 456 | if len(self._text["frames"]) == 1: 457 | if self._text_color: 458 | return colored_frame(self._text["frames"][0], self._text_color) 459 | 460 | # Return first frame (can't return original text because at this point it might be ellipsed) 461 | return self._text["frames"][0] 462 | 463 | frames = self._text["frames"] 464 | frame = frames[self._text_index] 465 | 466 | self._text_index += 1 467 | self._text_index = self._text_index % len(frames) 468 | 469 | if self._text_color: 470 | return colored_frame(frame, self._text_color) 471 | 472 | return frame 473 | 474 | def start(self, text=None): 475 | """Starts the spinner on a separate thread. 476 | Parameters 477 | ---------- 478 | text : None, optional 479 | Text to be used alongside spinner 480 | Returns 481 | ------- 482 | self 483 | """ 484 | if text is not None: 485 | self.text = text 486 | 487 | if self._spinner_id is not None: 488 | return self 489 | 490 | if not (self.enabled and self._check_stream()): 491 | return self 492 | 493 | self._hide_cursor() 494 | 495 | self._stop_spinner = threading.Event() 496 | self._spinner_thread = threading.Thread(target=self.render) 497 | self._spinner_thread.daemon = True 498 | self._render_frame() 499 | self._spinner_id = self._spinner_thread.name 500 | self._spinner_thread.start() 501 | 502 | return self 503 | 504 | def stop(self): 505 | """Stops the spinner and clears the line. 506 | Returns 507 | ------- 508 | self 509 | """ 510 | if self._spinner_thread and self._spinner_thread.is_alive(): 511 | self._stop_spinner.set() 512 | self._spinner_thread.join() 513 | 514 | if self.enabled: 515 | self.clear() 516 | 517 | self._frame_index = 0 518 | self._spinner_id = None 519 | self._show_cursor() 520 | return self 521 | 522 | def succeed(self, text=None): 523 | """Shows and persists success symbol and text and exits. 524 | Parameters 525 | ---------- 526 | text : None, optional 527 | Text to be shown alongside success symbol. 528 | Returns 529 | ------- 530 | self 531 | """ 532 | return self.stop_and_persist(symbol=LogSymbols.SUCCESS.value, text=text) 533 | 534 | def fail(self, text=None): 535 | """Shows and persists fail symbol and text and exits. 536 | Parameters 537 | ---------- 538 | text : None, optional 539 | Text to be shown alongside fail symbol. 540 | Returns 541 | ------- 542 | self 543 | """ 544 | return self.stop_and_persist(symbol=LogSymbols.ERROR.value, text=text) 545 | 546 | def warn(self, text=None): 547 | """Shows and persists warn symbol and text and exits. 548 | Parameters 549 | ---------- 550 | text : None, optional 551 | Text to be shown alongside warn symbol. 552 | Returns 553 | ------- 554 | self 555 | """ 556 | return self.stop_and_persist(symbol=LogSymbols.WARNING.value, text=text) 557 | 558 | def info(self, text=None): 559 | """Shows and persists info symbol and text and exits. 560 | Parameters 561 | ---------- 562 | text : None, optional 563 | Text to be shown alongside info symbol. 564 | Returns 565 | ------- 566 | self 567 | """ 568 | return self.stop_and_persist(symbol=LogSymbols.INFO.value, text=text) 569 | 570 | def stop_and_persist(self, symbol=" ", text=None): 571 | """Stops the spinner and persists the final frame to be shown. 572 | Parameters 573 | ---------- 574 | symbol : str, optional 575 | Symbol to be shown in final frame 576 | text: str, optional 577 | Text to be shown in final frame 578 | 579 | Returns 580 | ------- 581 | self 582 | """ 583 | if not self.enabled: 584 | return self 585 | 586 | symbol = decode_utf_8_text(symbol) 587 | 588 | if text is not None: 589 | text = decode_utf_8_text(text) 590 | else: 591 | text = self._text["original"] 592 | 593 | text = text.strip() 594 | 595 | if self._text_color: 596 | text = colored_frame(text, self._text_color) 597 | 598 | self.stop() 599 | 600 | output = "{0} {1}\n".format( 601 | *[(text, symbol) if self._placement == "right" else (symbol, text)][0] 602 | ) 603 | 604 | try: 605 | self._write(output) 606 | except UnicodeEncodeError: 607 | self._write(encode_utf_8_text(output)) 608 | 609 | return self 610 | -------------------------------------------------------------------------------- /halo/halo_notebook.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function, unicode_literals 2 | 3 | import sys 4 | import threading 5 | 6 | import halo.cursor as cursor 7 | 8 | from halo import Halo 9 | from halo._utils import colored_frame, decode_utf_8_text 10 | 11 | 12 | class HaloNotebook(Halo): 13 | def __init__( 14 | self, 15 | text="", 16 | color="cyan", 17 | text_color=None, 18 | spinner=None, 19 | placement="left", 20 | animation=None, 21 | interval=-1, 22 | enabled=True, 23 | stream=sys.stdout, 24 | ): 25 | super(HaloNotebook, self).__init__( 26 | text=text, 27 | color=color, 28 | text_color=text_color, 29 | spinner=spinner, 30 | placement=placement, 31 | animation=animation, 32 | interval=interval, 33 | enabled=enabled, 34 | stream=stream, 35 | ) 36 | self.output = self._make_output_widget() 37 | 38 | def _make_output_widget(self): 39 | from ipywidgets.widgets import Output 40 | 41 | return Output() 42 | 43 | # TODO: using property and setter 44 | def _output(self, text=""): 45 | return ({"name": "stdout", "output_type": "stream", "text": text},) 46 | 47 | def clear(self): 48 | if not self.enabled: 49 | return self 50 | 51 | with self.output: 52 | self.output.outputs += self._output("\r") 53 | self.output.outputs += self._output(self.CLEAR_LINE) 54 | 55 | self.output.outputs = self._output() 56 | self.output.close() 57 | return self 58 | 59 | def _render_frame(self): 60 | frame = self.frame() 61 | output = "\r{}".format(frame) 62 | with self.output: 63 | self.output.outputs += self._output(output) 64 | 65 | def start(self, text=None): 66 | if text is not None: 67 | self.text = text 68 | 69 | if not self.enabled or self._spinner_id is not None: 70 | return self 71 | 72 | if self._stream.isatty(): 73 | cursor.hide() 74 | 75 | self.output = self._make_output_widget() 76 | from IPython.display import display 77 | 78 | display(self.output) 79 | self._stop_spinner = threading.Event() 80 | self._spinner_thread = threading.Thread(target=self.render) 81 | self._spinner_thread.daemon = True 82 | self._render_frame() 83 | self._spinner_id = self._spinner_thread.name 84 | self._spinner_thread.start() 85 | 86 | return self 87 | 88 | def stop_and_persist(self, symbol=" ", text=None): 89 | """Stops the spinner and persists the final frame to be shown. 90 | Parameters 91 | ---------- 92 | symbol : str, optional 93 | Symbol to be shown in final frame 94 | text: str, optional 95 | Text to be shown in final frame 96 | 97 | Returns 98 | ------- 99 | self 100 | """ 101 | if not self.enabled: 102 | return self 103 | 104 | symbol = decode_utf_8_text(symbol) 105 | 106 | if text is not None: 107 | text = decode_utf_8_text(text) 108 | else: 109 | text = self._text["original"] 110 | 111 | text = text.strip() 112 | 113 | if self._text_color: 114 | text = colored_frame(text, self._text_color) 115 | 116 | self.stop() 117 | 118 | output = "\r{} {}\n".format( 119 | *[(text, symbol) if self._placement == "right" else (symbol, text)][0] 120 | ) 121 | 122 | with self.output: 123 | self.output.outputs = self._output(output) 124 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | coverage>=4.4.1 2 | pytest>=8.2.2 3 | pylint>=1.7.2 4 | tox>=4 5 | twine>=1.12.1 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | log_symbols>=0.0.14 2 | spinners>=0.0.24 3 | termcolor>=1.1.0 4 | colorama>=0.3.9 5 | six>=1.12.0 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | from setuptools import ( 3 | setup, 4 | find_packages, 5 | ) # pylint: disable=no-name-in-module,import-error 6 | 7 | 8 | def dependencies(file): 9 | with open(file) as f: 10 | return f.read().splitlines() 11 | 12 | 13 | with io.open("README.md", encoding="utf-8") as infile: 14 | long_description = infile.read() 15 | 16 | setup( 17 | name="halo", 18 | packages=find_packages(exclude=("tests", "examples", "art")), 19 | version="0.0.31", 20 | license="MIT", 21 | classifiers=[ 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3.5", 24 | "Programming Language :: Python :: 3.6", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3 :: Only", 28 | ], 29 | python_requires=">=3.4", 30 | description="Beautiful terminal spinners in Python", 31 | long_description=long_description, 32 | long_description_content_type="text/markdown", 33 | author="Manraj Singh", 34 | author_email="manrajsinghgrover@gmail.com", 35 | url="https://github.com/manrajgrover/halo", 36 | keywords=[ 37 | "console", 38 | "loading", 39 | "indicator", 40 | "progress", 41 | "cli", 42 | "spinner", 43 | "spinners", 44 | "terminal", 45 | "term", 46 | "busy", 47 | "wait", 48 | "idle", 49 | ], 50 | install_requires=dependencies("requirements.txt"), 51 | tests_require=dependencies("requirements-dev.txt"), 52 | include_package_data=True, 53 | extras_require={ 54 | "ipython": [ 55 | "IPython>=8.12.3", 56 | "ipywidgets>=8.1.3", 57 | ] 58 | }, 59 | ) 60 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manrajgrover/halo/b76fa94abae4505d0d5b14dcf1d77521c02482e4/tests/__init__.py -------------------------------------------------------------------------------- /tests/_utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for tests. 2 | """ 3 | import codecs 4 | import re 5 | 6 | 7 | def strip_ansi(string): 8 | """Strip ANSI encoding from given string. 9 | 10 | Parameters 11 | ---------- 12 | string : str 13 | String from which encoding needs to be removed 14 | 15 | Returns 16 | ------- 17 | str 18 | Encoding free string 19 | """ 20 | pattern = r'(\x1b\[|\x9b)[^@-_]*[@-_]|\x1b[@-_]' 21 | return re.sub(pattern, '', string, flags=re.I) 22 | 23 | 24 | def find_colors(string): 25 | """Find colors from given string 26 | 27 | Parameters 28 | ---------- 29 | string : str 30 | String from which colors need to be find 31 | 32 | Returns 33 | ------- 34 | str 35 | List of found colors 36 | """ 37 | return re.findall(r'\[\d\dm', string, flags=re.I) 38 | 39 | 40 | def decode_utf_8_text(text): 41 | """Decodes the text from utf-8 format. 42 | 43 | Parameters 44 | ---------- 45 | text : str 46 | Text to be decoded 47 | 48 | Returns 49 | ------- 50 | str 51 | Decoded text 52 | """ 53 | try: 54 | return codecs.decode(text, 'utf-8') 55 | except (TypeError, ValueError): 56 | return text 57 | 58 | 59 | def encode_utf_8_text(text): 60 | """Encodes the text to utf-8 format 61 | 62 | Parameters 63 | ---------- 64 | text : str 65 | Text to be encoded 66 | 67 | Returns 68 | ------- 69 | str 70 | Encoded text 71 | """ 72 | try: 73 | return codecs.encode(text, 'utf-8') 74 | except (TypeError, ValueError): 75 | return text 76 | -------------------------------------------------------------------------------- /tests/test_halo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """This module tests Halo spinners. 3 | """ 4 | import io 5 | import os 6 | import re 7 | import sys 8 | import time 9 | import unittest 10 | 11 | try: 12 | from cStringIO import StringIO 13 | except ImportError: 14 | from io import StringIO 15 | 16 | from spinners.spinners import Spinners 17 | 18 | from halo import Halo 19 | from halo._utils import get_terminal_columns, is_supported 20 | from tests._utils import strip_ansi, find_colors, encode_utf_8_text, decode_utf_8_text 21 | 22 | from termcolor import COLORS 23 | 24 | if sys.version_info.major == 2: 25 | get_coded_text = encode_utf_8_text 26 | else: 27 | get_coded_text = decode_utf_8_text 28 | 29 | if is_supported(): 30 | frames = [get_coded_text(frame) for frame in Spinners['dots'].value['frames']] 31 | default_spinner = Spinners['dots'].value 32 | else: 33 | frames = [get_coded_text(frame) for frame in Spinners['line'].value['frames']] 34 | default_spinner = Spinners['line'].value 35 | 36 | 37 | class SpecificException(Exception): 38 | """A unique exc class we know only our tests would raise""" 39 | 40 | 41 | class TestHalo(unittest.TestCase): 42 | """Test Halo enum for attribute values. 43 | """ 44 | TEST_FOLDER = os.path.dirname(os.path.abspath(__file__)) 45 | 46 | def setUp(self): 47 | """Set up things before beginning of each test. 48 | """ 49 | self._stream_file = os.path.join(self.TEST_FOLDER, 'test.txt') 50 | self._stream = io.open(self._stream_file, 'w+') 51 | self._stream_no_tty = StringIO() 52 | 53 | def _get_test_output(self, no_ansi=True): 54 | """Clean the output from stream and return it in list form. 55 | 56 | Returns 57 | ------- 58 | list 59 | Clean output from stream 60 | """ 61 | self._stream.seek(0) 62 | data = self._stream.readlines() 63 | output = {} 64 | output_text = [] 65 | output_colors = [] 66 | 67 | for line in data: 68 | clean_line = strip_ansi(line.strip('\n')) if no_ansi else line.strip('\n') 69 | if clean_line != '': 70 | output_text.append(get_coded_text(clean_line)) 71 | 72 | colors_found = find_colors(line.strip('\n')) 73 | if colors_found: 74 | tmp = [] 75 | for color in colors_found: 76 | tmp.append(re.sub(r'[^0-9]', '', color, flags=re.I)) 77 | output_colors.append(tmp) 78 | 79 | output['text'] = output_text 80 | output['colors'] = output_colors 81 | 82 | return output 83 | 84 | def test_basic_spinner(self): 85 | """Test the basic of basic spinners. 86 | """ 87 | spinner = Halo(text='foo', spinner='dots', stream=self._stream) 88 | 89 | spinner.start() 90 | time.sleep(1) 91 | spinner.stop() 92 | output = self._get_test_output()['text'] 93 | 94 | self.assertEqual(output[0], '{} foo'.format(frames[0])) 95 | self.assertEqual(output[1], '{} foo'.format(frames[1])) 96 | self.assertEqual(output[2], '{} foo'.format(frames[2])) 97 | 98 | def test_text_spinner_color(self): 99 | """Test basic spinner with available colors color (both spinner and text) 100 | """ 101 | for color, color_int in COLORS.items(): 102 | self._stream_file = os.path.join(self.TEST_FOLDER, 'test.txt') 103 | self._stream = io.open(self._stream_file, 'w+') 104 | 105 | spinner = Halo( 106 | text='foo', 107 | text_color=color, 108 | color=color, 109 | spinner='dots', 110 | stream=self._stream 111 | ) 112 | 113 | spinner.start() 114 | time.sleep(1) 115 | spinner.stop() 116 | output = self._get_test_output()['colors'] 117 | 118 | # check if spinner colors match 119 | self.assertEqual(color_int, int(output[0][0])) 120 | self.assertEqual(color_int, int(output[1][0])) 121 | self.assertEqual(color_int, int(output[2][0])) 122 | 123 | # check if text colors match 124 | self.assertEqual(color_int, int(output[0][1])) 125 | self.assertEqual(color_int, int(output[1][1])) 126 | self.assertEqual(color_int, int(output[2][1])) 127 | 128 | def test_spinner_getter(self): 129 | instance = Halo() 130 | if is_supported(): 131 | default_spinner_value = "dots" 132 | else: 133 | default_spinner_value = "line" 134 | 135 | instance.spinner = default_spinner_value 136 | self.assertEqual(default_spinner, instance.spinner) 137 | 138 | instance.spinner = "This_spinner_do_not_exist" 139 | self.assertEqual(default_spinner, instance.spinner) 140 | 141 | instance.spinner = -123 142 | self.assertEqual(default_spinner, instance.spinner) 143 | 144 | def test_text_stripping(self): 145 | """Test the text being stripped before output. 146 | """ 147 | spinner = Halo(text='foo\n', spinner='dots', stream=self._stream) 148 | 149 | spinner.start() 150 | time.sleep(1) 151 | spinner.succeed('foo\n') 152 | output = self._get_test_output()['text'] 153 | 154 | self.assertEqual(output[0], '{} foo'.format(frames[0])) 155 | self.assertEqual(output[1], '{} foo'.format(frames[1])) 156 | self.assertEqual(output[2], '{} foo'.format(frames[2])) 157 | 158 | pattern = re.compile(r'(✔|v) foo', re.UNICODE) 159 | 160 | self.assertRegex(output[-1], pattern) 161 | 162 | def test_text_ellipsing(self): 163 | """Test the text gets ellipsed if it's too long 164 | """ 165 | text = 'This is a text that it is too long. In fact, it exceeds the eighty column standard ' \ 166 | 'terminal width, which forces the text frame renderer to add an ellipse at the end of the ' \ 167 | 'text. ' * 6 168 | spinner = Halo(text=text, spinner='dots', stream=self._stream) 169 | 170 | spinner.start() 171 | time.sleep(1) 172 | spinner.succeed('End!') 173 | output = self._get_test_output()['text'] 174 | 175 | terminal_width = get_terminal_columns() 176 | 177 | # -6 of the ' (...)' ellipsis, -2 of the spinner and space 178 | self.assertEqual(output[0], '{} {} (...)'.format(frames[0], text[:terminal_width - 6 - 2])) 179 | self.assertEqual(output[1], '{} {} (...)'.format(frames[1], text[:terminal_width - 6 - 2])) 180 | self.assertEqual(output[2], '{} {} (...)'.format(frames[2], text[:terminal_width - 6 - 2])) 181 | 182 | pattern = re.compile(r'(✔|v) End!', re.UNICODE) 183 | 184 | self.assertRegex(output[-1], pattern) 185 | 186 | def test_text_animation(self): 187 | """Test the text gets animated when it is too long 188 | """ 189 | text = 'This is a text that it is too long. In fact, it exceeds the eighty column standard ' \ 190 | 'terminal width, which forces the text frame renderer to add an ellipse at the end of the ' \ 191 | 'text. ' * 6 192 | spinner = Halo(text=text, spinner='dots', stream=self._stream, animation='marquee') 193 | 194 | spinner.start() 195 | time.sleep(1) 196 | spinner.succeed('End!') 197 | output = self._get_test_output()['text'] 198 | 199 | terminal_width = get_terminal_columns() 200 | 201 | self.assertEqual(output[0], '{} {}'.format(frames[0], text[:terminal_width - 2])) 202 | self.assertEqual(output[1], '{} {}'.format(frames[1], text[1:terminal_width - 1])) 203 | self.assertEqual(output[2], '{} {}'.format(frames[2], text[2:terminal_width])) 204 | 205 | pattern = re.compile(r'(✔|v) End!', re.UNICODE) 206 | 207 | self.assertRegex(output[-1], pattern) 208 | 209 | def test_context_manager(self): 210 | """Test the basic of basic spinners used through the with statement. 211 | """ 212 | with Halo(text='foo', spinner='dots', stream=self._stream): 213 | time.sleep(1) 214 | output = self._get_test_output()['text'] 215 | 216 | self.assertEqual(output[0], '{} foo'.format(frames[0])) 217 | self.assertEqual(output[1], '{} foo'.format(frames[1])) 218 | self.assertEqual(output[2], '{} foo'.format(frames[2])) 219 | 220 | def test_context_manager_exceptions(self): 221 | """Test Halo context manager allows exceptions to bubble up 222 | """ 223 | with self.assertRaises(SpecificException): 224 | with Halo(text='foo', spinner='dots', stream=self._stream): 225 | raise SpecificException 226 | 227 | def test_decorator_spinner(self): 228 | """Test basic usage of spinners with the decorator syntax.""" 229 | 230 | @Halo(text="foo", spinner="dots", stream=self._stream) 231 | def decorated_function(): 232 | time.sleep(1) 233 | 234 | decorated_function() 235 | output = self._get_test_output()['text'] 236 | self.assertEqual(output[0], '{} foo'.format(frames[0])) 237 | self.assertEqual(output[1], '{} foo'.format(frames[1])) 238 | self.assertEqual(output[2], '{} foo'.format(frames[2])) 239 | 240 | def test_decorator_exceptions(self): 241 | """Test Halo decorator allows exceptions to bubble up""" 242 | 243 | @Halo(text="foo", spinner="dots", stream=self._stream) 244 | def decorated_function(): 245 | raise SpecificException 246 | 247 | with self.assertRaises(SpecificException): 248 | decorated_function() 249 | 250 | def test_initial_title_spinner(self): 251 | """Test Halo with initial title. 252 | """ 253 | spinner = Halo('bar', stream=self._stream) 254 | 255 | spinner.start() 256 | time.sleep(1) 257 | spinner.stop() 258 | output = self._get_test_output()['text'] 259 | 260 | self.assertEqual(output[0], '{} bar'.format(frames[0])) 261 | self.assertEqual(output[1], '{} bar'.format(frames[1])) 262 | self.assertEqual(output[2], '{} bar'.format(frames[2])) 263 | 264 | def test_id_not_created_before_start(self): 265 | """Test Spinner ID not created before start. 266 | """ 267 | spinner = Halo(stream=self._stream) 268 | self.assertEqual(spinner.spinner_id, None) 269 | 270 | def test_ignore_multiple_start_calls(self): 271 | """Test ignoring of multiple start calls. 272 | """ 273 | spinner = Halo(stream=self._stream) 274 | spinner.start() 275 | spinner_id = spinner.spinner_id 276 | spinner.start() 277 | self.assertEqual(spinner.spinner_id, spinner_id) 278 | spinner.stop() 279 | 280 | def test_chaining_start(self): 281 | """Test chaining start with constructor 282 | """ 283 | spinner = Halo(stream=self._stream).start() 284 | spinner_id = spinner.spinner_id 285 | self.assertIsNotNone(spinner_id) 286 | spinner.stop() 287 | 288 | def test_succeed(self): 289 | """Test succeed method 290 | """ 291 | spinner = Halo(stream=self._stream) 292 | spinner.start('foo') 293 | spinner.succeed('foo') 294 | 295 | output = self._get_test_output()['text'] 296 | pattern = re.compile(r'(✔|v) foo', re.UNICODE) 297 | 298 | self.assertRegex(output[-1], pattern) 299 | spinner.stop() 300 | 301 | def test_succeed_with_new_text(self): 302 | """Test succeed method with new text 303 | """ 304 | spinner = Halo(stream=self._stream) 305 | spinner.start('foo') 306 | spinner.succeed('bar') 307 | 308 | output = self._get_test_output()['text'] 309 | pattern = re.compile(r'(✔|v) bar', re.UNICODE) 310 | 311 | self.assertRegex(output[-1], pattern) 312 | spinner.stop() 313 | 314 | def test_info(self): 315 | """Test info method 316 | """ 317 | spinner = Halo(stream=self._stream) 318 | spinner.start('foo') 319 | spinner.info() 320 | 321 | output = self._get_test_output()['text'] 322 | pattern = re.compile(r'(ℹ|¡) foo', re.UNICODE) 323 | 324 | self.assertRegex(output[-1], pattern) 325 | spinner.stop() 326 | 327 | def test_fail(self): 328 | """Test fail method 329 | """ 330 | spinner = Halo(stream=self._stream) 331 | spinner.start('foo') 332 | spinner.fail() 333 | 334 | output = self._get_test_output()['text'] 335 | pattern = re.compile(r'(✖|×) foo', re.UNICODE) 336 | 337 | self.assertRegex(output[-1], pattern) 338 | spinner.stop() 339 | 340 | def test_warning(self): 341 | """Test warn method 342 | """ 343 | spinner = Halo(stream=self._stream) 344 | spinner.start('foo') 345 | spinner.warn('Warning!') 346 | 347 | output = self._get_test_output()['text'] 348 | pattern = re.compile(r'(⚠|!!) Warning!', re.UNICODE) 349 | 350 | self.assertRegex(output[-1], pattern) 351 | spinner.stop() 352 | 353 | def test_spinner_getters_setters(self): 354 | """Test spinner getters and setters. 355 | """ 356 | spinner = Halo() 357 | self.assertEqual(spinner.text, '') 358 | self.assertIsNone(spinner.text_color, None) 359 | self.assertEqual(spinner.color, 'cyan') 360 | self.assertIsNone(spinner.spinner_id) 361 | 362 | spinner.spinner = 'dots12' 363 | spinner.text = 'bar' 364 | spinner.text_color = 'red' 365 | spinner.color = 'red' 366 | 367 | self.assertEqual(spinner.text, 'bar') 368 | self.assertEqual(spinner.text_color, 'red') 369 | self.assertEqual(spinner.color, 'red') 370 | 371 | if is_supported(): 372 | self.assertEqual(spinner.spinner, Spinners['dots12'].value) 373 | else: 374 | self.assertEqual(spinner.spinner, default_spinner) 375 | 376 | spinner.spinner = 'dots11' 377 | if is_supported(): 378 | self.assertEqual(spinner.spinner, Spinners['dots11'].value) 379 | else: 380 | self.assertEqual(spinner.spinner, default_spinner) 381 | 382 | spinner.spinner = 'foo_bar' 383 | self.assertEqual(spinner.spinner, default_spinner) 384 | 385 | # Color is None 386 | spinner.text_color = None 387 | spinner.color = None 388 | spinner.start() 389 | spinner.stop() 390 | 391 | self.assertIsNone(spinner.text_color) 392 | self.assertIsNone(spinner.color) 393 | 394 | def test_unavailable_spinner_defaults(self): 395 | """Test unavailable spinner defaults. 396 | """ 397 | spinner = Halo('dot') 398 | 399 | self.assertEqual(spinner.text, 'dot') 400 | self.assertEqual(spinner.spinner, default_spinner) 401 | 402 | def test_if_enabled(self): 403 | """Test if spinner is enabled 404 | """ 405 | spinner = Halo(text='foo', enabled=False, stream=self._stream) 406 | spinner.start() 407 | time.sleep(1) 408 | spinner.fail() 409 | 410 | output = self._get_test_output()['text'] 411 | self.assertEqual(len(output), 0) 412 | self.assertEqual(output, []) 413 | 414 | def test_writing_disabled_on_closed_stream(self): 415 | """Test no I/O is performed on closed streams 416 | """ 417 | # BytesIO supports the writable() method, while StringIO does not, in 418 | # some versions of Python. We want to check whether the stream is 419 | # writable (e.g. for file streams which can be open but not writable), 420 | # but only if the stream supports it — otherwise we assume 421 | # open == writable. 422 | for io_class in (io.StringIO, io.BytesIO): 423 | stream = io_class() 424 | stream.close() 425 | 426 | # sanity checks 427 | self.assertTrue(stream.closed) 428 | self.assertRaises(ValueError, stream.isatty) 429 | self.assertRaises(ValueError, stream.write, u'') 430 | 431 | try: 432 | spinner = Halo(text='foo', stream=stream) 433 | spinner.start() 434 | time.sleep(0.5) 435 | spinner.stop() 436 | except ValueError as e: 437 | self.fail('Attempted to write to a closed stream: {}'.format(e)) 438 | 439 | def test_closing_stream_before_stopping(self): 440 | """Test no I/O is performed on streams closed before stop is called 441 | """ 442 | stream = io.StringIO() 443 | spinner = Halo(text='foo', stream=stream) 444 | spinner.start() 445 | time.sleep(0.5) 446 | 447 | # no exception raised after closing the stream means test was successful 448 | try: 449 | stream.close() 450 | 451 | time.sleep(0.5) 452 | spinner.stop() 453 | except ValueError as e: 454 | self.fail('Attempted to write to a closed stream: {}'.format(e)) 455 | 456 | def test_closing_stream_before_persistent(self): 457 | """Test no I/O is performed on streams closed before stop_and_persist is called 458 | """ 459 | stream = io.StringIO() 460 | spinner = Halo(text='foo', stream=stream) 461 | spinner.start() 462 | time.sleep(0.5) 463 | 464 | # no exception raised after closing the stream means test was successful 465 | try: 466 | stream.close() 467 | 468 | time.sleep(0.5) 469 | spinner.stop_and_persist('done') 470 | except ValueError as e: 471 | self.fail('Attempted to write to a closed stream: {}'.format(e)) 472 | 473 | def test_setting_enabled_property(self): 474 | """Test if spinner stops writing when enabled property set to False 475 | """ 476 | spinner = Halo(text='foo', stream=self._stream) 477 | spinner.start() 478 | time.sleep(0.5) 479 | 480 | spinner.enabled = False 481 | bytes_written = self._stream.tell() 482 | time.sleep(0.5) 483 | spinner.stop() 484 | 485 | total_bytes_written = self._stream.tell() 486 | self.assertEqual(total_bytes_written, bytes_written) 487 | 488 | def test_spinner_interval_default(self): 489 | """Test proper assignment of the default interval value. 490 | """ 491 | spinner = Halo() 492 | self.assertEqual(spinner._interval, default_spinner['interval']) 493 | 494 | def test_spinner_interval_argument(self): 495 | """Test proper assignment of the interval value from the constructor argument. 496 | """ 497 | spinner = Halo(interval=123) 498 | self.assertEqual(spinner._interval, 123) 499 | 500 | def test_spinner_interval_dict(self): 501 | """Test proper assignment of the interval value from a dictionary. 502 | """ 503 | spinner = Halo(spinner={'interval': 321, 'frames': ['+', '-']}) 504 | self.assertEqual(spinner._interval, 321) 505 | 506 | def test_invalid_placement(self): 507 | """Test invalid placement of spinner. 508 | """ 509 | 510 | with self.assertRaises(ValueError): 511 | Halo(placement='') 512 | Halo(placement='foo') 513 | Halo(placement=None) 514 | 515 | spinner = Halo(placement='left') 516 | with self.assertRaises(ValueError): 517 | spinner.placement = '' 518 | spinner.placement = 'foo' 519 | spinner.placement = None 520 | 521 | def test_default_placement(self): 522 | """Test default placement of spinner. 523 | """ 524 | 525 | spinner = Halo() 526 | self.assertEqual(spinner.placement, 'left') 527 | 528 | def test_right_placement(self): 529 | """Test right placement of spinner. 530 | """ 531 | spinner = Halo(text='foo', placement='right', stream=self._stream) 532 | spinner.start() 533 | time.sleep(1) 534 | 535 | output = self._get_test_output()['text'] 536 | (text, _) = output[-1].split(' ') 537 | self.assertEqual(text, 'foo') 538 | 539 | spinner.succeed() 540 | output = self._get_test_output()['text'] 541 | (text, symbol) = output[-1].split(' ') 542 | pattern = re.compile(r"(✔|v)", re.UNICODE) 543 | 544 | self.assertEqual(text, 'foo') 545 | self.assertRegex(symbol, pattern) 546 | spinner.stop() 547 | 548 | def test_bounce_animation(self): 549 | def filler_text(n_chars): 550 | return "_" * n_chars 551 | 552 | terminal_width = get_terminal_columns() 553 | 554 | text = "{}abc".format(filler_text(terminal_width)) 555 | expected_frames_without_appended_spinner = [ 556 | "{}".format(filler_text(terminal_width - 2)), 557 | "{}".format(filler_text(terminal_width - 2)), 558 | "{}".format(filler_text(terminal_width - 2)), 559 | "{}a".format(filler_text(terminal_width - 3)), 560 | "{}ab".format(filler_text(terminal_width - 4)), 561 | "{}abc".format(filler_text(terminal_width - 5)), 562 | "{}abc".format(filler_text(terminal_width - 5)), 563 | "{}ab".format(filler_text(terminal_width - 4)), 564 | "{}a".format(filler_text(terminal_width - 3)), 565 | "{}".format(filler_text(terminal_width - 2)), 566 | "{}".format(filler_text(terminal_width - 2)), 567 | "{}".format(filler_text(terminal_width - 2)), 568 | ] 569 | # Prepend the actual spinner 570 | expected_frames = [ 571 | "{} {}".format(frames[idx % frames.__len__()], frame) 572 | for idx, frame in enumerate(expected_frames_without_appended_spinner) 573 | ] 574 | spinner = Halo(text, animation="bounce", stream=self._stream) 575 | spinner.start() 576 | # Sleep a full bounce cycle 577 | time.sleep(1.2) 578 | spinner.stop() 579 | output = self._get_test_output()['text'] 580 | 581 | zipped_expected_and_actual_frame = zip(expected_frames, output) 582 | for multiple_frames in zipped_expected_and_actual_frame: 583 | expected_frame, actual_frame = multiple_frames 584 | self.assertEqual(expected_frame, actual_frame) 585 | 586 | def test_animation_setter(self): 587 | spinner = Halo("Asdf") 588 | spinner.animation = "bounce" 589 | self.assertEqual("bounce", spinner.animation) 590 | spinner.animation = "marquee" 591 | self.assertEqual("marquee", spinner.animation) 592 | 593 | def test_spinner_color(self): 594 | """Test ANSI escape characters are present 595 | """ 596 | 597 | for color, color_int in COLORS.items(): 598 | self._stream = io.open(self._stream_file, 'w+') # reset stream 599 | spinner = Halo(color=color, stream=self._stream) 600 | spinner.start() 601 | spinner.stop() 602 | 603 | output = self._get_test_output(no_ansi=False) 604 | output_merged = [arr for c in output['colors'] for arr in c] 605 | 606 | self.assertEqual(str(color_int) in output_merged, True) 607 | 608 | def test_redirect_stdout(self): 609 | """Test redirect stdout 610 | """ 611 | out = self._stream 612 | try: 613 | self._stream = self._stream_no_tty 614 | spinner = Halo(text='foo', spinner='dots', stream=self._stream) 615 | 616 | spinner.start() 617 | time.sleep(1) 618 | spinner.stop() 619 | output = self._get_test_output()['text'] 620 | finally: 621 | self._stream = out 622 | 623 | self.assertIn('foo', output[0]) 624 | 625 | def tearDown(self): 626 | pass 627 | 628 | 629 | if __name__ == '__main__': 630 | SUITE = unittest.TestLoader().loadTestsFromTestCase(TestHalo) 631 | unittest.TextTestRunner(verbosity=2).run(SUITE) 632 | -------------------------------------------------------------------------------- /tests/test_halo_notebook.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """This module tests HaloNotebook spinners. 3 | """ 4 | import os 5 | import re 6 | import sys 7 | import time 8 | import unittest 9 | 10 | from spinners.spinners import Spinners 11 | 12 | from halo import HaloNotebook 13 | from halo._utils import get_terminal_columns, is_supported 14 | from tests._utils import decode_utf_8_text, encode_utf_8_text, find_colors, strip_ansi 15 | 16 | from termcolor import COLORS 17 | 18 | if sys.version_info.major == 2: 19 | get_coded_text = encode_utf_8_text 20 | else: 21 | get_coded_text = decode_utf_8_text 22 | 23 | 24 | if is_supported(): 25 | frames = [get_coded_text(frame) for frame in Spinners['dots'].value['frames']] 26 | default_spinner = Spinners['dots'].value 27 | else: 28 | frames = [get_coded_text(frame) for frame in Spinners['line'].value['frames']] 29 | default_spinner = Spinners['line'].value 30 | 31 | 32 | class TestHaloNotebook(unittest.TestCase): 33 | """Test HaloNotebook enum for attribute values. 34 | """ 35 | TEST_FOLDER = os.path.dirname(os.path.abspath(__file__)) 36 | 37 | def setUp(self): 38 | """Set up things before beginning of each test. 39 | """ 40 | pass 41 | 42 | def _get_test_output(self, spinner, no_ansi=True): 43 | """Clean the output from Output widget and return it in list form. 44 | 45 | Returns 46 | ------- 47 | list 48 | Clean output from Output widget 49 | """ 50 | output = {} 51 | output_text = [] 52 | output_colors = [] 53 | 54 | for line in spinner.output.outputs: 55 | if no_ansi: 56 | clean_line = strip_ansi(line['text'].strip('\r')) 57 | else: 58 | clean_line = line['text'].strip('\r') 59 | 60 | if clean_line != '': 61 | output_text.append(get_coded_text(clean_line)) 62 | 63 | colors_found = find_colors(line['text'].strip('\r')) 64 | if colors_found: 65 | tmp = [] 66 | for color in colors_found: 67 | tmp.append(re.sub(r'[^0-9]', '', color, flags=re.I)) 68 | output_colors.append(tmp) 69 | 70 | output['text'] = output_text 71 | output['colors'] = output_colors 72 | 73 | return output 74 | 75 | def test_basic_spinner(self): 76 | """Test the basic of basic spinners. 77 | """ 78 | spinner = HaloNotebook(text='foo', spinner='dots') 79 | 80 | spinner.start() 81 | time.sleep(1) 82 | output = self._get_test_output(spinner)['text'] 83 | spinner.stop() 84 | 85 | self.assertEqual(output[0], '{} foo'.format(frames[0])) 86 | self.assertEqual(output[1], '{} foo'.format(frames[1])) 87 | self.assertEqual(output[2], '{} foo'.format(frames[2])) 88 | self.assertEqual(spinner.output.outputs, spinner._output('')) 89 | 90 | def test_text_spinner_color(self): 91 | """Test basic spinner with available colors color (both spinner and text) 92 | """ 93 | for color, color_int in COLORS.items(): 94 | spinner = HaloNotebook(text='foo', text_color=color, color=color, spinner='dots') 95 | 96 | spinner.start() 97 | time.sleep(1) 98 | output = self._get_test_output(spinner)['colors'] 99 | spinner.stop() 100 | 101 | # check if spinner colors match 102 | self.assertEqual(color_int, int(output[0][0])) 103 | self.assertEqual(color_int, int(output[1][0])) 104 | self.assertEqual(color_int, int(output[2][0])) 105 | 106 | # check if text colors match 107 | self.assertEqual(color_int, int(output[0][1])) 108 | self.assertEqual(color_int, int(output[1][1])) 109 | self.assertEqual(color_int, int(output[2][1])) 110 | 111 | def test_text_stripping(self): 112 | """Test the text being stripped before output. 113 | """ 114 | spinner = HaloNotebook(text='foo\n', spinner='dots') 115 | 116 | spinner.start() 117 | time.sleep(1) 118 | output = self._get_test_output(spinner)['text'] 119 | 120 | self.assertEqual(output[0], '{} foo'.format(frames[0])) 121 | self.assertEqual(output[1], '{} foo'.format(frames[1])) 122 | self.assertEqual(output[2], '{} foo'.format(frames[2])) 123 | 124 | spinner.succeed('foo\n') 125 | output = self._get_test_output(spinner)['text'] 126 | 127 | pattern = re.compile(r'(✔|v) foo', re.UNICODE) 128 | 129 | self.assertRegex(output[-1], pattern) 130 | 131 | def test_text_ellipsing(self): 132 | """Test the text gets ellipsed if it's too long 133 | """ 134 | text = 'This is a text that it is too long. In fact, it exceeds the eighty column standard ' \ 135 | 'terminal width, which forces the text frame renderer to add an ellipse at the end of the ' \ 136 | 'text. ' * 6 137 | spinner = HaloNotebook(text=text, spinner='dots') 138 | 139 | spinner.start() 140 | time.sleep(1) 141 | output = self._get_test_output(spinner)['text'] 142 | 143 | terminal_width = get_terminal_columns() 144 | 145 | # -6 of the ' (...)' ellipsis, -2 of the spinner and space 146 | self.assertEqual(output[0], '{} {} (...)'.format(frames[0], text[:terminal_width - 6 - 2])) 147 | self.assertEqual(output[1], '{} {} (...)'.format(frames[1], text[:terminal_width - 6 - 2])) 148 | self.assertEqual(output[2], '{} {} (...)'.format(frames[2], text[:terminal_width - 6 - 2])) 149 | 150 | spinner.succeed('End!') 151 | output = self._get_test_output(spinner)['text'] 152 | 153 | pattern = re.compile(r'(✔|v) End!', re.UNICODE) 154 | 155 | self.assertRegex(output[-1], pattern) 156 | 157 | def test_text_animation(self): 158 | """Test the text gets animated when it is too long 159 | """ 160 | text = 'This is a text that it is too long. In fact, it exceeds the eighty column standard ' \ 161 | 'terminal width, which forces the text frame renderer to add an ellipse at the end of the ' \ 162 | 'text. ' * 6 163 | spinner = HaloNotebook(text=text, spinner='dots', animation='marquee') 164 | 165 | spinner.start() 166 | time.sleep(1) 167 | output = self._get_test_output(spinner)['text'] 168 | 169 | terminal_width = get_terminal_columns() 170 | 171 | self.assertEqual(output[0], '{} {}'.format(frames[0], text[:terminal_width - 2])) 172 | self.assertEqual(output[1], '{} {}'.format(frames[1], text[1:terminal_width - 1])) 173 | self.assertEqual(output[2], '{} {}'.format(frames[2], text[2:terminal_width])) 174 | 175 | spinner.succeed('End!') 176 | output = self._get_test_output(spinner)['text'] 177 | 178 | pattern = re.compile(r'(✔|v) End!', re.UNICODE) 179 | 180 | self.assertRegex(output[-1], pattern) 181 | 182 | def test_context_manager(self): 183 | """Test the basic of basic spinners used through the with statement. 184 | """ 185 | with HaloNotebook(text='foo', spinner='dots') as spinner: 186 | time.sleep(1) 187 | output = self._get_test_output(spinner)['text'] 188 | 189 | self.assertEqual(output[0], '{} foo'.format(frames[0])) 190 | self.assertEqual(output[1], '{} foo'.format(frames[1])) 191 | self.assertEqual(output[2], '{} foo'.format(frames[2])) 192 | self.assertEqual(spinner.output.outputs, spinner._output('')) 193 | 194 | def test_decorator_spinner(self): 195 | """Test basic usage of spinners with the decorator syntax.""" 196 | 197 | @HaloNotebook(text="foo", spinner="dots") 198 | def decorated_function(): 199 | time.sleep(1) 200 | 201 | spinner = decorated_function.__closure__[1].cell_contents 202 | output = self._get_test_output(spinner)['text'] 203 | return output 204 | 205 | output = decorated_function() 206 | 207 | self.assertEqual(output[0], '{} foo'.format(frames[0])) 208 | self.assertEqual(output[1], '{} foo'.format(frames[1])) 209 | self.assertEqual(output[2], '{} foo'.format(frames[2])) 210 | 211 | def test_initial_title_spinner(self): 212 | """Test Halo with initial title. 213 | """ 214 | spinner = HaloNotebook('bar') 215 | 216 | spinner.start() 217 | time.sleep(1) 218 | output = self._get_test_output(spinner)['text'] 219 | spinner.stop() 220 | 221 | self.assertEqual(output[0], '{} bar'.format(frames[0])) 222 | self.assertEqual(output[1], '{} bar'.format(frames[1])) 223 | self.assertEqual(output[2], '{} bar'.format(frames[2])) 224 | self.assertEqual(spinner.output.outputs, spinner._output('')) 225 | 226 | def test_id_not_created_before_start(self): 227 | """Test Spinner ID not created before start. 228 | """ 229 | spinner = HaloNotebook() 230 | self.assertEqual(spinner.spinner_id, None) 231 | 232 | def test_ignore_multiple_start_calls(self): 233 | """Test ignoring of multiple start calls. 234 | """ 235 | spinner = HaloNotebook() 236 | spinner.start() 237 | spinner_id = spinner.spinner_id 238 | spinner.start() 239 | self.assertEqual(spinner.spinner_id, spinner_id) 240 | spinner.stop() 241 | 242 | def test_chaining_start(self): 243 | """Test chaining start with constructor 244 | """ 245 | spinner = HaloNotebook().start() 246 | spinner_id = spinner.spinner_id 247 | self.assertIsNotNone(spinner_id) 248 | spinner.stop() 249 | 250 | def test_succeed(self): 251 | """Test succeed method 252 | """ 253 | spinner = HaloNotebook() 254 | spinner.start('foo') 255 | spinner.succeed('foo') 256 | 257 | output = self._get_test_output(spinner)['text'] 258 | pattern = re.compile(r'(✔|v) foo', re.UNICODE) 259 | 260 | self.assertRegex(output[-1], pattern) 261 | spinner.stop() 262 | 263 | def test_succeed_with_new_text(self): 264 | """Test succeed method with new text 265 | """ 266 | spinner = HaloNotebook() 267 | spinner.start('foo') 268 | spinner.succeed('bar') 269 | 270 | output = self._get_test_output(spinner)['text'] 271 | pattern = re.compile(r'(✔|v) bar', re.UNICODE) 272 | 273 | self.assertRegex(output[-1], pattern) 274 | spinner.stop() 275 | 276 | def test_info(self): 277 | """Test info method 278 | """ 279 | spinner = HaloNotebook() 280 | spinner.start('foo') 281 | spinner.info() 282 | 283 | output = self._get_test_output(spinner)['text'] 284 | pattern = re.compile(r'(ℹ|¡) foo', re.UNICODE) 285 | 286 | self.assertRegex(output[-1], pattern) 287 | spinner.stop() 288 | 289 | def test_fail(self): 290 | """Test fail method 291 | """ 292 | spinner = HaloNotebook() 293 | spinner.start('foo') 294 | spinner.fail() 295 | 296 | output = self._get_test_output(spinner)['text'] 297 | pattern = re.compile(r'(✖|×) foo', re.UNICODE) 298 | 299 | self.assertRegex(output[-1], pattern) 300 | spinner.stop() 301 | 302 | def test_warning(self): 303 | """Test warn method 304 | """ 305 | spinner = HaloNotebook() 306 | spinner.start('foo') 307 | spinner.warn('Warning!') 308 | 309 | output = self._get_test_output(spinner)['text'] 310 | pattern = re.compile(r'(⚠|!!) Warning!', re.UNICODE) 311 | 312 | self.assertRegex(output[-1], pattern) 313 | spinner.stop() 314 | 315 | def test_spinner_getters_setters(self): 316 | """Test spinner getters and setters. 317 | """ 318 | spinner = HaloNotebook() 319 | self.assertEqual(spinner.text, '') 320 | self.assertEqual(spinner.color, 'cyan') 321 | self.assertIsNone(spinner.spinner_id) 322 | 323 | spinner.spinner = 'dots12' 324 | spinner.text = 'bar' 325 | spinner.color = 'red' 326 | 327 | self.assertEqual(spinner.text, 'bar') 328 | self.assertEqual(spinner.color, 'red') 329 | 330 | if is_supported(): 331 | self.assertEqual(spinner.spinner, Spinners['dots12'].value) 332 | else: 333 | self.assertEqual(spinner.spinner, default_spinner) 334 | 335 | spinner.spinner = 'dots11' 336 | if is_supported(): 337 | self.assertEqual(spinner.spinner, Spinners['dots11'].value) 338 | else: 339 | self.assertEqual(spinner.spinner, default_spinner) 340 | 341 | spinner.spinner = 'foo_bar' 342 | self.assertEqual(spinner.spinner, default_spinner) 343 | 344 | # Color is None 345 | spinner.color = None 346 | spinner.start() 347 | spinner.stop() 348 | self.assertIsNone(spinner.color) 349 | 350 | def test_unavailable_spinner_defaults(self): 351 | """Test unavailable spinner defaults. 352 | """ 353 | spinner = HaloNotebook('dot') 354 | 355 | self.assertEqual(spinner.text, 'dot') 356 | self.assertEqual(spinner.spinner, default_spinner) 357 | 358 | def test_if_enabled(self): 359 | """Test if spinner is enabled 360 | """ 361 | spinner = HaloNotebook(text="foo", enabled=False) 362 | spinner.start() 363 | time.sleep(1) 364 | output = self._get_test_output(spinner)['text'] 365 | spinner.clear() 366 | spinner.stop() 367 | 368 | self.assertEqual(len(output), 0) 369 | self.assertEqual(output, []) 370 | 371 | def test_invalid_placement(self): 372 | """Test invalid placement of spinner. 373 | """ 374 | 375 | with self.assertRaises(ValueError): 376 | HaloNotebook(placement='') 377 | HaloNotebook(placement='foo') 378 | HaloNotebook(placement=None) 379 | 380 | spinner = HaloNotebook(placement='left') 381 | with self.assertRaises(ValueError): 382 | spinner.placement = '' 383 | spinner.placement = 'foo' 384 | spinner.placement = None 385 | 386 | def test_default_placement(self): 387 | """Test default placement of spinner. 388 | """ 389 | 390 | spinner = HaloNotebook() 391 | self.assertEqual(spinner.placement, 'left') 392 | 393 | def test_right_placement(self): 394 | """Test right placement of spinner. 395 | """ 396 | spinner = HaloNotebook(text="foo", placement="right") 397 | spinner.start() 398 | time.sleep(1) 399 | 400 | output = self._get_test_output(spinner)['text'] 401 | (text, _) = output[-1].split(" ") 402 | self.assertEqual(text, "foo") 403 | 404 | spinner.succeed() 405 | output = self._get_test_output(spinner)['text'] 406 | (text, symbol) = output[-1].split(" ") 407 | pattern = re.compile(r"(✔|v)", re.UNICODE) 408 | 409 | self.assertEqual(text, "foo") 410 | self.assertRegex(symbol, pattern) 411 | spinner.stop() 412 | 413 | def test_spinner_color(self): 414 | """Test ANSI escape characters are present 415 | """ 416 | 417 | for color, color_int in COLORS.items(): 418 | spinner = HaloNotebook(color=color) 419 | spinner.start() 420 | output = self._get_test_output(spinner, no_ansi=False) 421 | spinner.stop() 422 | 423 | output_merged = [arr for c in output['colors'] for arr in c] 424 | 425 | self.assertEqual(str(color_int) in output_merged, True) 426 | 427 | def tearDown(self): 428 | """Clean up things after every test. 429 | """ 430 | pass 431 | 432 | 433 | if __name__ == '__main__': 434 | SUITE = unittest.TestLoader().loadTestsFromTestCase(TestHaloNotebook) 435 | unittest.TextTestRunner(verbosity=2).run(SUITE) 436 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310,311}-{linux,macos,windows}, 4 | lint 5 | skip_missing_interpreters = 6 | True 7 | 8 | [gh] 9 | python = 10 | 3.8 = py38, lint 11 | 3.9 = py39 12 | 3.10 = py310 13 | 3.11 = py311 14 | 15 | [testenv] 16 | # See https://github.com/pytest-dev/pytest/pull/5222#issuecomment-492428610 17 | download = True 18 | deps = 19 | -r{toxinidir}/requirements.txt 20 | -r{toxinidir}/requirements-dev.txt 21 | extras=ipython 22 | recreate = 23 | True 24 | setenv = 25 | LANG=en_US.UTF-8 26 | 27 | commands = 28 | pytest 29 | 30 | [testenv:lint] 31 | commands = 32 | pylint --errors-only --rcfile={toxinidir}/.pylintrc --output-format=colorized halo 33 | --------------------------------------------------------------------------------