├── .travis.yml ├── AUTHORS ├── CHANGES.yml ├── LICENSE.txt ├── MANIFEST.in ├── README ├── README.rst ├── ansiwrap ├── __init__.py ├── ansistate.py └── core.py ├── demo.py ├── pytest.ini ├── setup.py ├── test └── test_ansiwrap.py ├── tox.ini └── toxcov.ini /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | - "3.3" 6 | - "3.4" 7 | - "3.5" 8 | - "3.6" 9 | - "3.7-dev" 10 | - "pypy" 11 | - "pypy3.5" 12 | install: 13 | - pip install ansicolors 14 | - pip install textwrap3 15 | - python setup.py install 16 | script: py.test --assert=plain 17 | 18 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | AUTHOR 2 | Jonathan Eunice 3 | 4 | CONTRIBUTORS 5 | Wm. Minchin 6 | -------------------------------------------------------------------------------- /CHANGES.yml: -------------------------------------------------------------------------------- 1 | - 2 | version: 0.8.4 3 | date: January 23, 2019 4 | notes: > 5 | Updated testing matrix. 6 | 7 | - 8 | version: 0.8.3 9 | date: June 14, 2017 10 | notes: > 11 | Fixed edge case in `shorten()` reported by 12 | `MinchinWeb `_ 13 | 14 | - 15 | version: 0.8.2 16 | date: June 11, 2017 17 | notes: > 18 | Tweaked ``setup.py``. Packaging now makes ``textwrap3`` an install, not 19 | setup, requirement. Thanks to `MinchinWeb `_ 20 | for observing & fixing. 21 | 22 | 23 | - 24 | version: 0.8.1 25 | date: June 10, 2017 26 | notes: > 27 | Improved README and tests. 28 | 29 | 30 | - 31 | version: 0.8.0 32 | date: June 7, 2017 33 | notes: > 34 | Updated packaging strategy to repair install-ability issues that 35 | cropped up in recent releases. Both local and CI tests installed and 36 | ran code fine, but real-world installs had more trouble. Testing 37 | config hardened to make recurrence of that discrepancy less likely. 38 | 39 | Excised local copy of ``textwrap`` to ``textwrap3``, which is now 40 | a stand-alone module on PyPI. 41 | 42 | - 43 | version: 0.7.2 44 | date: June 6, 2017 45 | notes: > 46 | Minor code clean-up. 47 | 48 | Test coverage upped to 99%. Point of pride: 49 | Has better test coverage for embedded version 50 | of ``textwrap`` than the Python standard 51 | distribution does. 52 | 53 | - 54 | - 55 | version: 0.7.1 56 | date: June 4, 2017 57 | notes: > 58 | ``shorten()`` API now works across all Python versions, 59 | including with ANSI codes embedded. 60 | 61 | Revised coverage metric to 97 after major code infusion. 62 | 63 | - 64 | version: 0.7.0 65 | date: June 4, 2017 66 | notes: > 67 | Now includes a copy of the Python 3.6 ``textwrap`` as 68 | ``ansiwrap.textwrap``. Its testing module was also 69 | added. Both were slightly tweaked to support Python 2.6 70 | forward. But now there's a quality, tested version of 71 | the Python 3.6 ``textwrap`` back-ported to 2.x. 72 | 73 | ``textwrap`` has been made savvy to Unicode emdashes, 74 | which it surprisingly was not before. 75 | 76 | - 77 | version: 0.6.0 78 | date: June 3, 2017 79 | notes: > 80 | Upgraded monkey-patching strategy to make a copy of the ``textwrap`` 81 | module using ``imp``. We are only patching a copy, so safer, esp. 82 | with concurrency. 83 | 84 | ``shorten`` API added for 3.4 and following. 85 | 86 | - 87 | version: 0.5.5 88 | date: June 3, 2017 89 | notes: > 90 | Extended ``ansilen()`` to handle cases where it is used 91 | for length of lists and other non-string objects. This 92 | is in service of making the function a better and more 93 | robust version of ``len()`` for monkey-patching. 94 | 95 | - 96 | version: 0.5.2 97 | date: May 23, 2017 98 | notes: > 99 | Fixed bug that slipped through previous tests. 100 | 101 | - 102 | version: 0.5.0 103 | date: May 23, 2017 104 | notes: > 105 | First release. ``wrap`` and ``fill`` complete. 100% test 106 | line coverage. 107 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2017 Jonathan Eunice 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README 2 | include README.rst 3 | include pytest.ini 4 | include tox.ini 5 | include .travis.yml 6 | include CHANGES.yml 7 | include LICENSE.txt 8 | include AUTHORS 9 | include demo.py 10 | recursive-include test *.py 11 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonathaneunice/ansiwrap/20e2e8c78a54bdce947e38c069c5eb9c115423ae/README -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | | |travisci| |version| |versions| |impls| |wheel| |coverage| 3 | 4 | .. |travisci| image:: https://api.travis-ci.org/jonathaneunice/ansiwrap.svg 5 | :target: http://travis-ci.org/jonathaneunice/ansiwrap 6 | 7 | .. |version| image:: http://img.shields.io/pypi/v/ansiwrap.svg?style=flat 8 | :alt: PyPI Package latest release 9 | :target: https://pypi.python.org/pypi/ansiwrap 10 | 11 | .. |versions| image:: https://img.shields.io/pypi/pyversions/ansiwrap.svg 12 | :alt: Supported versions 13 | :target: https://pypi.python.org/pypi/ansiwrap 14 | 15 | .. |impls| image:: https://img.shields.io/pypi/implementation/ansiwrap.svg 16 | :alt: Supported implementations 17 | :target: https://pypi.python.org/pypi/ansiwrap 18 | 19 | .. |wheel| image:: https://img.shields.io/pypi/wheel/ansiwrap.svg 20 | :alt: Wheel packaging support 21 | :target: https://pypi.python.org/pypi/ansiwrap 22 | 23 | .. |coverage| image:: https://img.shields.io/badge/test_coverage-99%25-0000FF.svg 24 | :alt: Test line coverage 25 | :target: https://pypi.python.org/pypi/ansiwrap 26 | 27 | 28 | ``ansiwrap`` wraps text, like the standard ``textwrap`` module. 29 | But it also correctly wraps text that contains ANSI control 30 | sequences that colorize or style text. 31 | 32 | Where ``textwrap`` is fooled by the raw string length of those control codes, 33 | ``ansiwrap`` is not; it understands that however much those codes affect color 34 | and display style, they have no logical length. 35 | 36 | The API mirrors the ``wrap``, ``fill``, and ``shorten`` 37 | functions of ``textwrap``. For example:: 38 | 39 | from __future__ import print_function 40 | from colors import * # ansicolors on PyPI 41 | from ansiwrap import * 42 | 43 | s = ' '.join([red('this string'), 44 | blue('is going on a bit long'), 45 | green('and may need to be'), 46 | color('shortened a bit', fg='purple')]) 47 | 48 | print('-- original string --') 49 | print(s) 50 | print('-- now filled --') 51 | print(fill(s, 20)) 52 | print('-- now shortened / truncated --') 53 | print(shorten(s, 20, placeholder='...')) 54 | 55 | It also exports several other functions: 56 | 57 | * ``ansilen`` (giving the effective length of a string, ignoring ANSI control codes) 58 | * ``ansi_terminate_lines`` (propagates control codes though a list of strings/lines 59 | and terminates each line.) 60 | * ``strip_color`` (removes ANSI control codes from a string) 61 | 62 | See also the enclosed ``demo.py``. 63 | 64 | .. image:: https://content.screencast.com/users/jonathaneunice/folders/Jing/media/8db64be2-01cc-4da4-b46a-789c53c63b44/00000569.png 65 | :align: center 66 | -------------------------------------------------------------------------------- /ansiwrap/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import * 2 | 3 | __version__ = '0.8.4' 4 | 5 | -------------------------------------------------------------------------------- /ansiwrap/ansistate.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | 4 | _PY2 = sys.version_info[0] == 2 5 | 6 | 7 | class ANSIState(object): 8 | """ 9 | Manage running state of a sequence of ANSI codes. 10 | """ 11 | 12 | def __init__(self, fg=None, bg=None, style=None): 13 | self.fg = fg 14 | self.bg = bg 15 | self.style = style 16 | self.seen = [] 17 | 18 | def consume(self, code): 19 | """ 20 | Workhorse of the show. Accept a code, update the current 21 | state to reflect the impact of the code. 22 | """ 23 | if code.startswith('\x1b['): 24 | code = code[2:] 25 | if code == 'K': 26 | pass # discard EL 27 | elif code.endswith('m'): 28 | # SGR code 29 | vals = [int(v or 0) for v in code.rstrip('m').split(';')] 30 | # show(vals) 31 | while vals: 32 | top = vals.pop(0) 33 | if top == 0: 34 | self.fg = None 35 | self.bg = None 36 | self.style = None 37 | elif 1 <= top <= 9: 38 | if self.style is None: 39 | self.style = [] 40 | if top not in self.style: 41 | self.style.append(top) 42 | self.style = sorted(self.style) 43 | elif 21 <= top <= 29: 44 | antitop = top - 20 45 | if self.style is not None and antitop in self.style: 46 | self.style = [v for v in self.style if v != antitop] 47 | if not self.style: 48 | self.style = None 49 | elif 30 <= top < 38: 50 | self.fg = top 51 | elif top == 39: 52 | self.fg = None 53 | elif top == 38: 54 | under = vals.pop(0) 55 | if under == 5: 56 | self.fg = (38, 5, vals.pop(0)) 57 | elif under == 2: 58 | self.fg = (38, 2, vals.pop(0), 59 | vals.pop(0), vals.pop(0)) 60 | else: 61 | raise ValueError('cant parse fg') 62 | elif 40 <= top < 48: 63 | self.bg = top 64 | elif top == 49: 65 | self.bg = None 66 | elif top == 48: 67 | under = vals.pop(0) 68 | if under == 5: 69 | self.bg = (48, 5, vals.pop(0)) 70 | elif under == 2: 71 | self.bg = (48, 2, vals.pop(0), 72 | vals.pop(0), vals.pop(0)) 73 | else: 74 | raise ValueError('cant parse bg') 75 | assert not vals 76 | self.seen.append(code) 77 | 78 | def code(self): 79 | """ 80 | Return an ANSI code that creates the current state. 81 | """ 82 | 83 | def codearr(c): 84 | if c is None: 85 | return [] 86 | if isinstance(c, str): 87 | return [c] 88 | if isinstance(c, (tuple, list, set)): 89 | return ';'.join(str(p) for p in c) 90 | return [str(c)] 91 | 92 | raw_parts = [] 93 | raw_parts.extend(codearr(self.fg)) 94 | raw_parts.extend(codearr(self.bg)) 95 | raw_parts.extend(codearr(self.style)) 96 | parts = [p for p in raw_parts if p is not None] 97 | if parts: 98 | return '\x1b[{0}m'.format(';'.join(str(p) for p in parts)) 99 | else: 100 | return '' 101 | 102 | def __repr__(self): 103 | clsname = self.__class__.__name__ 104 | guts = 'fg={fg}, bg={bg}, style={style}'.format(**self.__dict__) 105 | return '{clsname}({guts})'.format(**vars()) 106 | 107 | if _PY2: 108 | def __unicode__(self): 109 | nn = lambda x: u'\u2014' if x is None else x 110 | return u'({0}, {1}, {2})'.format(nn(self.fg), nn(self.bg), nn(self.style)) 111 | else: 112 | def __str__(self): 113 | nn = lambda x: u'\u2014' if x is None else x 114 | return u'({0}, {1}, {2})'.format(nn(self.fg), nn(self.bg), nn(self.style)) 115 | -------------------------------------------------------------------------------- /ansiwrap/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function 2 | 3 | from ansiwrap.ansistate import ANSIState 4 | import re 5 | import sys 6 | import imp 7 | 8 | # import a copy of textwrap3 which we will viciously monkey-patch 9 | # to use our version of len, not the built-in 10 | import os 11 | a_textwrap = imp.load_module('a_textwrap', *imp.find_module('textwrap3')) 12 | 13 | 14 | __all__ = 'wrap fill shorten strip_color ansilen ansi_terminate_lines'.split() 15 | 16 | ANSIRE = re.compile('\x1b\\[(K|.*?m)') 17 | 18 | 19 | _PY2 = sys.version_info[0] == 2 20 | string_types = basestring if _PY2 else str 21 | 22 | 23 | def strip_color(s): 24 | """ 25 | Remove ANSI color/style sequences from a string. The set of all 26 | possibly ANSI sequences is large, so does not try to strip every 27 | possible one. But does strip some outliers seen not just in text 28 | generated by this module, but by other ANSI colorizers in the wild. 29 | Those include `\x1b[K` (aka EL or erase to end of line) and `\x1b[m` 30 | a terse version of the more common `\x1b[0m`. 31 | """ 32 | return ANSIRE.sub('', s) 33 | 34 | # strip_color provided here until correct version can be installed 35 | # via ansicolors 36 | 37 | 38 | def ansilen(s): 39 | """ 40 | Return the length of a string as it would be without common 41 | ANSI control codes. The check of string type not needed for 42 | pure string operations, but remembering we are using this to 43 | monkey-patch len(), needed because textwrap code can and does 44 | use len() for non-string measures. 45 | """ 46 | if isinstance(s, string_types): 47 | s_without_ansi = ANSIRE.sub('', s) 48 | return len(s_without_ansi) 49 | else: 50 | return len(s) 51 | 52 | 53 | # monkeypatch! 54 | a_textwrap.len = ansilen 55 | 56 | 57 | def _unified_indent(kwargs): 58 | """ 59 | Private helper. If kwargs has an `indent` parameter, that is 60 | made into the the value of both the `initial_indent` and the 61 | `subsequent_indent` parameters in the returned dictionary. 62 | """ 63 | indent = kwargs.get('indent') 64 | if indent is None: 65 | return kwargs 66 | unifed = kwargs.copy() 67 | del unifed['indent'] 68 | str_or_int = lambda val: ' ' * val if isinstance(val, int) else val 69 | if isinstance(indent, tuple): 70 | initial, subsequent = indent 71 | else: 72 | initial, subsequent = (indent, indent) 73 | 74 | initial, subsequent = indent if isinstance(indent, tuple) else (indent, indent) 75 | unifed['initial_indent'] = str_or_int(initial) 76 | unifed['subsequent_indent'] = str_or_int(subsequent) 77 | return unifed 78 | 79 | 80 | def wrap(s, width=70, **kwargs): 81 | """ 82 | Wrap a single paragraph of text, returning a list of wrapped lines. 83 | 84 | Designed to work exactly as `textwrap.wrap`, with two exceptions: 85 | 1. Wraps text containing ANSI control code sequences without considering 86 | the length of those (hidden, logically zero-length) sequences. 87 | 2. Accepts a unified `indent` parameter that, if present, sets the 88 | `initial_indent` and `subsequent_indent` parameters at the same time. 89 | """ 90 | kwargs = _unified_indent(kwargs) 91 | wrapped = a_textwrap.wrap(s, width, **kwargs) 92 | return ansi_terminate_lines(wrapped) 93 | 94 | 95 | def fill(s, width=70, **kwargs): 96 | """ 97 | Fill a single paragraph of text, returning a new string. 98 | 99 | Designed to work exactly as `textwrap.fill`, with two exceptions: 100 | 1. Fills text containing ANSI control code sequences without considering 101 | the length of those (hidden, logically zero-length) sequences. 102 | 2. Accepts a unified `indent` parameter that, if present, sets the 103 | `initial_indent` and `subsequent_indent` parameters at the same time. 104 | """ 105 | return '\n'.join(wrap(s, width, **kwargs)) 106 | 107 | 108 | def _ansi_optimize(s): 109 | # remove clear-to-end-of-line (EL) 110 | s = re.sub('\x1b\[K', '', s) 111 | return s 112 | 113 | 114 | # It is very appealing to think that we can write an optimize() routine, esp. 115 | # since textwrap can add some obviously-null sequences to strings (e.g. if 116 | # style was applied to spaces, but the spaces were then removed ad the end 117 | # of lines, leaving only styling). But this requires EXTREME CARE. ANSI is 118 | # very stateful. Some states simple string search would suggest are positive 119 | # e.g. (20-29, 39, 49) are explicitly negative, and only by parsing a stream 120 | # from a null state (either the last esc[m or the very beginning) can you truly 121 | # be sure you have parsed all the state transitions properly. The ANSIState 122 | # class would probably need to be used to for this. So beware. MANY snakes lurk 123 | # in this grass. 124 | 125 | 126 | def ansi_terminate_lines(lines): 127 | """ 128 | Walk through lines of text, terminating any outstanding color spans at 129 | the end of each line, and if one needed to be terminated, starting it on 130 | starting the color at the beginning of the next line. 131 | """ 132 | state = ANSIState() 133 | term_lines = [] 134 | end_code = None 135 | for line in lines: 136 | codes = ANSIRE.findall(line) 137 | for c in codes: 138 | state.consume(c) 139 | if end_code: # from prior line 140 | line = end_code + line 141 | end_code = state.code() 142 | if end_code: # from this line 143 | line = line + '\x1b[0m' 144 | 145 | term_lines.append(line) 146 | 147 | return term_lines 148 | 149 | 150 | def shorten(text, width, **kwargs): 151 | """Collapse and truncate the given text to fit in the given width. 152 | The text first has its whitespace collapsed. If it then fits in 153 | the *width*, it is returned as is. Otherwise, as many words 154 | as possible are joined and then the placeholder is appended:: 155 | >>> textwrap.shorten("Hello world!", width=12) 156 | 'Hello world!' 157 | >>> textwrap.shorten("Hello world!", width=11) 158 | 'Hello [...]' 159 | """ 160 | w = a_textwrap.TextWrapper(width=width, max_lines=1, **kwargs) 161 | unterm = w.wrap(' '.join(text.strip().split())) 162 | if not unterm: 163 | return '' 164 | term = ansi_terminate_lines(unterm[:1]) 165 | return term[0] 166 | 167 | 168 | # TODO: extend ANSI-savvy handling to other textwrap entry points such 169 | # as indent, dedent, and TextWrapper 170 | # TODO: shorten added for py34 and ff; is it worth back-porting? 171 | # TODO: should we provide a late model (py36) version of textwrap for prev 172 | # versions? has its behavior changed? would unicode issues make this a morass? 173 | # TODO: add lru_cache memoization to ansilen given textwrap's sloppy/excessive 174 | # use of the len function 175 | # TODO: tests (see https://github.com/python/cpython/blob/6f0eb93183519024cb360162bdd81b9faec97ba6/Lib/test/test_textwrap.py) 176 | # TODO: documentation 177 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import print_function 3 | from ansiwrap import * 4 | import textwrap 5 | from colors import * 6 | import sys 7 | 8 | try: 9 | ascii 10 | except NameError: # Python 2 11 | ascii = repr 12 | 13 | text = (red('This') + ' is ' + color('some', fg=11, bg=55, style='bold') + 14 | blue(' very nice') + ' ' + yellow('colored') + 15 | ' text ' + green('that is hard to') + yellow(' nicely ') 16 | + green('wrap because') + 17 | red(' of the ') + blue('ANSI', bg='yellow') + yellow(' codes.') + 18 | red(' But') + blue(' ansiwrap ') + green('does fine.')) 19 | 20 | 21 | text = ('textwrap\ndoes\nplain\ntext\nwell.\n' + red('But') + ' text ' + 22 | color('colored', fg=11, bg=55, style='bold') + 23 | yellow(' with ') + red('embedded ') + blue('ANSI', bg='yellow') + 24 | green(' codes') + yellow('?') 25 | + green(' Not') + 26 | red(' so ') + blue('good ') + magenta('there') + cyan('.')) 27 | # + color(' ansiwrap ', style='italic') + 28 | # yellow('has') + red(' no') + green(' such ') + blue('limits.')) 29 | 30 | 31 | print(ascii(text)) 32 | 33 | width = 30 34 | ruler = '----+----|' * 3 35 | 36 | print("All one line:") 37 | print(text) 38 | print() 39 | 40 | 41 | print(ruler) 42 | print(textwrap.fill(text, width)) 43 | 44 | print(ruler) 45 | print(fill(text, width)) 46 | 47 | print(ruler) 48 | print(textwrap.fill(strip_color(text), width)) 49 | 50 | print(ruler) 51 | print() 52 | print("ansiwrap output should look identical to") 53 | print("textwrap output...with the exception of color.") 54 | print() 55 | 56 | from say import say 57 | 58 | say(fill(text, width), prefix='| ') 59 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = test/*.py -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | from codecs import open 5 | 6 | 7 | def lines(text): 8 | """ 9 | Returns each non-blank line in text enclosed in a list. 10 | See http://pypi.python.org/pypi/textdata for more sophisticated version. 11 | """ 12 | return [l.strip() for l in text.strip().splitlines() if l.strip()] 13 | 14 | 15 | setup( 16 | name='ansiwrap', 17 | version='0.8.4', 18 | author='Jonathan Eunice', 19 | author_email='jonathan.eunice@gmail.com', 20 | description="textwrap, but savvy to ANSI colors and styles", 21 | long_description=open('README.rst', encoding='utf-8').read(), 22 | url='https://github.com/jonathaneunice/ansiwrap', 23 | license='Apache License 2.0', 24 | packages=['ansiwrap'], 25 | setup_requires=[], 26 | install_requires=['textwrap3>=0.9.2'], 27 | tests_require=['tox', 'pytest', 'ansicolors>=1.1.8', 'coverage', 'pytest-cov'], 28 | test_suite="test", 29 | zip_safe=False, 30 | keywords='text textwrap ANSI colors', 31 | classifiers=lines(""" 32 | Development Status :: 4 - Beta 33 | Environment :: Console 34 | Operating System :: OS Independent 35 | License :: OSI Approved :: Apache Software License 36 | Intended Audience :: Developers 37 | Programming Language :: Python 38 | Programming Language :: Python :: 2 39 | Programming Language :: Python :: 2.6 40 | Programming Language :: Python :: 2.7 41 | Programming Language :: Python :: 3 42 | Programming Language :: Python :: 3.3 43 | Programming Language :: Python :: 3.4 44 | Programming Language :: Python :: 3.5 45 | Programming Language :: Python :: 3.6 46 | Programming Language :: Python :: 3.7 47 | Programming Language :: Python :: Implementation :: CPython 48 | Programming Language :: Python :: Implementation :: PyPy 49 | Topic :: Software Development :: Libraries :: Python Modules 50 | Topic :: Text Processing 51 | Topic :: Text Processing :: Filters 52 | """) 53 | ) 54 | -------------------------------------------------------------------------------- /test/test_ansiwrap.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import textwrap3 as textwrap 4 | from colors import * # must come before ansiwrap import 5 | # so ansiwrap's better strip_color prevails 6 | 7 | from ansiwrap import * 8 | from ansiwrap.core import _ansi_optimize 9 | from ansiwrap.ansistate import ANSIState 10 | 11 | import pytest 12 | import random 13 | import sys 14 | 15 | _PY2 = sys.version_info[0] == 2 16 | VERSION = sys.version_info[:2] 17 | 18 | # explict test-at line lengths 19 | LINE_LENGTHS = [20, 27, 40, 41, 42, 43, 55, 70, 78, 79, 80, 100] 20 | 21 | # as an alternative to testing all lengths at all times, which is slow, 22 | # choose a few other lengths at random 23 | other_lengths = (random.sample(set(range(20, 120)).difference(LINE_LENGTHS), 2) + 24 | random.sample(set(range(120, 400)).difference(LINE_LENGTHS), 1)) 25 | LINE_LENGTHS.extend(other_lengths) 26 | 27 | 28 | 29 | def striplines(lines): 30 | return [strip_color(line) for line in lines] 31 | 32 | def lengths(lines): 33 | return [len(line) for line in lines] 34 | 35 | def same_behavior(text, width, **kwargs): 36 | """ 37 | Comparison fixture. Did ansiwrap wrap the text to the same number 38 | of lines, with the same number of visible characters per line, as textwrap 39 | did to the text without ANSI codes? 40 | """ 41 | no_ansi = strip_color(text) 42 | clean_wrap = textwrap.wrap(no_ansi, width, **kwargs) 43 | clean_fill = textwrap.fill(no_ansi, width, **kwargs) 44 | ansi_wrap = wrap(text, width, **kwargs) 45 | ansi_fill = fill(text, width, **kwargs) 46 | 47 | clean_wrap_lens = lengths(clean_wrap) 48 | ansi_wrap_lens = lengths(striplines(ansi_wrap)) 49 | 50 | assert len(clean_wrap) == len(ansi_wrap) 51 | assert len(clean_fill) == len(strip_color(ansi_fill)) 52 | assert clean_wrap_lens == ansi_wrap_lens 53 | 54 | 55 | def test_one(): 56 | # old demo text 57 | text = (red('This') + ' is ' + color('some', fg=11, bg=55, style='bold') + 58 | blue(' very nice') + ' ' + yellow('colored') + 59 | ' text ' + green('that is hard to') + yellow(' nicely ') 60 | + green('wrap because') + 61 | red(' of the ') + blue('ANSI', bg='yellow') + yellow(' codes.') + 62 | red(' But') + blue(' ansiwrap ') + green('does fine.')) 63 | for width in LINE_LENGTHS: 64 | same_behavior(text, width) 65 | 66 | 67 | def test_two(): 68 | # new demo text 69 | text = ('textwrap\ndoes\nplain\ntext\nwell.\n' + red('But') + ' text ' + 70 | color('colored', fg=11, bg=55, style='bold') + 71 | yellow(' with ') + red('embedded ') + blue('ANSI', bg='yellow') + 72 | green(' codes') + yellow('?') 73 | + green(' Not') + 74 | red(' so ') + blue('good ') + magenta('there') + cyan('.') 75 | + color(' ansiwrap ', style='italic') + 76 | yellow('has') + red(' no') + green(' such ') + blue('limits.')) 77 | for width in LINE_LENGTHS: 78 | same_behavior(text, width) 79 | 80 | 81 | def test_unified_indent(): 82 | text = ('textwrap\ndoes\nplain\ntext\nwell.\n' + red('But') + ' text ' + 83 | color('colored', fg=11, bg=55, style='bold') + 84 | yellow(' with ') + red('embedded ') + blue('ANSI', bg='yellow') + 85 | green(' codes') + yellow('?') 86 | + green(' Not') + 87 | red(' so ') + blue('good ') + magenta('there') + cyan('.') 88 | + color(' ansiwrap ', style='italic') + 89 | yellow('has') + red(' no') + green(' such ') + blue('limits.')) 90 | no_ansi = strip_color(text) 91 | 92 | def test_at_width(w, kw1, kw2): 93 | ansi_lines = wrap(text, w, **kw1) 94 | clean_lines = textwrap.wrap(no_ansi, w, **kw2) 95 | ansi_lens = lengths(striplines(ansi_lines)) 96 | clean_lens = lengths(clean_lines) 97 | assert ansi_lens == clean_lens 98 | 99 | WIDTHS = [0, 1, 2, 4, 5, 8, 10] 100 | max_indent = 9 101 | for width in LINE_LENGTHS: 102 | for indent_width in WIDTHS: 103 | indent_str = ' ' * indent_width 104 | test_at_width(width, dict(indent=indent_width), 105 | dict(initial_indent=indent_str, 106 | subsequent_indent=indent_str)) 107 | 108 | for width in LINE_LENGTHS: 109 | for indent_width in WIDTHS: 110 | for indent_width2 in WIDTHS: 111 | 112 | indent_str = ' ' * indent_width 113 | indent_str2 = ' ' * indent_width2 114 | 115 | # integer tuple 116 | test_at_width(width, dict(indent=(indent_width, indent_width2)), 117 | dict(initial_indent=indent_str, 118 | subsequent_indent=indent_str2)) 119 | # mixed tuple 120 | test_at_width(width, dict(indent=(indent_width, indent_str2)), 121 | dict(initial_indent=indent_str, 122 | subsequent_indent=indent_str2)) 123 | test_at_width(width, dict(indent=(indent_str, indent_width2)), 124 | dict(initial_indent=indent_str, 125 | subsequent_indent=indent_str2)) 126 | 127 | # string tuple 128 | test_at_width(width, dict(indent=(indent_str, indent_str2)), 129 | dict(initial_indent=indent_str, 130 | subsequent_indent=indent_str2)) 131 | 132 | def test_odd_states(): 133 | """ 134 | Attempt to put in codes that are not often seen with ansicolors module, 135 | but that are legit ANSI codes and used by other text processors. These inluce 136 | erase to end of line (EL) common in grep output, sepecifc style turn-offs 137 | that aren't "turn off everything," and truncated "turn off evertying." 138 | """ 139 | 140 | EL = '\x1b[K' 141 | 142 | text = ('textwrap\ndoes\nplain\ntext\nwell.\n' + red('But') + ' text ' + 143 | color('colored', fg=11, bg=55, style='bold') + 144 | yellow(' with ') + red('embedded ', style='underline') + blue('ANSI', bg='yellow') + 145 | green(' codes') + '\x1b[2;33;43m?\x1b[39;49m\x1b[m' + EL + 146 | green(' Not') + '\x1b[39m' + '\x1b[49m' + '\x1b[1m\x1b[21m' + 147 | red(' so ') + '\x1b[38;2;12;23;39;48;2;10;10;10mmgood\x1b[m ' + 148 | magenta('there') + cyan('.') + EL + 149 | color(' ansiwrap ', style='italic') + 150 | yellow('has') + red(' no') + green(' such ') + blue('limits.')) 151 | 152 | no_ansi = strip_color(text) 153 | 154 | for width in LINE_LENGTHS: 155 | assert strip_color(fill(text, width)) == textwrap.fill(no_ansi, width) 156 | 157 | 158 | def test_ANSIState_bad_states(): 159 | a = ANSIState() 160 | with pytest.raises(ValueError): 161 | a.consume('\x1b[38;7;200m') 162 | 163 | a = ANSIState() 164 | with pytest.raises(ValueError): 165 | a.consume('\x1b[48;7;200m') 166 | 167 | 168 | 169 | def test_ANSIState_misc(): 170 | 171 | a = ANSIState() 172 | a.consume('\x1b[33m') 173 | assert repr(a) == 'ANSIState(fg=33, bg=None, style=None)' 174 | 175 | stringify = unicode if _PY2 else str 176 | str_a = stringify(a) 177 | assert str_a == u'(33, —, —)' 178 | 179 | 180 | def test_optimize(): 181 | s = '\x1b[K\x1b[33msomething\x1b[0m\x1b[K' 182 | answer = '\x1b[33msomething\x1b[0m' 183 | assert _ansi_optimize(s) == answer 184 | 185 | 186 | def test_unterminated(): 187 | s = 'this is \x1b[33mgood and things are okay but very long and do not really fit on one line so maybe wrapping?' 188 | w = wrap(s, 50) 189 | assert w == ['this is \x1b[33mgood and things are okay but very long and\x1b[0m', 190 | '\x1b[33mdo not really fit on one line so maybe wrapping?\x1b[0m'] 191 | 192 | def test_known_text(): 193 | """Trst random text against a known good wrapping.""" 194 | r = ('gk zjpwxwqzq mnbafwsr agimmnmnv ylgy ebcdzrkfi eixtigdt skoxq zgjpqvrhf' 195 | ' i cuwdkjtl bhzljgwsd ljyq zjsem qgdn kwsc \x1b[31ml khcgnkl emxk wl svm ' 196 | 'ynk seumlnqhrh fxewvci\x1b[0m jxfbkiwwmz wdjwpw ndggihphir wcjftt t shzd ' 197 | 'cirjue kaxj fhw qezkffo knkag \x1b[33myfw cfpe uefaeywiq\x1b[0m rixxxykzd ' 198 | 'wu zcvfjbfy pcvhgqksxw uifumuxipr z \x1b[35mfm r vnvlc nnjbhwdjfv ' 199 | 'vkpxddyrsf obrlfup gghbvg nxfcqasnzf hj\x1b[0m') 200 | w = wrap(r, 30) 201 | assert w == ['gk zjpwxwqzq mnbafwsr', 202 | 'agimmnmnv ylgy ebcdzrkfi', 203 | 'eixtigdt skoxq zgjpqvrhf i', 204 | 'cuwdkjtl bhzljgwsd ljyq zjsem', 205 | 'qgdn kwsc \x1b[31ml khcgnkl emxk wl\x1b[0m', 206 | '\x1b[31msvm ynk seumlnqhrh fxewvci\x1b[0m', 207 | 'jxfbkiwwmz wdjwpw ndggihphir', 208 | 'wcjftt t shzd cirjue kaxj fhw', 209 | 'qezkffo knkag \x1b[33myfw cfpe\x1b[0m', 210 | '\x1b[33muefaeywiq\x1b[0m rixxxykzd wu', 211 | 'zcvfjbfy pcvhgqksxw uifumuxipr', 212 | 'z \x1b[35mfm r vnvlc nnjbhwdjfv\x1b[0m', 213 | '\x1b[35mvkpxddyrsf obrlfup gghbvg\x1b[0m', 214 | '\x1b[35mnxfcqasnzf hj\x1b[0m'] 215 | 216 | 217 | def test_shorten_basic(): 218 | # no ansi 219 | result = shorten('this is some really long text, no?', 15) 220 | expect = 'this is [...]' 221 | assert result == expect 222 | 223 | # ansi text 224 | result = shorten(red('this is some really long text, no?'), 15) 225 | expect = '\x1b[31mthis is [...]\x1b[0m' 226 | assert result == expect 227 | 228 | # ansi text and ansi placeholder 229 | result = shorten(red('this is some really long text, no?'), 15, 230 | placeholder=green('...')) 231 | expect = '\x1b[31mthis is some\x1b[32m...\x1b[0m' 232 | assert result == expect 233 | 234 | # ansi text and ansi Unicode placeholder 235 | result = shorten(red(u'this is some really long text, no?'), 15, 236 | placeholder=green(u'\u2026')) 237 | expect = u'\x1b[31mthis is some\x1b[32m\u2026\x1b[0m' 238 | assert result == expect 239 | 240 | # ansi Unicode text and ansi Unicode placeholder 241 | result = shorten(red(u'this is \u00fcber long text, no?'), 15, 242 | placeholder=green(u'\u2026')) 243 | expect = u'\x1b[31mthis is \u00fcber\x1b[32m\u2026\x1b[0m' 244 | assert result == expect 245 | 246 | def test_doc_example(): 247 | s = ' '.join([red('this string'), 248 | blue('is going on a bit long'), 249 | green('and may need to be'), 250 | color('shortened a bit', fg='purple')]) 251 | 252 | assert (s == '\x1b[31mthis string\x1b[0m \x1b[34mis going on a bit ' 253 | 'long\x1b[0m \x1b[32mand may need to be\x1b[0m ' 254 | '\x1b[38;2;128;0;128mshortened a bit\x1b[0m') 255 | 256 | assert (fill(s, 20) == '\x1b[31mthis string\x1b[0m \x1b[34mis ' 257 | 'going\x1b[0m\n\x1b[34mon a bit long\x1b[0m ' 258 | '\x1b[32mand\x1b[0m\n\x1b[32mmay need to ' 259 | 'be\x1b[0m\n\x1b[38;2;128;0;128mshortened a bit\x1b[0m') 260 | 261 | assert (shorten(s, 20, placeholder='...') == 262 | '\x1b[31mthis string\x1b[0m \x1b[34mis...\x1b[0m') 263 | 264 | def test_shorten_trivial(): 265 | assert shorten('', 79) == '' 266 | assert shorten(' ', 50) == '' 267 | assert shorten(' ', 55) == '' 268 | 269 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py3{6,7} 3 | 4 | [testenv] 5 | # changedir=test 6 | usedevelop=False 7 | deps= 8 | pytest 9 | ansicolors 10 | textwrap3 11 | commands= 12 | py.test {posargs: -l} 13 | -------------------------------------------------------------------------------- /toxcov.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = cov-init, py27, py3{6,7}, cov-report 3 | 4 | [testenv:cov-init] 5 | setenv = 6 | COVERAGE_FILE = .coverage 7 | deps = coverage 8 | commands = 9 | coverage erase 10 | 11 | [testenv:cov-report] 12 | setenv = 13 | COVERAGE_FILE = .coverage 14 | deps = coverage 15 | commands = 16 | coverage combine 17 | coverage report -m 18 | coverage html 19 | open htmlcov/index.html 20 | 21 | [testenv] 22 | # changedir=test 23 | usedevelop=True 24 | setenv = 25 | COVERAGE_FILE = .coverage.{envname} 26 | whitelist_externals= 27 | open 28 | deps= 29 | pytest 30 | ansicolors 31 | textwrap3 32 | coverage 33 | pytest-cov 34 | commands= 35 | py.test {posargs: -l --cov-report term-missing --cov=ansiwrap test} 36 | --------------------------------------------------------------------------------