jenkins-bot has submitted this change and it was merged.

Change subject: [IMPROV] UI: Always use color stack
......................................................................


[IMPROV] UI: Always use color stack

Previously only the colored Windows UI used a color stack because of the
transliteration. This is implementing the stack in general and each UI only
implements a different way to specify the color.

It is also removing the ambiguity between using the default color as the actual
default color and using the last color from the stack by adding a new color
`previous`.

Change-Id: Iecbfe7b633b3cb5dbe1772cbccb03641637a6612
---
M pywikibot/userinterfaces/terminal_interface_base.py
M pywikibot/userinterfaces/terminal_interface_unix.py
M pywikibot/userinterfaces/terminal_interface_win32.py
M tests/aspects.py
M tests/ui_tests.py
M tests/utils.py
6 files changed, 272 insertions(+), 89 deletions(-)

Approvals:
  John Vandenberg: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/pywikibot/userinterfaces/terminal_interface_base.py 
b/pywikibot/userinterfaces/terminal_interface_base.py
index 17bdd46..66487d5 100755
--- a/pywikibot/userinterfaces/terminal_interface_base.py
+++ b/pywikibot/userinterfaces/terminal_interface_base.py
@@ -46,10 +46,10 @@
     'white',
 ]
 
-colorTagR = re.compile('\03{(?P<name>%s)}' % '|'.join(colors))
+colorTagR = re.compile('\03{(?P<name>%s|previous)}' % '|'.join(colors))
 
 
-class UI:
+class UI(object):
 
     """Base for terminal user interfaces."""
 
@@ -114,30 +114,55 @@
         warnings_logger = logging.getLogger("py.warnings")
         warnings_logger.addHandler(warning_handler)
 
-    def printNonColorized(self, text, targetStream):
-        """
-        Write the text non colorized to the target stream.
+    def encounter_color(self, color, target_stream):
+        """Handle the next color encountered."""
+        raise NotImplementedError('The {0} class does not support '
+                                  'colors.'.format(self.__class__.__name__))
 
-        To each line which contains a color tag a ' ***' is added at the end.
-        """
-        lines = text.split('\n')
-        for i, line in enumerate(lines):
-            if i > 0:
-                line = "\n" + line
-            line, count = colorTagR.subn('', line)
-            if count > 0:
-                line += ' ***'
-            if PY2:
-                line = line.encode(self.encoding, 'replace')
-            targetStream.write(line)
+    def _write(self, text, target_stream):
+        """Optionally encode and write the text to the target stream."""
+        if PY2:
+            text = text.encode(self.encoding, 'replace')
+        target_stream.write(text)
 
-    printColorized = printNonColorized
+    def support_color(self, target_stream):
+        """Return whether the target stream does support colors."""
+        return False
 
-    def _print(self, text, targetStream):
-        if config.colorized_output:
-            self.printColorized(text, targetStream)
-        else:
-            self.printNonColorized(text, targetStream)
+    def _print(self, text, target_stream):
+        """Write the text to the target stream handling the colors."""
+        colorized = config.colorized_output and 
self.support_color(target_stream)
+        colored_line = False
+        # Color tags might be cascaded, e.g. because of transliteration.
+        # Therefore we need this stack.
+        color_stack = ['default']
+        text_parts = colorTagR.split(text) + ['default']
+        for index, (text, next_color) in enumerate(zip(text_parts[::2],
+                                                       text_parts[1::2])):
+            current_color = color_stack[-1]
+            if next_color == 'previous':
+                if len(color_stack) > 1:  # keep the last element in the stack
+                    color_stack.pop()
+                next_color = color_stack[-1]
+            else:
+                color_stack.append(next_color)
+
+            if current_color != next_color:
+                colored_line = True
+            if colored_line and not colorized:
+                if '\n' in text:  # Normal end of line
+                    text = text.replace('\n', ' ***\n', 1)
+                    colored_line = False
+                elif index == len(text_parts) // 2 - 1:  # Or end of text
+                    text += ' ***'
+                    colored_line = False
+
+            # print the text up to the tag.
+            self._write(text, target_stream)
+
+            if current_color != next_color and colorized:
+                # set the new color, but only if they change
+                self.encounter_color(color_stack[-1], target_stream)
 
     def output(self, text, toStdout=False, targetStream=None):
         """
@@ -176,7 +201,7 @@
                     # transliteration was successful. The replacement
                     # could consist of multiple letters.
                     # mark the transliterated letters in yellow.
-                    transliteratedText += '\03{lightyellow}%s\03{default}' \
+                    transliteratedText += '\03{lightyellow}%s\03{previous}' \
                                           % transliterated
                     # memorize if we replaced a single letter by multiple
                     # letters.
diff --git a/pywikibot/userinterfaces/terminal_interface_unix.py 
b/pywikibot/userinterfaces/terminal_interface_unix.py
index 60d8cb2..b9b4ca3 100755
--- a/pywikibot/userinterfaces/terminal_interface_unix.py
+++ b/pywikibot/userinterfaces/terminal_interface_unix.py
@@ -37,18 +37,17 @@
 
     """User interface for unix terminals."""
 
-    def printColorized(self, text, targetStream):
-        """Print the text colorized using the Unix colors."""
-        totalcount = 0
-        for key, value in unixColors.items():
-            ckey = '\03{%s}' % key
-            totalcount += text.count(ckey)
-            text = text.replace(ckey, value)
+    def support_color(self, target_stream):
+        """Return that the target stream supports colors."""
+        return True
 
-        if totalcount > 0:
-            # just to be sure, reset the color
-            text += unixColors['default']
+    def encounter_color(self, color, target_stream):
+        """Write the unix color directly to the stream."""
+        self._write(unixColors[color], target_stream)
 
+    def _write(self, text, target_stream):
+        """Optionally encode and write the text to the target stream."""
+        targetStream = target_stream
         if sys.version_info[0] == 2:
             # .encoding does not mean we can write unicode
             # to the stream pre-2.7.
diff --git a/pywikibot/userinterfaces/terminal_interface_win32.py 
b/pywikibot/userinterfaces/terminal_interface_win32.py
index 600a713..13cef1a 100755
--- a/pywikibot/userinterfaces/terminal_interface_win32.py
+++ b/pywikibot/userinterfaces/terminal_interface_win32.py
@@ -11,7 +11,6 @@
 
 import re
 
-from pywikibot.tools import PY2
 from pywikibot.userinterfaces import terminal_interface_base
 
 try:
@@ -68,43 +67,14 @@
         self.argv = argv
         self.encoding = 'utf-8'
 
-    def printColorized(self, text, targetStream):
-        """Print the text colorized to the target stream."""
-        std_out_handle = ctypes.windll.kernel32.GetStdHandle(-11)
-        # Color tags might be cascaded, e.g. because of transliteration.
-        # Therefore we need this stack.
-        colorStack = []
-        tagM = True
-        while tagM:
-            tagM = colorTagR.search(text)
-            if tagM:
-                # print the text up to the tag.
-                text_before_tag = text[:tagM.start()]
-                if PY2:
-                    text_before_tag = text_before_tag.encode(self.encoding, 
'replace')
-                targetStream.write(text_before_tag)
-                newColor = tagM.group('name')
-                if newColor == 'default':
-                    if len(colorStack) > 0:
-                        colorStack.pop()
-                        if len(colorStack) > 0:
-                            lastColor = colorStack[-1]
-                        else:
-                            lastColor = 'default'
-                        ctypes.windll.kernel32.SetConsoleTextAttribute(
-                            std_out_handle, windowsColors[lastColor])
-                else:
-                    colorStack.append(newColor)
-                    # set the new color
-                    ctypes.windll.kernel32.SetConsoleTextAttribute(
-                        std_out_handle, windowsColors[newColor])
-                text = text[tagM.end():]
-        # print the rest of the text
-        if PY2:
-            text = text.encode(self.encoding, 'replace')
-        targetStream.write(text)
-        # just to be sure, reset the color
-        ctypes.windll.kernel32.SetConsoleTextAttribute(std_out_handle, 
windowsColors['default'])
+    def support_color(self, target_stream):
+        """Return whether the target stream supports actually color."""
+        return getattr(target_stream, '_hConsole', None) is not None
+
+    def encounter_color(self, color, target_stream):
+        """Set the new color."""
+        ctypes.windll.kernel32.SetConsoleTextAttribute(
+            target_stream._hConsole, windowsColors[color])
 
     def _raw_input(self):
         data = self.stdin.readline()
diff --git a/tests/aspects.py b/tests/aspects.py
index de3ca79..750ea38 100644
--- a/tests/aspects.py
+++ b/tests/aspects.py
@@ -741,7 +741,8 @@
         if (('sites' not in dct and 'site' not in dct) or
                 ('site' in dct and not dct['site'])):
             # Prevent use of pywikibot.Site
-            bases = tuple([DisableSiteMixin] + list(bases))
+            if all(not issubclass(base, DisableSiteMixin) for base in bases):
+                bases = tuple([DisableSiteMixin] + list(bases))
 
             # 'pwb' tests will _usually_ require a site.  To ensure the
             # test class dependencies are declarative, this requires the
diff --git a/tests/ui_tests.py b/tests/ui_tests.py
index 46ac664..983fa3e 100644
--- a/tests/ui_tests.py
+++ b/tests/ui_tests.py
@@ -60,8 +60,13 @@
 from pywikibot.bot import (
     ui, DEBUG, VERBOSE, INFO, STDOUT, INPUT, WARNING, ERROR, CRITICAL
 )
+from pywikibot.tools import PY2
+from pywikibot.userinterfaces import (
+    terminal_interface_win32, terminal_interface_base, terminal_interface_unix,
+)
 
-from tests.utils import unittest
+from tests.aspects import TestCase
+from tests.utils import unittest, FakeModule
 
 if sys.version_info[0] > 2:
     unicode = str
@@ -425,7 +430,7 @@
         self.assertEqual(newstdout.getvalue(), '')
         self.assertEqual(
             newstderr.getvalue(),
-            'text \x1b[95mlight purple text\x1b[0m text\n\x1b[0m')
+            'text \x1b[95mlight purple text\x1b[0m text\n')
 
     def testOutputNoncolorizedText(self):
         pywikibot.config.colorized_output = False
@@ -436,18 +441,8 @@
             'text light purple text text ***\n')
 
     str2 = ('normal text \03{lightpurple} light purple ' +
-            '\03{lightblue} light blue \03{default} light purple ' +
+            '\03{lightblue} light blue \03{previous} light purple ' +
             '\03{default} normal text')
-
-    @unittest.expectedFailure
-    def testOutputColorCascade(self):
-        pywikibot.output(self.str2)
-        self.assertEqual(newstdout.getvalue(), '')
-        self.assertEqual(
-            newstderr.getvalue(),
-            'normal text \x1b[35;1m light purple ' +
-            '\x1b[94m light blue \x1b[35;1m light purple ' +
-            '\x1b[0m normal text\n\x1b[0m')
 
     def testOutputColorCascade_incorrect(self):
         """Test incorrect behavior of testOutputColorCascade."""
@@ -456,8 +451,8 @@
         self.assertEqual(
             newstderr.getvalue(),
             'normal text \x1b[95m light purple ' +
-            '\x1b[94m light blue \x1b[0m light purple ' +
-            '\x1b[0m normal text\n\x1b[0m')
+            '\x1b[94m light blue \x1b[95m light purple ' +
+            '\x1b[0m normal text\n')
 
 
 @unittest.skipUnless(os.name == 'posix', 'requires Unix console')
@@ -502,7 +497,7 @@
             'abcd \x1b[93mA\x1b[0m\x1b[93mB\x1b[0m\x1b[93mG\x1b[0m'
             '\x1b[93mD\x1b[0m \x1b[93ma\x1b[0m\x1b[93mb\x1b[0m\x1b[93mg'
             '\x1b[0m\x1b[93md\x1b[0m \x1b[93ma\x1b[0m\x1b[93mi\x1b[0m'
-            '\x1b[93mu\x1b[0m\x1b[93me\x1b[0m\x1b[93mo\x1b[0m\n\x1b[0m')
+            '\x1b[93mu\x1b[0m\x1b[93me\x1b[0m\x1b[93mo\x1b[0m\n')
 
 
 @unittest.skipUnless(os.name == 'nt', 'requires Windows console')
@@ -679,6 +674,181 @@
         self.assertEqual(lines, [u'Alpha', u'Bετα', u'Гамма', u'دلتا', u''])
 
 
+class FakeUITest(TestCase):
+
+    """Test case to allow doing uncolorized general UI tests."""
+
+    net = False
+
+    expected = 'Hello world you! ***'
+    expect_color = False
+    ui_class = terminal_interface_base.UI
+
+    def setUp(self):
+        """Create dummy instances for the test and patch encounter_color."""
+        super(FakeUITest, self).setUp()
+        if PY2:
+            self.stream = io.BytesIO()
+        else:
+            self.stream = io.StringIO()
+        self.ui_obj = self.ui_class()
+        self._orig_encounter_color = self.ui_obj.encounter_color
+        self.ui_obj.encounter_color = self._encounter_color
+        self._index = 0
+
+    def tearDown(self):
+        """Unpatch the encounter_color method."""
+        self.ui_obj.encounter_color = self._orig_encounter_color
+        super(FakeUITest, self).tearDown()
+        self.assertEqual(self._index,
+                         len(self._colors) if self.expect_color else 0)
+
+    def _getvalue(self):
+        """Get the value of the stream and also decode it on Python 2."""
+        value = self.stream.getvalue()
+        if PY2:
+            value = value.decode(self.ui_obj.encoding)
+        return value
+
+    def _encounter_color(self, color, target_stream):
+        """Patched encounter_color method."""
+        assert False, 'This method should not be invoked'
+
+    def test_no_color(self):
+        """Test a string without any colors."""
+        self._colors = tuple()
+        self.ui_obj._print('Hello world you!', self.stream)
+        self.assertEqual(self._getvalue(), 'Hello world you!')
+
+    def test_one_color(self):
+        """Test a string using one color."""
+        self._colors = (('red', 6), ('default', 10))
+        self.ui_obj._print('Hello \03{red}world you!', self.stream)
+        self.assertEqual(self._getvalue(), self.expected)
+
+    def test_flat_color(self):
+        """Test using colors with defaulting in between."""
+        self._colors = (('red', 6), ('default', 6), ('yellow', 3), ('default', 
1))
+        self.ui_obj._print('Hello \03{red}world \03{default}you\03{yellow}!',
+                           self.stream)
+        self.assertEqual(self._getvalue(), self.expected)
+
+    def test_stack_with_pop_color(self):
+        """Test using stacked colors and just poping the latest color."""
+        self._colors = (('red', 6), ('yellow', 6), ('red', 3), ('default', 1))
+        self.ui_obj._print('Hello \03{red}world \03{yellow}you\03{previous}!',
+                           self.stream)
+        self.assertEqual(self._getvalue(), self.expected)
+
+    def test_stack_implicit_color(self):
+        """Test using stacked colors without poping any."""
+        self._colors = (('red', 6), ('yellow', 6), ('default', 4))
+        self.ui_obj._print('Hello \03{red}world \03{yellow}you!', self.stream)
+        self.assertEqual(self._getvalue(), self.expected)
+
+    def test_one_color_newline(self):
+        """Test with trailing new line and one color."""
+        self._colors = (('red', 6), ('default', 11))
+        self.ui_obj._print('Hello \03{red}world you!\n', self.stream)
+        self.assertEqual(self._getvalue(), self.expected + '\n')
+
+
+class FakeUIColorizedTestBase(TestCase):
+
+    """Base class for test cases requiring that colorized output is active."""
+
+    expect_color = True
+
+    def setUp(self):
+        """Force colorized_output to True."""
+        super(FakeUIColorizedTestBase, self).setUp()
+        self._old_config = pywikibot.config2.colorized_output
+        pywikibot.config2.colorized_output = True
+
+    def tearDown(self):
+        """Undo colorized_output configuration."""
+        pywikibot.config2.colorized_output = self._old_config
+        super(FakeUIColorizedTestBase, self).tearDown()
+
+
+class FakeUnixTest(FakeUIColorizedTestBase, FakeUITest):
+
+    """Test case to allow doing colorized Unix tests in any environment."""
+
+    net = False
+
+    expected = 'Hello world you!'
+    ui_class = terminal_interface_unix.UnixUI
+
+    def _encounter_color(self, color, target_stream):
+        """Verify that the written data, color and stream are correct."""
+        self.assertIs(target_stream, self.stream)
+        expected_color = self._colors[self._index][0]
+        self._index += 1
+        self.assertEqual(color, expected_color)
+        self.assertEqual(len(self.stream.getvalue()),
+                         sum(e[1] for e in self._colors[:self._index]))
+
+
+class FakeWin32Test(FakeUIColorizedTestBase, FakeUITest):
+
+    """
+    Test case to allow doing colorized Win32 tests in any environment.
+
+    This only patches the ctypes import in the terminal_interface_win32 module.
+    As the Win32CtypesUI is using the std-streams from another import these 
will
+    be unpatched.
+    """
+
+    net = False
+
+    expected = 'Hello world you!'
+    ui_class = terminal_interface_win32.Win32CtypesUI
+
+    def setUp(self):
+        """Patch the ctypes import and initialize a stream and UI instance."""
+        super(FakeWin32Test, self).setUp()
+        self._orig_ctypes = terminal_interface_win32.ctypes
+        ctypes = FakeModule.create_dotted('ctypes.windll.kernel32')
+        ctypes.windll.kernel32.SetConsoleTextAttribute = self._handle_setattr
+        terminal_interface_win32.ctypes = ctypes
+        self.stream._hConsole = object()
+
+    def tearDown(self):
+        """Unpatch the ctypes import and check that all colors were used."""
+        terminal_interface_win32.ctypes = self._orig_ctypes
+        super(FakeWin32Test, self).tearDown()
+
+    def _encounter_color(self, color, target_stream):
+        """Call the original method."""
+        self._orig_encounter_color(color, target_stream)
+
+    def _handle_setattr(self, handle, attribute):
+        """Dummy method to handle SetConsoleTextAttribute."""
+        self.assertIs(handle, self.stream._hConsole)
+        color = self._colors[self._index][0]
+        self._index += 1
+        color = terminal_interface_win32.windowsColors[color]
+        self.assertEqual(attribute, color)
+        self.assertEqual(len(self.stream.getvalue()),
+                         sum(e[1] for e in self._colors[:self._index]))
+
+
+class FakeWin32UncolorizedTest(FakeWin32Test):
+
+    """Test case to allow doing uncolorized Win32 tests in any environment."""
+
+    net = False
+
+    expected = 'Hello world you! ***'
+    expect_color = False
+
+    def setUp(self):
+        """Change the local stream's console to None to disable colors."""
+        super(FakeWin32UncolorizedTest, self).setUp()
+        self.stream._hConsole = None
+
+
 if __name__ == "__main__":
     try:
         try:
diff --git a/tests/utils.py b/tests/utils.py
index d7c3119..79a9244 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -19,6 +19,7 @@
 import warnings
 
 from collections import Mapping
+from types import ModuleType
 from warnings import warn
 
 if sys.version_info[0] > 2:
@@ -126,6 +127,23 @@
     return False
 
 
+class FakeModule(ModuleType):
+
+    """An empty fake module."""
+
+    @classmethod
+    def create_dotted(cls, name):
+        """Create a chain of modules based on the name separated by periods."""
+        modules = name.split('.')
+        mod = None
+        for mod_name in modules[::-1]:
+            module = cls(str(mod_name))
+            if mod:
+                setattr(module, mod.__name__, mod)
+            mod = module
+        return mod
+
+
 class WarningSourceSkipContextManager(warnings.catch_warnings):
 
     """

-- 
To view, visit https://gerrit.wikimedia.org/r/195100
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Iecbfe7b633b3cb5dbe1772cbccb03641637a6612
Gerrit-PatchSet: 10
Gerrit-Project: pywikibot/core
Gerrit-Branch: master
Gerrit-Owner: XZise <commodorefabia...@gmx.de>
Gerrit-Reviewer: John Vandenberg <jay...@gmail.com>
Gerrit-Reviewer: Ladsgroup <ladsgr...@gmail.com>
Gerrit-Reviewer: Merlijn van Deen <valhall...@arctus.nl>
Gerrit-Reviewer: XZise <commodorefabia...@gmx.de>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
Pywikibot-commits mailing list
Pywikibot-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/pywikibot-commits

Reply via email to