Ticket #3844: parse-irc-formatting.diff

File parse-irc-formatting.diff, 12.9 KB (added by Jonathan Jacobs, 10 years ago)
  • twisted/words/test/test_irc.py

     
    1515from twisted.test.proto_helpers import StringTransport, StringIOWithoutClosing
    1616
    1717
     18
     19class TextAttributesTests(unittest.TestCase):
     20    def test_createNoParams(self):
     21        """
     22        Creating TextAttributes without any parameters produces something that
     23        describes unformatted text.
     24        """
     25        ta = irc.TextAttributes()
     26        self.assertEqual(set(), ta.attributes)
     27        self.assertEqual([None, None], ta.color)
     28        self.assertFalse(ta.hasColor())
     29
     30
     31    def test_createParams(self):
     32        """
     33        Creating TextAttributes with parameters results in something that
     34        correctly describes the desired formatting.
     35        """
     36        ta = irc.TextAttributes(['bold', 'bold', 'underline'], [1])
     37        self.assertEqual(set(['bold', 'underline']),
     38                         ta.attributes)
     39        self.assertTrue(ta.hasColor())
     40        self.assertEqual(ta.color, [1, None])
     41
     42        self.assertRaises(ValueError, irc.TextAttributes, [], [1, 2, 3])
     43
     44
     45    def test_color(self):
     46        """
     47        TextAttributes with colors behave correctly and result in
     48        well-formatted strings.
     49        """
     50        ta = irc.TextAttributes([], [1])
     51        self.assertEqual(ta.colorString(), '01')
     52
     53        ta = irc.TextAttributes([], [1, 2])
     54        self.assertEqual(ta.colorString(), '01,02')
     55
     56        ta = irc.TextAttributes()
     57        self.assertRaises(ValueError, ta.colorString)
     58        self.assertRaises(ValueError, ta.foreground, None)
     59        self.assertRaises(ValueError, ta.foreground, -1)
     60        self.assertRaises(ValueError, ta.foreground, 100)
     61        self.assertRaises(ValueError, ta.background, -1)
     62        self.assertRaises(ValueError, ta.background, 100)
     63
     64        ta.foreground(99)
     65        ta.background(None)
     66        self.assertEqual(ta.color, [99, None])
     67
     68        ta.background(0)
     69        self.assertEqual(ta.color, [99, 0])
     70
     71        ta.resetColor()
     72        self.assertFalse(ta.hasColor())
     73
     74
     75
     76class FormattedTextTests(unittest.TestCase):
     77    def attr(self, *attrs, **kw):
     78        """
     79        Helper function to produce TextAttributes.
     80        """
     81        color = kw.pop('color', None)
     82        return irc.TextAttributes(attrs, color)
     83
     84
     85    def test_assemble(self):
     86        """
     87        Assembling structured information results in the correct control codes
     88        appearing in the resulting string.
     89        """
     90        self.assertEqual(irc.assembleFormattedText([]), '')
     91
     92        formatted = [
     93            (self.attr('bold', 'underline'), 'foo'),
     94            (self.attr(), 'bar'),
     95            (self.attr('bold', color=[1,2]), 'baz')]
     96
     97        self.assertEqual(formatted,
     98            irc.parseFormattedText(
     99                irc.assembleFormattedText(formatted)))
     100
     101        # Attempting to apply an attribute to the empty string should still
     102        # produce two control codes.
     103        formatted = [
     104            (self.attr('bold'), '')]
     105        self.assertEqual(irc.FormattingState._formatNames['bold'] * 2,
     106                         irc.assembleFormattedText(formatted))
     107
     108
     109    def test_parseEmptyString(self):
     110        """
     111        Parsing an empty string results in an empty list of formatting
     112        information.
     113        """
     114        parsed = irc.parseFormattedText('')
     115        self.assertEqual(len(parsed), 0)
     116
     117
     118    def test_parseUnformattedText(self):
     119        """
     120        Parsing unformatted text results in text with TextAttributes that
     121        constitute a no-op.
     122        """
     123        parsed = irc.parseFormattedText('hello')
     124        self.assertEqual(len(parsed), 1)
     125
     126        attrs, text = parsed[0]
     127        self.assertEqual(attrs, irc.TextAttributes())
     128        self.assertEqual(text, 'hello')
     129
     130
     131
    18132stringSubjects = [
    19133    "Hello, this is a nice string with no complications.",
    20134    "xargs%(NUL)smight%(NUL)slike%(NUL)sthis" % {'NUL': irc.NUL },
  • twisted/words/protocols/irc.py

     
    21462146        return s
    21472147
    21482148
     2149
     2150class TextAttributes(object):
     2151    """
     2152    State of text attributes.
     2153
     2154    @type attributes: C{set} of C{str}
     2155    @ivar attributes: Currently set text attributes
     2156
     2157    @type _color: C{list} of C{int}
     2158    @ivar _color: C{[foreground, background]}
     2159    """
     2160    def __init__(self, attributes=None, color=None):
     2161        """
     2162        """
     2163        if attributes is None:
     2164            attributes = set()
     2165        self.attributes = set(attributes)
     2166
     2167        if not color:
     2168            self.resetColor()
     2169        else:
     2170            if len(color) > 2:
     2171                raise ValueError('"color" must be a 2-tuple of integers less than 99 or None')
     2172            self.resetColor()
     2173            self.foreground(color[0])
     2174            if len(color) > 1:
     2175                self.background(color[1])
     2176
     2177
     2178    def __eq__(self, other):
     2179        if isinstance(other, TextAttributes):
     2180            return (other.attributes == self.attributes and
     2181                    other.color == self.color)
     2182        return False
     2183
     2184
     2185    def __repr__(self):
     2186        attrs = list(self.attributes)
     2187        if self.hasColor():
     2188            attrs.append('color=' + repr(self.color))
     2189        attrs = ' ' + ' '.join(attrs)
     2190        return '<%s%s>' % (type(self).__name__, attrs.rstrip())
     2191
     2192
     2193    def hasColor(self):
     2194        """
     2195        Determine whether the color attribute has been set or not.
     2196        """
     2197        return self.color[0] is not None
     2198
     2199
     2200    def colorString(self):
     2201        """
     2202        Construct a color string for the current color.
     2203
     2204        @raise ValueError: If there is no color set
     2205
     2206        @rtype: C{str}
     2207        @return C{fg,bg}
     2208        """
     2209        if self.hasColor():
     2210            a, b = self.color
     2211            a = '%02d' % a
     2212            if b is not None:
     2213                b = '%02d' % self.color[1]
     2214            return ','.join(filter(None, (a, b)))
     2215
     2216        raise ValueError('Color string is not available for text '
     2217                         'attributes without color')
     2218
     2219
     2220    def foreground(self, color):
     2221        """
     2222        Set the foreground color.
     2223        """
     2224        if not 99 >= color >= 0:
     2225            raise ValueError('Foreground must be in the range [0;100)')
     2226        self.color[0] = color
     2227
     2228
     2229    def background(self, color):
     2230        """
     2231        Set the background color.
     2232        """
     2233        if color is not None and not 99 >= color >= 0:
     2234            raise ValueError('Background must be in the range [0;100) or None')
     2235        self.color[1] = color
     2236
     2237
     2238    def toggle(self, attr):
     2239        """
     2240        Toggle an attribute's state.
     2241        """
     2242        if attr in self.attributes:
     2243            self.attributes.remove(attr)
     2244        else:
     2245            self.attributes.add(attr)
     2246
     2247
     2248    def resetColor(self):
     2249        """
     2250        Reset the color to the default value.
     2251        """
     2252        self.color = [None, None]
     2253
     2254
     2255    def copy(self):
     2256        """
     2257        Make a copy of this object.
     2258        """
     2259        return copy.deepcopy(self)
     2260
     2261
     2262
     2263class FormattingState(CommandDispatcherMixin):
     2264    """
     2265    A finite-state machine that parses formatted IRC text.
     2266
     2267    Currently handled formatting includes: bold, reverse, underline,
     2268    mIRC color codes and the ability to remove all current formatting.
     2269
     2270    @type _formatCodes: C{dict} mapping C{str} to C{str}
     2271    @cvar _formatCodes: Mapping of format code values to names
     2272
     2273    @type _formatNames: C{dict} mapping C{str} to C{str}
     2274    @cvar _formatNames: Mapping of format code names to values
     2275
     2276    @type state: C{str}
     2277    @ivar state: Current state of the FSM
     2278
     2279    @type _buffer: C{str}
     2280    @ivar _buffer: Accumulation buffer
     2281
     2282    @type _attrs: L{TextAttributes}
     2283    @ivar _attrs: Current state of text attributes
     2284
     2285    @type _result: C{list}
     2286    @ivar _result: Emitted parse results
     2287    """
     2288    prefix = 'state'
     2289
     2290    _formatCodes = {
     2291        '\x0f': 'off',
     2292        '\x02': 'bold',
     2293        '\x03': 'color',
     2294        '\x16': 'reverse',
     2295        '\x1f': 'underline'}
     2296
     2297
     2298    _formatNames = dict(map(reversed, _formatCodes.iteritems()))
     2299
     2300
     2301    def __init__(self):
     2302        self.state = 'text'
     2303        self._buffer = ''
     2304        self._attrs = TextAttributes()
     2305        self._result = []
     2306
     2307
     2308    def process(self, ch):
     2309        """
     2310        Handle input.
     2311
     2312        @type ch: C{str}
     2313        @param ch: A single character of input to process
     2314        """
     2315        self.dispatch(self.state, ch)
     2316
     2317
     2318    def complete(self):
     2319        """
     2320        Flush the current buffer and return the final parsed result.
     2321
     2322        @rtype: C{list} of C{(TextAttributes, str)}
     2323        """
     2324        self.emit()
     2325        return self._result
     2326
     2327
     2328    def emit(self):
     2329        """
     2330        Add the currently parsed input to the result.
     2331        """
     2332        if self._buffer:
     2333            self._result.append((self._attrs, self._buffer))
     2334            self._buffer = ''
     2335        self._attrs = self._attrs.copy()
     2336
     2337
     2338    def state_text(self, ch):
     2339        """
     2340        Handle the "text" state.
     2341
     2342        Along with regular text, single token formatting codes are handled
     2343        in this state too.
     2344        """
     2345        formatName = self._formatCodes.get(ch)
     2346        if formatName == 'color':
     2347            self.emit()
     2348            self.state = 'colorForeground'
     2349        else:
     2350            if formatName is None:
     2351                self._buffer += ch
     2352            else:
     2353                if self._buffer:
     2354                    self.emit()
     2355
     2356                if formatName == 'off':
     2357                    self._attrs = TextAttributes()
     2358                else:
     2359                    self._attrs.toggle(formatName)
     2360
     2361
     2362    def state_colorForeground(self, ch):
     2363        """
     2364        Handle the foreground color state.
     2365
     2366        Foreground colors can consist of up to two digits and may optionally
     2367        end in a C{,}. Any non-digit or non-comma characters are treated as
     2368        invalid input and result in the state being reset to "text".
     2369        """
     2370        # Color codes may only be a maximum of two characters.
     2371        if ch.isdigit() and len(self._buffer) < 2:
     2372            self._buffer += ch
     2373        else:
     2374            if self._buffer:
     2375                self._attrs.foreground(int(self._buffer))
     2376            else:
     2377                # If there were no digits, then this has been an empty color
     2378                # code and we can reset the color state.
     2379                self._attrs.resetColor()
     2380
     2381            if ch == ',' and self._buffer:
     2382                # If there's a comma and it's not the first thing, move on to
     2383                # the background state.
     2384                self._buffer = ''
     2385                self.state = 'colorBackground'
     2386            else:
     2387                # Otherwise, this is a bogus color code, fall back to text.
     2388                self._buffer = ''
     2389                self.state = 'text'
     2390                self.emit()
     2391                self.process(ch)
     2392
     2393
     2394    def state_colorBackground(self, ch):
     2395        """
     2396        Handle the background color state.
     2397
     2398        Background colors can consist of up to two digits and must occur after
     2399        a foreground color and must be preceded by a C{,}. Any non-digit
     2400        character is treated as invalid input and results in the state being
     2401        set to "text".
     2402        """
     2403        # Color codes may only be a maximum of two characters.
     2404        if ch.isdigit() and len(self._buffer) < 2:
     2405            self._buffer += ch
     2406        else:
     2407            if self._buffer:
     2408                self._attrs.background(int(self._buffer))
     2409                self._buffer = ''
     2410
     2411            self.emit()
     2412            self.state = 'text'
     2413            self.process(ch)
     2414
     2415
     2416
     2417def parseFormattedText(text):
     2418    """
     2419    Parse text containing IRC formatting codes into structured information.
     2420
     2421    @type text: C{str}
     2422
     2423    @rtype: C{list} of C{(TextAttributes, str)}
     2424    """
     2425    state = FormattingState()
     2426
     2427    for ch in text:
     2428        state.process(ch)
     2429
     2430    return state.complete()
     2431
     2432
     2433
     2434def assembleFormattedText(formatted):
     2435    """
     2436    Assemble formatted text from structured information.
     2437
     2438    @type formatted: C{list} of C{(TextAttributes, str)}
     2439
     2440    @rtype: C{str}
     2441    """
     2442    def _simpleAttrs(textAttr):
     2443        for name in textAttr.attributes:
     2444            yield FormattingState._formatNames[name]
     2445
     2446    def _assemble():
     2447        for textAttrs, text in formatted:
     2448            if textAttrs.hasColor():
     2449                yield FormattingState._formatNames['color']
     2450                yield textAttrs.colorString()
     2451
     2452            _sa = ''.join(_simpleAttrs(textAttrs))
     2453            yield _sa + text + _sa
     2454            if textAttrs.hasColor():
     2455                yield FormattingState._formatNames['color']
     2456
     2457    return ''.join(_assemble())
     2458
     2459
     2460
    21492461# CTCP constants and helper functions
    21502462
    21512463X_DELIM = chr(001)