From 896739353a613f23c007d9acaa2809010a522a37 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 16 Sep 2022 11:10:14 +0200 Subject: Adding upstream version 2.2.0. Signed-off-by: Daniel Baumann --- .gitignore | 69 +++++++ .travis.yml | 25 +++ CONTRIBUTING.md | 35 ++++ LICENSE | 21 ++ README.rst | 229 +++++++++++++++++++++ appveyor.yml | 17 ++ colorclass/__init__.py | 38 ++++ colorclass/__main__.py | 33 +++ colorclass/codes.py | 229 +++++++++++++++++++++ colorclass/color.py | 220 ++++++++++++++++++++ colorclass/core.py | 342 +++++++++++++++++++++++++++++++ colorclass/parse.py | 96 +++++++++ colorclass/search.py | 49 +++++ colorclass/toggles.py | 42 ++++ colorclass/windows.py | 388 +++++++++++++++++++++++++++++++++++ example.png | Bin 0 -> 295535 bytes example.py | 229 +++++++++++++++++++++ example_windows.png | Bin 0 -> 155348 bytes setup.py | 63 ++++++ tests/__init__.py | 1 + tests/conftest.py | 78 +++++++ tests/screenshot.py | 299 +++++++++++++++++++++++++++ tests/sub_box_green_win10.bmp | Bin 0 -> 5026 bytes tests/sub_box_green_winxp.bmp | Bin 0 -> 7254 bytes tests/sub_box_sans_win10.bmp | Bin 0 -> 5026 bytes tests/sub_box_sans_winxp.bmp | Bin 0 -> 7254 bytes tests/sub_red_dark_fg_win10.bmp | Bin 0 -> 446 bytes tests/sub_red_dark_fg_winxp.bmp | Bin 0 -> 702 bytes tests/sub_red_light_fg_win10.bmp | Bin 0 -> 418 bytes tests/sub_red_light_fg_winxp.bmp | Bin 0 -> 702 bytes tests/sub_red_sans_win10.bmp | Bin 0 -> 446 bytes tests/sub_red_sans_winxp.bmp | Bin 0 -> 882 bytes tests/test___main__.py | 64 ++++++ tests/test_codes.py | 137 +++++++++++++ tests/test_color.py | 185 +++++++++++++++++ tests/test_core.py | 398 ++++++++++++++++++++++++++++++++++++ tests/test_example.py | 96 +++++++++ tests/test_parse.py | 79 +++++++ tests/test_search.py | 51 +++++ tests/test_toggles.py | 29 +++ tests/test_windows.py | 429 +++++++++++++++++++++++++++++++++++++++ tox.ini | 78 +++++++ 42 files changed, 4049 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 appveyor.yml create mode 100644 colorclass/__init__.py create mode 100644 colorclass/__main__.py create mode 100644 colorclass/codes.py create mode 100644 colorclass/color.py create mode 100644 colorclass/core.py create mode 100644 colorclass/parse.py create mode 100644 colorclass/search.py create mode 100644 colorclass/toggles.py create mode 100644 colorclass/windows.py create mode 100644 example.png create mode 100755 example.py create mode 100644 example_windows.png create mode 100755 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/screenshot.py create mode 100644 tests/sub_box_green_win10.bmp create mode 100644 tests/sub_box_green_winxp.bmp create mode 100644 tests/sub_box_sans_win10.bmp create mode 100644 tests/sub_box_sans_winxp.bmp create mode 100644 tests/sub_red_dark_fg_win10.bmp create mode 100644 tests/sub_red_dark_fg_winxp.bmp create mode 100644 tests/sub_red_light_fg_win10.bmp create mode 100644 tests/sub_red_light_fg_winxp.bmp create mode 100644 tests/sub_red_sans_win10.bmp create mode 100644 tests/sub_red_sans_winxp.bmp create mode 100644 tests/test___main__.py create mode 100644 tests/test_codes.py create mode 100644 tests/test_color.py create mode 100644 tests/test_core.py create mode 100644 tests/test_example.py create mode 100644 tests/test_parse.py create mode 100644 tests/test_search.py create mode 100644 tests/test_toggles.py create mode 100644 tests/test_windows.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11757d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +*.rpm +requirements*.txt + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +#Ipython Notebook +.ipynb_checkpoints + +# IntelliJ +.idea/ + +test*.png diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e919857 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,25 @@ +# Configure. +language: python +python: 3.5 +sudo: false + +# Run. +install: pip install appveyor-artifacts coveralls tox +script: tox -e lint,py35,py34,py33,pypy3,pypy,py27,py26 +after_success: + - mv .coverage .coverage.travis + - appveyor-artifacts -mi download + - coverage combine + - coveralls + +# Deploy. +deploy: + provider: pypi + user: Robpol86 + password: + secure: + "JYR5ZVOHqZnr4uq8qtA9bM0+pBCfenTUApgSK2eMY3AoQ/Xi4UmcJvsGQkX70wq4twstRm\ + twpb/oFkAuxLMKkK7AJOTt9lKzqjF62xm/yGilDIYMZGCWi30OcRuUSQsEaE1Bq0H1TxciV\ + /ztcdwcXpTq2+oNQz9M7sbH7Czmdbw=" + on: + tags: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2ac3098 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing + +Everyone that wants to contribute to the project should read this document. + +## Getting Started + +You may follow these steps if you wish to create a pull request. Fork the repo and clone it on your local machine. Then +in the project's directory: + +```bash +virtualenv env # Create a virtualenv for the project's dependencies. +source env/bin/activate # Activate the virtualenv. +pip install tox # Install tox, which runs linting and tests. +tox # This runs all tests on your local machine. Make sure they pass. +``` + +If you don't have Python 2.6, 2.7, and 3.4 installed, you can manually run tests on one specific version by running +`tox -e lint,py27` (for Python 2.7) instead. + +## Consistency and Style + +Keep code style consistent with the rest of the project. Some suggestions: + +1. **Write tests for your new features.** `if new_feature else` **Write tests for bug-causing scenarios.** +2. Write docstrings for all classes, functions, methods, modules, etc. +3. Document all function/method arguments and return values. +4. Document all class variables instance variables. +5. Documentation guidelines also apply to tests, though not as strict. +6. Keep code style consistent, such as the kind of quotes to use and spacing. +7. Don't use `except:` or `except Exception:` unless you have a `raise` in the block. Be specific about error handling. +8. Don't use `isinstance()` (it breaks [duck typing](https://en.wikipedia.org/wiki/Duck_typing#In_Python)). + +## Thanks + +Thanks for fixing bugs or adding features to the project! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d314c3c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Robpol86 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..b236580 --- /dev/null +++ b/README.rst @@ -0,0 +1,229 @@ +========== +colorclass +========== + +Yet another ANSI color text library for Python. Provides "auto colors" for dark/light terminals. Works on Linux, OS X, +and Windows. For Windows support you just need to call ``Windows.enable()`` in your application. + +On Linux/OS X ``autocolors`` are toggled by calling ``set_light_background()`` and ``set_dark_background()``. On Windows +this can be done automatically if you call ``Windows.enable(auto_colors=True)``. Even though the latest Windows 10 does +support ANSI color codes natively, you still need to run Windows.enable() to take advantage of automatically detecting +the console's background color. + +In Python2.x this library subclasses ``unicode``, while on Python3.x it subclasses ``str``. + +* Python 2.6, 2.7, PyPy, PyPy3, 3.3, 3.4, and 3.5 supported on Linux and OS X. +* Python 2.6, 2.7, 3.3, 3.4, and 3.5 supported on Windows (both 32 and 64 bit versions of Python). + +.. image:: https://img.shields.io/appveyor/ci/Robpol86/colorclass/master.svg?style=flat-square&label=AppVeyor%20CI + :target: https://ci.appveyor.com/project/Robpol86/colorclass + :alt: Build Status Windows + +.. image:: https://img.shields.io/travis/Robpol86/colorclass/master.svg?style=flat-square&label=Travis%20CI + :target: https://travis-ci.org/Robpol86/colorclass + :alt: Build Status + +.. image:: https://img.shields.io/coveralls/Robpol86/colorclass/master.svg?style=flat-square&label=Coveralls + :target: https://coveralls.io/github/Robpol86/colorclass + :alt: Coverage Status + +.. image:: https://img.shields.io/pypi/v/colorclass.svg?style=flat-square&label=Latest + :target: https://pypi.python.org/pypi/colorclass + :alt: Latest Version + +.. image:: https://img.shields.io/pypi/dm/colorclass.svg?style=flat-square&label=PyPI%20Downloads + :target: https://pypi.python.org/pypi/colorclass + :alt: Downloads + +Quickstart +========== + +Install: + +.. code:: bash + + pip install colorclass + +Piped Command Line +================== + +It is possible to pipe curly-bracket tagged (or regular ANSI coded) text to Python in the command line to produce color +text. Some examples: + +.. code:: bash + + echo "{red}Red{/red}" |python -m colorclass # Red colored text. + echo -e "\033[31mRed\033[0m" | COLOR_DISABLE=true python -m colorclass # Strip colors + echo -e "\033[31mRed\033[0m" | COLOR_ENABLE=true python -m colorclass &> file.txt # Force colors. + +Export these environment variables as "true" to enable/disable some features: + +=============== ============================================ +Env Variable Description +=============== ============================================ +COLOR_ENABLE Force colors even when piping to a file. +COLOR_DISABLE Strip all colors from incoming text. +COLOR_LIGHT Use light colored text for dark backgrounds. +COLOR_DARK Use dark colored text for light backgrounds. +=============== ============================================ + +Example Implementation +====================== + +.. image:: https://github.com/Robpol86/colorclass/raw/master/example.png?raw=true + :alt: Example Script Screenshot + +.. image:: https://github.com/Robpol86/colorclass/raw/master/example_windows.png?raw=true + :alt: Example Windows Screenshot + +Source code for the example code is: `example.py `_ + +Usage +===== + +Different colors are chosen using curly-bracket tags, such as ``{red}{/red}``. For a list of available colors, call +``colorclass.list_tags()``. + +The available "auto colors" tags are: + +* autoblack +* autored +* autogreen +* autoyellow +* autoblue +* automagenta +* autocyan +* autowhite +* autobgblack +* autobgred +* autobggreen +* autobgyellow +* autobgblue +* autobgmagenta +* autobgcyan +* autobgwhite + +Methods of Class instances try to return sane data, such as: + +.. code:: python + + from colorclass import Color + color_string = Color('{red}Test{/red}') + + color_string + u'\x1b[31mTest\x1b[39m' + + len(color_string) + 4 + + color_string.istitle() + True + +There are also a couple of helper attributes for all Color instances: + +.. code:: python + + color_string.value_colors + '\x1b[31mTest\x1b[39m' + + color_string.value_no_colors + 'Test' + +Changelog +========= + +This project adheres to `Semantic Versioning `_. + +2.2.0 - 2016-05-14 +------------------ + +Added + * ``disable_if_no_tty()`` function to conditionally disable colors when STDERR and STDOUT are not streams. + +Changed + * Colors enabled by default always, like it was before v2.0.0. + +2.1.1 - 2016-05-10 +------------------ + +Fixed + * Printing box drawing characters on Windows from Python 2.6. + +2.1.0 - 2016-05-07 +------------------ + +Added + * ``keep_tags`` boolean keyword argument to Color(). Prevents colorclass from parsing curly brackets. + * Automatically skip replacing stderr/stdout streams on latest Windows 10 versions with native ANSI color support. + +Changed + * Refactored most of windows.py. + * Background color determined from either stderr or stdout, instead of just one stream (e.g. piping stderr to file). + +Fixed + * https://github.com/Robpol86/colorclass/issues/16 + * https://github.com/Robpol86/colorclass/issues/18 + +2.0.0 - 2016-04-10 +------------------ + +Added + * Python 3.5 support. + * ``enable_all_colors()``, ``is_enabled()``, and ``is_light()`` toggle functions. + * Library can be used as a script (e.g. ``echo "{red}Red{/red}" |python -m colorclass``). + * Ability to add/multiply Color instances just like str. + * Ability to iterate a Color instance and have each character keep its color codes. + +Changed + * Converted library from Python module to a package. + * ``set_light_background()`` and ``set_dark_background()`` no longer enable colors. Use ``enable_all_colors()``. + * Colors are disabled by default when STDERR and STDOUT are not streams (piped to files/null). Similar to ``grep``. + * Reduce size of ANSI escape sequences by removing codes that have no effect. e.g. ``\033[31;35m`` to ``\033[35m``. + * Color methods that return strings now return Color instances instead of str instances. + +Fixed + * https://github.com/Robpol86/colorclass/issues/15 + * https://github.com/Robpol86/colorclass/issues/17 + +1.2.0 - 2015-03-19 +------------------ + +Added + * Convenience single-color methods by `Marc Abramowitz `_. + +1.1.2 - 2015-01-07 +------------------ + +Fixed + * Maintaining ``Color`` type through ``.encode()`` and ``.decode()`` chains. + +1.1.1 - 2014-11-03 +------------------ + +Fixed + * Python 2.7 64-bit original colors bug on Windows. + * resetting colors when ``reset_atexit`` is True. + * Improved sorting of ``list_tags()``. + +1.1.0 - 2014-11-01 +------------------ + +Added + * Native Windows support and automatic background colors. + +1.0.2 - 2014-10-20 +------------------ + +Added + * Ability to disable/strip out all colors. + +1.0.1 - 2014-09-11 +------------------ + +Fixed + * ``splitlines()`` method. + +1.0.0 - 2014-09-01 +------------------ + +* Initial release. diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..5b14160 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,17 @@ +# Configure. +artifacts: + - path: .coverage + +# Run. +init: set PATH=C:\Python35-x64;C:\Python35-x64\Scripts;%PATH% +install: + - appveyor DownloadFile https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-desktop.ps1 + - ps: .\enable-desktop +build_script: pip install tox +test_script: tox -e lint,py35,py34,py33,py27,py26,py35x64,py34x64,py33x64,py27x64,py26x64 + +# Post. +on_finish: + - appveyor PushArtifact test_example_test_windows_screenshot.png + - appveyor PushArtifact test_windows_test_enable_disable.png + - appveyor PushArtifact test_windows_test_box_characters.png diff --git a/colorclass/__init__.py b/colorclass/__init__.py new file mode 100644 index 0000000..cae4016 --- /dev/null +++ b/colorclass/__init__.py @@ -0,0 +1,38 @@ +"""Colorful worry-free console applications for Linux, Mac OS X, and Windows. + +Supported natively on Linux and Mac OSX (Just Works), and on Windows it works the same if Windows.enable() is called. + +Gives you expected and sane results from methods like len() and .capitalize(). + +https://github.com/Robpol86/colorclass +https://pypi.python.org/pypi/colorclass +""" + +from colorclass.codes import list_tags # noqa +from colorclass.color import Color # noqa +from colorclass.toggles import disable_all_colors # noqa +from colorclass.toggles import disable_if_no_tty # noqa +from colorclass.toggles import enable_all_colors # noqa +from colorclass.toggles import is_enabled # noqa +from colorclass.toggles import is_light # noqa +from colorclass.toggles import set_dark_background # noqa +from colorclass.toggles import set_light_background # noqa +from colorclass.windows import Windows # noqa + + +__all__ = ( + 'Color', + 'disable_all_colors', + 'enable_all_colors', + 'is_enabled', + 'is_light', + 'list_tags', + 'set_dark_background', + 'set_light_background', + 'Windows', +) + + +__author__ = '@Robpol86' +__license__ = 'MIT' +__version__ = '2.2.0' diff --git a/colorclass/__main__.py b/colorclass/__main__.py new file mode 100644 index 0000000..d8f3f00 --- /dev/null +++ b/colorclass/__main__.py @@ -0,0 +1,33 @@ +"""Called by "python -m". Allows package to be used as a script. + +Example usage: +echo "{red}Red{/red}" |python -m colorclass +""" + +from __future__ import print_function + +import fileinput +import os + +from colorclass.color import Color +from colorclass.toggles import disable_all_colors +from colorclass.toggles import enable_all_colors +from colorclass.toggles import set_dark_background +from colorclass.toggles import set_light_background +from colorclass.windows import Windows + +TRUTHY = ('true', '1', 'yes', 'on') + + +if __name__ == '__main__': + if os.environ.get('COLOR_ENABLE', '').lower() in TRUTHY: + enable_all_colors() + elif os.environ.get('COLOR_DISABLE', '').lower() in TRUTHY: + disable_all_colors() + if os.environ.get('COLOR_LIGHT', '').lower() in TRUTHY: + set_light_background() + elif os.environ.get('COLOR_DARK', '').lower() in TRUTHY: + set_dark_background() + Windows.enable() + for LINE in fileinput.input(): + print(Color(LINE)) diff --git a/colorclass/codes.py b/colorclass/codes.py new file mode 100644 index 0000000..b0ecb03 --- /dev/null +++ b/colorclass/codes.py @@ -0,0 +1,229 @@ +"""Handles mapping between color names and ANSI codes and determining auto color codes.""" + +import sys +from collections import Mapping + +BASE_CODES = { + '/all': 0, 'b': 1, 'f': 2, 'i': 3, 'u': 4, 'flash': 5, 'outline': 6, 'negative': 7, 'invis': 8, 'strike': 9, + '/b': 22, '/f': 22, '/i': 23, '/u': 24, '/flash': 25, '/outline': 26, '/negative': 27, '/invis': 28, + '/strike': 29, '/fg': 39, '/bg': 49, + + 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34, 'magenta': 35, 'cyan': 36, 'white': 37, + + 'bgblack': 40, 'bgred': 41, 'bggreen': 42, 'bgyellow': 43, 'bgblue': 44, 'bgmagenta': 45, 'bgcyan': 46, + 'bgwhite': 47, + + 'hiblack': 90, 'hired': 91, 'higreen': 92, 'hiyellow': 93, 'hiblue': 94, 'himagenta': 95, 'hicyan': 96, + 'hiwhite': 97, + + 'hibgblack': 100, 'hibgred': 101, 'hibggreen': 102, 'hibgyellow': 103, 'hibgblue': 104, 'hibgmagenta': 105, + 'hibgcyan': 106, 'hibgwhite': 107, + + 'autored': None, 'autoblack': None, 'automagenta': None, 'autowhite': None, 'autoblue': None, 'autoyellow': None, + 'autogreen': None, 'autocyan': None, + + 'autobgred': None, 'autobgblack': None, 'autobgmagenta': None, 'autobgwhite': None, 'autobgblue': None, + 'autobgyellow': None, 'autobggreen': None, 'autobgcyan': None, + + '/black': 39, '/red': 39, '/green': 39, '/yellow': 39, '/blue': 39, '/magenta': 39, '/cyan': 39, '/white': 39, + '/hiblack': 39, '/hired': 39, '/higreen': 39, '/hiyellow': 39, '/hiblue': 39, '/himagenta': 39, '/hicyan': 39, + '/hiwhite': 39, + + '/bgblack': 49, '/bgred': 49, '/bggreen': 49, '/bgyellow': 49, '/bgblue': 49, '/bgmagenta': 49, '/bgcyan': 49, + '/bgwhite': 49, '/hibgblack': 49, '/hibgred': 49, '/hibggreen': 49, '/hibgyellow': 49, '/hibgblue': 49, + '/hibgmagenta': 49, '/hibgcyan': 49, '/hibgwhite': 49, + + '/autored': 39, '/autoblack': 39, '/automagenta': 39, '/autowhite': 39, '/autoblue': 39, '/autoyellow': 39, + '/autogreen': 39, '/autocyan': 39, + + '/autobgred': 49, '/autobgblack': 49, '/autobgmagenta': 49, '/autobgwhite': 49, '/autobgblue': 49, + '/autobgyellow': 49, '/autobggreen': 49, '/autobgcyan': 49, +} + + +class ANSICodeMapping(Mapping): + """Read-only dictionary, resolves closing tags and automatic colors. Iterates only used color tags. + + :cvar bool DISABLE_COLORS: Disable colors (strip color codes). + :cvar bool LIGHT_BACKGROUND: Use low intensity color codes. + """ + + DISABLE_COLORS = False + LIGHT_BACKGROUND = False + + def __init__(self, value_markup): + """Constructor. + + :param str value_markup: String with {color} tags. + """ + self.whitelist = [k for k in BASE_CODES if '{' + k + '}' in value_markup] + + def __getitem__(self, item): + """Return value for key or None if colors are disabled. + + :param str item: Key. + + :return: Color code integer. + :rtype: int + """ + if item not in self.whitelist: + raise KeyError(item) + if self.DISABLE_COLORS: + return None + return getattr(self, item, BASE_CODES[item]) + + def __iter__(self): + """Iterate dictionary.""" + return iter(self.whitelist) + + def __len__(self): + """Dictionary length.""" + return len(self.whitelist) + + @classmethod + def disable_all_colors(cls): + """Disable all colors. Strips any color tags or codes.""" + cls.DISABLE_COLORS = True + + @classmethod + def enable_all_colors(cls): + """Enable all colors. Strips any color tags or codes.""" + cls.DISABLE_COLORS = False + + @classmethod + def disable_if_no_tty(cls): + """Disable all colors only if there is no TTY available. + + :return: True if colors are disabled, False if stderr or stdout is a TTY. + :rtype: bool + """ + if sys.stdout.isatty() or sys.stderr.isatty(): + return False + cls.disable_all_colors() + return True + + @classmethod + def set_dark_background(cls): + """Choose dark colors for all 'auto'-prefixed codes for readability on light backgrounds.""" + cls.LIGHT_BACKGROUND = False + + @classmethod + def set_light_background(cls): + """Choose dark colors for all 'auto'-prefixed codes for readability on light backgrounds.""" + cls.LIGHT_BACKGROUND = True + + @property + def autoblack(self): + """Return automatic black foreground color depending on background color.""" + return BASE_CODES['black' if ANSICodeMapping.LIGHT_BACKGROUND else 'hiblack'] + + @property + def autored(self): + """Return automatic red foreground color depending on background color.""" + return BASE_CODES['red' if ANSICodeMapping.LIGHT_BACKGROUND else 'hired'] + + @property + def autogreen(self): + """Return automatic green foreground color depending on background color.""" + return BASE_CODES['green' if ANSICodeMapping.LIGHT_BACKGROUND else 'higreen'] + + @property + def autoyellow(self): + """Return automatic yellow foreground color depending on background color.""" + return BASE_CODES['yellow' if ANSICodeMapping.LIGHT_BACKGROUND else 'hiyellow'] + + @property + def autoblue(self): + """Return automatic blue foreground color depending on background color.""" + return BASE_CODES['blue' if ANSICodeMapping.LIGHT_BACKGROUND else 'hiblue'] + + @property + def automagenta(self): + """Return automatic magenta foreground color depending on background color.""" + return BASE_CODES['magenta' if ANSICodeMapping.LIGHT_BACKGROUND else 'himagenta'] + + @property + def autocyan(self): + """Return automatic cyan foreground color depending on background color.""" + return BASE_CODES['cyan' if ANSICodeMapping.LIGHT_BACKGROUND else 'hicyan'] + + @property + def autowhite(self): + """Return automatic white foreground color depending on background color.""" + return BASE_CODES['white' if ANSICodeMapping.LIGHT_BACKGROUND else 'hiwhite'] + + @property + def autobgblack(self): + """Return automatic black background color depending on background color.""" + return BASE_CODES['bgblack' if ANSICodeMapping.LIGHT_BACKGROUND else 'hibgblack'] + + @property + def autobgred(self): + """Return automatic red background color depending on background color.""" + return BASE_CODES['bgred' if ANSICodeMapping.LIGHT_BACKGROUND else 'hibgred'] + + @property + def autobggreen(self): + """Return automatic green background color depending on background color.""" + return BASE_CODES['bggreen' if ANSICodeMapping.LIGHT_BACKGROUND else 'hibggreen'] + + @property + def autobgyellow(self): + """Return automatic yellow background color depending on background color.""" + return BASE_CODES['bgyellow' if ANSICodeMapping.LIGHT_BACKGROUND else 'hibgyellow'] + + @property + def autobgblue(self): + """Return automatic blue background color depending on background color.""" + return BASE_CODES['bgblue' if ANSICodeMapping.LIGHT_BACKGROUND else 'hibgblue'] + + @property + def autobgmagenta(self): + """Return automatic magenta background color depending on background color.""" + return BASE_CODES['bgmagenta' if ANSICodeMapping.LIGHT_BACKGROUND else 'hibgmagenta'] + + @property + def autobgcyan(self): + """Return automatic cyan background color depending on background color.""" + return BASE_CODES['bgcyan' if ANSICodeMapping.LIGHT_BACKGROUND else 'hibgcyan'] + + @property + def autobgwhite(self): + """Return automatic white background color depending on background color.""" + return BASE_CODES['bgwhite' if ANSICodeMapping.LIGHT_BACKGROUND else 'hibgwhite'] + + +def list_tags(): + """List the available tags. + + :return: List of 4-item tuples: opening tag, closing tag, main ansi value, closing ansi value. + :rtype: list + """ + # Build reverse dictionary. Keys are closing tags, values are [closing ansi, opening tag, opening ansi]. + reverse_dict = dict() + for tag, ansi in sorted(BASE_CODES.items()): + if tag.startswith('/'): + reverse_dict[tag] = [ansi, None, None] + else: + reverse_dict['/' + tag][1:] = [tag, ansi] + + # Collapse + four_item_tuples = [(v[1], k, v[2], v[0]) for k, v in reverse_dict.items()] + + # Sort. + def sorter(four_item): + """Sort /all /fg /bg first, then b i u flash, then auto colors, then dark colors, finally light colors. + + :param iter four_item: [opening tag, closing tag, main ansi value, closing ansi value] + + :return Sorting weight. + :rtype: int + """ + if not four_item[2]: # /all /fg /bg + return four_item[3] - 200 + if four_item[2] < 10 or four_item[0].startswith('auto'): # b f i u or auto colors + return four_item[2] - 100 + return four_item[2] + four_item_tuples.sort(key=sorter) + + return four_item_tuples diff --git a/colorclass/color.py b/colorclass/color.py new file mode 100644 index 0000000..2849d06 --- /dev/null +++ b/colorclass/color.py @@ -0,0 +1,220 @@ +"""Color class used by library users.""" + +from colorclass.core import ColorStr + + +class Color(ColorStr): + """Unicode (str in Python3) subclass with ANSI terminal text color support. + + Example syntax: Color('{red}Sample Text{/red}') + + Example without parsing logic: Color('{red}Sample Text{/red}', keep_tags=True) + + For a list of codes, call: colorclass.list_tags() + """ + + @classmethod + def colorize(cls, color, string, auto=False): + """Color-code entire string using specified color. + + :param str color: Color of string. + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + tag = '{0}{1}'.format('auto' if auto else '', color) + return cls('{%s}%s{/%s}' % (tag, string, tag)) + + @classmethod + def black(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('black', string, auto=auto) + + @classmethod + def bgblack(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('bgblack', string, auto=auto) + + @classmethod + def red(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('red', string, auto=auto) + + @classmethod + def bgred(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('bgred', string, auto=auto) + + @classmethod + def green(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('green', string, auto=auto) + + @classmethod + def bggreen(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('bggreen', string, auto=auto) + + @classmethod + def yellow(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('yellow', string, auto=auto) + + @classmethod + def bgyellow(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('bgyellow', string, auto=auto) + + @classmethod + def blue(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('blue', string, auto=auto) + + @classmethod + def bgblue(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('bgblue', string, auto=auto) + + @classmethod + def magenta(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('magenta', string, auto=auto) + + @classmethod + def bgmagenta(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('bgmagenta', string, auto=auto) + + @classmethod + def cyan(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('cyan', string, auto=auto) + + @classmethod + def bgcyan(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('bgcyan', string, auto=auto) + + @classmethod + def white(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('white', string, auto=auto) + + @classmethod + def bgwhite(cls, string, auto=False): + """Color-code entire string. + + :param str string: String to colorize. + :param bool auto: Enable auto-color (dark/light terminal). + + :return: Class instance for colorized string. + :rtype: Color + """ + return cls.colorize('bgwhite', string, auto=auto) diff --git a/colorclass/core.py b/colorclass/core.py new file mode 100644 index 0000000..481bb40 --- /dev/null +++ b/colorclass/core.py @@ -0,0 +1,342 @@ +"""String subclass that handles ANSI color codes.""" + +from colorclass.codes import ANSICodeMapping +from colorclass.parse import parse_input, RE_SPLIT +from colorclass.search import build_color_index, find_char_color + +PARENT_CLASS = type(u'') + + +def apply_text(incoming, func): + """Call `func` on text portions of incoming color string. + + :param iter incoming: Incoming string/ColorStr/string-like object to iterate. + :param func: Function to call with string portion as first and only parameter. + + :return: Modified string, same class type as incoming string. + """ + split = RE_SPLIT.split(incoming) + for i, item in enumerate(split): + if not item or RE_SPLIT.match(item): + continue + split[i] = func(item) + return incoming.__class__().join(split) + + +class ColorBytes(bytes): + """Str (bytes in Python3) subclass, .decode() overridden to return unicode (str in Python3) subclass instance.""" + + def __new__(cls, *args, **kwargs): + """Save original class so decode() returns an instance of it.""" + original_class = kwargs.pop('original_class') + combined_args = [cls] + list(args) + instance = bytes.__new__(*combined_args, **kwargs) + instance.original_class = original_class + return instance + + def decode(self, encoding='utf-8', errors='strict'): + """Decode using the codec registered for encoding. Default encoding is 'utf-8'. + + errors may be given to set a different error handling scheme. Default is 'strict' meaning that encoding errors + raise a UnicodeDecodeError. Other possible values are 'ignore' and 'replace' as well as any other name + registered with codecs.register_error that is able to handle UnicodeDecodeErrors. + + :param str encoding: Codec. + :param str errors: Error handling scheme. + """ + original_class = getattr(self, 'original_class') + return original_class(super(ColorBytes, self).decode(encoding, errors)) + + +class ColorStr(PARENT_CLASS): + """Core color class.""" + + def __new__(cls, *args, **kwargs): + """Parse color markup and instantiate.""" + keep_tags = kwargs.pop('keep_tags', False) + + # Parse string. + value_markup = args[0] if args else PARENT_CLASS() # e.g. '{red}test{/red}' + value_colors, value_no_colors = parse_input(value_markup, ANSICodeMapping.DISABLE_COLORS, keep_tags) + color_index = build_color_index(value_colors) + + # Instantiate. + color_args = [cls, value_colors] + list(args[1:]) + instance = PARENT_CLASS.__new__(*color_args, **kwargs) + + # Add additional attributes and return. + instance.value_colors = value_colors + instance.value_no_colors = value_no_colors + instance.has_colors = value_colors != value_no_colors + instance.color_index = color_index + return instance + + def __add__(self, other): + """Concatenate.""" + return self.__class__(self.value_colors + other, keep_tags=True) + + def __getitem__(self, item): + """Retrieve character.""" + try: + color_pos = self.color_index[int(item)] + except TypeError: # slice + return super(ColorStr, self).__getitem__(item) + return self.__class__(find_char_color(self.value_colors, color_pos), keep_tags=True) + + def __iter__(self): + """Yield one color-coded character at a time.""" + for color_pos in self.color_index: + yield self.__class__(find_char_color(self.value_colors, color_pos)) + + def __len__(self): + """Length of string without color codes (what users expect).""" + return self.value_no_colors.__len__() + + def __mod__(self, other): + """String substitution (like printf).""" + return self.__class__(self.value_colors % other, keep_tags=True) + + def __mul__(self, other): + """Multiply string.""" + return self.__class__(self.value_colors * other, keep_tags=True) + + def __repr__(self): + """Representation of a class instance (like datetime.datetime.now()).""" + return '{name}({value})'.format(name=self.__class__.__name__, value=repr(self.value_colors)) + + def capitalize(self): + """Return a copy of the string with only its first character capitalized.""" + return apply_text(self, lambda s: s.capitalize()) + + def center(self, width, fillchar=None): + """Return centered in a string of length width. Padding is done using the specified fill character or space. + + :param int width: Length of output string. + :param str fillchar: Use this character instead of spaces. + """ + if fillchar is not None: + result = self.value_no_colors.center(width, fillchar) + else: + result = self.value_no_colors.center(width) + return self.__class__(result.replace(self.value_no_colors, self.value_colors), keep_tags=True) + + def count(self, sub, start=0, end=-1): + """Return the number of non-overlapping occurrences of substring sub in string[start:end]. + + Optional arguments start and end are interpreted as in slice notation. + + :param str sub: Substring to search. + :param int start: Beginning position. + :param int end: Stop comparison at this position. + """ + return self.value_no_colors.count(sub, start, end) + + def endswith(self, suffix, start=0, end=None): + """Return True if ends with the specified suffix, False otherwise. + + With optional start, test beginning at that position. With optional end, stop comparing at that position. + suffix can also be a tuple of strings to try. + + :param str suffix: Suffix to search. + :param int start: Beginning position. + :param int end: Stop comparison at this position. + """ + args = [suffix, start] + ([] if end is None else [end]) + return self.value_no_colors.endswith(*args) + + def encode(self, encoding=None, errors='strict'): + """Encode using the codec registered for encoding. encoding defaults to the default encoding. + + errors may be given to set a different error handling scheme. Default is 'strict' meaning that encoding errors + raise a UnicodeEncodeError. Other possible values are 'ignore', 'replace' and 'xmlcharrefreplace' as well as any + other name registered with codecs.register_error that is able to handle UnicodeEncodeErrors. + + :param str encoding: Codec. + :param str errors: Error handling scheme. + """ + return ColorBytes(super(ColorStr, self).encode(encoding, errors), original_class=self.__class__) + + def decode(self, encoding=None, errors='strict'): + """Decode using the codec registered for encoding. encoding defaults to the default encoding. + + errors may be given to set a different error handling scheme. Default is 'strict' meaning that encoding errors + raise a UnicodeDecodeError. Other possible values are 'ignore' and 'replace' as well as any other name + registered with codecs.register_error that is able to handle UnicodeDecodeErrors. + + :param str encoding: Codec. + :param str errors: Error handling scheme. + """ + return self.__class__(super(ColorStr, self).decode(encoding, errors), keep_tags=True) + + def find(self, sub, start=None, end=None): + """Return the lowest index where substring sub is found, such that sub is contained within string[start:end]. + + Optional arguments start and end are interpreted as in slice notation. + + :param str sub: Substring to search. + :param int start: Beginning position. + :param int end: Stop comparison at this position. + """ + return self.value_no_colors.find(sub, start, end) + + def format(self, *args, **kwargs): + """Return a formatted version, using substitutions from args and kwargs. + + The substitutions are identified by braces ('{' and '}'). + """ + return self.__class__(super(ColorStr, self).format(*args, **kwargs), keep_tags=True) + + def index(self, sub, start=None, end=None): + """Like S.find() but raise ValueError when the substring is not found. + + :param str sub: Substring to search. + :param int start: Beginning position. + :param int end: Stop comparison at this position. + """ + return self.value_no_colors.index(sub, start, end) + + def isalnum(self): + """Return True if all characters in string are alphanumeric and there is at least one character in it.""" + return self.value_no_colors.isalnum() + + def isalpha(self): + """Return True if all characters in string are alphabetic and there is at least one character in it.""" + return self.value_no_colors.isalpha() + + def isdecimal(self): + """Return True if there are only decimal characters in string, False otherwise.""" + return self.value_no_colors.isdecimal() + + def isdigit(self): + """Return True if all characters in string are digits and there is at least one character in it.""" + return self.value_no_colors.isdigit() + + def isnumeric(self): + """Return True if there are only numeric characters in string, False otherwise.""" + return self.value_no_colors.isnumeric() + + def isspace(self): + """Return True if all characters in string are whitespace and there is at least one character in it.""" + return self.value_no_colors.isspace() + + def istitle(self): + """Return True if string is a titlecased string and there is at least one character in it. + + That is uppercase characters may only follow uncased characters and lowercase characters only cased ones. Return + False otherwise. + """ + return self.value_no_colors.istitle() + + def isupper(self): + """Return True if all cased characters are uppercase and there is at least one cased character in it.""" + return self.value_no_colors.isupper() + + def join(self, iterable): + """Return a string which is the concatenation of the strings in the iterable. + + :param iterable: Join items in this iterable. + """ + return self.__class__(super(ColorStr, self).join(iterable), keep_tags=True) + + def ljust(self, width, fillchar=None): + """Return left-justified string of length width. Padding is done using the specified fill character or space. + + :param int width: Length of output string. + :param str fillchar: Use this character instead of spaces. + """ + if fillchar is not None: + result = self.value_no_colors.ljust(width, fillchar) + else: + result = self.value_no_colors.ljust(width) + return self.__class__(result.replace(self.value_no_colors, self.value_colors), keep_tags=True) + + def rfind(self, sub, start=None, end=None): + """Return the highest index where substring sub is found, such that sub is contained within string[start:end]. + + Optional arguments start and end are interpreted as in slice notation. + + :param str sub: Substring to search. + :param int start: Beginning position. + :param int end: Stop comparison at this position. + """ + return self.value_no_colors.rfind(sub, start, end) + + def rindex(self, sub, start=None, end=None): + """Like .rfind() but raise ValueError when the substring is not found. + + :param str sub: Substring to search. + :param int start: Beginning position. + :param int end: Stop comparison at this position. + """ + return self.value_no_colors.rindex(sub, start, end) + + def rjust(self, width, fillchar=None): + """Return right-justified string of length width. Padding is done using the specified fill character or space. + + :param int width: Length of output string. + :param str fillchar: Use this character instead of spaces. + """ + if fillchar is not None: + result = self.value_no_colors.rjust(width, fillchar) + else: + result = self.value_no_colors.rjust(width) + return self.__class__(result.replace(self.value_no_colors, self.value_colors), keep_tags=True) + + def splitlines(self, keepends=False): + """Return a list of the lines in the string, breaking at line boundaries. + + Line breaks are not included in the resulting list unless keepends is given and True. + + :param bool keepends: Include linebreaks. + """ + return [self.__class__(l) for l in self.value_colors.splitlines(keepends)] + + def startswith(self, prefix, start=0, end=-1): + """Return True if string starts with the specified prefix, False otherwise. + + With optional start, test beginning at that position. With optional end, stop comparing at that position. prefix + can also be a tuple of strings to try. + + :param str prefix: Prefix to search. + :param int start: Beginning position. + :param int end: Stop comparison at this position. + """ + return self.value_no_colors.startswith(prefix, start, end) + + def swapcase(self): + """Return a copy of the string with uppercase characters converted to lowercase and vice versa.""" + return apply_text(self, lambda s: s.swapcase()) + + def title(self): + """Return a titlecased version of the string. + + That is words start with uppercase characters, all remaining cased characters have lowercase. + """ + return apply_text(self, lambda s: s.title()) + + def translate(self, table): + """Return a copy of the string, where all characters have been mapped through the given translation table. + + Table must be a mapping of Unicode ordinals to Unicode ordinals, strings, or None. Unmapped characters are left + untouched. Characters mapped to None are deleted. + + :param table: Translation table. + """ + return apply_text(self, lambda s: s.translate(table)) + + def upper(self): + """Return a copy of the string converted to uppercase.""" + return apply_text(self, lambda s: s.upper()) + + def zfill(self, width): + """Pad a numeric string with zeros on the left, to fill a field of the specified width. + + The string is never truncated. + + :param int width: Length of output string. + """ + if not self.value_no_colors: + result = self.value_no_colors.zfill(width) + else: + result = self.value_colors.replace(self.value_no_colors, self.value_no_colors.zfill(width)) + return self.__class__(result, keep_tags=True) diff --git a/colorclass/parse.py b/colorclass/parse.py new file mode 100644 index 0000000..46dc28e --- /dev/null +++ b/colorclass/parse.py @@ -0,0 +1,96 @@ +"""Parse color markup tags into ANSI escape sequences.""" + +import re + +from colorclass.codes import ANSICodeMapping, BASE_CODES + +CODE_GROUPS = ( + tuple(set(str(i) for i in BASE_CODES.values() if i and (40 <= i <= 49 or 100 <= i <= 109))), # bg colors + tuple(set(str(i) for i in BASE_CODES.values() if i and (30 <= i <= 39 or 90 <= i <= 99))), # fg colors + ('1', '22'), ('2', '22'), ('3', '23'), ('4', '24'), ('5', '25'), ('6', '26'), ('7', '27'), ('8', '28'), ('9', '29'), +) +RE_ANSI = re.compile(r'(\033\[([\d;]+)m)') +RE_COMBINE = re.compile(r'\033\[([\d;]+)m\033\[([\d;]+)m') +RE_SPLIT = re.compile(r'(\033\[[\d;]+m)') + + +def prune_overridden(ansi_string): + """Remove color codes that are rendered ineffective by subsequent codes in one escape sequence then sort codes. + + :param str ansi_string: Incoming ansi_string with ANSI color codes. + + :return: Color string with pruned color sequences. + :rtype: str + """ + multi_seqs = set(p for p in RE_ANSI.findall(ansi_string) if ';' in p[1]) # Sequences with multiple color codes. + + for escape, codes in multi_seqs: + r_codes = list(reversed(codes.split(';'))) + + # Nuke everything before {/all}. + try: + r_codes = r_codes[:r_codes.index('0') + 1] + except ValueError: + pass + + # Thin out groups. + for group in CODE_GROUPS: + for pos in reversed([i for i, n in enumerate(r_codes) if n in group][1:]): + r_codes.pop(pos) + + # Done. + reduced_codes = ';'.join(sorted(r_codes, key=int)) + if codes != reduced_codes: + ansi_string = ansi_string.replace(escape, '\033[' + reduced_codes + 'm') + + return ansi_string + + +def parse_input(tagged_string, disable_colors, keep_tags): + """Perform the actual conversion of tags to ANSI escaped codes. + + Provides a version of the input without any colors for len() and other methods. + + :param str tagged_string: The input unicode value. + :param bool disable_colors: Strip all colors in both outputs. + :param bool keep_tags: Skip parsing curly bracket tags into ANSI escape sequences. + + :return: 2-item tuple. First item is the parsed output. Second item is a version of the input without any colors. + :rtype: tuple + """ + codes = ANSICodeMapping(tagged_string) + output_colors = getattr(tagged_string, 'value_colors', tagged_string) + + # Convert: '{b}{red}' -> '\033[1m\033[31m' + if not keep_tags: + for tag, replacement in (('{' + k + '}', '' if v is None else '\033[%dm' % v) for k, v in codes.items()): + output_colors = output_colors.replace(tag, replacement) + + # Strip colors. + output_no_colors = RE_ANSI.sub('', output_colors) + if disable_colors: + return output_no_colors, output_no_colors + + # Combine: '\033[1m\033[31m' -> '\033[1;31m' + while True: + simplified = RE_COMBINE.sub(r'\033[\1;\2m', output_colors) + if simplified == output_colors: + break + output_colors = simplified + + # Prune: '\033[31;32;33;34;35m' -> '\033[35m' + output_colors = prune_overridden(output_colors) + + # Deduplicate: '\033[1;mT\033[1;mE\033[1;mS\033[1;mT' -> '\033[1;mTEST' + previous_escape = None + segments = list() + for item in (i for i in RE_SPLIT.split(output_colors) if i): + if RE_SPLIT.match(item): + if item != previous_escape: + segments.append(item) + previous_escape = item + else: + segments.append(item) + output_colors = ''.join(segments) + + return output_colors, output_no_colors diff --git a/colorclass/search.py b/colorclass/search.py new file mode 100644 index 0000000..555402d --- /dev/null +++ b/colorclass/search.py @@ -0,0 +1,49 @@ +"""Determine color of characters that may or may not be adjacent to ANSI escape sequences.""" + +from colorclass.parse import RE_SPLIT + + +def build_color_index(ansi_string): + """Build an index between visible characters and a string with invisible color codes. + + :param str ansi_string: String with color codes (ANSI escape sequences). + + :return: Position of visible characters in color string (indexes match non-color string). + :rtype: tuple + """ + mapping = list() + color_offset = 0 + for item in (i for i in RE_SPLIT.split(ansi_string) if i): + if RE_SPLIT.match(item): + color_offset += len(item) + else: + for _ in range(len(item)): + mapping.append(color_offset) + color_offset += 1 + return tuple(mapping) + + +def find_char_color(ansi_string, pos): + """Determine what color a character is in the string. + + :param str ansi_string: String with color codes (ANSI escape sequences). + :param int pos: Position of the character in the ansi_string. + + :return: Character along with all surrounding color codes. + :rtype: str + """ + result = list() + position = 0 # Set to None when character is found. + for item in (i for i in RE_SPLIT.split(ansi_string) if i): + if RE_SPLIT.match(item): + result.append(item) + if position is not None: + position += len(item) + elif position is not None: + for char in item: + if position == pos: + result.append(char) + position = None + break + position += 1 + return ''.join(result) diff --git a/colorclass/toggles.py b/colorclass/toggles.py new file mode 100644 index 0000000..1ba6bce --- /dev/null +++ b/colorclass/toggles.py @@ -0,0 +1,42 @@ +"""Convenience functions to enable/disable features.""" + +from colorclass.codes import ANSICodeMapping + + +def disable_all_colors(): + """Disable all colors. Strip any color tags or codes.""" + ANSICodeMapping.disable_all_colors() + + +def enable_all_colors(): + """Enable colors.""" + ANSICodeMapping.enable_all_colors() + + +def disable_if_no_tty(): + """Disable all colors if there is no TTY available. + + :return: True if colors are disabled, False if stderr or stdout is a TTY. + :rtype: bool + """ + return ANSICodeMapping.disable_if_no_tty() + + +def is_enabled(): + """Are colors enabled.""" + return not ANSICodeMapping.DISABLE_COLORS + + +def set_light_background(): + """Choose dark colors for all 'auto'-prefixed codes for readability on light backgrounds.""" + ANSICodeMapping.set_light_background() + + +def set_dark_background(): + """Choose dark colors for all 'auto'-prefixed codes for readability on light backgrounds.""" + ANSICodeMapping.set_dark_background() + + +def is_light(): + """Are background colors for light backgrounds.""" + return ANSICodeMapping.LIGHT_BACKGROUND diff --git a/colorclass/windows.py b/colorclass/windows.py new file mode 100644 index 0000000..8f69478 --- /dev/null +++ b/colorclass/windows.py @@ -0,0 +1,388 @@ +"""Windows console screen buffer handlers.""" + +from __future__ import print_function + +import atexit +import ctypes +import re +import sys + +from colorclass.codes import ANSICodeMapping, BASE_CODES +from colorclass.core import RE_SPLIT + +ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 +INVALID_HANDLE_VALUE = -1 +IS_WINDOWS = sys.platform == 'win32' +RE_NUMBER_SEARCH = re.compile(r'\033\[([\d;]+)m') +STD_ERROR_HANDLE = -12 +STD_OUTPUT_HANDLE = -11 +WINDOWS_CODES = { + '/all': -33, '/fg': -39, '/bg': -49, + + 'black': 0, 'red': 4, 'green': 2, 'yellow': 6, 'blue': 1, 'magenta': 5, 'cyan': 3, 'white': 7, + + 'bgblack': -8, 'bgred': 64, 'bggreen': 32, 'bgyellow': 96, 'bgblue': 16, 'bgmagenta': 80, 'bgcyan': 48, + 'bgwhite': 112, + + 'hiblack': 8, 'hired': 12, 'higreen': 10, 'hiyellow': 14, 'hiblue': 9, 'himagenta': 13, 'hicyan': 11, 'hiwhite': 15, + + 'hibgblack': 128, 'hibgred': 192, 'hibggreen': 160, 'hibgyellow': 224, 'hibgblue': 144, 'hibgmagenta': 208, + 'hibgcyan': 176, 'hibgwhite': 240, + + '/black': -39, '/red': -39, '/green': -39, '/yellow': -39, '/blue': -39, '/magenta': -39, '/cyan': -39, + '/white': -39, '/hiblack': -39, '/hired': -39, '/higreen': -39, '/hiyellow': -39, '/hiblue': -39, '/himagenta': -39, + '/hicyan': -39, '/hiwhite': -39, + + '/bgblack': -49, '/bgred': -49, '/bggreen': -49, '/bgyellow': -49, '/bgblue': -49, '/bgmagenta': -49, + '/bgcyan': -49, '/bgwhite': -49, '/hibgblack': -49, '/hibgred': -49, '/hibggreen': -49, '/hibgyellow': -49, + '/hibgblue': -49, '/hibgmagenta': -49, '/hibgcyan': -49, '/hibgwhite': -49, +} + + +class COORD(ctypes.Structure): + """COORD structure. http://msdn.microsoft.com/en-us/library/windows/desktop/ms682119.""" + + _fields_ = [ + ('X', ctypes.c_short), + ('Y', ctypes.c_short), + ] + + +class SmallRECT(ctypes.Structure): + """SMALL_RECT structure. http://msdn.microsoft.com/en-us/library/windows/desktop/ms686311.""" + + _fields_ = [ + ('Left', ctypes.c_short), + ('Top', ctypes.c_short), + ('Right', ctypes.c_short), + ('Bottom', ctypes.c_short), + ] + + +class ConsoleScreenBufferInfo(ctypes.Structure): + """CONSOLE_SCREEN_BUFFER_INFO structure. http://msdn.microsoft.com/en-us/library/windows/desktop/ms682093.""" + + _fields_ = [ + ('dwSize', COORD), + ('dwCursorPosition', COORD), + ('wAttributes', ctypes.c_ushort), + ('srWindow', SmallRECT), + ('dwMaximumWindowSize', COORD) + ] + + +def init_kernel32(kernel32=None): + """Load a unique instance of WinDLL into memory, set arg/return types, and get stdout/err handles. + + 1. Since we are setting DLL function argument types and return types, we need to maintain our own instance of + kernel32 to prevent overriding (or being overwritten by) user's own changes to ctypes.windll.kernel32. + 2. While we're doing all this we might as well get the handles to STDOUT and STDERR streams. + 3. If either stream has already been replaced set return value to INVALID_HANDLE_VALUE to indicate it shouldn't be + replaced. + + :raise AttributeError: When called on a non-Windows platform. + + :param kernel32: Optional mock kernel32 object. For testing. + + :return: Loaded kernel32 instance, stderr handle (int), stdout handle (int). + :rtype: tuple + """ + if not kernel32: + kernel32 = ctypes.LibraryLoader(ctypes.WinDLL).kernel32 # Load our own instance. Unique memory address. + kernel32.GetStdHandle.argtypes = [ctypes.c_ulong] + kernel32.GetStdHandle.restype = ctypes.c_void_p + kernel32.GetConsoleScreenBufferInfo.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(ConsoleScreenBufferInfo), + ] + kernel32.GetConsoleScreenBufferInfo.restype = ctypes.c_long + + # Get handles. + if hasattr(sys.stderr, '_original_stream'): + stderr = INVALID_HANDLE_VALUE + else: + stderr = kernel32.GetStdHandle(STD_ERROR_HANDLE) + if hasattr(sys.stdout, '_original_stream'): + stdout = INVALID_HANDLE_VALUE + else: + stdout = kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + + return kernel32, stderr, stdout + + +def get_console_info(kernel32, handle): + """Get information about this current console window. + + http://msdn.microsoft.com/en-us/library/windows/desktop/ms683231 + https://code.google.com/p/colorama/issues/detail?id=47 + https://bitbucket.org/pytest-dev/py/src/4617fe46/py/_io/terminalwriter.py + + Windows 10 Insider since around February 2016 finally introduced support for ANSI colors. No need to replace stdout + and stderr streams to intercept colors and issue multiple SetConsoleTextAttribute() calls for these consoles. + + :raise OSError: When GetConsoleScreenBufferInfo or GetConsoleMode API calls fail. + + :param ctypes.windll.kernel32 kernel32: Loaded kernel32 instance. + :param int handle: stderr or stdout handle. + + :return: Foreground and background colors (integers) as well as native ANSI support (bool). + :rtype: tuple + """ + # Query Win32 API. + csbi = ConsoleScreenBufferInfo() # Populated by GetConsoleScreenBufferInfo. + lpcsbi = ctypes.byref(csbi) + dword = ctypes.c_ulong() # Populated by GetConsoleMode. + lpdword = ctypes.byref(dword) + if not kernel32.GetConsoleScreenBufferInfo(handle, lpcsbi) or not kernel32.GetConsoleMode(handle, lpdword): + raise ctypes.WinError() + + # Parse data. + # buffer_width = int(csbi.dwSize.X - 1) + # buffer_height = int(csbi.dwSize.Y) + # terminal_width = int(csbi.srWindow.Right - csbi.srWindow.Left) + # terminal_height = int(csbi.srWindow.Bottom - csbi.srWindow.Top) + fg_color = csbi.wAttributes % 16 + bg_color = csbi.wAttributes & 240 + native_ansi = bool(dword.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) + + return fg_color, bg_color, native_ansi + + +def bg_color_native_ansi(kernel32, stderr, stdout): + """Get background color and if console supports ANSI colors natively for both streams. + + :param ctypes.windll.kernel32 kernel32: Loaded kernel32 instance. + :param int stderr: stderr handle. + :param int stdout: stdout handle. + + :return: Background color (int) and native ANSI support (bool). + :rtype: tuple + """ + try: + if stderr == INVALID_HANDLE_VALUE: + raise OSError + bg_color, native_ansi = get_console_info(kernel32, stderr)[1:] + except OSError: + try: + if stdout == INVALID_HANDLE_VALUE: + raise OSError + bg_color, native_ansi = get_console_info(kernel32, stdout)[1:] + except OSError: + bg_color, native_ansi = WINDOWS_CODES['black'], False + return bg_color, native_ansi + + +class WindowsStream(object): + """Replacement stream which overrides sys.stdout or sys.stderr. When writing or printing, ANSI codes are converted. + + ANSI (Linux/Unix) color codes are converted into win32 system calls, changing the next character's color before + printing it. Resources referenced: + https://github.com/tartley/colorama + http://www.cplusplus.com/articles/2ywTURfi/ + http://thomasfischer.biz/python-and-windows-terminal-colors/ + http://stackoverflow.com/questions/17125440/c-win32-console-color + http://www.tysos.org/svn/trunk/mono/corlib/System/WindowsConsoleDriver.cs + http://stackoverflow.com/questions/287871/print-in-terminal-with-colors-using-python + http://msdn.microsoft.com/en-us/library/windows/desktop/ms682088#_win32_character_attributes + + :cvar list ALL_BG_CODES: List of bg Windows codes. Used to determine if requested color is foreground or background. + :cvar dict COMPILED_CODES: Translation dict. Keys are ANSI codes (values of BASE_CODES), values are Windows codes. + :ivar int default_fg: Foreground Windows color code at the time of instantiation. + :ivar int default_bg: Background Windows color code at the time of instantiation. + """ + + ALL_BG_CODES = [v for k, v in WINDOWS_CODES.items() if k.startswith('bg') or k.startswith('hibg')] + COMPILED_CODES = dict((v, WINDOWS_CODES[k]) for k, v in BASE_CODES.items() if k in WINDOWS_CODES) + + def __init__(self, kernel32, stream_handle, original_stream): + """Constructor. + + :param ctypes.windll.kernel32 kernel32: Loaded kernel32 instance. + :param int stream_handle: stderr or stdout handle. + :param original_stream: sys.stderr or sys.stdout before being overridden by this class' instance. + """ + self._kernel32 = kernel32 + self._stream_handle = stream_handle + self._original_stream = original_stream + self.default_fg, self.default_bg = self.colors + + def __getattr__(self, item): + """If an attribute/function/etc is not defined in this function, retrieve the one from the original stream. + + Fixes ipython arrow key presses. + """ + return getattr(self._original_stream, item) + + @property + def colors(self): + """Return the current foreground and background colors.""" + try: + return get_console_info(self._kernel32, self._stream_handle)[:2] + except OSError: + return WINDOWS_CODES['white'], WINDOWS_CODES['black'] + + @colors.setter + def colors(self, color_code): + """Change the foreground and background colors for subsequently printed characters. + + None resets colors to their original values (when class was instantiated). + + Since setting a color requires including both foreground and background codes (merged), setting just the + foreground color resets the background color to black, and vice versa. + + This function first gets the current background and foreground colors, merges in the requested color code, and + sets the result. + + However if we need to remove just the foreground color but leave the background color the same (or vice versa) + such as when {/red} is used, we must merge the default foreground color with the current background color. This + is the reason for those negative values. + + :param int color_code: Color code from WINDOWS_CODES. + """ + if color_code is None: + color_code = WINDOWS_CODES['/all'] + + # Get current color code. + current_fg, current_bg = self.colors + + # Handle special negative codes. Also determine the final color code. + if color_code == WINDOWS_CODES['/fg']: + final_color_code = self.default_fg | current_bg # Reset the foreground only. + elif color_code == WINDOWS_CODES['/bg']: + final_color_code = current_fg | self.default_bg # Reset the background only. + elif color_code == WINDOWS_CODES['/all']: + final_color_code = self.default_fg | self.default_bg # Reset both. + elif color_code == WINDOWS_CODES['bgblack']: + final_color_code = current_fg # Black background. + else: + new_is_bg = color_code in self.ALL_BG_CODES + final_color_code = color_code | (current_fg if new_is_bg else current_bg) + + # Set new code. + self._kernel32.SetConsoleTextAttribute(self._stream_handle, final_color_code) + + def write(self, p_str): + """Write to stream. + + :param str p_str: string to print. + """ + for segment in RE_SPLIT.split(p_str): + if not segment: + # Empty string. p_str probably starts with colors so the first item is always ''. + continue + if not RE_SPLIT.match(segment): + # No color codes, print regular text. + print(segment, file=self._original_stream, end='') + self._original_stream.flush() + continue + for color_code in (int(c) for c in RE_NUMBER_SEARCH.findall(segment)[0].split(';')): + if color_code in self.COMPILED_CODES: + self.colors = self.COMPILED_CODES[color_code] + + +class Windows(object): + """Enable and disable Windows support for ANSI color character codes. + + Call static method Windows.enable() to enable color support for the remainder of the process' lifetime. + + This class is also a context manager. You can do this: + with Windows(): + print(Color('{autored}Test{/autored}')) + + Or this: + with Windows(auto_colors=True): + print(Color('{autored}Test{/autored}')) + """ + + @classmethod + def disable(cls): + """Restore sys.stderr and sys.stdout to their original objects. Resets colors to their original values. + + :return: If streams restored successfully. + :rtype: bool + """ + # Skip if not on Windows. + if not IS_WINDOWS: + return False + + # Restore default colors. + if hasattr(sys.stderr, '_original_stream'): + getattr(sys, 'stderr').color = None + if hasattr(sys.stdout, '_original_stream'): + getattr(sys, 'stdout').color = None + + # Restore original streams. + changed = False + if hasattr(sys.stderr, '_original_stream'): + changed = True + sys.stderr = getattr(sys.stderr, '_original_stream') + if hasattr(sys.stdout, '_original_stream'): + changed = True + sys.stdout = getattr(sys.stdout, '_original_stream') + + return changed + + @staticmethod + def is_enabled(): + """Return True if either stderr or stdout has colors enabled.""" + return hasattr(sys.stderr, '_original_stream') or hasattr(sys.stdout, '_original_stream') + + @classmethod + def enable(cls, auto_colors=False, reset_atexit=False): + """Enable color text with print() or sys.stdout.write() (stderr too). + + :param bool auto_colors: Automatically selects dark or light colors based on current terminal's background + color. Only works with {autored} and related tags. + :param bool reset_atexit: Resets original colors upon Python exit (in case you forget to reset it yourself with + a closing tag). Does nothing on native ANSI consoles. + + :return: If streams replaced successfully. + :rtype: bool + """ + if not IS_WINDOWS: + return False # Windows only. + + # Get values from init_kernel32(). + kernel32, stderr, stdout = init_kernel32() + if stderr == INVALID_HANDLE_VALUE and stdout == INVALID_HANDLE_VALUE: + return False # No valid handles, nothing to do. + + # Get console info. + bg_color, native_ansi = bg_color_native_ansi(kernel32, stderr, stdout) + + # Set auto colors: + if auto_colors: + if bg_color in (112, 96, 240, 176, 224, 208, 160): + ANSICodeMapping.set_light_background() + else: + ANSICodeMapping.set_dark_background() + + # Don't replace streams if ANSI codes are natively supported. + if native_ansi: + return False + + # Reset on exit if requested. + if reset_atexit: + atexit.register(cls.disable) + + # Overwrite stream references. + if stderr != INVALID_HANDLE_VALUE: + sys.stderr.flush() + sys.stderr = WindowsStream(kernel32, stderr, sys.stderr) + if stdout != INVALID_HANDLE_VALUE: + sys.stdout.flush() + sys.stdout = WindowsStream(kernel32, stdout, sys.stdout) + + return True + + def __init__(self, auto_colors=False): + """Constructor.""" + self.auto_colors = auto_colors + + def __enter__(self): + """Context manager, enables colors on Windows.""" + self.enable(auto_colors=self.auto_colors) + + def __exit__(self, *_): + """Context manager, disabled colors on Windows.""" + self.disable() diff --git a/example.png b/example.png new file mode 100644 index 0000000..352aa54 Binary files /dev/null and b/example.png differ diff --git a/example.py b/example.py new file mode 100755 index 0000000..c8a8f79 --- /dev/null +++ b/example.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python +"""Example usage of colorclass. + +Just prints sample text and exits. + +Usage: + example.py print [(-n|-c)] [(-l|-d)] [-w FILE] + example.py -h | --help + +Options: + -h --help Show this screen. + -c --colors Enable colors even when piped to another program. + -d --dark-bg Autocolors for black/dark backgrounds on Linux/OSX. + -l --light-bg Autocolors for white/light backgrounds on Linux/OSX. + -n --no-colors Strip out any foreground or background colors. + -w FILE --wait=FILE Wait for user create FILE, then exit. For testing. +""" + +from __future__ import print_function + +import os +import sys +import time + +from docopt import docopt + +from colorclass import Color +from colorclass import disable_all_colors, enable_all_colors, is_enabled +from colorclass import set_dark_background, set_light_background +from colorclass import Windows + +OPTIONS = docopt(__doc__) if __name__ == '__main__' else dict() + + +def main(): + """Main function called upon script execution.""" + if OPTIONS.get('--no-colors'): + disable_all_colors() + elif OPTIONS.get('--colors'): + enable_all_colors() + + if is_enabled() and os.name == 'nt': + Windows.enable(auto_colors=True, reset_atexit=True) + elif OPTIONS.get('--light-bg'): + set_light_background() + elif OPTIONS.get('--dark-bg'): + set_dark_background() + + # Light or dark colors. + print('Autocolors for all backgrounds:') + print(Color(' {autoblack}Black{/black} {autored}Red{/red} {autogreen}Green{/green} '), end='') + print(Color('{autoyellow}Yellow{/yellow} {autoblue}Blue{/blue} {automagenta}Magenta{/magenta} '), end='') + print(Color('{autocyan}Cyan{/cyan} {autowhite}White{/white}')) + + print(Color(' {autobgblack}{autoblack}Black{/black}{/bgblack} '), end='') + print(Color('{autobgblack}{autored}Red{/red}{/bgblack} {autobgblack}{autogreen}Green{/green}{/bgblack} '), end='') + print(Color('{autobgblack}{autoyellow}Yellow{/yellow}{/bgblack} '), end='') + print(Color('{autobgblack}{autoblue}Blue{/blue}{/bgblack} '), end='') + print(Color('{autobgblack}{automagenta}Magenta{/magenta}{/bgblack} '), end='') + print(Color('{autobgblack}{autocyan}Cyan{/cyan}{/bgblack} {autobgblack}{autowhite}White{/white}{/bgblack}')) + + print(Color(' {autobgred}{autoblack}Black{/black}{/bgred} {autobgred}{autored}Red{/red}{/bgred} '), end='') + print(Color('{autobgred}{autogreen}Green{/green}{/bgred} {autobgred}{autoyellow}Yellow{/yellow}{/bgred} '), end='') + print(Color('{autobgred}{autoblue}Blue{/blue}{/bgred} {autobgred}{automagenta}Magenta{/magenta}{/bgred} '), end='') + print(Color('{autobgred}{autocyan}Cyan{/cyan}{/bgred} {autobgred}{autowhite}White{/white}{/bgred}')) + + print(Color(' {autobggreen}{autoblack}Black{/black}{/bggreen} '), end='') + print(Color('{autobggreen}{autored}Red{/red}{/bggreen} {autobggreen}{autogreen}Green{/green}{/bggreen} '), end='') + print(Color('{autobggreen}{autoyellow}Yellow{/yellow}{/bggreen} '), end='') + print(Color('{autobggreen}{autoblue}Blue{/blue}{/bggreen} '), end='') + print(Color('{autobggreen}{automagenta}Magenta{/magenta}{/bggreen} '), end='') + print(Color('{autobggreen}{autocyan}Cyan{/cyan}{/bggreen} {autobggreen}{autowhite}White{/white}{/bggreen}')) + + print(Color(' {autobgyellow}{autoblack}Black{/black}{/bgyellow} '), end='') + print(Color('{autobgyellow}{autored}Red{/red}{/bgyellow} '), end='') + print(Color('{autobgyellow}{autogreen}Green{/green}{/bgyellow} '), end='') + print(Color('{autobgyellow}{autoyellow}Yellow{/yellow}{/bgyellow} '), end='') + print(Color('{autobgyellow}{autoblue}Blue{/blue}{/bgyellow} '), end='') + print(Color('{autobgyellow}{automagenta}Magenta{/magenta}{/bgyellow} '), end='') + print(Color('{autobgyellow}{autocyan}Cyan{/cyan}{/bgyellow} {autobgyellow}{autowhite}White{/white}{/bgyellow}')) + + print(Color(' {autobgblue}{autoblack}Black{/black}{/bgblue} {autobgblue}{autored}Red{/red}{/bgblue} '), end='') + print(Color('{autobgblue}{autogreen}Green{/green}{/bgblue} '), end='') + print(Color('{autobgblue}{autoyellow}Yellow{/yellow}{/bgblue} {autobgblue}{autoblue}Blue{/blue}{/bgblue} '), end='') + print(Color('{autobgblue}{automagenta}Magenta{/magenta}{/bgblue} '), end='') + print(Color('{autobgblue}{autocyan}Cyan{/cyan}{/bgblue} {autobgblue}{autowhite}White{/white}{/bgblue}')) + + print(Color(' {autobgmagenta}{autoblack}Black{/black}{/bgmagenta} '), end='') + print(Color('{autobgmagenta}{autored}Red{/red}{/bgmagenta} '), end='') + print(Color('{autobgmagenta}{autogreen}Green{/green}{/bgmagenta} '), end='') + print(Color('{autobgmagenta}{autoyellow}Yellow{/yellow}{/bgmagenta} '), end='') + print(Color('{autobgmagenta}{autoblue}Blue{/blue}{/bgmagenta} '), end='') + print(Color('{autobgmagenta}{automagenta}Magenta{/magenta}{/bgmagenta} '), end='') + print(Color('{autobgmagenta}{autocyan}Cyan{/cyan}{/bgmagenta} '), end='') + print(Color('{autobgmagenta}{autowhite}White{/white}{/bgmagenta}')) + + print(Color(' {autobgcyan}{autoblack}Black{/black}{/bgcyan} {autobgcyan}{autored}Red{/red}{/bgcyan} '), end='') + print(Color('{autobgcyan}{autogreen}Green{/green}{/bgcyan} '), end='') + print(Color('{autobgcyan}{autoyellow}Yellow{/yellow}{/bgcyan} {autobgcyan}{autoblue}Blue{/blue}{/bgcyan} '), end='') + print(Color('{autobgcyan}{automagenta}Magenta{/magenta}{/bgcyan} '), end='') + print(Color('{autobgcyan}{autocyan}Cyan{/cyan}{/bgcyan} {autobgcyan}{autowhite}White{/white}{/bgcyan}')) + + print(Color(' {autobgwhite}{autoblack}Black{/black}{/bgwhite} '), end='') + print(Color('{autobgwhite}{autored}Red{/red}{/bgwhite} {autobgwhite}{autogreen}Green{/green}{/bgwhite} '), end='') + print(Color('{autobgwhite}{autoyellow}Yellow{/yellow}{/bgwhite} '), end='') + print(Color('{autobgwhite}{autoblue}Blue{/blue}{/bgwhite} '), end='') + print(Color('{autobgwhite}{automagenta}Magenta{/magenta}{/bgwhite} '), end='') + print(Color('{autobgwhite}{autocyan}Cyan{/cyan}{/bgwhite} {autobgwhite}{autowhite}White{/white}{/bgwhite}')) + print() + + # Light colors. + print('Light colors for dark backgrounds:') + print(Color(' {hiblack}Black{/black} {hired}Red{/red} {higreen}Green{/green} '), end='') + print(Color('{hiyellow}Yellow{/yellow} {hiblue}Blue{/blue} {himagenta}Magenta{/magenta} '), end='') + print(Color('{hicyan}Cyan{/cyan} {hiwhite}White{/white}')) + + print(Color(' {hibgblack}{hiblack}Black{/black}{/bgblack} '), end='') + print(Color('{hibgblack}{hired}Red{/red}{/bgblack} {hibgblack}{higreen}Green{/green}{/bgblack} '), end='') + print(Color('{hibgblack}{hiyellow}Yellow{/yellow}{/bgblack} '), end='') + print(Color('{hibgblack}{hiblue}Blue{/blue}{/bgblack} '), end='') + print(Color('{hibgblack}{himagenta}Magenta{/magenta}{/bgblack} '), end='') + print(Color('{hibgblack}{hicyan}Cyan{/cyan}{/bgblack} {hibgblack}{hiwhite}White{/white}{/bgblack}')) + + print(Color(' {hibgred}{hiblack}Black{/black}{/bgred} {hibgred}{hired}Red{/red}{/bgred} '), end='') + print(Color('{hibgred}{higreen}Green{/green}{/bgred} {hibgred}{hiyellow}Yellow{/yellow}{/bgred} '), end='') + print(Color('{hibgred}{hiblue}Blue{/blue}{/bgred} {hibgred}{himagenta}Magenta{/magenta}{/bgred} '), end='') + print(Color('{hibgred}{hicyan}Cyan{/cyan}{/bgred} {hibgred}{hiwhite}White{/white}{/bgred}')) + + print(Color(' {hibggreen}{hiblack}Black{/black}{/bggreen} '), end='') + print(Color('{hibggreen}{hired}Red{/red}{/bggreen} {hibggreen}{higreen}Green{/green}{/bggreen} '), end='') + print(Color('{hibggreen}{hiyellow}Yellow{/yellow}{/bggreen} '), end='') + print(Color('{hibggreen}{hiblue}Blue{/blue}{/bggreen} '), end='') + print(Color('{hibggreen}{himagenta}Magenta{/magenta}{/bggreen} '), end='') + print(Color('{hibggreen}{hicyan}Cyan{/cyan}{/bggreen} {hibggreen}{hiwhite}White{/white}{/bggreen}')) + + print(Color(' {hibgyellow}{hiblack}Black{/black}{/bgyellow} '), end='') + print(Color('{hibgyellow}{hired}Red{/red}{/bgyellow} '), end='') + print(Color('{hibgyellow}{higreen}Green{/green}{/bgyellow} '), end='') + print(Color('{hibgyellow}{hiyellow}Yellow{/yellow}{/bgyellow} '), end='') + print(Color('{hibgyellow}{hiblue}Blue{/blue}{/bgyellow} '), end='') + print(Color('{hibgyellow}{himagenta}Magenta{/magenta}{/bgyellow} '), end='') + print(Color('{hibgyellow}{hicyan}Cyan{/cyan}{/bgyellow} {hibgyellow}{hiwhite}White{/white}{/bgyellow}')) + + print(Color(' {hibgblue}{hiblack}Black{/black}{/bgblue} {hibgblue}{hired}Red{/red}{/bgblue} '), end='') + print(Color('{hibgblue}{higreen}Green{/green}{/bgblue} '), end='') + print(Color('{hibgblue}{hiyellow}Yellow{/yellow}{/bgblue} {hibgblue}{hiblue}Blue{/blue}{/bgblue} '), end='') + print(Color('{hibgblue}{himagenta}Magenta{/magenta}{/bgblue} '), end='') + print(Color('{hibgblue}{hicyan}Cyan{/cyan}{/bgblue} {hibgblue}{hiwhite}White{/white}{/bgblue}')) + + print(Color(' {hibgmagenta}{hiblack}Black{/black}{/bgmagenta} '), end='') + print(Color('{hibgmagenta}{hired}Red{/red}{/bgmagenta} '), end='') + print(Color('{hibgmagenta}{higreen}Green{/green}{/bgmagenta} '), end='') + print(Color('{hibgmagenta}{hiyellow}Yellow{/yellow}{/bgmagenta} '), end='') + print(Color('{hibgmagenta}{hiblue}Blue{/blue}{/bgmagenta} '), end='') + print(Color('{hibgmagenta}{himagenta}Magenta{/magenta}{/bgmagenta} '), end='') + print(Color('{hibgmagenta}{hicyan}Cyan{/cyan}{/bgmagenta} '), end='') + print(Color('{hibgmagenta}{hiwhite}White{/white}{/bgmagenta}')) + + print(Color(' {hibgcyan}{hiblack}Black{/black}{/bgcyan} {hibgcyan}{hired}Red{/red}{/bgcyan} '), end='') + print(Color('{hibgcyan}{higreen}Green{/green}{/bgcyan} '), end='') + print(Color('{hibgcyan}{hiyellow}Yellow{/yellow}{/bgcyan} {hibgcyan}{hiblue}Blue{/blue}{/bgcyan} '), end='') + print(Color('{hibgcyan}{himagenta}Magenta{/magenta}{/bgcyan} '), end='') + print(Color('{hibgcyan}{hicyan}Cyan{/cyan}{/bgcyan} {hibgcyan}{hiwhite}White{/white}{/bgcyan}')) + + print(Color(' {hibgwhite}{hiblack}Black{/black}{/bgwhite} '), end='') + print(Color('{hibgwhite}{hired}Red{/red}{/bgwhite} {hibgwhite}{higreen}Green{/green}{/bgwhite} '), end='') + print(Color('{hibgwhite}{hiyellow}Yellow{/yellow}{/bgwhite} '), end='') + print(Color('{hibgwhite}{hiblue}Blue{/blue}{/bgwhite} '), end='') + print(Color('{hibgwhite}{himagenta}Magenta{/magenta}{/bgwhite} '), end='') + print(Color('{hibgwhite}{hicyan}Cyan{/cyan}{/bgwhite} {hibgwhite}{hiwhite}White{/white}{/bgwhite}')) + print() + + # Dark colors. + print('Dark colors for light backgrounds:') + print(Color(' {black}Black{/black} {red}Red{/red} {green}Green{/green} {yellow}Yellow{/yellow} '), end='') + print(Color('{blue}Blue{/blue} {magenta}Magenta{/magenta} {cyan}Cyan{/cyan} {white}White{/white}')) + + print(Color(' {bgblack}{black}Black{/black}{/bgblack} {bgblack}{red}Red{/red}{/bgblack} '), end='') + print(Color('{bgblack}{green}Green{/green}{/bgblack} {bgblack}{yellow}Yellow{/yellow}{/bgblack} '), end='') + print(Color('{bgblack}{blue}Blue{/blue}{/bgblack} {bgblack}{magenta}Magenta{/magenta}{/bgblack} '), end='') + print(Color('{bgblack}{cyan}Cyan{/cyan}{/bgblack} {bgblack}{white}White{/white}{/bgblack}')) + + print(Color(' {bgred}{black}Black{/black}{/bgred} {bgred}{red}Red{/red}{/bgred} '), end='') + print(Color('{bgred}{green}Green{/green}{/bgred} {bgred}{yellow}Yellow{/yellow}{/bgred} '), end='') + print(Color('{bgred}{blue}Blue{/blue}{/bgred} {bgred}{magenta}Magenta{/magenta}{/bgred} '), end='') + print(Color('{bgred}{cyan}Cyan{/cyan}{/bgred} {bgred}{white}White{/white}{/bgred}')) + + print(Color(' {bggreen}{black}Black{/black}{/bggreen} {bggreen}{red}Red{/red}{/bggreen} '), end='') + print(Color('{bggreen}{green}Green{/green}{/bggreen} {bggreen}{yellow}Yellow{/yellow}{/bggreen} '), end='') + print(Color('{bggreen}{blue}Blue{/blue}{/bggreen} {bggreen}{magenta}Magenta{/magenta}{/bggreen} '), end='') + print(Color('{bggreen}{cyan}Cyan{/cyan}{/bggreen} {bggreen}{white}White{/white}{/bggreen}')) + + print(Color(' {bgyellow}{black}Black{/black}{/bgyellow} {bgyellow}{red}Red{/red}{/bgyellow} '), end='') + print(Color('{bgyellow}{green}Green{/green}{/bgyellow} {bgyellow}{yellow}Yellow{/yellow}{/bgyellow} '), end='') + print(Color('{bgyellow}{blue}Blue{/blue}{/bgyellow} {bgyellow}{magenta}Magenta{/magenta}{/bgyellow} '), end='') + print(Color('{bgyellow}{cyan}Cyan{/cyan}{/bgyellow} {bgyellow}{white}White{/white}{/bgyellow}')) + + print(Color(' {bgblue}{black}Black{/black}{/bgblue} {bgblue}{red}Red{/red}{/bgblue} '), end='') + print(Color('{bgblue}{green}Green{/green}{/bgblue} {bgblue}{yellow}Yellow{/yellow}{/bgblue} '), end='') + print(Color('{bgblue}{blue}Blue{/blue}{/bgblue} {bgblue}{magenta}Magenta{/magenta}{/bgblue} '), end='') + print(Color('{bgblue}{cyan}Cyan{/cyan}{/bgblue} {bgblue}{white}White{/white}{/bgblue}')) + + print(Color(' {bgmagenta}{black}Black{/black}{/bgmagenta} {bgmagenta}{red}Red{/red}{/bgmagenta} '), end='') + print(Color('{bgmagenta}{green}Green{/green}{/bgmagenta} {bgmagenta}{yellow}Yellow{/yellow}{/bgmagenta} '), end='') + print(Color('{bgmagenta}{blue}Blue{/blue}{/bgmagenta} {bgmagenta}{magenta}Magenta{/magenta}{/bgmagenta} '), end='') + print(Color('{bgmagenta}{cyan}Cyan{/cyan}{/bgmagenta} {bgmagenta}{white}White{/white}{/bgmagenta}')) + + print(Color(' {bgcyan}{black}Black{/black}{/bgcyan} {bgcyan}{red}Red{/red}{/bgcyan} '), end='') + print(Color('{bgcyan}{green}Green{/green}{/bgcyan} {bgcyan}{yellow}Yellow{/yellow}{/bgcyan} '), end='') + print(Color('{bgcyan}{blue}Blue{/blue}{/bgcyan} {bgcyan}{magenta}Magenta{/magenta}{/bgcyan} '), end='') + print(Color('{bgcyan}{cyan}Cyan{/cyan}{/bgcyan} {bgcyan}{white}White{/white}{/bgcyan}')) + + print(Color(' {bgwhite}{black}Black{/black}{/bgwhite} {bgwhite}{red}Red{/red}{/bgwhite} '), end='') + print(Color('{bgwhite}{green}Green{/green}{/bgwhite} {bgwhite}{yellow}Yellow{/yellow}{/bgwhite} '), end='') + print(Color('{bgwhite}{blue}Blue{/blue}{/bgwhite} {bgwhite}{magenta}Magenta{/magenta}{/bgwhite} '), end='') + print(Color('{bgwhite}{cyan}Cyan{/cyan}{/bgwhite} {bgwhite}{white}White{/white}{/bgwhite}')) + + if OPTIONS['--wait']: + print('Waiting for {0} to exist within 10 seconds...'.format(OPTIONS['--wait']), file=sys.stderr, end='') + stop_after = time.time() + 20 + while not os.path.exists(OPTIONS['--wait']) and time.time() < stop_after: + print('.', file=sys.stderr, end='') + sys.stderr.flush() + time.sleep(0.5) + print(' done') + + +if __name__ == '__main__': + main() diff --git a/example_windows.png b/example_windows.png new file mode 100644 index 0000000..a171bb1 Binary files /dev/null and b/example_windows.png differ diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..3f22821 --- /dev/null +++ b/setup.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +"""Setup script for the project.""" + +from __future__ import print_function + +import codecs +import os + +from setuptools import setup + + +def readme(): + """Try to read README.rst or return empty string if failed. + + :return: File contents. + :rtype: str + """ + path = os.path.realpath(os.path.join(os.path.dirname(__file__), 'README.rst')) + handle = None + try: + handle = codecs.open(path, encoding='utf-8') + return handle.read(131072) + except IOError: + return '' + finally: + getattr(handle, 'close', lambda: None)() + + +setup( + author='@Robpol86', + author_email='robpol86@gmail.com', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Environment :: Console', + 'Environment :: MacOS X', + 'Environment :: Win32 (MS Windows)', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX :: Linux', + 'Operating System :: POSIX', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Topic :: Software Development :: Libraries', + 'Topic :: Terminals', + 'Topic :: Text Processing :: Markup', + ], + description='Colorful worry-free console applications for Linux, Mac OS X, and Windows.', + install_requires=[], + keywords='Shell Bash ANSI ASCII terminal console colors automatic', + license='MIT', + long_description=readme(), + name='colorclass', + packages=['colorclass'], + url='https://github.com/Robpol86/colorclass', + version='2.2.0', + zip_safe=True, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..bf16d1e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Allows importing from conftest.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5f404db --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,78 @@ +"""Configure tests.""" + +import py +import pytest + +from colorclass.codes import ANSICodeMapping +from colorclass.color import Color +from colorclass.core import ColorStr, PARENT_CLASS + +PROJECT_ROOT = py.path.local(__file__).dirpath().join('..') + + +@pytest.fixture(autouse=True) +def set_defaults(monkeypatch): + """Set ANSICodeMapping defaults before each test. + + :param monkeypatch: pytest fixture. + """ + monkeypatch.setattr(ANSICodeMapping, 'DISABLE_COLORS', False) + monkeypatch.setattr(ANSICodeMapping, 'LIGHT_BACKGROUND', False) + + +def assert_both_values(actual, expected_plain, expected_color, kind=None): + """Handle asserts for color and non-color strings in color and non-color tests. + + :param ColorStr actual: Return value of ColorStr class method. + :param expected_plain: Expected non-color value. + :param expected_color: Expected color value. + :param str kind: Type of string to test. + """ + if kind.endswith('plain'): + assert actual.value_colors == expected_plain + assert actual.value_no_colors == expected_plain + assert actual.has_colors is False + elif kind.endswith('color'): + assert actual.value_colors == expected_color + assert actual.value_no_colors == expected_plain + if '\033' in actual.value_colors: + assert actual.has_colors is True + else: + assert actual.has_colors is False + else: + assert actual == expected_plain + + if kind.startswith('ColorStr'): + assert actual.__class__ == ColorStr + elif kind.startswith('Color'): + assert actual.__class__ == Color + + +def get_instance(kind, sample=None, color='red'): + """Get either a string, non-color ColorStr, or color ColorStr instance. + + :param str kind: Type of string to test. + :param iter sample: Input test to derive instances from. + :param str color: Color tags to use. Default is red. + + :return: Instance. + """ + # First determine which class/type to use. + if kind.startswith('ColorStr'): + cls = ColorStr + elif kind.startswith('Color'): + cls = Color + else: + cls = PARENT_CLASS + + # Next handle NoneType samples. + if sample is None: + return cls() + + # Finally handle non-None samples. + if kind.endswith('plain'): + return cls(sample) + elif kind.endswith('color'): + tags = '{%s}' % color, '{/%s}' % color + return cls(tags[0] + sample + tags[1]) + return sample diff --git a/tests/screenshot.py b/tests/screenshot.py new file mode 100644 index 0000000..cc391eb --- /dev/null +++ b/tests/screenshot.py @@ -0,0 +1,299 @@ +"""Take screenshots and search for subimages in images.""" + +import ctypes +import os +import random +import struct +import subprocess +import time + +try: + from itertools import izip +except ImportError: + izip = zip # Py3 + +from colorclass.windows import WINDOWS_CODES +from tests.conftest import PROJECT_ROOT + +STARTF_USEFILLATTRIBUTE = 0x00000010 +STARTF_USESHOWWINDOW = getattr(subprocess, 'STARTF_USESHOWWINDOW', 1) +STILL_ACTIVE = 259 +SW_MAXIMIZE = 3 + + +class StartupInfo(ctypes.Structure): + """STARTUPINFO structure.""" + + _fields_ = [ + ('cb', ctypes.c_ulong), + ('lpReserved', ctypes.c_char_p), + ('lpDesktop', ctypes.c_char_p), + ('lpTitle', ctypes.c_char_p), + ('dwX', ctypes.c_ulong), + ('dwY', ctypes.c_ulong), + ('dwXSize', ctypes.c_ulong), + ('dwYSize', ctypes.c_ulong), + ('dwXCountChars', ctypes.c_ulong), + ('dwYCountChars', ctypes.c_ulong), + ('dwFillAttribute', ctypes.c_ulong), + ('dwFlags', ctypes.c_ulong), + ('wShowWindow', ctypes.c_ushort), + ('cbReserved2', ctypes.c_ushort), + ('lpReserved2', ctypes.c_char_p), + ('hStdInput', ctypes.c_ulong), + ('hStdOutput', ctypes.c_ulong), + ('hStdError', ctypes.c_ulong), + ] + + def __init__(self, maximize=False, title=None, white_bg=False): + """Constructor. + + :param bool maximize: Start process in new console window, maximized. + :param bool white_bg: New console window will be black text on white background. + :param bytes title: Set new window title to this instead of exe path. + """ + super(StartupInfo, self).__init__() + self.cb = ctypes.sizeof(self) + if maximize: + self.dwFlags |= STARTF_USESHOWWINDOW + self.wShowWindow = SW_MAXIMIZE + if title: + self.lpTitle = ctypes.c_char_p(title) + if white_bg: + self.dwFlags |= STARTF_USEFILLATTRIBUTE + self.dwFillAttribute = WINDOWS_CODES['hibgwhite'] | WINDOWS_CODES['black'] + + +class ProcessInfo(ctypes.Structure): + """PROCESS_INFORMATION structure.""" + + _fields_ = [ + ('hProcess', ctypes.c_void_p), + ('hThread', ctypes.c_void_p), + ('dwProcessId', ctypes.c_ulong), + ('dwThreadId', ctypes.c_ulong), + ] + + +class RunNewConsole(object): + """Run the command in a new console window. Windows only. Use in a with statement. + + subprocess sucks and really limits your access to the win32 API. Its implementation is half-assed. Using this so + that STARTUPINFO.lpTitle actually works and STARTUPINFO.dwFillAttribute produce the expected result. + """ + + def __init__(self, command, maximized=False, title=None, white_bg=False): + """Constructor. + + :param iter command: Command to run. + :param bool maximized: Start process in new console window, maximized. + :param bytes title: Set new window title to this. Needed by user32.FindWindow. + :param bool white_bg: New console window will be black text on white background. + """ + if title is None: + title = 'pytest-{0}-{1}'.format(os.getpid(), random.randint(1000, 9999)).encode('ascii') + self.startup_info = StartupInfo(maximize=maximized, title=title, white_bg=white_bg) + self.process_info = ProcessInfo() + self.command_str = subprocess.list2cmdline(command).encode('ascii') + self._handles = list() + self._kernel32 = ctypes.LibraryLoader(ctypes.WinDLL).kernel32 + self._kernel32.GetExitCodeProcess.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)] + self._kernel32.GetExitCodeProcess.restype = ctypes.c_long + + def __del__(self): + """Close win32 handles.""" + while self._handles: + try: + self._kernel32.CloseHandle(self._handles.pop(0)) # .pop() is thread safe. + except IndexError: + break + + def __enter__(self): + """Entering the `with` block. Runs the process.""" + if not self._kernel32.CreateProcessA( + None, # lpApplicationName + self.command_str, # lpCommandLine + None, # lpProcessAttributes + None, # lpThreadAttributes + False, # bInheritHandles + subprocess.CREATE_NEW_CONSOLE, # dwCreationFlags + None, # lpEnvironment + str(PROJECT_ROOT).encode('ascii'), # lpCurrentDirectory + ctypes.byref(self.startup_info), # lpStartupInfo + ctypes.byref(self.process_info) # lpProcessInformation + ): + raise ctypes.WinError() + + # Add handles added by the OS. + self._handles.append(self.process_info.hProcess) + self._handles.append(self.process_info.hThread) + + # Get hWnd. + self.hwnd = 0 + for _ in range(int(5 / 0.1)): + # Takes time for console window to initialize. + self.hwnd = ctypes.windll.user32.FindWindowA(None, self.startup_info.lpTitle) + if self.hwnd: + break + time.sleep(0.1) + assert self.hwnd + + # Return generator that yields window size/position. + return self._iter_pos() + + def __exit__(self, *_): + """Cleanup.""" + try: + # Verify process exited 0. + status = ctypes.c_ulong(STILL_ACTIVE) + while status.value == STILL_ACTIVE: + time.sleep(0.1) + if not self._kernel32.GetExitCodeProcess(self.process_info.hProcess, ctypes.byref(status)): + raise ctypes.WinError() + assert status.value == 0 + finally: + # Close handles. + self.__del__() + + def _iter_pos(self): + """Yield new console window's current position and dimensions. + + :return: Yields region the new window is in (left, upper, right, lower). + :rtype: tuple + """ + rect = ctypes.create_string_buffer(16) # To be written to by GetWindowRect. RECT structure. + while ctypes.windll.user32.GetWindowRect(self.hwnd, rect): + left, top, right, bottom = struct.unpack('llll', rect.raw) + width, height = right - left, bottom - top + assert width > 1 + assert height > 1 + yield left, top, right, bottom + raise StopIteration + + +def iter_rows(pil_image): + """Yield tuple of pixels for each row in the image. + + itertools.izip in Python 2.x and zip in Python 3.x are writen in C. Much faster than anything else I've found + written in pure Python. + + From: + http://stackoverflow.com/questions/1624883/alternative-way-to-split-a-list-into-groups-of-n/1625023#1625023 + + :param PIL.Image.Image pil_image: Image to read from. + + :return: Yields rows. + :rtype: tuple + """ + iterator = izip(*(iter(pil_image.getdata()),) * pil_image.width) + for row in iterator: + yield row + + +def get_most_interesting_row(pil_image): + """Look for a row in the image that has the most unique pixels. + + :param PIL.Image.Image pil_image: Image to read from. + + :return: Row (tuple of pixel tuples), row as a set, first pixel tuple, y offset from top. + :rtype: tuple + """ + final = (None, set(), None, None) # row, row_set, first_pixel, y_pos + for y_pos, row in enumerate(iter_rows(pil_image)): + row_set = set(row) + if len(row_set) > len(final[1]): + final = row, row_set, row[0], y_pos + if len(row_set) == pil_image.width: + break # Can't get bigger. + return final + + +def count_subimages(screenshot, subimg): + """Check how often subimg appears in the screenshot image. + + :param PIL.Image.Image screenshot: Screen shot to search through. + :param PIL.Image.Image subimg: Subimage to search for. + + :return: Number of times subimg appears in the screenshot. + :rtype: int + """ + # Get row to search for. + si_pixels = list(subimg.getdata()) # Load entire subimg into memory. + si_width = subimg.width + si_height = subimg.height + si_row, si_row_set, si_pixel, si_y = get_most_interesting_row(subimg) + occurrences = 0 + + # Look for subimg row in screenshot, then crop and compare pixel arrays. + for y_pos, row in enumerate(iter_rows(screenshot)): + if si_row_set - set(row): + continue # Some pixels not found. + for x_pos in range(screenshot.width - si_width + 1): + if row[x_pos] != si_pixel: + continue # First pixel does not match. + if row[x_pos:x_pos + si_width] != si_row: + continue # Row does not match. + # Found match for interesting row of subimg in screenshot. + y_corrected = y_pos - si_y + with screenshot.crop((x_pos, y_corrected, x_pos + si_width, y_corrected + si_height)) as cropped: + if list(cropped.getdata()) == si_pixels: + occurrences += 1 + + return occurrences + + +def try_candidates(screenshot, subimg_candidates, expected_count): + """Call count_subimages() for each subimage candidate until. + + If you get ImportError run "pip install pillow". Only OSX and Windows is supported. + + :param PIL.Image.Image screenshot: Screen shot to search through. + :param iter subimg_candidates: Subimage paths to look for. List of strings. + :param int expected_count: Try until any a subimage candidate is found this many times. + + :return: Number of times subimg appears in the screenshot. + :rtype: int + """ + from PIL import Image + count_found = 0 + + for subimg_path in subimg_candidates: + with Image.open(subimg_path) as rgba_s: + with rgba_s.convert(mode='RGB') as subimg: + # Make sure subimage isn't too large. + assert subimg.width < 256 + assert subimg.height < 256 + + # Count. + count_found = count_subimages(screenshot, subimg) + if count_found == expected_count: + break # No need to try other candidates. + + return count_found + + +def screenshot_until_match(save_to, timeout, subimg_candidates, expected_count, gen): + """Take screenshots until one of the 'done' subimages is found. Image is saved when subimage found or at timeout. + + If you get ImportError run "pip install pillow". Only OSX and Windows is supported. + + :param str save_to: Save screenshot to this PNG file path when expected count found or timeout. + :param int timeout: Give up after these many seconds. + :param iter subimg_candidates: Subimage paths to look for. List of strings. + :param int expected_count: Keep trying until any of subimg_candidates is found this many times. + :param iter gen: Generator yielding window position and size to crop screenshot to. + """ + from PIL import ImageGrab + assert save_to.endswith('.png') + stop_after = time.time() + timeout + + # Take screenshots until subimage is found. + while True: + with ImageGrab.grab(next(gen)) as rgba: + with rgba.convert(mode='RGB') as screenshot: + count_found = try_candidates(screenshot, subimg_candidates, expected_count) + if count_found == expected_count or time.time() > stop_after: + screenshot.save(save_to) + assert count_found == expected_count + return + time.sleep(0.5) diff --git a/tests/sub_box_green_win10.bmp b/tests/sub_box_green_win10.bmp new file mode 100644 index 0000000..485788e Binary files /dev/null and b/tests/sub_box_green_win10.bmp differ diff --git a/tests/sub_box_green_winxp.bmp b/tests/sub_box_green_winxp.bmp new file mode 100644 index 0000000..0823dd0 Binary files /dev/null and b/tests/sub_box_green_winxp.bmp differ diff --git a/tests/sub_box_sans_win10.bmp b/tests/sub_box_sans_win10.bmp new file mode 100644 index 0000000..48344b9 Binary files /dev/null and b/tests/sub_box_sans_win10.bmp differ diff --git a/tests/sub_box_sans_winxp.bmp b/tests/sub_box_sans_winxp.bmp new file mode 100644 index 0000000..8277d1e Binary files /dev/null and b/tests/sub_box_sans_winxp.bmp differ diff --git a/tests/sub_red_dark_fg_win10.bmp b/tests/sub_red_dark_fg_win10.bmp new file mode 100644 index 0000000..4d66fa9 Binary files /dev/null and b/tests/sub_red_dark_fg_win10.bmp differ diff --git a/tests/sub_red_dark_fg_winxp.bmp b/tests/sub_red_dark_fg_winxp.bmp new file mode 100644 index 0000000..2846c5a Binary files /dev/null and b/tests/sub_red_dark_fg_winxp.bmp differ diff --git a/tests/sub_red_light_fg_win10.bmp b/tests/sub_red_light_fg_win10.bmp new file mode 100644 index 0000000..43229a3 Binary files /dev/null and b/tests/sub_red_light_fg_win10.bmp differ diff --git a/tests/sub_red_light_fg_winxp.bmp b/tests/sub_red_light_fg_winxp.bmp new file mode 100644 index 0000000..e930459 Binary files /dev/null and b/tests/sub_red_light_fg_winxp.bmp differ diff --git a/tests/sub_red_sans_win10.bmp b/tests/sub_red_sans_win10.bmp new file mode 100644 index 0000000..738c172 Binary files /dev/null and b/tests/sub_red_sans_win10.bmp differ diff --git a/tests/sub_red_sans_winxp.bmp b/tests/sub_red_sans_winxp.bmp new file mode 100644 index 0000000..a9f0f2e Binary files /dev/null and b/tests/sub_red_sans_winxp.bmp differ diff --git a/tests/test___main__.py b/tests/test___main__.py new file mode 100644 index 0000000..17a4b76 --- /dev/null +++ b/tests/test___main__.py @@ -0,0 +1,64 @@ +"""Test objects in module.""" + +import subprocess +import sys + +import pytest + +from colorclass.windows import IS_WINDOWS + + +def test_import_do_nothing(): + """Make sure importing __main__ doesn't print anything.""" + command = [sys.executable, '-c', "from colorclass.__main__ import TRUTHY; assert TRUTHY"] + proc = subprocess.Popen(command, stderr=subprocess.STDOUT, stdout=subprocess.PIPE) + output = proc.communicate() + assert proc.poll() == 0 + assert not output[0] + assert not output[1] + + +@pytest.mark.parametrize('colors', [True, False, None]) +@pytest.mark.parametrize('light', [True, False, None]) +def test(monkeypatch, colors, light): + """Test package as a script. + + :param monkeypatch: pytest fixture. + :param bool colors: Enable, disable, or don't touch colors using CLI args or env variables. + :param bool light: Enable light, dark, or don't touch auto colors using CLI args or env variables. + """ + command = [sys.executable, '-m', 'colorclass' if sys.version_info >= (2, 7) else 'colorclass.__main__'] + stdin = '{autored}Red{/autored} {red}Red{/red} {hired}Red{/hired}'.encode() + + # Set options. + if colors is True: + monkeypatch.setenv('COLOR_ENABLE', 'true') + elif colors is False: + monkeypatch.setenv('COLOR_DISABLE', 'true') + if light is True: + monkeypatch.setenv('COLOR_LIGHT', 'true') + elif light is False: + monkeypatch.setenv('COLOR_DARK', 'true') + + # Run. + proc = subprocess.Popen(command, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, stdin=subprocess.PIPE) + output = proc.communicate(stdin)[0].decode() + assert proc.poll() == 0 + assert 'Red' in output + + # Verify colors. Output is always stripped of all colors on Windows when piped to non-console (e.g. pytest). + if colors is False or IS_WINDOWS: + assert '\033[' not in output + assert 'Red Red Red' in output + return + assert '\033[' in output + + # Verify light bg. + count_dark_fg = output.count('\033[31mRed') + count_light_fg = output.count('\033[91mRed') + if light: + assert count_dark_fg == 2 + assert count_light_fg == 1 + else: + assert count_dark_fg == 1 + assert count_light_fg == 2 diff --git a/tests/test_codes.py b/tests/test_codes.py new file mode 100644 index 0000000..dd146c8 --- /dev/null +++ b/tests/test_codes.py @@ -0,0 +1,137 @@ +"""Test objects in module.""" + +import errno +import os +import subprocess +import sys +import time + +import pytest + +from colorclass.codes import ANSICodeMapping, BASE_CODES, list_tags +from colorclass.windows import IS_WINDOWS + + +def test_ansi_code_mapping_whitelist(): + """Test whitelist enforcement.""" + auto_codes = ANSICodeMapping('{green}{bgred}Test{/all}') + + # Test __getitem__. + with pytest.raises(KeyError): + assert not auto_codes['red'] + assert auto_codes['green'] == 32 + + # Test iter and len. + assert sorted(auto_codes) == ['/all', 'bgred', 'green'] + assert len(auto_codes) == 3 + + +@pytest.mark.parametrize('toggle', ['light', 'dark', 'none']) +def test_auto_toggles(toggle): + """Test auto colors and ANSICodeMapping class toggles. + + :param str toggle: Toggle method to call. + """ + # Toggle. + if toggle == 'light': + ANSICodeMapping.enable_all_colors() + ANSICodeMapping.set_light_background() + assert ANSICodeMapping.DISABLE_COLORS is False + assert ANSICodeMapping.LIGHT_BACKGROUND is True + elif toggle == 'dark': + ANSICodeMapping.enable_all_colors() + ANSICodeMapping.set_dark_background() + assert ANSICodeMapping.DISABLE_COLORS is False + assert ANSICodeMapping.LIGHT_BACKGROUND is False + else: + ANSICodeMapping.disable_all_colors() + assert ANSICodeMapping.DISABLE_COLORS is True + assert ANSICodeMapping.LIGHT_BACKGROUND is False + + # Test iter and len. + auto_codes = ANSICodeMapping('}{'.join([''] + list(BASE_CODES) + [''])) + count = 0 + for k, v in auto_codes.items(): + count += 1 + assert str(k) == k + assert v is None or int(v) == v + assert len(auto_codes) == count + + # Test foreground properties. + key_fg = '{autoblack}{autored}{autogreen}{autoyellow}{autoblue}{automagenta}{autocyan}{autowhite}' + actual = key_fg.format(**auto_codes) + if toggle == 'light': + assert actual == '3031323334353637' + elif toggle == 'dark': + assert actual == '9091929394959697' + else: + assert actual == 'NoneNoneNoneNoneNoneNoneNoneNone' + + # Test background properties. + key_fg = '{autobgblack}{autobgred}{autobggreen}{autobgyellow}{autobgblue}{autobgmagenta}{autobgcyan}{autobgwhite}' + actual = key_fg.format(**auto_codes) + if toggle == 'light': + assert actual == '4041424344454647' + elif toggle == 'dark': + assert actual == '100101102103104105106107' + else: + assert actual == 'NoneNoneNoneNoneNoneNoneNoneNone' + + +def test_list_tags(): + """Test list_tags().""" + actual = list_tags() + assert ('red', '/red', 31, 39) in actual + assert sorted(t for i in actual for t in i[:2] if t is not None) == sorted(BASE_CODES) + + +@pytest.mark.parametrize('tty', [False, True]) +def test_disable_colors_piped(tty): + """Verify colors enabled by default when piped to TTY and disabled when not. + + :param bool tty: Pipe to TTY/terminal? + """ + assert_statement = 'assert __import__("colorclass").codes.ANSICodeMapping.disable_if_no_tty() is {bool}' + command_colors_enabled = [sys.executable, '-c', assert_statement.format(bool='False')] + command_colors_disabled = [sys.executable, '-c', assert_statement.format(bool='True')] + + # Run piped to this pytest process. + if not tty: # Outputs piped to non-terminal/non-tty. Colors disabled by default. + proc = subprocess.Popen(command_colors_disabled, stderr=subprocess.STDOUT, stdout=subprocess.PIPE) + output = proc.communicate() + assert not output[0] + assert not output[1] + assert proc.poll() == 0 + return + + # Run through a new console window (Windows). + if IS_WINDOWS: + c_flags = subprocess.CREATE_NEW_CONSOLE + proc = subprocess.Popen(command_colors_enabled, close_fds=True, creationflags=c_flags) + proc.communicate() # Pipes directed towards new console window. Not worth doing screenshot image processing. + assert proc.poll() == 0 + return + + # Run through pseudo tty (Linux/OSX). + master, slave = __import__('pty').openpty() + proc = subprocess.Popen(command_colors_enabled, stderr=subprocess.STDOUT, stdout=slave, close_fds=True) + os.close(slave) + + # Read output. + output = '' + while True: + try: + data = os.read(master, 1024).decode() + except OSError as exc: + if exc.errno != errno.EIO: # EIO means EOF on some systems. + raise + data = None + if data: + output += data + elif proc.poll() is None: + time.sleep(0.01) + else: + break + os.close(master) + assert not output + assert proc.poll() == 0 diff --git a/tests/test_color.py b/tests/test_color.py new file mode 100644 index 0000000..d5c7170 --- /dev/null +++ b/tests/test_color.py @@ -0,0 +1,185 @@ +"""Test objects in module.""" + +import sys +from functools import partial + +import pytest + +from colorclass.color import Color +from tests.conftest import assert_both_values, get_instance + + +def test_colorize_methods(): + """Test colorize convenience methods.""" + assert Color.black('TEST').value_colors == '\033[30mTEST\033[39m' + assert Color.bgblack('TEST').value_colors == '\033[40mTEST\033[49m' + assert Color.red('TEST').value_colors == '\033[31mTEST\033[39m' + assert Color.bgred('TEST').value_colors == '\033[41mTEST\033[49m' + assert Color.green('TEST').value_colors == '\033[32mTEST\033[39m' + assert Color.bggreen('TEST').value_colors == '\033[42mTEST\033[49m' + assert Color.yellow('TEST').value_colors == '\033[33mTEST\033[39m' + assert Color.bgyellow('TEST').value_colors == '\033[43mTEST\033[49m' + assert Color.blue('TEST').value_colors == '\033[34mTEST\033[39m' + assert Color.bgblue('TEST').value_colors == '\033[44mTEST\033[49m' + assert Color.magenta('TEST').value_colors == '\033[35mTEST\033[39m' + assert Color.bgmagenta('TEST').value_colors == '\033[45mTEST\033[49m' + assert Color.cyan('TEST').value_colors == '\033[36mTEST\033[39m' + assert Color.bgcyan('TEST').value_colors == '\033[46mTEST\033[49m' + assert Color.white('TEST').value_colors == '\033[37mTEST\033[39m' + assert Color.bgwhite('TEST').value_colors == '\033[47mTEST\033[49m' + + assert Color.black('this is a test.', auto=True) == Color('{autoblack}this is a test.{/autoblack}') + assert Color.black('this is a test.') == Color('{black}this is a test.{/black}') + assert Color.bgblack('this is a test.', auto=True) == Color('{autobgblack}this is a test.{/autobgblack}') + assert Color.bgblack('this is a test.') == Color('{bgblack}this is a test.{/bgblack}') + assert Color.red('this is a test.', auto=True) == Color('{autored}this is a test.{/autored}') + assert Color.red('this is a test.') == Color('{red}this is a test.{/red}') + assert Color.bgred('this is a test.', auto=True) == Color('{autobgred}this is a test.{/autobgred}') + assert Color.bgred('this is a test.') == Color('{bgred}this is a test.{/bgred}') + assert Color.green('this is a test.', auto=True) == Color('{autogreen}this is a test.{/autogreen}') + assert Color.green('this is a test.') == Color('{green}this is a test.{/green}') + assert Color.bggreen('this is a test.', auto=True) == Color('{autobggreen}this is a test.{/autobggreen}') + assert Color.bggreen('this is a test.') == Color('{bggreen}this is a test.{/bggreen}') + assert Color.yellow('this is a test.', auto=True) == Color('{autoyellow}this is a test.{/autoyellow}') + assert Color.yellow('this is a test.') == Color('{yellow}this is a test.{/yellow}') + assert Color.bgyellow('this is a test.', auto=True) == Color('{autobgyellow}this is a test.{/autobgyellow}') + assert Color.bgyellow('this is a test.') == Color('{bgyellow}this is a test.{/bgyellow}') + assert Color.blue('this is a test.', auto=True) == Color('{autoblue}this is a test.{/autoblue}') + assert Color.blue('this is a test.') == Color('{blue}this is a test.{/blue}') + assert Color.bgblue('this is a test.', auto=True) == Color('{autobgblue}this is a test.{/autobgblue}') + assert Color.bgblue('this is a test.') == Color('{bgblue}this is a test.{/bgblue}') + assert Color.magenta('this is a test.', auto=True) == Color('{automagenta}this is a test.{/automagenta}') + assert Color.magenta('this is a test.') == Color('{magenta}this is a test.{/magenta}') + assert Color.bgmagenta('this is a test.', auto=True) == Color('{autobgmagenta}this is a test.{/autobgmagenta}') + assert Color.bgmagenta('this is a test.') == Color('{bgmagenta}this is a test.{/bgmagenta}') + assert Color.cyan('this is a test.', auto=True) == Color('{autocyan}this is a test.{/autocyan}') + assert Color.cyan('this is a test.') == Color('{cyan}this is a test.{/cyan}') + assert Color.bgcyan('this is a test.', auto=True) == Color('{autobgcyan}this is a test.{/autobgcyan}') + assert Color.bgcyan('this is a test.') == Color('{bgcyan}this is a test.{/bgcyan}') + assert Color.white('this is a test.', auto=True) == Color('{autowhite}this is a test.{/autowhite}') + assert Color.white('this is a test.') == Color('{white}this is a test.{/white}') + assert Color.bgwhite('this is a test.', auto=True) == Color('{autobgwhite}this is a test.{/autobgwhite}') + assert Color.bgwhite('this is a test.') == Color('{bgwhite}this is a test.{/bgwhite}') + + +@pytest.mark.parametrize('kind', ['str', 'Color plain', 'Color color']) +def test_chaining(kind): + """Test chaining Color instances. + + :param str kind: Type of string to test. + """ + assert_both = partial(assert_both_values, kind=kind) + + # Test string. + instance = get_instance(kind, 'TEST') + for color in ('green', 'blue', 'yellow'): + instance = get_instance(kind, instance, color) + assert_both(instance, 'TEST', '\033[31mTEST\033[39m') + + # Test empty. + instance = get_instance(kind) + for color in ('red', 'green', 'blue', 'yellow'): + instance = get_instance(kind, instance, color) + assert_both(instance, '', '\033[39m') + + # Test complicated. + instance = 'TEST' + for color in ('black', 'bgred', 'green', 'bgyellow', 'blue', 'bgmagenta', 'cyan', 'bgwhite'): + instance = get_instance(kind, instance, color=color) + assert_both(instance, 'TEST', '\033[30;41mTEST\033[39;49m') + + # Test format and length. + instance = get_instance(kind, '{0}').format(get_instance(kind, 'TEST')) + assert_both(instance, 'TEST', '\033[31mTEST\033[39m') + assert len(instance) == 4 + instance = get_instance(kind, '{0}').format(instance) + assert_both(instance, 'TEST', '\033[31mTEST\033[39m') + assert len(instance) == 4 + instance = get_instance(kind, '{0}').format(instance) + assert_both(instance, 'TEST', '\033[31mTEST\033[39m') + assert len(instance) == 4 + + +@pytest.mark.parametrize('kind', ['str', 'Color plain', 'Color color']) +def test_empty(kind): + """Test with empty string. + + :param str kind: Type of string to test. + """ + instance = get_instance(kind, u'') + assert_both = partial(assert_both_values, kind=kind) + + assert len(instance) == 0 + assert_both(instance * 2, '', '\033[39m') + assert_both(instance + instance, '', '\033[39m') + with pytest.raises(IndexError): + assert instance[0] + assert not [i for i in instance] + assert not list(instance) + + assert instance.encode('utf-8') == instance.encode('utf-8') + assert instance.encode('utf-8').decode('utf-8') == instance + assert_both(instance.encode('utf-8').decode('utf-8'), '', '\033[39m') + assert_both(instance.__class__.encode(instance, 'utf-8').decode('utf-8'), '', '\033[39m') + assert len(instance.encode('utf-8').decode('utf-8')) == 0 + assert_both(instance.format(value=''), '', '\033[39m') + + assert_both(instance.capitalize(), '', '\033[39m') + assert_both(instance.center(5), ' ', '\033[39m ') + assert instance.count('') == 1 + assert instance.count('t') == 0 + assert instance.endswith('') is True + assert instance.endswith('me') is False + assert instance.find('') == 0 + assert instance.find('t') == -1 + + assert instance.index('') == 0 + with pytest.raises(ValueError): + assert instance.index('t') + assert instance.isalnum() is False + assert instance.isalpha() is False + if sys.version_info[0] != 2: + assert instance.isdecimal() is False + assert instance.isdigit() is False + if sys.version_info[0] != 2: + assert instance.isnumeric() is False + assert instance.isspace() is False + assert instance.istitle() is False + assert instance.isupper() is False + + assert_both(instance.join(['A', 'B']), 'AB', 'A\033[39mB') + assert_both(instance.ljust(5), ' ', '\033[39m ') + assert instance.rfind('') == 0 + assert instance.rfind('t') == -1 + assert instance.rindex('') == 0 + with pytest.raises(ValueError): + assert instance.rindex('t') + assert_both(instance.rjust(5), ' ', '\033[39m ') + if kind in ('str', 'Color plain'): + assert instance.splitlines() == list() + else: + assert instance.splitlines() == ['\033[39m'] + assert instance.startswith('') is True + assert instance.startswith('T') is False + assert_both(instance.swapcase(), '', '\033[39m') + + assert_both(instance.title(), '', '\033[39m') + assert_both(instance.translate({ord('t'): u'1', ord('e'): u'2', ord('s'): u'3'}), '', '\033[39m') + assert_both(instance.upper(), '', '\033[39m') + assert_both(instance.zfill(0), '', '') + assert_both(instance.zfill(1), '0', '0') + + +def test_keep_tags(): + """Test keep_tags keyword arg.""" + assert_both = partial(assert_both_values, kind='Color color') + + instance = Color('{red}Test{/red}', keep_tags=True) + assert_both(instance, '{red}Test{/red}', '{red}Test{/red}') + assert_both(instance.upper(), '{RED}TEST{/RED}', '{RED}TEST{/RED}') + assert len(instance) == 15 + + instance = Color('{red}\033[41mTest\033[49m{/red}', keep_tags=True) + assert_both(instance, '{red}Test{/red}', '{red}\033[41mTest\033[49m{/red}') + assert_both(instance.upper(), '{RED}TEST{/RED}', '{RED}\033[41mTEST\033[49m{/RED}') + assert len(instance) == 15 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..2d398cd --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,398 @@ +"""Test objects in module.""" + +import sys +from functools import partial + +import pytest + +from colorclass.core import apply_text, ColorStr +from tests.conftest import assert_both_values, get_instance + + +def test_apply_text(): + """Test apply_text().""" + assert apply_text('', lambda _: 0 / 0) == '' + assert apply_text('TEST', lambda s: s.lower()) == 'test' + assert apply_text('!\033[31mRed\033[0m', lambda s: s.upper()) == '!\033[31mRED\033[0m' + assert apply_text('\033[1mA \033[31mB \033[32;41mC \033[0mD', lambda _: '') == '\033[1m\033[31m\033[32;41m\033[0m' + + +@pytest.mark.parametrize('kind', ['str', 'ColorStr plain', 'ColorStr color']) +def test_dunder(kind): + """Test "dunder" methods (double-underscore). + + :param str kind: Type of string to test. + """ + instance = get_instance(kind, 'test ME ') + assert_both = partial(assert_both_values, kind=kind) + + assert len(instance) == 8 + + if kind == 'str': + assert repr(instance) == "'test ME '" + elif kind == 'ColorStr plain': + assert repr(instance) == "ColorStr('test ME ')" + else: + assert repr(instance) == "ColorStr('\\x1b[31mtest ME \\x1b[39m')" + + assert_both(instance.__class__('1%s2' % instance), '1test ME 2', '1\033[31mtest ME \033[39m2') + assert_both(get_instance(kind, '1%s2') % 'test ME ', '1test ME 2', '\033[31m1test ME 2\033[39m') + assert_both(get_instance(kind, '1%s2') % instance, '1test ME 2', '\033[31m1test ME \033[39m2') + + assert_both(instance * 2, 'test ME test ME ', '\033[31mtest ME test ME \033[39m') + assert_both(instance + instance, 'test ME test ME ', '\033[31mtest ME test ME \033[39m') + assert_both(instance + 'more', 'test ME more', '\033[31mtest ME \033[39mmore') + assert_both(instance.__class__('more' + instance), 'moretest ME ', 'more\033[31mtest ME \033[39m') + instance *= 2 + assert_both(instance, 'test ME test ME ', '\033[31mtest ME test ME \033[39m') + instance += 'more' + assert_both(instance, 'test ME test ME more', '\033[31mtest ME test ME \033[39mmore') + + assert_both(instance[0], 't', '\033[31mt\033[39m') + assert_both(instance[4], ' ', '\033[31m \033[39m') + assert_both(instance[-1], 'e', '\033[39me') + # assert_both(instance[1:-1], 'est ME test ME mor', '\033[31mest ME test ME \033[39mmor') + # assert_both(instance[1:9:2], 'etM ', '\033[31metM \033[39m') + # assert_both(instance[-1::-1], 'erom EM tset EM tset', 'erom\033[31m EM tset EM tset\033[39m') + + with pytest.raises(IndexError): + assert instance[20] + + actual = [i for i in instance] + assert len(actual) == 20 + assert actual == list(instance) + assert_both(actual[0], 't', '\033[31mt\033[39m') + assert_both(actual[1], 'e', '\033[31me\033[39m') + assert_both(actual[2], 's', '\033[31ms\033[39m') + assert_both(actual[3], 't', '\033[31mt\033[39m') + assert_both(actual[4], ' ', '\033[31m \033[39m') + assert_both(actual[5], 'M', '\033[31mM\033[39m') + assert_both(actual[6], 'E', '\033[31mE\033[39m') + assert_both(actual[7], ' ', '\033[31m \033[39m') + assert_both(actual[8], 't', '\033[31mt\033[39m') + assert_both(actual[9], 'e', '\033[31me\033[39m') + assert_both(actual[10], 's', '\033[31ms\033[39m') + assert_both(actual[11], 't', '\033[31mt\033[39m') + assert_both(actual[12], ' ', '\033[31m \033[39m') + assert_both(actual[13], 'M', '\033[31mM\033[39m') + assert_both(actual[14], 'E', '\033[31mE\033[39m') + assert_both(actual[15], ' ', '\033[31m \033[39m') + assert_both(actual[16], 'm', '\033[39mm') + assert_both(actual[17], 'o', '\033[39mo') + assert_both(actual[18], 'r', '\033[39mr') + assert_both(actual[19], 'e', '\033[39me') + + +@pytest.mark.parametrize('kind', ['str', 'ColorStr plain', 'ColorStr color']) +def test_encode_decode(kind): + """Test encode and decode methods. + + :param str kind: Type of string to test. + """ + assert_both = partial(assert_both_values, kind=kind) + instance = get_instance(kind, 'test ME') + + if sys.version_info[0] == 2: + assert instance.encode('utf-8') == instance + assert instance.decode('utf-8') == instance + assert_both(instance.decode('utf-8'), 'test ME', '\033[31mtest ME\033[39m') + assert_both(instance.__class__.decode(instance, 'utf-8'), 'test ME', '\033[31mtest ME\033[39m') + assert len(instance.decode('utf-8')) == 7 + else: + assert instance.encode('utf-8') != instance + assert instance.encode('utf-8') == instance.encode('utf-8') + assert instance.encode('utf-8').decode('utf-8') == instance + assert_both(instance.encode('utf-8').decode('utf-8'), 'test ME', '\033[31mtest ME\033[39m') + assert_both(instance.__class__.encode(instance, 'utf-8').decode('utf-8'), 'test ME', '\033[31mtest ME\033[39m') + assert len(instance.encode('utf-8').decode('utf-8')) == 7 + + +@pytest.mark.parametrize('mode', ['fg within bg', 'bg within fg']) +@pytest.mark.parametrize('kind', ['str', 'ColorStr plain', 'ColorStr color']) +def test_format(kind, mode): + """Test format method. + + :param str kind: Type of string to test. + :param str mode: Which combination to test. + """ + assert_both = partial(assert_both_values, kind=kind) + + # Test str.format(ColorStr()). + instance = get_instance(kind, 'test me') + assert_both(instance.__class__('1{0}2'.format(instance)), '1test me2', '1\033[31mtest me\033[39m2') + assert_both(instance.__class__(str.format('1{0}2', instance)), '1test me2', '1\033[31mtest me\033[39m2') + + # Get actual. + template_pos = get_instance(kind, 'a{0}c{0}', 'bgred' if mode == 'fg within bg' else 'red') + template_kw = get_instance(kind, 'a{value}c{value}', 'bgred' if mode == 'fg within bg' else 'red') + instance = get_instance(kind, 'B', 'green' if mode == 'fg within bg' else 'bggreen') + + # Get expected. + expected = ['aBcB', None] + if mode == 'fg within bg': + expected[1] = '\033[41ma\033[32mB\033[39mc\033[32mB\033[39;49m' + else: + expected[1] = '\033[31ma\033[42mB\033[49mc\033[42mB\033[39;49m' + + # Test. + assert_both(template_pos.format(instance), expected[0], expected[1]) + assert_both(template_kw.format(value=instance), expected[0], expected[1]) + assert_both(instance.__class__.format(template_pos, instance), expected[0], expected[1]) + assert_both(instance.__class__.format(template_kw, value=instance), expected[0], expected[1]) + + +@pytest.mark.parametrize('kind', ['str', 'ColorStr plain', 'ColorStr color']) +def test_format_mixed(kind): + """Test format method with https://github.com/Robpol86/colorclass/issues/16 in mind. + + :param str kind: Type of string to test. + """ + instance = get_instance(kind, 'XXX: ') + '{0}' + assert_both = partial(assert_both_values, kind=kind) + + assert_both(instance, 'XXX: {0}', '\033[31mXXX: \033[39m{0}') + assert_both(instance.format('{blue}Moo{/blue}'), 'XXX: {blue}Moo{/blue}', '\033[31mXXX: \033[39m{blue}Moo{/blue}') + assert_both(instance.format(get_instance(kind, 'Moo', 'blue')), 'XXX: Moo', '\033[31mXXX: \033[34mMoo\033[39m') + + +@pytest.mark.parametrize('kind', ['str', 'ColorStr plain', 'ColorStr color']) +def test_c_f(kind): + """Test C through F methods. + + :param str kind: Type of string to test. + """ + instance = get_instance(kind, 'test me') + assert_both = partial(assert_both_values, kind=kind) + + assert_both(instance.capitalize(), 'Test me', '\033[31mTest me\033[39m') + + assert_both(instance.center(11), ' test me ', ' \033[31mtest me\033[39m ') + assert_both(instance.center(11, '.'), '..test me..', '..\033[31mtest me\033[39m..') + assert_both(instance.center(12), ' test me ', ' \033[31mtest me\033[39m ') + + assert instance.count('t') == 2 + + assert instance.endswith('me') is True + assert instance.endswith('ME') is False + + assert instance.find('t') == 0 + assert instance.find('t', 0) == 0 + assert instance.find('t', 0, 1) == 0 + assert instance.find('t', 1) == 3 + assert instance.find('t', 1, 4) == 3 + assert instance.find('t', 1, 3) == -1 + assert instance.find('x') == -1 + assert instance.find('m') == 5 + + +@pytest.mark.parametrize('kind', ['str', 'ColorStr plain', 'ColorStr color']) +def test_i(kind): + """Test I methods. + + :param str kind: Type of string to test. + """ + instance = get_instance(kind, 'tantamount') + assert instance.index('t') == 0 + assert instance.index('t', 0) == 0 + assert instance.index('t', 0, 1) == 0 + assert instance.index('t', 1) == 3 + assert instance.index('t', 1, 4) == 3 + assert instance.index('m') == 5 + with pytest.raises(ValueError): + assert instance.index('t', 1, 3) + with pytest.raises(ValueError): + assert instance.index('x') + + assert instance.isalnum() is True + assert get_instance(kind, '123').isalnum() is True + assert get_instance(kind, '.').isalnum() is False + + assert instance.isalpha() is True + assert get_instance(kind, '.').isalpha() is False + + if sys.version_info[0] != 2: + assert instance.isdecimal() is False + assert get_instance(kind, '123').isdecimal() is True + assert get_instance(kind, '.').isdecimal() is False + + assert instance.isdigit() is False + assert get_instance(kind, '123').isdigit() is True + assert get_instance(kind, '.').isdigit() is False + + if sys.version_info[0] != 2: + assert instance.isnumeric() is False + assert get_instance(kind, '123').isnumeric() is True + assert get_instance(kind, '.').isnumeric() is False + + assert instance.isspace() is False + assert get_instance(kind, ' ').isspace() is True + + assert instance.istitle() is False + assert get_instance(kind, 'Test').istitle() is True + + assert instance.isupper() is False + assert get_instance(kind, 'TEST').isupper() is True + + +@pytest.mark.parametrize('kind', ['str', 'ColorStr plain', 'ColorStr color']) +def test_j_s(kind): + """Test J to S methods. + + :param str kind: Type of string to test. + """ + instance = get_instance(kind, 'test me') + assert_both = partial(assert_both_values, kind=kind) + + assert_both(instance.join(['A', 'B']), 'Atest meB', 'A\033[31mtest me\033[39mB') + iterable = [get_instance(kind, 'A', 'green'), get_instance(kind, 'B', 'green')] + assert_both(instance.join(iterable), 'Atest meB', '\033[32mA\033[31mtest me\033[32mB\033[39m') + + assert_both(instance.ljust(11), 'test me ', '\033[31mtest me\033[39m ') + assert_both(instance.ljust(11, '.'), 'test me....', '\033[31mtest me\033[39m....') + assert_both(instance.ljust(12), 'test me ', '\033[31mtest me\033[39m ') + + assert instance.rfind('t') == 3 + assert instance.rfind('t', 0) == 3 + assert instance.rfind('t', 0, 4) == 3 + assert instance.rfind('t', 0, 3) == 0 + assert instance.rfind('t', 3, 3) == -1 + assert instance.rfind('x') == -1 + assert instance.rfind('m') == 5 + + tantamount = get_instance(kind, 'tantamount') + assert tantamount.rindex('t') == 9 + assert tantamount.rindex('t', 0) == 9 + assert tantamount.rindex('t', 0, 5) == 3 + assert tantamount.rindex('m') == 5 + with pytest.raises(ValueError): + assert tantamount.rindex('t', 1, 3) + with pytest.raises(ValueError): + assert tantamount.rindex('x') + + assert_both(instance.rjust(11), ' test me', ' \033[31mtest me\033[39m') + assert_both(instance.rjust(11, '.'), '....test me', '....\033[31mtest me\033[39m') + assert_both(instance.rjust(12), ' test me', ' \033[31mtest me\033[39m') + + actual = get_instance(kind, '1\n2\n3').splitlines() + assert len(actual) == 3 + # assert_both(actual[0], '1', '\033[31m1\033[39m') + # assert_both(actual[1], '2', '\033[31m2\033[39m') + # assert_both(actual[2], '3', '\033[31m3\033[39m') + + assert instance.startswith('t') is True + assert instance.startswith('T') is False + + assert_both(get_instance(kind, 'AbC').swapcase(), 'aBc', '\033[31maBc\033[39m') + + +@pytest.mark.parametrize('kind', ['str', 'ColorStr plain', 'ColorStr color']) +def test_t_z(kind): + """Test T to Z methods. + + :param str kind: Type of string to test. + """ + instance = get_instance(kind, u'test me') + assert_both = partial(assert_both_values, kind=kind) + + assert_both(instance.title(), 'Test Me', '\033[31mTest Me\033[39m') + assert_both(get_instance(kind, 'TEST YOU').title(), 'Test You', '\033[31mTest You\033[39m') + + table = {ord('t'): u'1', ord('e'): u'2', ord('s'): u'3'} + assert_both(instance.translate(table), '1231 m2', '\033[31m1231 m2\033[39m') + + assert_both(instance.upper(), 'TEST ME', '\033[31mTEST ME\033[39m') + + number = get_instance(kind, '350') + assert_both(number.zfill(1), '350', '\033[31m350\033[39m') + assert_both(number.zfill(2), '350', '\033[31m350\033[39m') + assert_both(number.zfill(3), '350', '\033[31m350\033[39m') + assert_both(number.zfill(4), '0350', '\033[31m0350\033[39m') + assert_both(number.zfill(10), '0000000350', '\033[31m0000000350\033[39m') + assert_both(get_instance(kind, '-350').zfill(5), '-0350', '\033[31m-0350\033[39m') + assert_both(get_instance(kind, '-10.3').zfill(5), '-10.3', '\033[31m-10.3\033[39m') + assert_both(get_instance(kind, '-10.3').zfill(6), '-010.3', '\033[31m-010.3\033[39m') + + +@pytest.mark.parametrize('kind', ['str', 'ColorStr plain', 'ColorStr color']) +def test_empty(kind): + """Test with empty string. + + :param str kind: Type of string to test. + """ + instance = get_instance(kind, u'') + assert_both = partial(assert_both_values, kind=kind) + + assert len(instance) == 0 + assert_both(instance * 2, '', '\033[39m') + assert_both(instance + instance, '', '\033[39m') + with pytest.raises(IndexError): + assert instance[0] + assert not [i for i in instance] + assert not list(instance) + + assert instance.encode('utf-8') == instance.encode('utf-8') + assert instance.encode('utf-8').decode('utf-8') == instance + assert_both(instance.encode('utf-8').decode('utf-8'), '', '\033[39m') + assert_both(instance.__class__.encode(instance, 'utf-8').decode('utf-8'), '', '\033[39m') + assert len(instance.encode('utf-8').decode('utf-8')) == 0 + assert_both(instance.format(value=''), '', '\033[39m') + + assert_both(instance.capitalize(), '', '\033[39m') + assert_both(instance.center(5), ' ', '\033[39m ') + assert instance.count('') == 1 + assert instance.count('t') == 0 + assert instance.endswith('') is True + assert instance.endswith('me') is False + assert instance.find('') == 0 + assert instance.find('t') == -1 + + assert instance.index('') == 0 + with pytest.raises(ValueError): + assert instance.index('t') + assert instance.isalnum() is False + assert instance.isalpha() is False + if sys.version_info[0] != 2: + assert instance.isdecimal() is False + assert instance.isdigit() is False + if sys.version_info[0] != 2: + assert instance.isnumeric() is False + assert instance.isspace() is False + assert instance.istitle() is False + assert instance.isupper() is False + + assert_both(instance.join(['A', 'B']), 'AB', 'A\033[39mB') + assert_both(instance.ljust(5), ' ', '\033[39m ') + assert instance.rfind('') == 0 + assert instance.rfind('t') == -1 + assert instance.rindex('') == 0 + with pytest.raises(ValueError): + assert instance.rindex('t') + assert_both(instance.rjust(5), ' ', '\033[39m ') + if kind in ('str', 'ColorStr plain'): + assert instance.splitlines() == list() + else: + assert instance.splitlines() == ['\033[39m'] + assert instance.startswith('') is True + assert instance.startswith('T') is False + assert_both(instance.swapcase(), '', '\033[39m') + + assert_both(instance.title(), '', '\033[39m') + assert_both(instance.translate({ord('t'): u'1', ord('e'): u'2', ord('s'): u'3'}), '', '\033[39m') + assert_both(instance.upper(), '', '\033[39m') + assert_both(instance.zfill(0), '', '') + assert_both(instance.zfill(1), '0', '0') + + +def test_keep_tags(): + """Test keep_tags keyword arg.""" + assert_both = partial(assert_both_values, kind='ColorStr color') + + instance = ColorStr('{red}Test{/red}', keep_tags=True) + assert_both(instance, '{red}Test{/red}', '{red}Test{/red}') + assert_both(instance.upper(), '{RED}TEST{/RED}', '{RED}TEST{/RED}') + assert len(instance) == 15 + + instance = ColorStr('{red}\033[41mTest\033[49m{/red}', keep_tags=True) + assert_both(instance, '{red}Test{/red}', '{red}\033[41mTest\033[49m{/red}') + assert_both(instance.upper(), '{RED}TEST{/RED}', '{RED}\033[41mTEST\033[49m{/RED}') + assert len(instance) == 15 diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..7ee8c05 --- /dev/null +++ b/tests/test_example.py @@ -0,0 +1,96 @@ +"""Test example script.""" + +import subprocess +import sys + +import pytest + +from colorclass.windows import IS_WINDOWS +from tests.conftest import PROJECT_ROOT +from tests.screenshot import RunNewConsole, screenshot_until_match + + +@pytest.mark.parametrize('colors', [True, False, None]) +@pytest.mark.parametrize('light_bg', [True, False, None]) +def test_piped(colors, light_bg): + """Test script with output piped to non-tty (this pytest process). + + :param bool colors: Enable, disable, or omit color arguments (default is no colors due to no tty). + :param bool light_bg: Enable light, dark, or omit light/dark arguments. + """ + command = [sys.executable, str(PROJECT_ROOT.join('example.py')), 'print'] + + # Set options. + if colors is True: + command.append('--colors') + elif colors is False: + command.append('--no-colors') + if light_bg is True: + command.append('--light-bg') + elif light_bg is False: + command.append('--dark-bg') + + # Run. + proc = subprocess.Popen(command, stderr=subprocess.STDOUT, stdout=subprocess.PIPE) + output = proc.communicate()[0].decode() + assert proc.poll() == 0 + assert 'Autocolors for all backgrounds' in output + assert 'Red' in output + + # Verify colors. Output is always stripped of all colors on Windows when piped to non-console (e.g. pytest). + if colors is False or IS_WINDOWS: + assert '\033[' not in output + assert 'Black Red Green Yellow Blue Magenta Cyan White' in output + return + assert '\033[' in output + + # Verify light bg. + count_dark_fg = output.count('\033[31mRed') + count_light_fg = output.count('\033[91mRed') + if light_bg: + assert count_dark_fg == 2 + assert count_light_fg == 1 + else: + assert count_dark_fg == 1 + assert count_light_fg == 2 + + +@pytest.mark.skipif(str(not IS_WINDOWS)) +@pytest.mark.parametrize('colors,light_bg', [ + (True, False), + (True, True), + (False, False), + (None, False), +]) +def test_windows_screenshot(colors, light_bg): + """Test script on Windows in a new console window. Take a screenshot to verify colors work. + + :param bool colors: Enable, disable, or omit color arguments (default has colors). + :param bool light_bg: Create console with white background color. + """ + screenshot = PROJECT_ROOT.join('test_example_test_windows_screenshot.png') + if screenshot.check(): + screenshot.remove() + command = [sys.executable, str(PROJECT_ROOT.join('example.py')), 'print', '-w', str(screenshot)] + + # Set options. + if colors is True: + command.append('--colors') + elif colors is False: + command.append('--no-colors') + + # Setup expected. + if colors is False: + candidates = [str(p) for p in PROJECT_ROOT.join('tests').listdir('sub_red_sans_*.bmp')] + expected_count = 27 + elif light_bg: + candidates = [str(p) for p in PROJECT_ROOT.join('tests').listdir('sub_red_dark_fg_*.bmp')] + expected_count = 2 + else: + candidates = [str(p) for p in PROJECT_ROOT.join('tests').listdir('sub_red_light_fg_*.bmp')] + expected_count = 2 + assert candidates + + # Run. + with RunNewConsole(command, maximized=True, white_bg=light_bg) as gen: + screenshot_until_match(str(screenshot), 15, candidates, expected_count, gen) diff --git a/tests/test_parse.py b/tests/test_parse.py new file mode 100644 index 0000000..c93d77f --- /dev/null +++ b/tests/test_parse.py @@ -0,0 +1,79 @@ +"""Test objects in module.""" + +import pytest + +from colorclass.parse import parse_input, prune_overridden + + +@pytest.mark.parametrize('in_,expected', [ + ('', ''), + ('test', 'test'), + ('\033[31mTEST\033[0m', '\033[31mTEST\033[0m'), + ('\033[32;31mTEST\033[39;0m', '\033[31mTEST\033[0m'), + ('\033[1;2mTEST\033[22;22m', '\033[1;2mTEST\033[22m'), + ('\033[1;1;1;1;1;1mTEST\033[22m', '\033[1mTEST\033[22m'), + ('\033[31;32;41;42mTEST\033[39;49m', '\033[32;42mTEST\033[39;49m'), +]) +def test_prune_overridden(in_, expected): + """Test function. + + :param str in_: Input string to pass to function. + :param str expected: Expected return value. + """ + actual = prune_overridden(in_) + assert actual == expected + + +@pytest.mark.parametrize('disable', [True, False]) +@pytest.mark.parametrize('in_,expected_colors,expected_no_colors', [ + ('', '', ''), + ('test', 'test', 'test'), + ('{b}TEST{/b}', '\033[1mTEST\033[22m', 'TEST'), + ('{red}{bgred}TEST{/all}', '\033[31;41mTEST\033[0m', 'TEST'), + ('{b}A {red}B {green}{bgred}C {/all}', '\033[1mA \033[31mB \033[32;41mC \033[0m', 'A B C '), + ('C {/all}{b}{blue}{hiblue}{bgcyan}D {/all}', 'C \033[0;1;46;94mD \033[0m', 'C D '), + ('D {/all}{i}\033[31;103mE {/all}', 'D \033[0;3;31;103mE \033[0m', 'D E '), + ('{b}{red}{bgblue}{/all}{i}TEST{/all}', '\033[0;3mTEST\033[0m', 'TEST'), + ('{red}{green}{blue}{black}{yellow}TEST{/fg}{/all}', '\033[33mTEST\033[0m', 'TEST'), + ('{bgred}{bggreen}{bgblue}{bgblack}{bgyellow}TEST{/bg}{/all}', '\033[43mTEST\033[0m', 'TEST'), + ('{red}T{red}E{red}S{red}T{/all}', '\033[31mTEST\033[0m', 'TEST'), + ('{red}T{/all}E{/all}S{/all}T{/all}', '\033[31mT\033[0mEST', 'TEST'), + ('{red}{bgblue}TES{red}{bgblue}T{/all}', '\033[31;44mTEST\033[0m', 'TEST'), +]) +def test_parse_input(disable, in_, expected_colors, expected_no_colors): + """Test function. + + :param bool disable: Disable colors? + :param str in_: Input string to pass to function. + :param str expected_colors: Expected first item of return value. + :param str expected_no_colors: Expected second item of return value. + """ + actual_colors, actual_no_colors = parse_input(in_, disable, False) + if disable: + assert actual_colors == expected_no_colors + else: + assert actual_colors == expected_colors + assert actual_no_colors == expected_no_colors + + +@pytest.mark.parametrize('disable', [True, False]) +@pytest.mark.parametrize('in_,expected_colors,expected_no_colors', [ + ('', '', ''), + ('test', 'test', 'test'), + ('{b}TEST{/b}', '{b}TEST{/b}', '{b}TEST{/b}'), + ('D {/all}{i}\033[31;103mE {/all}', 'D {/all}{i}\033[31;103mE {/all}', 'D {/all}{i}E {/all}'), +]) +def test_parse_input_keep_tags(disable, in_, expected_colors, expected_no_colors): + """Test function with keep_tags=True. + + :param bool disable: Disable colors? + :param str in_: Input string to pass to function. + :param str expected_colors: Expected first item of return value. + :param str expected_no_colors: Expected second item of return value. + """ + actual_colors, actual_no_colors = parse_input(in_, disable, True) + if disable: + assert actual_colors == expected_no_colors + else: + assert actual_colors == expected_colors + assert actual_no_colors == expected_no_colors diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..0d5000d --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,51 @@ +"""Test objects in module.""" + +import pytest + +from colorclass.search import build_color_index, find_char_color + + +@pytest.mark.parametrize('in_,expected', [ + ['', ()], + ['TEST', (0, 1, 2, 3)], + ['!\033[31mRed\033[0m', (0, 6, 7, 8)], + ['\033[1mA \033[31mB \033[32;41mC \033[0mD', (4, 5, 11, 12, 21, 22, 27)], +]) +def test_build_color_index(in_, expected): + """Test function. + + :param str in_: Input string to pass to function. + :param str expected: Expected return value. + """ + actual = build_color_index(in_) + assert actual == expected + + +@pytest.mark.parametrize('in_,pos,expected', [ + ('TEST', 0, 'T'), + + ('\033[31mTEST', 0, '\033[31mT'), + ('\033[31mTEST', 3, '\033[31mT'), + + ('\033[31mT\033[32mE\033[33mS\033[34mT', 0, '\033[31mT\033[32m\033[33m\033[34m'), + ('\033[31mT\033[32mE\033[33mS\033[34mT', 2, '\033[31m\033[32m\033[33mS\033[34m'), + + ('\033[31mTEST\033[0m', 1, '\033[31mE\033[0m'), + ('\033[31mTEST\033[0m', 3, '\033[31mT\033[0m'), + + ('T\033[31mES\033[0mT', 0, 'T\033[31m\033[0m'), + ('T\033[31mES\033[0mT', 1, '\033[31mE\033[0m'), + ('T\033[31mES\033[0mT', 2, '\033[31mS\033[0m'), + ('T\033[31mES\033[0mT', 3, '\033[31m\033[0mT'), +]) +def test_find_char_color(in_, pos, expected): + """Test function. + + :param str in_: Input string to pass to function. + :param int pos: Character position in non-color string to lookup. + :param str expected: Expected return value. + """ + index = build_color_index(in_) + color_pos = index[pos] + actual = find_char_color(in_, color_pos) + assert actual == expected diff --git a/tests/test_toggles.py b/tests/test_toggles.py new file mode 100644 index 0000000..a2a6cda --- /dev/null +++ b/tests/test_toggles.py @@ -0,0 +1,29 @@ +"""Test objects in module.""" + +from colorclass import toggles + + +def test_disable(): + """Test functions.""" + toggles.disable_all_colors() + assert not toggles.is_enabled() + toggles.enable_all_colors() + assert toggles.is_enabled() + toggles.disable_all_colors() + assert not toggles.is_enabled() + toggles.enable_all_colors() + assert toggles.is_enabled() + assert toggles.disable_if_no_tty() # pytest pipes stderr/stdout. + assert not toggles.is_enabled() + + +def test_light_bg(): + """Test functions.""" + toggles.set_dark_background() + assert not toggles.is_light() + toggles.set_light_background() + assert toggles.is_enabled() + toggles.set_dark_background() + assert not toggles.is_light() + toggles.set_light_background() + assert toggles.is_enabled() diff --git a/tests/test_windows.py b/tests/test_windows.py new file mode 100644 index 0000000..e96e4f9 --- /dev/null +++ b/tests/test_windows.py @@ -0,0 +1,429 @@ +"""Test Windows methods.""" + +from __future__ import print_function + +import ctypes +import sys +from textwrap import dedent + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +import pytest + +from colorclass import windows +from colorclass.codes import ANSICodeMapping +from colorclass.color import Color +from tests.conftest import PROJECT_ROOT +from tests.screenshot import RunNewConsole, screenshot_until_match + + +class MockKernel32(object): + """Mock kernel32.""" + + def __init__(self, stderr=windows.INVALID_HANDLE_VALUE, stdout=windows.INVALID_HANDLE_VALUE, set_mode=0x0): + """Constructor.""" + self.set_mode = set_mode + self.stderr = stderr + self.stdout = stdout + self.wAttributes = 7 + + def GetConsoleMode(self, _, dword_pointer): # noqa + """Mock GetConsoleMode. + + :param _: Unused handle. + :param dword_pointer: ctypes.byref(lpdword) return value. + """ + ulong_ptr = ctypes.POINTER(ctypes.c_ulong) + dword = ctypes.cast(dword_pointer, ulong_ptr).contents # Dereference pointer. + dword.value = self.set_mode + return 1 + + def GetConsoleScreenBufferInfo(self, _, csbi_pointer): # noqa + """Mock GetConsoleScreenBufferInfo. + + :param _: Unused handle. + :param csbi_pointer: ctypes.byref(csbi) return value. + """ + struct_ptr = ctypes.POINTER(windows.ConsoleScreenBufferInfo) + csbi = ctypes.cast(csbi_pointer, struct_ptr).contents # Dereference pointer. + csbi.wAttributes = self.wAttributes + return 1 + + def GetStdHandle(self, handle): # noqa + """Mock GetStdHandle. + + :param int handle: STD_ERROR_HANDLE or STD_OUTPUT_HANDLE. + """ + return self.stderr if handle == windows.STD_ERROR_HANDLE else self.stdout + + def SetConsoleTextAttribute(self, _, color_code): # noqa + """Mock SetConsoleTextAttribute. + + :param _: Unused handle. + :param int color_code: Merged color code to set. + """ + self.wAttributes = color_code + return 1 + + +class MockSys(object): + """Mock sys standard library module.""" + + def __init__(self, stderr=None, stdout=None): + """Constructor.""" + self.stderr = stderr or type('', (), {}) + self.stdout = stdout or type('', (), {}) + + +@pytest.mark.skipif(str(not windows.IS_WINDOWS)) +def test_init_kernel32_unique(): + """Make sure function doesn't override other LibraryLoaders.""" + k32_a = ctypes.LibraryLoader(ctypes.WinDLL).kernel32 + k32_a.GetStdHandle.argtypes = [ctypes.c_void_p] + k32_a.GetStdHandle.restype = ctypes.c_ulong + + k32_b, stderr_b, stdout_b = windows.init_kernel32() + + k32_c = ctypes.LibraryLoader(ctypes.WinDLL).kernel32 + k32_c.GetStdHandle.argtypes = [ctypes.c_long] + k32_c.GetStdHandle.restype = ctypes.c_short + + k32_d, stderr_d, stdout_d = windows.init_kernel32() + + # Verify external. + assert k32_a.GetStdHandle.argtypes == [ctypes.c_void_p] + assert k32_a.GetStdHandle.restype == ctypes.c_ulong + assert k32_c.GetStdHandle.argtypes == [ctypes.c_long] + assert k32_c.GetStdHandle.restype == ctypes.c_short + + # Verify ours. + assert k32_b.GetStdHandle.argtypes == [ctypes.c_ulong] + assert k32_b.GetStdHandle.restype == ctypes.c_void_p + assert k32_d.GetStdHandle.argtypes == [ctypes.c_ulong] + assert k32_d.GetStdHandle.restype == ctypes.c_void_p + assert stderr_b == stderr_d + assert stdout_b == stdout_d + + +@pytest.mark.parametrize('stderr_invalid', [False, True]) +@pytest.mark.parametrize('stdout_invalid', [False, True]) +def test_init_kernel32_valid_handle(monkeypatch, stderr_invalid, stdout_invalid): + """Test valid/invalid handle handling. + + :param monkeypatch: pytest fixture. + :param bool stderr_invalid: Mock stderr is valid. + :param bool stdout_invalid: Mock stdout is valid. + """ + mock_sys = MockSys() + monkeypatch.setattr(windows, 'sys', mock_sys) + if stderr_invalid: + setattr(mock_sys.stderr, '_original_stream', True) + if stdout_invalid: + setattr(mock_sys.stdout, '_original_stream', True) + + stderr, stdout = windows.init_kernel32(MockKernel32(stderr=100, stdout=200))[1:] + + if stderr_invalid and stdout_invalid: + assert stderr == windows.INVALID_HANDLE_VALUE + assert stdout == windows.INVALID_HANDLE_VALUE + elif stdout_invalid: + assert stderr == 100 + assert stdout == windows.INVALID_HANDLE_VALUE + elif stderr_invalid: + assert stderr == windows.INVALID_HANDLE_VALUE + assert stdout == 200 + else: + assert stderr == 100 + assert stdout == 200 + + +def test_get_console_info(): + """Test function.""" + # Test error. + if windows.IS_WINDOWS: + with pytest.raises(OSError): + windows.get_console_info(windows.init_kernel32()[0], windows.INVALID_HANDLE_VALUE) + + # Test no error with mock methods. + kernel32 = MockKernel32() + fg_color, bg_color, native_ansi = windows.get_console_info(kernel32, windows.INVALID_HANDLE_VALUE) + assert fg_color == 7 + assert bg_color == 0 + assert native_ansi is False + + # Test different console modes. + for not_native in (0x0, 0x1, 0x2, 0x1 | 0x2): + kernel32.set_mode = not_native + assert not windows.get_console_info(kernel32, windows.INVALID_HANDLE_VALUE)[-1] + for native in (i | 0x4 for i in (0x0, 0x1, 0x2, 0x1 | 0x2)): + kernel32.set_mode = native + assert windows.get_console_info(kernel32, windows.INVALID_HANDLE_VALUE)[-1] + + +@pytest.mark.parametrize('stderr', [1, windows.INVALID_HANDLE_VALUE]) +@pytest.mark.parametrize('stdout', [2, windows.INVALID_HANDLE_VALUE]) +def test_bg_color_native_ansi(stderr, stdout): + """Test function. + + :param int stderr: Value of parameter. + :param int stdout: Value of parameter. + """ + kernel32 = MockKernel32(set_mode=0x4) + kernel32.wAttributes = 240 + actual = windows.bg_color_native_ansi(kernel32, stderr, stdout) + if stderr == windows.INVALID_HANDLE_VALUE and stdout == windows.INVALID_HANDLE_VALUE: + expected = 0, False + else: + expected = 240, True + assert actual == expected + + +def test_windows_stream(): + """Test class.""" + # Test error. + if windows.IS_WINDOWS: + stream = windows.WindowsStream(windows.init_kernel32()[0], windows.INVALID_HANDLE_VALUE, StringIO()) + assert stream.colors == (windows.WINDOWS_CODES['white'], windows.WINDOWS_CODES['black']) + stream.colors = windows.WINDOWS_CODES['red'] | windows.WINDOWS_CODES['bgblue'] # No exception, just ignore. + assert stream.colors == (windows.WINDOWS_CODES['white'], windows.WINDOWS_CODES['black']) + + # Test __getattr__() and color resetting. + original_stream = StringIO() + stream = windows.WindowsStream(MockKernel32(), windows.INVALID_HANDLE_VALUE, original_stream) + assert stream.writelines == original_stream.writelines # Test __getattr__(). + assert stream.colors == (windows.WINDOWS_CODES['white'], windows.WINDOWS_CODES['black']) + stream.colors = windows.WINDOWS_CODES['red'] | windows.WINDOWS_CODES['bgblue'] + assert stream.colors == (windows.WINDOWS_CODES['red'], windows.WINDOWS_CODES['bgblue']) + stream.colors = None # Resets colors to original. + assert stream.colors == (windows.WINDOWS_CODES['white'], windows.WINDOWS_CODES['black']) + + # Test special negative codes. + stream.colors = windows.WINDOWS_CODES['red'] | windows.WINDOWS_CODES['bgblue'] + stream.colors = windows.WINDOWS_CODES['/fg'] + assert stream.colors == (windows.WINDOWS_CODES['white'], windows.WINDOWS_CODES['bgblue']) + stream.colors = windows.WINDOWS_CODES['red'] | windows.WINDOWS_CODES['bgblue'] + stream.colors = windows.WINDOWS_CODES['/bg'] + assert stream.colors == (windows.WINDOWS_CODES['red'], windows.WINDOWS_CODES['black']) + stream.colors = windows.WINDOWS_CODES['red'] | windows.WINDOWS_CODES['bgblue'] + stream.colors = windows.WINDOWS_CODES['bgblack'] + assert stream.colors == (windows.WINDOWS_CODES['red'], windows.WINDOWS_CODES['black']) + + # Test write. + stream.write(Color('{/all}A{red}B{bgblue}C')) + original_stream.seek(0) + assert original_stream.read() == 'ABC' + assert stream.colors == (windows.WINDOWS_CODES['red'], windows.WINDOWS_CODES['bgblue']) + + # Test ignore invalid code. + original_stream.seek(0) + original_stream.truncate() + stream.write('\x1b[0mA\x1b[31mB\x1b[44;999mC') + original_stream.seek(0) + assert original_stream.read() == 'ABC' + assert stream.colors == (windows.WINDOWS_CODES['red'], windows.WINDOWS_CODES['bgblue']) + + +@pytest.mark.skipif(str(windows.IS_WINDOWS)) +def test_windows_nix(): + """Test enable/disable on non-Windows platforms.""" + with windows.Windows(): + assert not windows.Windows.is_enabled() + assert not hasattr(sys.stderr, '_original_stream') + assert not hasattr(sys.stdout, '_original_stream') + assert not windows.Windows.is_enabled() + assert not hasattr(sys.stderr, '_original_stream') + assert not hasattr(sys.stdout, '_original_stream') + + +def test_windows_auto_colors(monkeypatch): + """Test Windows class with/out valid_handle and with/out auto_colors. Don't replace streams. + + :param monkeypatch: pytest fixture. + """ + mock_sys = MockSys() + monkeypatch.setattr(windows, 'atexit', type('', (), {'register': staticmethod(lambda _: 0 / 0)})) + monkeypatch.setattr(windows, 'IS_WINDOWS', True) + monkeypatch.setattr(windows, 'sys', mock_sys) + monkeypatch.setattr(ANSICodeMapping, 'LIGHT_BACKGROUND', None) + + # Test no valid handles. + kernel32 = MockKernel32() + monkeypatch.setattr(windows, 'init_kernel32', lambda: (kernel32, -1, -1)) + assert not windows.Windows.enable() + assert not windows.Windows.is_enabled() + assert not hasattr(mock_sys.stderr, '_original_stream') + assert not hasattr(mock_sys.stdout, '_original_stream') + assert ANSICodeMapping.LIGHT_BACKGROUND is None + + # Test auto colors dark background. + kernel32.set_mode = 0x4 # Enable native ANSI to have Windows skip replacing streams. + monkeypatch.setattr(windows, 'init_kernel32', lambda: (kernel32, 1, 2)) + assert not windows.Windows.enable(auto_colors=True) + assert not windows.Windows.is_enabled() + assert not hasattr(mock_sys.stderr, '_original_stream') + assert not hasattr(mock_sys.stdout, '_original_stream') + assert ANSICodeMapping.LIGHT_BACKGROUND is False + + # Test auto colors light background. + kernel32.wAttributes = 240 + assert not windows.Windows.enable(auto_colors=True) + assert not windows.Windows.is_enabled() + assert not hasattr(mock_sys.stderr, '_original_stream') + assert not hasattr(mock_sys.stdout, '_original_stream') + assert ANSICodeMapping.LIGHT_BACKGROUND is True + + +@pytest.mark.parametrize('valid', ['stderr', 'stdout', 'both']) +def test_windows_replace_streams(monkeypatch, tmpdir, valid): + """Test Windows class stdout and stderr replacement. + + :param monkeypatch: pytest fixture. + :param tmpdir: pytest fixture. + :param str valid: Which mock stream(s) should be valid. + """ + ac = list() # atexit called. + mock_sys = MockSys(stderr=tmpdir.join('stderr').open(mode='wb'), stdout=tmpdir.join('stdout').open(mode='wb')) + monkeypatch.setattr(windows, 'atexit', type('', (), {'register': staticmethod(lambda _: ac.append(1))})) + monkeypatch.setattr(windows, 'IS_WINDOWS', True) + monkeypatch.setattr(windows, 'sys', mock_sys) + + # Mock init_kernel32. + stderr = 1 if valid in ('stderr', 'both') else windows.INVALID_HANDLE_VALUE + stdout = 2 if valid in ('stdout', 'both') else windows.INVALID_HANDLE_VALUE + monkeypatch.setattr(windows, 'init_kernel32', lambda: (MockKernel32(), stderr, stdout)) + + # Test. + assert windows.Windows.enable(reset_atexit=True) + assert windows.Windows.is_enabled() + assert len(ac) == 1 + if stderr != windows.INVALID_HANDLE_VALUE: + assert hasattr(mock_sys.stderr, '_original_stream') + else: + assert not hasattr(mock_sys.stderr, '_original_stream') + if stdout != windows.INVALID_HANDLE_VALUE: + assert hasattr(mock_sys.stdout, '_original_stream') + else: + assert not hasattr(mock_sys.stdout, '_original_stream') + + # Test multiple disable. + assert windows.Windows.disable() + assert not windows.Windows.is_enabled() + assert not windows.Windows.disable() + assert not windows.Windows.is_enabled() + + # Test context manager. + with windows.Windows(): + assert windows.Windows.is_enabled() + assert not windows.Windows.is_enabled() + + +@pytest.mark.skipif(str(not windows.IS_WINDOWS)) +def test_enable_disable(tmpdir): + """Test enabling, disabling, repeat. Make sure colors still work. + + :param tmpdir: pytest fixture. + """ + screenshot = PROJECT_ROOT.join('test_windows_test_enable_disable.png') + if screenshot.check(): + screenshot.remove() + script = tmpdir.join('script.py') + command = [sys.executable, str(script)] + + script.write(dedent("""\ + from __future__ import print_function + import os, time + from colorclass import Color, Windows + + with Windows(auto_colors=True): + print(Color('{autored}Red{/autored}')) + print('Red') + with Windows(auto_colors=True): + print(Color('{autored}Red{/autored}')) + print('Red') + + stop_after = time.time() + 20 + while not os.path.exists(r'%s') and time.time() < stop_after: + time.sleep(0.5) + """) % str(screenshot)) + + # Setup expected. + with_colors = [str(p) for p in PROJECT_ROOT.join('tests').listdir('sub_red_light_fg_*.bmp')] + sans_colors = [str(p) for p in PROJECT_ROOT.join('tests').listdir('sub_red_sans_*.bmp')] + assert with_colors + assert sans_colors + + # Run. + with RunNewConsole(command) as gen: + screenshot_until_match(str(screenshot), 15, with_colors, 2, gen) + screenshot_until_match(str(screenshot), 15, sans_colors, 2, gen) + + +@pytest.mark.skipif(str(not windows.IS_WINDOWS)) +def test_box_characters(tmpdir): + """Test for unicode errors with special characters. + + :param tmpdir: pytest fixture. + """ + screenshot = PROJECT_ROOT.join('test_windows_test_box_characters.png') + if screenshot.check(): + screenshot.remove() + script = tmpdir.join('script.py') + command = [sys.executable, str(script)] + + script.write(dedent("""\ + from __future__ import print_function + import os, time + from colorclass import Color, Windows + + Windows.enable(auto_colors=True) + chars = [ + '+', '-', '|', + b'\\xb3'.decode('ibm437'), + b'\\xb4'.decode('ibm437'), + b'\\xb9'.decode('ibm437'), + b'\\xba'.decode('ibm437'), + b'\\xbb'.decode('ibm437'), + b'\\xbc'.decode('ibm437'), + b'\\xbf'.decode('ibm437'), + b'\\xc0'.decode('ibm437'), + b'\\xc1'.decode('ibm437'), + b'\\xc2'.decode('ibm437'), + b'\\xc3'.decode('ibm437'), + b'\\xc4'.decode('ibm437'), + b'\\xc5'.decode('ibm437'), + b'\\xc8'.decode('ibm437'), + b'\\xc9'.decode('ibm437'), + b'\\xca'.decode('ibm437'), + b'\\xcb'.decode('ibm437'), + b'\\xcc'.decode('ibm437'), + b'\\xcd'.decode('ibm437'), + b'\\xce'.decode('ibm437'), + b'\\xd9'.decode('ibm437'), + b'\\xda'.decode('ibm437'), + ] + + for c in chars: + print(c, end='') + print() + for c in chars: + print(Color.green(c, auto=True), end='') + print() + + stop_after = time.time() + 20 + while not os.path.exists(r'%s') and time.time() < stop_after: + time.sleep(0.5) + """) % str(screenshot)) + + # Setup expected. + with_colors = [str(p) for p in PROJECT_ROOT.join('tests').listdir('sub_box_green_*.bmp')] + sans_colors = [str(p) for p in PROJECT_ROOT.join('tests').listdir('sub_box_sans_*.bmp')] + assert with_colors + assert sans_colors + + # Run. + with RunNewConsole(command) as gen: + screenshot_until_match(str(screenshot), 15, with_colors, 1, gen) + screenshot_until_match(str(screenshot), 15, sans_colors, 1, gen) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..c214557 --- /dev/null +++ b/tox.ini @@ -0,0 +1,78 @@ +[general] +author = @Robpol86 +license = MIT +name = colorclass +version = 2.2.0 + +[tox] +envlist = lint,py{34,27,26} + +[testenv] +commands = + python -c "import os, sys; sys.platform == 'win32' and os.system('easy_install pillow')" + py.test --cov-append --cov-report term-missing --cov-report xml --cov {[general]name} --cov-config tox.ini \ + {posargs:tests} +deps = + docopt + pytest-cov +passenv = + WINDIR +setenv = + PYTHON_EGG_CACHE = {envtmpdir} +usedevelop = True + +[testenv:py35x64] +basepython = C:\Python35-x64\python.exe + +[testenv:py34x64] +basepython = C:\Python34-x64\python.exe + +[testenv:py33x64] +basepython = C:\Python33-x64\python.exe + +[testenv:py27x64] +basepython = C:\Python27-x64\python.exe + +[testenv:py26x64] +basepython = C:\Python26-x64\python.exe + +[testenv:lint] +commands = + coverage erase + python setup.py check --strict + python setup.py check --strict -m + python setup.py check --strict -s + flake8 --application-import-names={[general]name},tests + pylint --rcfile=tox.ini setup.py {[general]name} + python -c "assert '{[general]author}' == __import__('{[general]name}').__author__" + python -c "assert '{[general]license}' == __import__('{[general]name}').__license__" + python -c "assert '{[general]version}' == __import__('{[general]name}').__version__" + python -c "assert 'author=\'{[general]author}\'' in open('setup.py').read(102400)" + python -c "assert 'license=\'{[general]license}\'' in open('setup.py').read(102400)" + python -c "assert 'version=\'{[general]version}\'' in open('setup.py').read(102400)" + python -c "assert '\n{[general]version} - ' in open('README.rst').read(102400)" +deps = + coverage==4.0.3 + flake8==2.5.4 + flake8-import-order==0.5 + flake8-pep257==1.0.5 + pep8-naming==0.3.3 + pylint==1.5.4 + +[flake8] +exclude = .tox/*,build/*,docs/*,env/*,get-pip.py +ignore = D203 +import-order-style = google +max-line-length = 120 +statistics = True + +[pylint] +ignore = .tox/*,build/*,docs/*,env/*,get-pip.py +max-line-length = 120 +reports = no +disable = + too-few-public-methods, + too-many-public-methods, + +[run] +branch = True -- cgit v1.2.3