diff options
author | Erik Rose <grinch@grinchcentral.com> | 2015-02-03 00:42:53 -0500 |
---|---|---|
committer | Erik Rose <grinch@grinchcentral.com> | 2015-02-03 00:42:53 -0500 |
commit | 3d8ea2b1ce2bd66018d1d08bf7105089ba495bf6 (patch) | |
tree | e16c98d0039a492cfc67653635d293daaca998cf /blessings | |
parent | 2405ab79a7c92aadadf437500d790eae2e65fb0f (diff) | |
parent | 7eb082718b8cd02a7a0c55e873a3f16b96d64ca6 (diff) | |
download | blessings-3d8ea2b1ce2bd66018d1d08bf7105089ba495bf6.tar.gz |
Rename instances of "blessed" to "blessings", including the package dir itself. Fix #75.
Diffstat (limited to 'blessings')
-rw-r--r-- | blessings/__init__.py | 16 | ||||
-rw-r--r-- | blessings/_binterms.py | 872 | ||||
-rw-r--r-- | blessings/formatters.py | 332 | ||||
-rw-r--r-- | blessings/keyboard.py | 276 | ||||
-rw-r--r-- | blessings/sequences.py | 688 | ||||
-rw-r--r-- | blessings/terminal.py | 783 | ||||
-rw-r--r-- | blessings/tests/__init__.py | 0 | ||||
-rw-r--r-- | blessings/tests/accessories.py | 240 | ||||
-rw-r--r-- | blessings/tests/test_core.py | 457 | ||||
-rw-r--r-- | blessings/tests/test_formatters.py | 403 | ||||
-rw-r--r-- | blessings/tests/test_keyboard.py | 902 | ||||
-rw-r--r-- | blessings/tests/test_length_sequence.py | 342 | ||||
-rw-r--r-- | blessings/tests/test_sequences.py | 549 | ||||
-rw-r--r-- | blessings/tests/test_wrap.py | 105 | ||||
-rw-r--r-- | blessings/tests/wall.ans | 7 |
15 files changed, 5972 insertions, 0 deletions
diff --git a/blessings/__init__.py b/blessings/__init__.py new file mode 100644 index 0000000..3622d0e --- /dev/null +++ b/blessings/__init__.py @@ -0,0 +1,16 @@ +""" +A thin, practical wrapper around terminal capabilities in Python + +http://pypi.python.org/pypi/blessings +""" +import platform as _platform +if ('3', '0', '0') <= _platform.python_version_tuple() < ('3', '2', '2+'): + # Good till 3.2.10 + # Python 3.x < 3.2.3 has a bug in which tparm() erroneously takes a string. + raise ImportError('Blessings needs Python 3.2.3 or greater for Python 3 ' + 'support due to http://bugs.python.org/issue10570.') + + +from .terminal import Terminal + +__all__ = ('Terminal',) diff --git a/blessings/_binterms.py b/blessings/_binterms.py new file mode 100644 index 0000000..6c93ee0 --- /dev/null +++ b/blessings/_binterms.py @@ -0,0 +1,872 @@ +""" Exports a list of binary terminals blessings is not able to cope with. """ +#: This list of terminals is manually managed, it describes all of the terminals +#: that blessings cannot measure the sequence length for; they contain +#: binary-packed capabilities instead of numerics, so it is not possible to +#: build regular expressions in the way that sequences.py does. +#: +#: This may be generated by exporting TEST_BINTERMS, then analyzing the +#: jUnit result xml written to the project folder. +binary_terminals = u""" +9term +aaa+dec +aaa+rv +aaa+rv-100 +aaa+rv-30 +aaa-rv-unk +abm80 +abm85 +abm85e +abm85h +abm85h-old +act4 +act5 +addrinfo +adds980 +adm+sgr +adm+sgr-100 +adm+sgr-30 +adm11 +adm1178 +adm12 +adm1a +adm2 +adm20 +adm21 +adm22 +adm3 +adm31 +adm31-old +adm3a +adm3a+ +adm42 +adm42-ns +adm5 +aepro +aj510 +aj830 +alto-h19 +altos4 +altos7 +altos7pc +ampex175 +ampex175-b +ampex210 +ampex232 +ampex232w +ampex80 +annarbor4080 +ansi+arrows +ansi+csr +ansi+cup +ansi+enq +ansi+erase +ansi+idc +ansi+idl +ansi+idl1 +ansi+inittabs +ansi+local +ansi+local1 +ansi+pp +ansi+rca +ansi+rep +ansi+sgr +ansi+sgrbold +ansi+sgrdim +ansi+sgrso +ansi+sgrul +ansi+tabs +ansi-color-2-emx +ansi-color-3-emx +ansi-emx +ansi-mini +ansi-mr +ansi-mtabs +ansi-nt +ansi.sys +ansi.sys-old +ansi.sysk +ansi77 +apollo +apple-80 +apple-ae +apple-soroc +apple-uterm +apple-uterm-vb +apple-videx +apple-videx2 +apple-videx3 +apple-vm80 +apple2e +apple2e-p +apple80p +appleII +appleIIgs +atari +att4415+nl +att4420 +att4424m +att5310 +att5310-100 +att5310-30 +att5620-s +avatar +avatar0 +avatar0+ +avt+s +aws +awsc +bantam +basis +beacon +beehive +blit +bq300-8 +bq300-8-pc +bq300-8-pc-rv +bq300-8-pc-w +bq300-8-pc-w-rv +bq300-8rv +bq300-8w +bq300-w-8rv +c100 +c100-rv +c108 +c108-4p +c108-rv +c108-rv-4p +c108-w +ca22851 +cbblit +cbunix +cci +cci-100 +cci-30 +cdc456 +cdc721 +cdc721-esc +cdc721-esc-100 +cdc721-esc-30 +cdc721ll +cdc752 +cdc756 +cit101e +cit101e-132 +cit101e-n +cit101e-n132 +cit80 +citoh +citoh-6lpi +citoh-8lpi +citoh-comp +citoh-elite +citoh-pica +citoh-prop +coco3 +color_xterm +commodore +contel300 +contel301 +cops10 +ct8500 +ctrm +ctrm-100 +ctrm-30 +cyb110 +cyb83 +d132 +d200 +d200-100 +d200-30 +d210-dg +d210-dg-100 +d210-dg-30 +d211-dg +d211-dg-100 +d211-dg-30 +d216-dg +d216-dg-100 +d216-dg-30 +d216-unix +d216-unix-25 +d217-unix +d217-unix-25 +d220 +d220-100 +d220-30 +d220-7b +d220-7b-100 +d220-7b-30 +d220-dg +d220-dg-100 +d220-dg-30 +d230c +d230c-100 +d230c-30 +d230c-dg +d230c-dg-100 +d230c-dg-30 +d400 +d400-100 +d400-30 +d410-dg +d410-dg-100 +d410-dg-30 +d412-dg +d412-dg-100 +d412-dg-30 +d412-unix +d412-unix-25 +d412-unix-s +d412-unix-sr +d412-unix-w +d413-unix +d413-unix-25 +d413-unix-s +d413-unix-sr +d413-unix-w +d414-unix +d414-unix-25 +d414-unix-s +d414-unix-sr +d414-unix-w +d430c-dg +d430c-dg-100 +d430c-dg-30 +d430c-dg-ccc +d430c-dg-ccc-100 +d430c-dg-ccc-30 +d430c-unix +d430c-unix-100 +d430c-unix-25 +d430c-unix-25-100 +d430c-unix-25-30 +d430c-unix-25-ccc +d430c-unix-30 +d430c-unix-ccc +d430c-unix-s +d430c-unix-s-ccc +d430c-unix-sr +d430c-unix-sr-ccc +d430c-unix-w +d430c-unix-w-ccc +d470c +d470c-7b +d470c-dg +d555-dg +d577-dg +d800 +delta +dg+ccc +dg+color +dg+color8 +dg+fixed +dg-generic +dg200 +dg210 +dg211 +dg450 +dg460-ansi +dg6053 +dg6053-old +dgkeys+11 +dgkeys+15 +dgkeys+7b +dgkeys+8b +dgmode+color +dgmode+color8 +dgunix+ccc +dgunix+fixed +diablo1620 +diablo1620-m8 +diablo1640 +diablo1640-lm +diablo1740-lm +digilog +djgpp203 +dm1520 +dm2500 +dm3025 +dm3045 +dmchat +dmterm +dp8242 +dt100 +dt100w +dt110 +dt80-sas +dtc300s +dtc382 +dumb +dw1 +dw2 +dw3 +dw4 +dwk +ecma+color +ecma+sgr +elks +elks-glasstty +elks-vt52 +emu +ep40 +ep48 +esprit +esprit-am +ex155 +f100 +f100-rv +f110 +f110-14 +f110-14w +f110-w +f200 +f200-w +f200vi +f200vi-w +falco +falco-p +fos +fox +gator-52 +gator-52t +glasstty +gnome +gnome+pcfkeys +gnome-2007 +gnome-2008 +gnome-256color +gnome-fc5 +gnome-rh72 +gnome-rh80 +gnome-rh90 +go140 +go140w +gs6300 +gsi +gt40 +gt42 +guru+rv +guru+s +h19 +h19-bs +h19-g +h19-u +h19-us +h19k +ha8675 +ha8686 +hft-old +hmod1 +hp+arrows +hp+color +hp+labels +hp+pfk+arrows +hp+pfk+cr +hp+pfk-cr +hp+printer +hp2 +hp236 +hp262x +hp2641a +hp300h +hp700-wy +hp70092 +hp9837 +hp9845 +hpterm +hpterm-color +hz1000 +hz1420 +hz1500 +hz1510 +hz1520 +hz1520-noesc +hz1552 +hz1552-rv +hz2000 +i100 +i400 +ibcs2 +ibm+16color +ibm+color +ibm-apl +ibm-system1 +ibm3101 +ibm3151 +ibm3161 +ibm3161-C +ibm3162 +ibm3164 +ibm327x +ibm5081-c +ibm8514-c +ibmaed +ibmapa8c +ibmapa8c-c +ibmega +ibmega-c +ibmmono +ibmvga +ibmvga-c +icl6404 +icl6404-w +ifmr +ims-ansi +ims950 +ims950-b +ims950-rv +intertube +intertube2 +intext +intext2 +kaypro +kermit +kermit-am +klone+acs +klone+color +klone+koi8acs +klone+sgr +klone+sgr-dumb +klone+sgr8 +konsole +konsole+pcfkeys +konsole-16color +konsole-256color +konsole-base +konsole-linux +konsole-solaris +konsole-vt100 +konsole-vt420pc +konsole-xf3x +konsole-xf4x +kt7 +kt7ix +ln03 +ln03-w +lpr +luna +megatek +mgterm +microb +mime +mime-fb +mime-hb +mime2a +mime2a-s +mime314 +mime3a +mime3ax +minitel1 +minitel1b +mlterm+pcfkeys +mm340 +modgraph2 +msk227 +msk22714 +msk227am +mt70 +ncr160vppp +ncr160vpwpp +ncr160wy50+pp +ncr160wy50+wpp +ncr160wy60pp +ncr160wy60wpp +ncr260vppp +ncr260vpwpp +ncr260wy325pp +ncr260wy325wpp +ncr260wy350pp +ncr260wy350wpp +ncr260wy50+pp +ncr260wy50+wpp +ncr260wy60pp +ncr260wy60wpp +ncr7900i +ncr7900iv +ncr7901 +ncrvt100an +ncrvt100wan +ndr9500 +ndr9500-25 +ndr9500-25-mc +ndr9500-25-mc-nl +ndr9500-25-nl +ndr9500-mc +ndr9500-mc-nl +ndr9500-nl +nec5520 +newhpkeyboard +nextshell +northstar +nsterm+c +nsterm+c41 +nsterm+s +nwp511 +oblit +oc100 +oldpc3 +origpc3 +osborne +osborne-w +osexec +otek4112 +owl +p19 +pc-coherent +pc-venix +pc6300plus +pcix +pckermit +pckermit120 +pe1251 +pe7000c +pe7000m +pilot +pmcons +prism2 +prism4 +prism5 +pro350 +psterm-fast +psterm-fast-100 +psterm-fast-30 +pt100 +pt210 +pt250 +pty +qansi +qansi-g +qansi-m +qansi-t +qansi-w +qdss +qnx +qnx-100 +qnx-30 +qnxm +qnxm-100 +qnxm-30 +qnxt +qnxt-100 +qnxt-30 +qnxt2 +qnxtmono +qnxtmono-100 +qnxtmono-30 +qnxw +qnxw-100 +qnxw-30 +qume5 +qvt101 +qvt101+ +qvt102 +qvt119+ +qvt119+-25 +qvt119+-25-w +qvt119+-w +rbcomm +rbcomm-nam +rbcomm-w +rca +regent100 +regent20 +regent25 +regent40 +regent40+ +regent60 +rt6221 +rt6221-w +rtpc +rxvt+pcfkeys +scanset +screen+fkeys +screen-16color +screen-16color-bce +screen-16color-bce-s +screen-16color-s +screen-256color +screen-256color-bce +screen-256color-bce-s +screen-256color-s +screen-bce +screen-s +screen-w +screen.linux +screen.rxvt +screen.teraterm +screen.xterm-r6 +screen2 +screen3 +screwpoint +sibo +simterm +soroc120 +soroc140 +st52 +superbee-xsb +superbeeic +superbrain +swtp +synertek +t10 +t1061 +t1061f +t3700 +t3800 +tandem6510 +tandem653 +tandem653-100 +tandem653-30 +tek +tek4013 +tek4014 +tek4014-sm +tek4015 +tek4015-sm +tek4023 +tek4105 +tek4107 +tek4113-nd +tek4205 +tek4205-100 +tek4205-30 +tek4207-s +teraterm +teraterm2.3 +teraterm4.59 +terminet1200 +ti700 +ti931 +trs16 +trs2 +tt +tty33 +tty37 +tty43 +tvi803 +tvi9065 +tvi910 +tvi910+ +tvi912 +tvi912b +tvi912b+2p +tvi912b+dim +tvi912b+dim-100 +tvi912b+dim-30 +tvi912b+mc +tvi912b+mc-100 +tvi912b+mc-30 +tvi912b+printer +tvi912b+vb +tvi912b-2p +tvi912b-2p-mc +tvi912b-2p-mc-100 +tvi912b-2p-mc-30 +tvi912b-2p-p +tvi912b-2p-unk +tvi912b-mc +tvi912b-mc-100 +tvi912b-mc-30 +tvi912b-p +tvi912b-unk +tvi912b-vb +tvi912b-vb-mc +tvi912b-vb-mc-100 +tvi912b-vb-mc-30 +tvi912b-vb-p +tvi912b-vb-unk +tvi920b +tvi920b+fn +tvi920b-2p +tvi920b-2p-mc +tvi920b-2p-mc-100 +tvi920b-2p-mc-30 +tvi920b-2p-p +tvi920b-2p-unk +tvi920b-mc +tvi920b-mc-100 +tvi920b-mc-30 +tvi920b-p +tvi920b-unk +tvi920b-vb +tvi920b-vb-mc +tvi920b-vb-mc-100 +tvi920b-vb-mc-30 +tvi920b-vb-p +tvi920b-vb-unk +tvi921 +tvi924 +tvi925 +tvi925-hi +tvi92B +tvi92D +tvi950 +tvi950-2p +tvi950-4p +tvi950-rv +tvi950-rv-2p +tvi950-rv-4p +tvipt +unknown +vanilla +vc303 +vc404 +vc404-s +vc414 +vc415 +vi200 +vi200-f +vi200-rv +vi50 +vi500 +vi50adm +vi55 +viewpoint +vp3a+ +vp60 +vp90 +vremote +vt100+enq +vt100+fnkeys +vt100+keypad +vt100+pfkeys +vt100-s +vt102+enq +vt200-js +vt220+keypad +vt50h +vt52 +vt61 +wsiris +wy100 +wy100q +wy120 +wy120-25 +wy120-vb +wy160 +wy160-25 +wy160-42 +wy160-43 +wy160-tek +wy160-tek-100 +wy160-tek-30 +wy160-vb +wy30 +wy30-mc +wy30-mc-100 +wy30-mc-30 +wy30-vb +wy325 +wy325-25 +wy325-42 +wy325-43 +wy325-vb +wy350 +wy350-100 +wy350-30 +wy350-vb +wy350-vb-100 +wy350-vb-30 +wy350-w +wy350-w-100 +wy350-w-30 +wy350-wvb +wy350-wvb-100 +wy350-wvb-30 +wy370 +wy370-100 +wy370-105k +wy370-105k-100 +wy370-105k-30 +wy370-30 +wy370-EPC +wy370-EPC-100 +wy370-EPC-30 +wy370-nk +wy370-nk-100 +wy370-nk-30 +wy370-rv +wy370-rv-100 +wy370-rv-30 +wy370-tek +wy370-tek-100 +wy370-tek-30 +wy370-vb +wy370-vb-100 +wy370-vb-30 +wy370-w +wy370-w-100 +wy370-w-30 +wy370-wvb +wy370-wvb-100 +wy370-wvb-30 +wy50 +wy50-mc +wy50-mc-100 +wy50-mc-30 +wy50-vb +wy60 +wy60-25 +wy60-42 +wy60-43 +wy60-vb +wy99-ansi +wy99a-ansi +wy99f +wy99fa +wy99gt +wy99gt-25 +wy99gt-vb +wy99gt-tek +wyse-vp +xerox1720 +xerox820 +xfce +xnuppc+100x37 +xnuppc+112x37 +xnuppc+128x40 +xnuppc+128x48 +xnuppc+144x48 +xnuppc+160x64 +xnuppc+200x64 +xnuppc+200x75 +xnuppc+256x96 +xnuppc+80x25 +xnuppc+80x30 +xnuppc+90x30 +xnuppc+c +xnuppc+c-100 +xnuppc+c-30 +xtalk +xtalk-100 +xtalk-30 +xterm+256color +xterm+256color-100 +xterm+256color-30 +xterm+88color +xterm+88color-100 +xterm+88color-30 +xterm+app +xterm+edit +xterm+noapp +xterm+pc+edit +xterm+pcc0 +xterm+pcc1 +xterm+pcc2 +xterm+pcc3 +xterm+pce2 +xterm+pcf0 +xterm+pcf2 +xterm+pcfkeys +xterm+r6f2 +xterm+vt+edit +xterm-vt52 +z100 +z100bw +z29 +zen30 +zen50 +ztx +""".split() + +__all__ = ('binary_terminals',) diff --git a/blessings/formatters.py b/blessings/formatters.py new file mode 100644 index 0000000..54f090c --- /dev/null +++ b/blessings/formatters.py @@ -0,0 +1,332 @@ +"This sub-module provides formatting functions." +import curses +import sys + +_derivatives = ('on', 'bright', 'on_bright',) + +_colors = set('black red green yellow blue magenta cyan white'.split()) +_compoundables = set('bold underline reverse blink dim italic shadow ' + 'standout subscript superscript'.split()) + +#: Valid colors and their background (on), bright, and bright-bg derivatives. +COLORS = set(['_'.join((derivitive, color)) + for derivitive in _derivatives + for color in _colors]) | _colors + +#: All valid compoundable names. +COMPOUNDABLES = (COLORS | _compoundables) + +if sys.version_info[0] == 3: + text_type = str + basestring = str +else: + text_type = unicode # noqa + + +class ParameterizingString(text_type): + """A Unicode string which can be called as a parameterizing termcap. + + For example:: + + >> term = Terminal() + >> color = ParameterizingString(term.color, term.normal, 'color') + >> color(9)('color #9') + u'\x1b[91mcolor #9\x1b(B\x1b[m' + """ + + def __new__(cls, *args): + """P.__new__(cls, cap, [normal, [name]]) + + :arg cap: parameterized string suitable for curses.tparm() + :arg normal: terminating sequence for this capability. + :arg name: name of this terminal capability. + """ + assert len(args) and len(args) < 4, args + new = text_type.__new__(cls, args[0]) + new._normal = len(args) > 1 and args[1] or u'' + new._name = len(args) > 2 and args[2] or u'<not specified>' + return new + + def __call__(self, *args): + """P(*args) -> FormattingString() + + Return evaluated terminal capability (self), receiving arguments + ``*args``, followed by the terminating sequence (self.normal) into + a FormattingString capable of being called. + """ + try: + # Re-encode the cap, because tparm() takes a bytestring in Python + # 3. However, appear to be a plain Unicode string otherwise so + # concats work. + attr = curses.tparm(self.encode('latin1'), *args).decode('latin1') + return FormattingString(attr, self._normal) + except TypeError as err: + # If the first non-int (i.e. incorrect) arg was a string, suggest + # something intelligent: + if len(args) and isinstance(args[0], basestring): + raise TypeError( + "A native or nonexistent capability template, %r received" + " invalid argument %r: %s. You probably misspelled a" + " formatting call like `bright_red'" % ( + self._name, args, err)) + # Somebody passed a non-string; I don't feel confident + # guessing what they were trying to do. + raise + except curses.error as err: + # ignore 'tparm() returned NULL', you won't get any styling, + # even if does_styling is True. This happens on win32 platforms + # with http://www.lfd.uci.edu/~gohlke/pythonlibs/#curses installed + if "tparm() returned NULL" not in text_type(err): + raise + return NullCallableString() + + +class ParameterizingProxyString(text_type): + """A Unicode string which can be called to proxy missing termcap entries. + + For example:: + + >>> from blessings import Terminal + >>> term = Terminal('screen') + >>> hpa = ParameterizingString(term.hpa, term.normal, 'hpa') + >>> hpa(9) + u'' + >>> fmt = u'\x1b[{0}G' + >>> fmt_arg = lambda *arg: (arg[0] + 1,) + >>> hpa = ParameterizingProxyString((fmt, fmt_arg), term.normal, 'hpa') + >>> hpa(9) + u'\x1b[10G' + """ + + def __new__(cls, *args): + """P.__new__(cls, (fmt, callable), [normal, [name]]) + + :arg fmt: format string suitable for displaying terminal sequences. + :arg callable: receives __call__ arguments for formatting fmt. + :arg normal: terminating sequence for this capability. + :arg name: name of this terminal capability. + """ + assert len(args) and len(args) < 4, args + assert type(args[0]) is tuple, args[0] + assert callable(args[0][1]), args[0][1] + new = text_type.__new__(cls, args[0][0]) + new._fmt_args = args[0][1] + new._normal = len(args) > 1 and args[1] or u'' + new._name = len(args) > 2 and args[2] or u'<not specified>' + return new + + def __call__(self, *args): + """P(*args) -> FormattingString() + + Return evaluated terminal capability format, (self), using callable + ``self._fmt_args`` receiving arguments ``*args``, followed by the + terminating sequence (self.normal) into a FormattingString capable + of being called. + """ + return FormattingString(self.format(*self._fmt_args(*args)), + self._normal) + + +def get_proxy_string(term, attr): + """ Proxy and return callable StringClass for proxied attributes. + + We know that some kinds of terminal kinds support sequences that the + terminfo database always report -- such as the 'move_x' attribute for + terminal type 'screen' and 'ansi', or 'hide_cursor' for 'ansi'. + + Returns instance of ParameterizingProxyString or NullCallableString. + """ + # normalize 'screen-256color', or 'ansi.sys' to its basic names + term_kind = next(iter(_kind for _kind in ('screen', 'ansi',) + if term.kind.startswith(_kind)), term) + return { + 'screen': { + # proxy move_x/move_y for 'screen' terminal type. + 'hpa': ParameterizingProxyString( + (u'\x1b[{0}G', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'vpa': ParameterizingProxyString( + (u'\x1b[{0}d', lambda *arg: (arg[0] + 1,)), term.normal, attr), + }, + 'ansi': { + # proxy show/hide cursor for 'ansi' terminal type. + 'civis': ParameterizingProxyString( + (u'\x1b[?25l', lambda *arg: ()), term.normal, attr), + 'cnorm': ParameterizingProxyString( + (u'\x1b[?25h', lambda *arg: ()), term.normal, attr), + 'hpa': ParameterizingProxyString( + (u'\x1b[{0}G', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'vpa': ParameterizingProxyString( + (u'\x1b[{0}d', lambda *arg: (arg[0] + 1,)), term.normal, attr), + } + }.get(term_kind, {}).get(attr, None) + + +class FormattingString(text_type): + """A Unicode string which can be called using ``text``, + returning a new string, ``attr`` + ``text`` + ``normal``:: + + >> style = FormattingString(term.bright_blue, term.normal) + >> style('Big Blue') + u'\x1b[94mBig Blue\x1b(B\x1b[m' + """ + + def __new__(cls, *args): + """P.__new__(cls, sequence, [normal]) + :arg sequence: terminal attribute sequence. + :arg normal: terminating sequence for this attribute. + """ + assert 1 <= len(args) <= 2, args + new = text_type.__new__(cls, args[0]) + new._normal = len(args) > 1 and args[1] or u'' + return new + + def __call__(self, text): + """P(text) -> unicode + + Return string ``text``, joined by specified video attribute, + (self), and followed by reset attribute sequence (term.normal). + """ + if len(self): + return u''.join((self, text, self._normal)) + return text + + +class NullCallableString(text_type): + """A dummy callable Unicode to stand in for ``FormattingString`` and + ``ParameterizingString`` for terminals that cannot perform styling. + """ + def __new__(cls): + new = text_type.__new__(cls, u'') + return new + + def __call__(self, *args): + """Return a Unicode or whatever you passed in as the first arg + (hopefully a string of some kind). + + When called with an int as the first arg, return an empty Unicode. An + int is a good hint that I am a ``ParameterizingString``, as there are + only about half a dozen string-returning capabilities listed in + terminfo(5) which accept non-int arguments, they are seldom used. + + When called with a non-int as the first arg (no no args at all), return + the first arg, acting in place of ``FormattingString`` without + any attributes. + """ + if len(args) != 1 or isinstance(args[0], int): + # I am acting as a ParameterizingString. + + # tparm can take not only ints but also (at least) strings as its + # 2nd...nth argument. But we don't support callable parameterizing + # capabilities that take non-ints yet, so we can cheap out here. + + # TODO(erikrose): Go through enough of the motions in the + # capability resolvers to determine which of 2 special-purpose + # classes, NullParameterizableString or NullFormattingString, + # to return, and retire this one. + + # As a NullCallableString, even when provided with a parameter, + # such as t.color(5), we must also still be callable, fe: + # + # >>> t.color(5)('shmoo') + # + # is actually simplified result of NullCallable()() on terminals + # without color support, so turtles all the way down: we return + # another instance. + return NullCallableString() + return args[0] + + +def split_compound(compound): + """Split a possibly compound format string into segments. + + >>> split_compound('bold_underline_bright_blue_on_red') + ['bold', 'underline', 'bright_blue', 'on_red'] + + """ + merged_segs = [] + # These occur only as prefixes, so they can always be merged: + mergeable_prefixes = ['on', 'bright', 'on_bright'] + for s in compound.split('_'): + if merged_segs and merged_segs[-1] in mergeable_prefixes: + merged_segs[-1] += '_' + s + else: + merged_segs.append(s) + return merged_segs + + +def resolve_capability(term, attr): + """Return a Unicode string for the terminal capability ``attr``, + or an empty string if not found, or if terminal is without styling + capabilities. + """ + # Decode sequences as latin1, as they are always 8-bit bytes, so when + # b'\xff' is returned, this must be decoded to u'\xff'. + if not term.does_styling: + return u'' + val = curses.tigetstr(term._sugar.get(attr, attr)) + return u'' if val is None else val.decode('latin1') + + +def resolve_color(term, color): + """resolve_color(T, color) -> FormattingString() + + Resolve a ``color`` name to callable capability, ``FormattingString`` + unless ``term.number_of_colors`` is 0, then ``NullCallableString``. + + Valid ``color`` capabilities names are any of the simple color + names, such as ``red``, or compounded, such as ``on_bright_green``. + """ + # NOTE(erikrose): Does curses automatically exchange red and blue and cyan + # and yellow when a terminal supports setf/setb rather than setaf/setab? + # I'll be blasted if I can find any documentation. The following + # assumes it does. + color_cap = (term._background_color if 'on_' in color else + term._foreground_color) + + # curses constants go up to only 7, so add an offset to get at the + # bright colors at 8-15: + offset = 8 if 'bright_' in color else 0 + base_color = color.rsplit('_', 1)[-1] + if term.number_of_colors == 0: + return NullCallableString() + + attr = 'COLOR_%s' % (base_color.upper(),) + fmt_attr = color_cap(getattr(curses, attr) + offset) + return FormattingString(fmt_attr, term.normal) + + +def resolve_attribute(term, attr): + """Resolve a sugary or plain capability name, color, or compound + formatting name into a *callable* unicode string capability, + ``ParameterizingString`` or ``FormattingString``. + """ + # A simple color, such as `red' or `blue'. + if attr in COLORS: + return resolve_color(term, attr) + + # A direct compoundable, such as `bold' or `on_red'. + if attr in COMPOUNDABLES: + sequence = resolve_capability(term, attr) + return FormattingString(sequence, term.normal) + + # Given `bold_on_red', resolve to ('bold', 'on_red'), RECURSIVE + # call for each compounding section, joined and returned as + # a completed completed FormattingString. + formatters = split_compound(attr) + if all(fmt in COMPOUNDABLES for fmt in formatters): + resolution = (resolve_attribute(term, fmt) for fmt in formatters) + return FormattingString(u''.join(resolution), term.normal) + else: + # otherwise, this is our end-game: given a sequence such as 'csr' + # (change scrolling region), return a ParameterizingString instance, + # that when called, performs and returns the final string after curses + # capability lookup is performed. + tparm_capseq = resolve_capability(term, attr) + if not tparm_capseq: + # and, for special terminals, such as 'screen', provide a Proxy + # ParameterizingString for attributes they do not claim to support, + # but actually do! (such as 'hpa' and 'vpa'). + proxy = get_proxy_string(term, term._sugar.get(attr, attr)) + if proxy is not None: + return proxy + return ParameterizingString(tparm_capseq, term.normal, attr) diff --git a/blessings/keyboard.py b/blessings/keyboard.py new file mode 100644 index 0000000..280606d --- /dev/null +++ b/blessings/keyboard.py @@ -0,0 +1,276 @@ +"This sub-module provides 'keyboard awareness'." + +__author__ = 'Jeff Quast <contact@jeffquast.com>' +__license__ = 'MIT' + +__all__ = ('Keystroke', 'get_keyboard_codes', 'get_keyboard_sequences',) + +import curses.has_key +import collections +import curses +import sys + +if hasattr(collections, 'OrderedDict'): + OrderedDict = collections.OrderedDict +else: + # python 2.6 requires 3rd party library + import ordereddict + OrderedDict = ordereddict.OrderedDict + +get_curses_keycodes = lambda: dict( + ((keyname, getattr(curses, keyname)) + for keyname in dir(curses) + if keyname.startswith('KEY_')) +) + +# override a few curses constants with easier mnemonics, +# there may only be a 1:1 mapping, so for those who desire +# to use 'KEY_DC' from, perhaps, ported code, recommend +# that they simply compare with curses.KEY_DC. +CURSES_KEYCODE_OVERRIDE_MIXIN = ( + ('KEY_DELETE', curses.KEY_DC), + ('KEY_INSERT', curses.KEY_IC), + ('KEY_PGUP', curses.KEY_PPAGE), + ('KEY_PGDOWN', curses.KEY_NPAGE), + ('KEY_ESCAPE', curses.KEY_EXIT), + ('KEY_SUP', curses.KEY_SR), + ('KEY_SDOWN', curses.KEY_SF), + ('KEY_UP_LEFT', curses.KEY_A1), + ('KEY_UP_RIGHT', curses.KEY_A3), + ('KEY_CENTER', curses.KEY_B2), + ('KEY_BEGIN', curses.KEY_BEG), +) + +# Inject KEY_{names} that we think would be useful, there are no curses +# definitions for the keypad keys. We need keys that generate multibyte +# sequences, though it is useful to have some aliases for basic control +# characters such as TAB. +_lastval = max(get_curses_keycodes().values()) +for key in ('TAB', 'KP_MULTIPLY', 'KP_ADD', 'KP_SEPARATOR', 'KP_SUBTRACT', + 'KP_DECIMAL', 'KP_DIVIDE', 'KP_EQUAL', 'KP_0', 'KP_1', 'KP_2', + 'KP_3', 'KP_4', 'KP_5', 'KP_6', 'KP_7', 'KP_8', 'KP_9'): + _lastval += 1 + setattr(curses, 'KEY_{0}'.format(key), _lastval) + +if sys.version_info[0] == 3: + text_type = str + unichr = chr +else: + text_type = unicode # noqa + + +class Keystroke(text_type): + """A unicode-derived class for describing keyboard input returned by + the ``inkey()`` method of ``Terminal``, which may, at times, be a + multibyte sequence, providing properties ``is_sequence`` as ``True`` + when the string is a known sequence, and ``code``, which returns an + integer value that may be compared against the terminal class attributes + such as ``KEY_LEFT``. + """ + def __new__(cls, ucs='', code=None, name=None): + new = text_type.__new__(cls, ucs) + new._name = name + new._code = code + return new + + @property + def is_sequence(self): + "Whether the value represents a multibyte sequence (bool)." + return self._code is not None + + def __repr__(self): + return self._name is None and text_type.__repr__(self) or self._name + __repr__.__doc__ = text_type.__doc__ + + @property + def name(self): + "String-name of key sequence, such as ``'KEY_LEFT'`` (str)." + return self._name + + @property + def code(self): + "Integer keycode value of multibyte sequence (int)." + return self._code + + +def get_keyboard_codes(): + """get_keyboard_codes() -> dict + + Returns dictionary of (code, name) pairs for curses keyboard constant + values and their mnemonic name. Such as key ``260``, with the value of + its identity, ``KEY_LEFT``. These are derived from the attributes by the + same of the curses module, with the following exceptions: + + * ``KEY_DELETE`` in place of ``KEY_DC`` + * ``KEY_INSERT`` in place of ``KEY_IC`` + * ``KEY_PGUP`` in place of ``KEY_PPAGE`` + * ``KEY_PGDOWN`` in place of ``KEY_NPAGE`` + * ``KEY_ESCAPE`` in place of ``KEY_EXIT`` + * ``KEY_SUP`` in place of ``KEY_SR`` + * ``KEY_SDOWN`` in place of ``KEY_SF`` + """ + keycodes = OrderedDict(get_curses_keycodes()) + keycodes.update(CURSES_KEYCODE_OVERRIDE_MIXIN) + + # invert dictionary (key, values) => (values, key), preferring the + # last-most inserted value ('KEY_DELETE' over 'KEY_DC'). + return dict(zip(keycodes.values(), keycodes.keys())) + + +def _alternative_left_right(term): + """_alternative_left_right(T) -> dict + + Return dict of sequences ``term._cuf1``, and ``term._cub1``, + valued as ``KEY_RIGHT``, ``KEY_LEFT`` when appropriate if available. + + some terminals report a different value for *kcuf1* than *cuf1*, but + actually send the value of *cuf1* for right arrow key (which is + non-destructive space). + """ + keymap = dict() + if term._cuf1 and term._cuf1 != u' ': + keymap[term._cuf1] = curses.KEY_RIGHT + if term._cub1 and term._cub1 != u'\b': + keymap[term._cub1] = curses.KEY_LEFT + return keymap + + +def get_keyboard_sequences(term): + """get_keyboard_sequences(T) -> (OrderedDict) + + Initialize and return a keyboard map and sequence lookup table, + (sequence, constant) from blessings Terminal instance ``term``, + where ``sequence`` is a multibyte input sequence, such as u'\x1b[D', + and ``constant`` is a constant, such as term.KEY_LEFT. The return + value is an OrderedDict instance, with their keys sorted longest-first. + """ + # A small gem from curses.has_key that makes this all possible, + # _capability_names: a lookup table of terminal capability names for + # keyboard sequences (fe. kcub1, key_left), keyed by the values of + # constants found beginning with KEY_ in the main curses module + # (such as KEY_LEFT). + # + # latin1 encoding is used so that bytes in 8-bit range of 127-255 + # have equivalent chr() and unichr() values, so that the sequence + # of a kermit or avatar terminal, for example, remains unchanged + # in its byte sequence values even when represented by unicode. + # + capability_names = curses.has_key._capability_names + sequence_map = dict(( + (seq.decode('latin1'), val) + for (seq, val) in ( + (curses.tigetstr(cap), val) + for (val, cap) in capability_names.items() + ) if seq + ) if term.does_styling else ()) + + sequence_map.update(_alternative_left_right(term)) + sequence_map.update(DEFAULT_SEQUENCE_MIXIN) + + # This is for fast lookup matching of sequences, preferring + # full-length sequence such as ('\x1b[D', KEY_LEFT) + # over simple sequences such as ('\x1b', KEY_EXIT). + return OrderedDict(( + (seq, sequence_map[seq]) for seq in sorted( + sequence_map.keys(), key=len, reverse=True))) + + +def resolve_sequence(text, mapper, codes): + """resolve_sequence(text, mapper, codes) -> Keystroke() + + Returns first matching Keystroke() instance for sequences found in + ``mapper`` beginning with input ``text``, where ``mapper`` is an + OrderedDict of unicode multibyte sequences, such as u'\x1b[D' paired by + their integer value (260), and ``codes`` is a dict of integer values (260) + paired by their mnemonic name, 'KEY_LEFT'. + """ + for sequence, code in mapper.items(): + if text.startswith(sequence): + return Keystroke(ucs=sequence, code=code, name=codes[code]) + return Keystroke(ucs=text and text[0] or u'') + +"""In a perfect world, terminal emulators would always send exactly what +the terminfo(5) capability database plans for them, accordingly by the +value of the ``TERM`` name they declare. + +But this isn't a perfect world. Many vt220-derived terminals, such as +those declaring 'xterm', will continue to send vt220 codes instead of +their native-declared codes, for backwards-compatibility. + +This goes for many: rxvt, putty, iTerm. + +These "mixins" are used for *all* terminals, regardless of their type. + +Furthermore, curses does not provide sequences sent by the keypad, +at least, it does not provide a way to distinguish between keypad 0 +and numeric 0. +""" +DEFAULT_SEQUENCE_MIXIN = ( + # these common control characters (and 127, ctrl+'?') mapped to + # an application key definition. + (unichr(10), curses.KEY_ENTER), + (unichr(13), curses.KEY_ENTER), + (unichr(8), curses.KEY_BACKSPACE), + (unichr(9), curses.KEY_TAB), + (unichr(27), curses.KEY_EXIT), + (unichr(127), curses.KEY_DC), + + (u"\x1b[A", curses.KEY_UP), + (u"\x1b[B", curses.KEY_DOWN), + (u"\x1b[C", curses.KEY_RIGHT), + (u"\x1b[D", curses.KEY_LEFT), + (u"\x1b[F", curses.KEY_END), + (u"\x1b[H", curses.KEY_HOME), + # not sure where these are from .. please report + (u"\x1b[K", curses.KEY_END), + (u"\x1b[U", curses.KEY_NPAGE), + (u"\x1b[V", curses.KEY_PPAGE), + + # keys sent after term.smkx (keypad_xmit) is emitted, source: + # http://www.xfree86.org/current/ctlseqs.html#PC-Style%20Function%20Keys + # http://fossies.org/linux/rxvt/doc/rxvtRef.html#KeyCodes + # + # keypad, numlock on + (u"\x1bOM", curses.KEY_ENTER), # return + (u"\x1bOj", curses.KEY_KP_MULTIPLY), # * + (u"\x1bOk", curses.KEY_KP_ADD), # + + (u"\x1bOl", curses.KEY_KP_SEPARATOR), # , + (u"\x1bOm", curses.KEY_KP_SUBTRACT), # - + (u"\x1bOn", curses.KEY_KP_DECIMAL), # . + (u"\x1bOo", curses.KEY_KP_DIVIDE), # / + (u"\x1bOX", curses.KEY_KP_EQUAL), # = + (u"\x1bOp", curses.KEY_KP_0), # 0 + (u"\x1bOq", curses.KEY_KP_1), # 1 + (u"\x1bOr", curses.KEY_KP_2), # 2 + (u"\x1bOs", curses.KEY_KP_3), # 3 + (u"\x1bOt", curses.KEY_KP_4), # 4 + (u"\x1bOu", curses.KEY_KP_5), # 5 + (u"\x1bOv", curses.KEY_KP_6), # 6 + (u"\x1bOw", curses.KEY_KP_7), # 7 + (u"\x1bOx", curses.KEY_KP_8), # 8 + (u"\x1bOy", curses.KEY_KP_9), # 9 + + # keypad, numlock off + (u"\x1b[1~", curses.KEY_FIND), # find + (u"\x1b[2~", curses.KEY_IC), # insert (0) + (u"\x1b[3~", curses.KEY_DC), # delete (.), "Execute" + (u"\x1b[4~", curses.KEY_SELECT), # select + (u"\x1b[5~", curses.KEY_PPAGE), # pgup (9) + (u"\x1b[6~", curses.KEY_NPAGE), # pgdown (3) + (u"\x1b[7~", curses.KEY_HOME), # home + (u"\x1b[8~", curses.KEY_END), # end + (u"\x1b[OA", curses.KEY_UP), # up (8) + (u"\x1b[OB", curses.KEY_DOWN), # down (2) + (u"\x1b[OC", curses.KEY_RIGHT), # right (6) + (u"\x1b[OD", curses.KEY_LEFT), # left (4) + (u"\x1b[OF", curses.KEY_END), # end (1) + (u"\x1b[OH", curses.KEY_HOME), # home (7) + + # The vt220 placed F1-F4 above the keypad, in place of actual + # F1-F4 were local functions (hold screen, print screen, + # set up, data/talk, break). + (u"\x1bOP", curses.KEY_F1), + (u"\x1bOQ", curses.KEY_F2), + (u"\x1bOR", curses.KEY_F3), + (u"\x1bOS", curses.KEY_F4), +) diff --git a/blessings/sequences.py b/blessings/sequences.py new file mode 100644 index 0000000..80649c3 --- /dev/null +++ b/blessings/sequences.py @@ -0,0 +1,688 @@ +# encoding: utf-8 +" This sub-module provides 'sequence awareness' for blessings." + +__author__ = 'Jeff Quast <contact@jeffquast.com>' +__license__ = 'MIT' + +__all__ = ('init_sequence_patterns', 'Sequence', 'SequenceTextWrapper',) + +# built-ins +import functools +import textwrap +import warnings +import math +import sys +import re + +# local +from ._binterms import binary_terminals as _BINTERM_UNSUPPORTED + +# 3rd-party +import wcwidth # https://github.com/jquast/wcwidth + +_BINTERM_UNSUPPORTED_MSG = ( + u"Terminal kind {0!r} contains binary-packed capabilities, blessings " + u"is likely to fail to measure the length of its sequences.") + +if sys.version_info[0] == 3: + text_type = str +else: + text_type = unicode # noqa + + +def _merge_sequences(inp): + """Merge a list of input sequence patterns for use in a regular expression. + Order by lengthyness (full sequence set precedent over subset), + and exclude any empty (u'') sequences. + """ + return sorted(list(filter(None, inp)), key=len, reverse=True) + + +def _build_numeric_capability(term, cap, optional=False, + base_num=99, nparams=1): + """ Build regexp from capabilities having matching numeric + parameter contained within termcap value: n->(\d+). + """ + _cap = getattr(term, cap) + opt = '?' if optional else '' + if _cap: + args = (base_num,) * nparams + cap_re = re.escape(_cap(*args)) + for num in range(base_num - 1, base_num + 2): + # search for matching ascii, n-1 through n+1 + if str(num) in cap_re: + # modify & return n to matching digit expression + cap_re = cap_re.replace(str(num), r'(\d+)%s' % (opt,)) + return cap_re + warnings.warn('Unknown parameter in %r (%r, %r)' % (cap, _cap, cap_re)) + return None # no such capability + + +def _build_any_numeric_capability(term, cap, num=99, nparams=1): + """ Build regexp from capabilities having *any* digit parameters + (substitute matching \d with pattern \d and return). + """ + _cap = getattr(term, cap) + if _cap: + cap_re = re.escape(_cap(*((num,) * nparams))) + cap_re = re.sub('(\d+)', r'(\d+)', cap_re) + if r'(\d+)' in cap_re: + return cap_re + warnings.warn('Missing numerics in %r, %r' % (cap, cap_re)) + return None # no such capability + + +def get_movement_sequence_patterns(term): + """ Build and return set of regexp for capabilities of ``term`` known + to cause movement. + """ + bnc = functools.partial(_build_numeric_capability, term) + + return set([ + # carriage_return + re.escape(term.cr), + # column_address: Horizontal position, absolute + bnc(cap='hpa'), + # row_address: Vertical position #1 absolute + bnc(cap='vpa'), + # cursor_address: Move to row #1 columns #2 + bnc(cap='cup', nparams=2), + # cursor_down: Down one line + re.escape(term.cud1), + # cursor_home: Home cursor (if no cup) + re.escape(term.home), + # cursor_left: Move left one space + re.escape(term.cub1), + # cursor_right: Non-destructive space (move right one space) + re.escape(term.cuf1), + # cursor_up: Up one line + re.escape(term.cuu1), + # param_down_cursor: Down #1 lines + bnc(cap='cud', optional=True), + # restore_cursor: Restore cursor to position of last save_cursor + re.escape(term.rc), + # clear_screen: clear screen and home cursor + re.escape(term.clear), + # enter/exit_fullscreen: switch to alternate screen buffer + re.escape(term.enter_fullscreen), + re.escape(term.exit_fullscreen), + # forward cursor + term._cuf, + # backward cursor + term._cub, + ]) + + +def get_wontmove_sequence_patterns(term): + """ Build and return set of regexp for capabilities of ``term`` known + not to cause any movement. + """ + bnc = functools.partial(_build_numeric_capability, term) + bna = functools.partial(_build_any_numeric_capability, term) + + return list([ + # print_screen: Print contents of screen + re.escape(term.mc0), + # prtr_off: Turn off printer + re.escape(term.mc4), + # prtr_on: Turn on printer + re.escape(term.mc5), + # save_cursor: Save current cursor position (P) + re.escape(term.sc), + # set_tab: Set a tab in every row, current columns + re.escape(term.hts), + # enter_bold_mode: Turn on bold (extra bright) mode + re.escape(term.bold), + # enter_standout_mode + re.escape(term.standout), + # enter_subscript_mode + re.escape(term.subscript), + # enter_superscript_mode + re.escape(term.superscript), + # enter_underline_mode: Begin underline mode + re.escape(term.underline), + # enter_blink_mode: Turn on blinking + re.escape(term.blink), + # enter_dim_mode: Turn on half-bright mode + re.escape(term.dim), + # cursor_invisible: Make cursor invisible + re.escape(term.civis), + # cursor_visible: Make cursor very visible + re.escape(term.cvvis), + # cursor_normal: Make cursor appear normal (undo civis/cvvis) + re.escape(term.cnorm), + # clear_all_tabs: Clear all tab stops + re.escape(term.tbc), + # change_scroll_region: Change region to line #1 to line #2 + bnc(cap='csr', nparams=2), + # clr_bol: Clear to beginning of line + re.escape(term.el1), + # clr_eol: Clear to end of line + re.escape(term.el), + # clr_eos: Clear to end of screen + re.escape(term.clear_eos), + # delete_character: Delete character + re.escape(term.dch1), + # delete_line: Delete line (P*) + re.escape(term.dl1), + # erase_chars: Erase #1 characters + bnc(cap='ech'), + # insert_line: Insert line (P*) + re.escape(term.il1), + # parm_dch: Delete #1 characters + bnc(cap='dch'), + # parm_delete_line: Delete #1 lines + bnc(cap='dl'), + # exit_alt_charset_mode: End alternate character set (P) + re.escape(term.rmacs), + # exit_am_mode: Turn off automatic margins + re.escape(term.rmam), + # exit_attribute_mode: Turn off all attributes + re.escape(term.sgr0), + # exit_ca_mode: Strings to end programs using cup + re.escape(term.rmcup), + # exit_insert_mode: Exit insert mode + re.escape(term.rmir), + # exit_standout_mode: Exit standout mode + re.escape(term.rmso), + # exit_underline_mode: Exit underline mode + re.escape(term.rmul), + # flash_hook: Flash switch hook + re.escape(term.hook), + # flash_screen: Visible bell (may not move cursor) + re.escape(term.flash), + # keypad_local: Leave 'keyboard_transmit' mode + re.escape(term.rmkx), + # keypad_xmit: Enter 'keyboard_transmit' mode + re.escape(term.smkx), + # meta_off: Turn off meta mode + re.escape(term.rmm), + # meta_on: Turn on meta mode (8th-bit on) + re.escape(term.smm), + # orig_pair: Set default pair to its original value + re.escape(term.op), + # parm_ich: Insert #1 characters + bnc(cap='ich'), + # parm_index: Scroll forward #1 + bnc(cap='indn'), + # parm_insert_line: Insert #1 lines + bnc(cap='il'), + # erase_chars: Erase #1 characters + bnc(cap='ech'), + # parm_rindex: Scroll back #1 lines + bnc(cap='rin'), + # parm_up_cursor: Up #1 lines + bnc(cap='cuu'), + # scroll_forward: Scroll text up (P) + re.escape(term.ind), + # scroll_reverse: Scroll text down (P) + re.escape(term.rev), + # tab: Tab to next 8-space hardware tab stop + re.escape(term.ht), + # set_a_background: Set background color to #1, using ANSI escape + bna(cap='setab', num=1), + bna(cap='setab', num=(term.number_of_colors - 1)), + # set_a_foreground: Set foreground color to #1, using ANSI escape + bna(cap='setaf', num=1), + bna(cap='setaf', num=(term.number_of_colors - 1)), + ] + [ + # set_attributes: Define video attributes #1-#9 (PG9) + # ( not *exactly* legal, being extra forgiving. ) + bna(cap='sgr', nparams=_num) for _num in range(1, 10) + # reset_{1,2,3}string: Reset string + ] + list(map(re.escape, (term.r1, term.r2, term.r3,)))) + + +def init_sequence_patterns(term): + """Given a Terminal instance, ``term``, this function processes + and parses several known terminal capabilities, and builds and + returns a dictionary database of regular expressions, which may + be re-attached to the terminal by attributes of the same key-name: + + ``_re_will_move`` + any sequence matching this pattern will cause the terminal + cursor to move (such as *term.home*). + + ``_re_wont_move`` + any sequence matching this pattern will not cause the cursor + to move (such as *term.bold*). + + ``_re_cuf`` + regular expression that matches term.cuf(N) (move N characters forward), + or None if temrinal is without cuf sequence. + + ``_cuf1`` + *term.cuf1* sequence (cursor forward 1 character) as a static value. + + ``_re_cub`` + regular expression that matches term.cub(N) (move N characters backward), + or None if terminal is without cub sequence. + + ``_cub1`` + *term.cuf1* sequence (cursor backward 1 character) as a static value. + + These attributes make it possible to perform introspection on strings + containing sequences generated by this terminal, to determine the + printable length of a string. + """ + if term.kind in _BINTERM_UNSUPPORTED: + warnings.warn(_BINTERM_UNSUPPORTED_MSG.format(term.kind)) + + # Build will_move, a list of terminal capabilities that have + # indeterminate effects on the terminal cursor position. + _will_move = set() + if term.does_styling: + _will_move = _merge_sequences(get_movement_sequence_patterns(term)) + + # Build wont_move, a list of terminal capabilities that mainly affect + # video attributes, for use with measure_length(). + _wont_move = set() + if term.does_styling: + _wont_move = _merge_sequences(get_wontmove_sequence_patterns(term)) + _wont_move += [ + # some last-ditch match efforts; well, xterm and aixterm is going + # to throw \x1b(B and other oddities all around, so, when given + # input such as ansi art (see test using wall.ans), and well, + # theres no reason a vt220 terminal shouldn't be able to recognize + # blue_on_red, even if it didn't cause it to be generated. these + # are final "ok, i will match this, anyway" + re.escape(u'\x1b') + r'\[(\d+)m', + re.escape(u'\x1b') + r'\[(\d+)\;(\d+)m', + re.escape(u'\x1b') + r'\[(\d+)\;(\d+)\;(\d+)m', + re.escape(u'\x1b') + r'\[(\d+)\;(\d+)\;(\d+)\;(\d+)m', + re.escape(u'\x1b(B'), + ] + + # compile as regular expressions, OR'd. + _re_will_move = re.compile('(%s)' % ('|'.join(_will_move))) + _re_wont_move = re.compile('(%s)' % ('|'.join(_wont_move))) + + # static pattern matching for horizontal_distance(ucs, term) + bnc = functools.partial(_build_numeric_capability, term) + + # parm_right_cursor: Move #1 characters to the right + _cuf = bnc(cap='cuf', optional=True) + _re_cuf = re.compile(_cuf) if _cuf else None + + # cursor_right: Non-destructive space (move right one space) + _cuf1 = term.cuf1 + + # parm_left_cursor: Move #1 characters to the left + _cub = bnc(cap='cub', optional=True) + _re_cub = re.compile(_cub) if _cub else None + + # cursor_left: Move left one space + _cub1 = term.cub1 + + return {'_re_will_move': _re_will_move, + '_re_wont_move': _re_wont_move, + '_re_cuf': _re_cuf, + '_re_cub': _re_cub, + '_cuf1': _cuf1, + '_cub1': _cub1, } + + +class SequenceTextWrapper(textwrap.TextWrapper): + def __init__(self, width, term, **kwargs): + self.term = term + textwrap.TextWrapper.__init__(self, width, **kwargs) + + def _wrap_chunks(self, chunks): + """ + escape-sequence aware variant of _wrap_chunks. Though + movement sequences, such as term.left() are certainly not + honored, sequences such as term.bold() are, and are not + broken mid-sequence. + """ + lines = [] + if self.width <= 0 or not isinstance(self.width, int): + raise ValueError("invalid width %r(%s) (must be integer > 0)" % ( + self.width, type(self.width))) + term = self.term + drop_whitespace = not hasattr(self, 'drop_whitespace' + ) or self.drop_whitespace + chunks.reverse() + while chunks: + cur_line = [] + cur_len = 0 + if lines: + indent = self.subsequent_indent + else: + indent = self.initial_indent + width = self.width - len(indent) + if drop_whitespace and ( + Sequence(chunks[-1], term).strip() == '' and lines): + del chunks[-1] + while chunks: + chunk_len = Sequence(chunks[-1], term).length() + if cur_len + chunk_len <= width: + cur_line.append(chunks.pop()) + cur_len += chunk_len + else: + break + if chunks and Sequence(chunks[-1], term).length() > width: + self._handle_long_word(chunks, cur_line, cur_len, width) + if drop_whitespace and ( + cur_line and Sequence(cur_line[-1], term).strip() == ''): + del cur_line[-1] + if cur_line: + lines.append(indent + u''.join(cur_line)) + return lines + + def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): + """_handle_long_word(chunks : [string], + cur_line : [string], + cur_len : int, width : int) + + Handle a chunk of text (most likely a word, not whitespace) that + is too long to fit in any line. + """ + # Figure out when indent is larger than the specified width, and make + # sure at least one character is stripped off on every pass + if width < 1: + space_left = 1 + else: + space_left = width - cur_len + + # If we're allowed to break long words, then do so: put as much + # of the next chunk onto the current line as will fit. + if self.break_long_words: + term = self.term + chunk = reversed_chunks[-1] + nxt = 0 + for idx in range(0, len(chunk)): + if idx == nxt: + # at sequence, point beyond it, + nxt = idx + measure_length(chunk[idx:], term) + if nxt <= idx: + # point beyond next sequence, if any, + # otherwise point to next character + nxt = idx + measure_length(chunk[idx:], term) + 1 + if Sequence(chunk[:nxt], term).length() > space_left: + break + else: + # our text ends with a sequence, such as in text + # u'!\x1b(B\x1b[m', set index at at end (nxt) + idx = nxt + + cur_line.append(chunk[:idx]) + reversed_chunks[-1] = chunk[idx:] + + # Otherwise, we have to preserve the long word intact. Only add + # it to the current line if there's nothing already there -- + # that minimizes how much we violate the width constraint. + elif not cur_line: + cur_line.append(reversed_chunks.pop()) + + # If we're not allowed to break long words, and there's already + # text on the current line, do nothing. Next time through the + # main loop of _wrap_chunks(), we'll wind up here again, but + # cur_len will be zero, so the next line will be entirely + # devoted to the long word that we can't handle right now. + + +SequenceTextWrapper.__doc__ = textwrap.TextWrapper.__doc__ + + +class Sequence(text_type): + """ + This unicode-derived class understands the effect of escape sequences + of printable length, allowing a properly implemented .rjust(), .ljust(), + .center(), and .len() + """ + + def __new__(cls, sequence_text, term): + """Sequence(sequence_text, term) -> unicode object + + :arg sequence_text: A string containing sequences. + :arg term: Terminal instance this string was created with. + """ + new = text_type.__new__(cls, sequence_text) + new._term = term + return new + + def ljust(self, width, fillchar=u' '): + """S.ljust(width, fillchar) -> unicode + + Returns string derived from unicode string ``S``, left-adjusted + by trailing whitespace padding ``fillchar``.""" + rightside = fillchar * int((max(0.0, float(width - self.length()))) + / float(len(fillchar))) + return u''.join((self, rightside)) + + def rjust(self, width, fillchar=u' '): + """S.rjust(width, fillchar=u'') -> unicode + + Returns string derived from unicode string ``S``, right-adjusted + by leading whitespace padding ``fillchar``.""" + leftside = fillchar * int((max(0.0, float(width - self.length()))) + / float(len(fillchar))) + return u''.join((leftside, self)) + + def center(self, width, fillchar=u' '): + """S.center(width, fillchar=u'') -> unicode + + Returns string derived from unicode string ``S``, centered + and surrounded with whitespace padding ``fillchar``.""" + split = max(0.0, float(width) - self.length()) / 2 + leftside = fillchar * int((max(0.0, math.floor(split))) + / float(len(fillchar))) + rightside = fillchar * int((max(0.0, math.ceil(split))) + / float(len(fillchar))) + return u''.join((leftside, self, rightside)) + + def length(self): + """S.length() -> int + + Returns printable length of unicode string ``S`` that may contain + terminal sequences. + + Although accounted for, strings containing sequences such as + ``term.clear`` will not give accurate returns, it is not + considered lengthy (a length of 0). Combining characters, + are also not considered lengthy. + + Strings containing ``term.left`` or ``\b`` will cause "overstrike", + but a length less than 0 is not ever returned. So ``_\b+`` is a + length of 1 (``+``), but ``\b`` is simply a length of 0. + + Some characters may consume more than one cell, mainly those CJK + Unified Ideographs (Chinese, Japanese, Korean) defined by Unicode + as half or full-width characters. + + For example: + >>> from blessings import Terminal + >>> from blessings.sequences import Sequence + >>> term = Terminal() + >>> Sequence(term.clear + term.red(u'コンニチハ')).length() + 5 + """ + # because combining characters may return -1, "clip" their length to 0. + clip = functools.partial(max, 0) + return sum(clip(wcwidth.wcwidth(w_char)) + for w_char in self.strip_seqs()) + + def strip(self, chars=None): + """S.strip([chars]) -> unicode + + Return a copy of the string S with terminal sequences removed, and + leading and trailing whitespace removed. + + If chars is given and not None, remove characters in chars instead. + """ + return self.strip_seqs().strip(chars) + + def lstrip(self, chars=None): + """S.lstrip([chars]) -> unicode + + Return a copy of the string S with terminal sequences and leading + whitespace removed. + + If chars is given and not None, remove characters in chars instead. + """ + return self.strip_seqs().lstrip(chars) + + def rstrip(self, chars=None): + """S.rstrip([chars]) -> unicode + + Return a copy of the string S with terminal sequences and trailing + whitespace removed. + + If chars is given and not None, remove characters in chars instead. + """ + return self.strip_seqs().rstrip(chars) + + def strip_seqs(self): + """S.strip_seqs() -> unicode + + Return a string without sequences for a string that contains + sequences for the Terminal with which they were created. + + Where sequence ``move_right(n)`` is detected, it is replaced with + ``n * u' '``, and where ``move_left()`` or ``\\b`` is detected, + those last-most characters are destroyed. + + All other sequences are simply removed. An example, + >>> from blessings import Terminal + >>> from blessings.sequences import Sequence + >>> term = Terminal() + >>> Sequence(term.clear + term.red(u'test')).strip_seqs() + u'test' + """ + # nxt: points to first character beyond current escape sequence. + # width: currently estimated display length. + input = self.padd() + outp = u'' + nxt = 0 + for idx in range(0, len(input)): + if idx == nxt: + # at sequence, point beyond it, + nxt = idx + measure_length(input[idx:], self._term) + if nxt <= idx: + # append non-sequence to outp, + outp += input[idx] + # point beyond next sequence, if any, + # otherwise point to next character + nxt = idx + measure_length(input[idx:], self._term) + 1 + return outp + + def padd(self): + """S.padd() -> unicode + Make non-destructive space or backspace into destructive ones. + + Where sequence ``move_right(n)`` is detected, it is replaced with + ``n * u' '``. Where sequence ``move_left(n)`` or ``\\b`` is + detected, those last-most characters are destroyed. + """ + outp = u'' + nxt = 0 + for idx in range(0, text_type.__len__(self)): + width = horizontal_distance(self[idx:], self._term) + if width != 0: + nxt = idx + measure_length(self[idx:], self._term) + if width > 0: + outp += u' ' * width + elif width < 0: + outp = outp[:width] + if nxt <= idx: + outp += self[idx] + nxt = idx + 1 + return outp + + +def measure_length(ucs, term): + """measure_length(S, term) -> int + + Returns non-zero for string ``S`` that begins with a terminal sequence, + that is: the width of the first unprintable sequence found in S. For use + as a *next* pointer to skip past sequences. If string ``S`` is not a + sequence, 0 is returned. + + A sequence may be a typical terminal sequence beginning with Escape + (``\x1b``), especially a Control Sequence Initiator (``CSI``, ``\x1b[``, + ...), or those of ``\a``, ``\b``, ``\r``, ``\n``, ``\xe0`` (shift in), + ``\x0f`` (shift out). They do not necessarily have to begin with CSI, they + need only match the capabilities of attributes ``_re_will_move`` and + ``_re_wont_move`` of terminal ``term``. + """ + + # simple terminal control characters, + ctrl_seqs = u'\a\b\r\n\x0e\x0f' + + if any([ucs.startswith(_ch) for _ch in ctrl_seqs]): + return 1 + + # known multibyte sequences, + matching_seq = term and ( + term._re_will_move.match(ucs) or + term._re_wont_move.match(ucs) or + term._re_cub and term._re_cub.match(ucs) or + term._re_cuf and term._re_cuf.match(ucs) + ) + + if matching_seq: + start, end = matching_seq.span() + return end + + # none found, must be printable! + return 0 + + +def termcap_distance(ucs, cap, unit, term): + """termcap_distance(S, cap, unit, term) -> int + + Match horizontal distance by simple ``cap`` capability name, ``cub1`` or + ``cuf1``, with string matching the sequences identified by Terminal + instance ``term`` and a distance of ``unit`` *1* or *-1*, for right and + left, respectively. + + Otherwise, by regular expression (using dynamic regular expressions built + using ``cub(n)`` and ``cuf(n)``. Failing that, any of the standard SGR + sequences (``\033[C``, ``\033[D``, ``\033[nC``, ``\033[nD``). + + Returns 0 if unmatched. + """ + assert cap in ('cuf', 'cub') + # match cub1(left), cuf1(right) + one = getattr(term, '_%s1' % (cap,)) + if one and ucs.startswith(one): + return unit + + # match cub(n), cuf(n) using regular expressions + re_pattern = getattr(term, '_re_%s' % (cap,)) + _dist = re_pattern and re_pattern.match(ucs) + if _dist: + return unit * int(_dist.group(1)) + + return 0 + + +def horizontal_distance(ucs, term): + """horizontal_distance(S, term) -> int + + Returns Integer ``<n>`` in SGR sequence of form ``<ESC>[<n>C`` + (T.move_right(n)), or ``-(n)`` in sequence of form ``<ESC>[<n>D`` + (T.move_left(n)). Returns -1 for backspace (0x08), Otherwise 0. + + Tabstop (``\t``) cannot be correctly calculated, as the relative column + position cannot be determined: 8 is always (and, incorrectly) returned. + """ + + if ucs.startswith('\b'): + return -1 + + elif ucs.startswith('\t'): + # As best as I can prove it, a tabstop is always 8 by default. + # Though, given that blessings is: + # + # 1. unaware of the output device's current cursor position, and + # 2. unaware of the location the callee may chose to output any + # given string, + # + # It is not possible to determine how many cells any particular + # \t would consume on the output device! + return 8 + + return (termcap_distance(ucs, 'cub', -1, term) or + termcap_distance(ucs, 'cuf', 1, term) or + 0) diff --git a/blessings/terminal.py b/blessings/terminal.py new file mode 100644 index 0000000..60fecbe --- /dev/null +++ b/blessings/terminal.py @@ -0,0 +1,783 @@ +"This primary module provides the Terminal class." +# standard modules +import collections +import contextlib +import functools +import warnings +import platform +import codecs +import curses +import locale +import select +import struct +import time +import sys +import os + +try: + import termios + import fcntl + import tty +except ImportError: + tty_methods = ('setraw', 'cbreak', 'kbhit', 'height', 'width') + msg_nosupport = ( + "One or more of the modules: 'termios', 'fcntl', and 'tty' " + "are not found on your platform '{0}'. The following methods " + "of Terminal are dummy/no-op unless a deriving class overrides " + "them: {1}".format(sys.platform.lower(), ', '.join(tty_methods))) + warnings.warn(msg_nosupport) + HAS_TTY = False +else: + HAS_TTY = True + +try: + from io import UnsupportedOperation as IOUnsupportedOperation +except ImportError: + class IOUnsupportedOperation(Exception): + """A dummy exception to take the place of Python 3's + ``io.UnsupportedOperation`` in Python 2.5""" + +try: + _ = InterruptedError + del _ +except NameError: + # alias py2 exception to py3 + InterruptedError = select.error + +# local imports +from .formatters import ( + ParameterizingString, + NullCallableString, + resolve_capability, + resolve_attribute, +) + +from .sequences import ( + init_sequence_patterns, + SequenceTextWrapper, + Sequence, +) + +from .keyboard import ( + get_keyboard_sequences, + get_keyboard_codes, + resolve_sequence, +) + + +class Terminal(object): + """A wrapper for curses and related terminfo(5) terminal capabilities. + + Instance attributes: + + ``stream`` + The stream the terminal outputs to. It's convenient to pass the stream + around with the terminal; it's almost always needed when the terminal + is and saves sticking lots of extra args on client functions in + practice. + """ + + #: Sugary names for commonly-used capabilities + _sugar = dict( + save='sc', + restore='rc', + # 'clear' clears the whole screen. + clear_eol='el', + clear_bol='el1', + clear_eos='ed', + position='cup', # deprecated + enter_fullscreen='smcup', + exit_fullscreen='rmcup', + move='cup', + move_x='hpa', + move_y='vpa', + move_left='cub1', + move_right='cuf1', + move_up='cuu1', + move_down='cud1', + hide_cursor='civis', + normal_cursor='cnorm', + reset_colors='op', # oc doesn't work on my OS X terminal. + normal='sgr0', + reverse='rev', + italic='sitm', + no_italic='ritm', + shadow='sshm', + no_shadow='rshm', + standout='smso', + no_standout='rmso', + subscript='ssubm', + no_subscript='rsubm', + superscript='ssupm', + no_superscript='rsupm', + underline='smul', + no_underline='rmul') + + def __init__(self, kind=None, stream=None, force_styling=False): + """Initialize the terminal. + + If ``stream`` is not a tty, I will default to returning an empty + Unicode string for all capability values, so things like piping your + output to a file won't strew escape sequences all over the place. The + ``ls`` command sets a precedent for this: it defaults to columnar + output when being sent to a tty and one-item-per-line when not. + + :arg kind: A terminal string as taken by ``setupterm()``. Defaults to + the value of the ``TERM`` environment variable. + :arg stream: A file-like object representing the terminal. Defaults to + the original value of stdout, like ``curses.initscr()`` does. + :arg force_styling: Whether to force the emission of capabilities, even + if we don't seem to be in a terminal. This comes in handy if users + are trying to pipe your output through something like ``less -r``, + which supports terminal codes just fine but doesn't appear itself + to be a terminal. Just expose a command-line option, and set + ``force_styling`` based on it. Terminal initialization sequences + will be sent to ``stream`` if it has a file descriptor and to + ``sys.__stdout__`` otherwise. (``setupterm()`` demands to send them + somewhere, and stdout is probably where the output is ultimately + headed. If not, stderr is probably bound to the same terminal.) + + If you want to force styling to not happen, pass + ``force_styling=None``. + + """ + global _CUR_TERM + self.keyboard_fd = None + + # default stream is stdout, keyboard only valid as stdin when + # output stream is stdout and output stream is a tty + if stream is None or stream == sys.__stdout__: + stream = sys.__stdout__ + self.keyboard_fd = sys.__stdin__.fileno() + + try: + stream_fd = (stream.fileno() if hasattr(stream, 'fileno') + and callable(stream.fileno) else None) + except IOUnsupportedOperation: + stream_fd = None + + self._is_a_tty = stream_fd is not None and os.isatty(stream_fd) + self._does_styling = ((self.is_a_tty or force_styling) and + force_styling is not None) + + # keyboard_fd only non-None if both stdin and stdout is a tty. + self.keyboard_fd = (self.keyboard_fd + if self.keyboard_fd is not None and + self.is_a_tty and os.isatty(self.keyboard_fd) + else None) + self._normal = None # cache normal attr, preventing recursive lookups + + # The descriptor to direct terminal initialization sequences to. + # sys.__stdout__ seems to always have a descriptor of 1, even if output + # is redirected. + self._init_descriptor = (stream_fd is None and sys.__stdout__.fileno() + or stream_fd) + self._kind = kind or os.environ.get('TERM', 'unknown') + + if self.does_styling: + # Make things like tigetstr() work. Explicit args make setupterm() + # work even when -s is passed to nosetests. Lean toward sending + # init sequences to the stream if it has a file descriptor, and + # send them to stdout as a fallback, since they have to go + # somewhere. + try: + if (platform.python_implementation() == 'PyPy' and + isinstance(self._kind, unicode)): + # pypy/2.4.0_2/libexec/lib_pypy/_curses.py, line 1131 + # TypeError: initializer for ctype 'char *' must be a str + curses.setupterm(self._kind.encode('ascii'), self._init_descriptor) + else: + curses.setupterm(self._kind, self._init_descriptor) + except curses.error as err: + warnings.warn('Failed to setupterm(kind={0!r}): {1}' + .format(self._kind, err)) + self._kind = None + self._does_styling = False + else: + if _CUR_TERM is None or self._kind == _CUR_TERM: + _CUR_TERM = self._kind + else: + warnings.warn( + 'A terminal of kind "%s" has been requested; due to an' + ' internal python curses bug, terminal capabilities' + ' for a terminal of kind "%s" will continue to be' + ' returned for the remainder of this process.' % ( + self._kind, _CUR_TERM,)) + + for re_name, re_val in init_sequence_patterns(self).items(): + setattr(self, re_name, re_val) + + # build database of int code <=> KEY_NAME + self._keycodes = get_keyboard_codes() + + # store attributes as: self.KEY_NAME = code + for key_code, key_name in self._keycodes.items(): + setattr(self, key_name, key_code) + + # build database of sequence <=> KEY_NAME + self._keymap = get_keyboard_sequences(self) + + self._keyboard_buf = collections.deque() + if self.keyboard_fd is not None: + locale.setlocale(locale.LC_ALL, '') + self._encoding = locale.getpreferredencoding() or 'ascii' + try: + self._keyboard_decoder = codecs.getincrementaldecoder( + self._encoding)() + except LookupError as err: + warnings.warn('%s, fallback to ASCII for keyboard.' % (err,)) + self._encoding = 'ascii' + self._keyboard_decoder = codecs.getincrementaldecoder( + self._encoding)() + + self.stream = stream + + def __getattr__(self, attr): + """Return a terminal capability as Unicode string. + + For example, ``term.bold`` is a unicode string that may be prepended + to text to set the video attribute for bold, which should also be + terminated with the pairing ``term.normal``. + + This capability is also callable, so you can use ``term.bold("hi")`` + which results in the joining of (term.bold, "hi", term.normal). + + Compound formatters may also be used, for example: + ``term.bold_blink_red_on_green("merry x-mas!")``. + + For a parametrized capability such as ``cup`` (cursor_address), pass + the parameters as arguments ``some_term.cup(line, column)``. See + manual page terminfo(5) for a complete list of capabilities. + """ + if not self.does_styling: + return NullCallableString() + val = resolve_attribute(self, attr) + # Cache capability codes. + setattr(self, attr, val) + return val + + @property + def kind(self): + """Name of this terminal type as string.""" + return self._kind + + @property + def does_styling(self): + """Whether this instance will emit terminal sequences (bool).""" + return self._does_styling + + @property + def is_a_tty(self): + """Whether the ``stream`` associated with this instance is a terminal + (bool).""" + return self._is_a_tty + + @property + def height(self): + """T.height -> int + + The height of the terminal in characters. + """ + return self._height_and_width().ws_row + + @property + def width(self): + """T.width -> int + + The width of the terminal in characters. + """ + return self._height_and_width().ws_col + + @staticmethod + def _winsize(fd): + """T._winsize -> WINSZ(ws_row, ws_col, ws_xpixel, ws_ypixel) + + The tty connected by file desriptor fd is queried for its window size, + and returned as a collections.namedtuple instance WINSZ. + + May raise exception IOError. + """ + if HAS_TTY: + data = fcntl.ioctl(fd, termios.TIOCGWINSZ, WINSZ._BUF) + return WINSZ(*struct.unpack(WINSZ._FMT, data)) + return WINSZ(ws_row=24, ws_col=80, ws_xpixel=0, ws_ypixel=0) + + def _height_and_width(self): + """Return a tuple of (terminal height, terminal width). + """ + # TODO(jquast): hey kids, even if stdout is redirected to a file, + # we can still query sys.__stdin__.fileno() for our terminal size. + # -- of course, if both are redirected, we have no use for this fd. + for fd in (self._init_descriptor, sys.__stdout__): + try: + if fd is not None: + return self._winsize(fd) + except IOError: + pass + + return WINSZ(ws_row=int(os.getenv('LINES', '25')), + ws_col=int(os.getenv('COLUMNS', '80')), + ws_xpixel=None, + ws_ypixel=None) + + @contextlib.contextmanager + def location(self, x=None, y=None): + """Return a context manager for temporarily moving the cursor. + + Move the cursor to a certain position on entry, let you print stuff + there, then return the cursor to its original position:: + + term = Terminal() + with term.location(2, 5): + print 'Hello, world!' + for x in xrange(10): + print 'I can do it %i times!' % x + + Specify ``x`` to move to a certain column, ``y`` to move to a certain + row, both, or neither. If you specify neither, only the saving and + restoration of cursor position will happen. This can be useful if you + simply want to restore your place after doing some manual cursor + movement. + + """ + # Save position and move to the requested column, row, or both: + self.stream.write(self.save) + if x is not None and y is not None: + self.stream.write(self.move(y, x)) + elif x is not None: + self.stream.write(self.move_x(x)) + elif y is not None: + self.stream.write(self.move_y(y)) + try: + yield + finally: + # Restore original cursor position: + self.stream.write(self.restore) + + @contextlib.contextmanager + def fullscreen(self): + """Return a context manager that enters fullscreen mode while inside it + and restores normal mode on leaving. + + Fullscreen mode is characterized by instructing the terminal emulator + to store and save the current screen state (all screen output), switch + to "alternate screen". Upon exiting, the previous screen state is + returned. + + This call may not be tested; only one screen state may be saved at a + time. + """ + self.stream.write(self.enter_fullscreen) + try: + yield + finally: + self.stream.write(self.exit_fullscreen) + + @contextlib.contextmanager + def hidden_cursor(self): + """Return a context manager that hides the cursor upon entering, + and makes it visible again upon exiting.""" + self.stream.write(self.hide_cursor) + try: + yield + finally: + self.stream.write(self.normal_cursor) + + @property + def color(self): + """Returns capability that sets the foreground color. + + The capability is unparameterized until called and passed a number + (0-15), at which point it returns another string which represents a + specific color change. This second string can further be called to + color a piece of text and set everything back to normal afterward. + + :arg num: The number, 0-15, of the color + + """ + if not self.does_styling: + return NullCallableString() + return ParameterizingString(self._foreground_color, + self.normal, 'color') + + @property + def on_color(self): + "Returns capability that sets the background color." + if not self.does_styling: + return NullCallableString() + return ParameterizingString(self._background_color, + self.normal, 'on_color') + + @property + def normal(self): + "Returns sequence that resets video attribute." + if self._normal: + return self._normal + self._normal = resolve_capability(self, 'normal') + return self._normal + + @property + def number_of_colors(self): + """Return the number of colors the terminal supports. + + Common values are 0, 8, 16, 88, and 256. Most commonly + this may be used to test color capabilities at all:: + + if term.number_of_colors: + ...""" + # trim value to 0, as tigetnum('colors') returns -1 if no support, + # -2 if no such capability. + return max(0, self.does_styling and curses.tigetnum('colors') or -1) + + @property + def _foreground_color(self): + return self.setaf or self.setf + + @property + def _background_color(self): + return self.setab or self.setb + + def ljust(self, text, width=None, fillchar=u' '): + """T.ljust(text, [width], [fillchar]) -> unicode + + Return string ``text``, left-justified by printable length ``width``. + Padding is done using the specified fill character (default is a + space). Default ``width`` is the attached terminal's width. ``text`` + may contain terminal sequences.""" + if width is None: + width = self.width + return Sequence(text, self).ljust(width, fillchar) + + def rjust(self, text, width=None, fillchar=u' '): + """T.rjust(text, [width], [fillchar]) -> unicode + + Return string ``text``, right-justified by printable length ``width``. + Padding is done using the specified fill character (default is a + space). Default ``width`` is the attached terminal's width. ``text`` + may contain terminal sequences.""" + if width is None: + width = self.width + return Sequence(text, self).rjust(width, fillchar) + + def center(self, text, width=None, fillchar=u' '): + """T.center(text, [width], [fillchar]) -> unicode + + Return string ``text``, centered by printable length ``width``. + Padding is done using the specified fill character (default is a + space). Default ``width`` is the attached terminal's width. ``text`` + may contain terminal sequences.""" + if width is None: + width = self.width + return Sequence(text, self).center(width, fillchar) + + def length(self, text): + """T.length(text) -> int + + Return the printable length of string ``text``, which may contain + terminal sequences. Strings containing sequences such as 'clear', + which repositions the cursor, does not give accurate results, and + their printable length is evaluated *0*.. + """ + return Sequence(text, self).length() + + def strip(self, text, chars=None): + """T.strip(text) -> unicode + + Return string ``text`` with terminal sequences removed, and leading + and trailing whitespace removed. + """ + return Sequence(text, self).strip(chars) + + def rstrip(self, text, chars=None): + """T.rstrip(text) -> unicode + + Return string ``text`` with terminal sequences and trailing whitespace + removed. + """ + return Sequence(text, self).rstrip(chars) + + def lstrip(self, text, chars=None): + """T.lstrip(text) -> unicode + + Return string ``text`` with terminal sequences and leading whitespace + removed. + """ + return Sequence(text, self).lstrip(chars) + + def strip_seqs(self, text): + """T.strip_seqs(text) -> unicode + + Return string ``text`` stripped only of its sequences. + """ + return Sequence(text, self).strip_seqs() + + def wrap(self, text, width=None, **kwargs): + """T.wrap(text, [width=None, **kwargs ..]) -> list[unicode] + + Wrap paragraphs containing escape sequences ``text`` to the full + ``width`` of Terminal instance *T*, unless ``width`` is specified. + Wrapped by the virtual printable length, irregardless of the video + attribute sequences it may contain, allowing text containing colors, + bold, underline, etc. to be wrapped. + + Returns a list of strings that may contain escape sequences. See + ``textwrap.TextWrapper`` for all available additional kwargs to + customize wrapping behavior such as ``subsequent_indent``. + """ + width = self.width if width is None else width + lines = [] + for line in text.splitlines(): + lines.extend( + (_linewrap for _linewrap in SequenceTextWrapper( + width=width, term=self, **kwargs).wrap(text)) + if line.strip() else (u'',)) + + return lines + + def _next_char(self): + """T._next_char() -> unicode + + Read and decode next byte from keyboard stream. May return u'' + if decoding is not yet complete, or completed unicode character. + Should always return bytes when self._char_is_ready() returns True. + + Implementors of input streams other than os.read() on the stdin fd + should derive and override this method. + """ + assert self.keyboard_fd is not None + byte = os.read(self.keyboard_fd, 1) + return self._keyboard_decoder.decode(byte, final=False) + + def _char_is_ready(self, timeout=None, interruptable=True): + """T._char_is_ready([timeout=None]) -> bool + + Returns True if a keypress has been detected on keyboard. + + When ``timeout`` is 0, this call is non-blocking, Otherwise blocking + until keypress is detected (default). When ``timeout`` is a positive + number, returns after ``timeout`` seconds have elapsed. + + If input is not a terminal, False is always returned. + """ + # Special care is taken to handle a custom SIGWINCH handler, which + # causes select() to be interrupted with errno 4 (EAGAIN) -- + # it is ignored, and a new timeout value is derived from the previous, + # unless timeout becomes negative, because signal handler has blocked + # beyond timeout, then False is returned. Otherwise, when timeout is 0, + # we continue to block indefinitely (default). + stime = time.time() + check_w, check_x, ready_r = [], [], [None, ] + check_r = [self.keyboard_fd] if self.keyboard_fd is not None else [] + + while HAS_TTY and True: + try: + ready_r, ready_w, ready_x = select.select( + check_r, check_w, check_x, timeout) + except InterruptedError: + if not interruptable: + return u'' + if timeout is not None: + # subtract time already elapsed, + timeout -= time.time() - stime + if timeout > 0: + continue + # no time remains after handling exception (rare) + ready_r = [] + break + else: + break + + return False if self.keyboard_fd is None else check_r == ready_r + + @contextlib.contextmanager + def keystroke_input(self, raw=False): + """Return a context manager that sets up the terminal to do + key-at-a-time input. + + On entering the context manager, "cbreak" mode is activated, disabling + line buffering of keyboard input and turning off automatic echoing of + input. (You must explicitly print any input if you'd like it shown.) + Also referred to as 'rare' mode, this is the opposite of 'cooked' mode, + the default for most shells. + + If ``raw`` is True, enter "raw" mode instead. Raw mode differs in that + the interrupt, quit, suspend, and flow control characters are all + passed through as their raw character values instead of generating a + signal. + + More information can be found in the manual page for curses.h, + http://www.openbsd.org/cgi-bin/man.cgi?query=cbreak + + The python manual for curses, + http://docs.python.org/2/library/curses.html + + Note also that setcbreak sets VMIN = 1 and VTIME = 0, + http://www.unixwiz.net/techtips/termios-vmin-vtime.html + """ + if HAS_TTY and self.keyboard_fd is not None: + # Save current terminal mode: + save_mode = termios.tcgetattr(self.keyboard_fd) + mode_setter = tty.setraw if raw else tty.setcbreak + mode_setter(self.keyboard_fd, termios.TCSANOW) + try: + yield + finally: + # Restore prior mode: + termios.tcsetattr(self.keyboard_fd, + termios.TCSAFLUSH, + save_mode) + else: + yield + + @contextlib.contextmanager + def keypad(self): + """ + Context manager that enables keypad input (*keyboard_transmit* mode). + + This enables the effect of calling the curses function keypad(3x): + display terminfo(5) capability `keypad_xmit` (smkx) upon entering, + and terminfo(5) capability `keypad_local` (rmkx) upon exiting. + + On an IBM-PC keypad of ttype *xterm*, with numlock off, the + lower-left diagonal key transmits sequence ``\\x1b[F``, ``KEY_END``. + + However, upon entering keypad mode, ``\\x1b[OF`` is transmitted, + translating to ``KEY_LL`` (lower-left key), allowing diagonal + direction keys to be determined. + """ + try: + self.stream.write(self.smkx) + yield + finally: + self.stream.write(self.rmkx) + + def keystroke(self, timeout=None, esc_delay=0.35, interruptable=True): + """T.keystroke(timeout=None, [esc_delay, [interruptable]]) -> Keystroke + + Receive next keystroke from keyboard (stdin), blocking until a + keypress is received or ``timeout`` elapsed, if specified. + + When used without the context manager ``cbreak``, stdin remains + line-buffered, and this function will block until return is pressed, + even though only one unicode character is returned at a time.. + + The value returned is an instance of ``Keystroke``, with properties + ``is_sequence``, and, when True, non-None values for attributes + ``code`` and ``name``. The value of ``code`` may be compared against + attributes of this terminal beginning with *KEY*, such as + ``KEY_ESCAPE``. + + To distinguish between ``KEY_ESCAPE`` and sequences beginning with + escape, the ``esc_delay`` specifies the amount of time after receiving + the escape character (chr(27)) to seek for the completion + of other application keys before returning ``KEY_ESCAPE``. + + Normally, when this function is interrupted by a signal, such as the + installment of SIGWINCH, this function will ignore this interruption + and continue to poll for input up to the ``timeout`` specified. If + you'd rather this function return ``u''`` early, specify ``False`` for + ``interruptable``. + """ + # TODO(jquast): "meta sends escape", where alt+1 would send '\x1b1', + # what do we do with that? Surely, something useful. + # comparator to term.KEY_meta('x') ? + # TODO(jquast): Ctrl characters, KEY_CTRL_[A-Z], and the rest; + # KEY_CTRL_\, KEY_CTRL_{, etc. are not legitimate + # attributes. comparator to term.KEY_ctrl('z') ? + + if timeout is None and self.keyboard_fd is None: + raise NoKeyboard( + 'Waiting for a keystroke on a terminal with no keyboard ' + 'attached and no timeout would take a long time. Add a ' + 'timeout and revise your program logic.') + + def time_left(stime, timeout): + """time_left(stime, timeout) -> float + + Returns time-relative time remaining before ``timeout`` + after time elapsed since ``stime``. + """ + if timeout is not None: + if timeout == 0: + return 0 + return max(0, timeout - (time.time() - stime)) + + resolve = functools.partial(resolve_sequence, + mapper=self._keymap, + codes=self._keycodes) + + stime = time.time() + + # re-buffer previously received keystrokes, + ucs = u'' + while self._keyboard_buf: + ucs += self._keyboard_buf.pop() + + # receive all immediately available bytes + while self._char_is_ready(0): + ucs += self._next_char() + + # decode keystroke, if any + ks = resolve(text=ucs) + + # so long as the most immediately received or buffered keystroke is + # incomplete, (which may be a multibyte encoding), block until until + # one is received. + while not ks and self._char_is_ready(time_left(stime, timeout), + interruptable): + ucs += self._next_char() + ks = resolve(text=ucs) + + # handle escape key (KEY_ESCAPE) vs. escape sequence (which begins + # with KEY_ESCAPE, \x1b[, \x1bO, or \x1b?), up to esc_delay when + # received. This is not optimal, but causes least delay when + # (currently unhandled, and rare) "meta sends escape" is used, + # or when an unsupported sequence is sent. + if ks.code == self.KEY_ESCAPE: + esctime = time.time() + while (ks.code == self.KEY_ESCAPE and + self._char_is_ready(time_left(esctime, esc_delay))): + ucs += self._next_char() + ks = resolve(text=ucs) + + # buffer any remaining text received + self._keyboard_buf.extendleft(ucs[len(ks):]) + return ks + + +class NoKeyboard(Exception): + """Exception raised when a Terminal that has no means of input connected is + asked to retrieve a keystroke without an infinite timeout.""" + + +# From libcurses/doc/ncurses-intro.html (ESR, Thomas Dickey, et. al): +# +# "After the call to setupterm(), the global variable cur_term is set to +# point to the current structure of terminal capabilities. By calling +# setupterm() for each terminal, and saving and restoring cur_term, it +# is possible for a program to use two or more terminals at once." +# +# However, if you study Python's ./Modules/_cursesmodule.c, you'll find: +# +# if (!initialised_setupterm && setupterm(termstr,fd,&err) == ERR) { +# +# Python - perhaps wrongly - will not allow for re-initialisation of new +# terminals through setupterm(), so the value of cur_term cannot be changed +# once set: subsequent calls to setupterm() have no effect. +# +# Therefore, the ``kind`` of each Terminal() is, in essence, a singleton. +# This global variable reflects that, and a warning is emitted if somebody +# expects otherwise. + +_CUR_TERM = None + +WINSZ = collections.namedtuple('WINSZ', ( + 'ws_row', # /* rows, in characters */ + 'ws_col', # /* columns, in characters */ + 'ws_xpixel', # /* horizontal size, pixels */ + 'ws_ypixel', # /* vertical size, pixels */ +)) +#: format of termios structure +WINSZ._FMT = 'hhhh' +#: buffer of termios structure appropriate for ioctl argument +WINSZ._BUF = '\x00' * struct.calcsize(WINSZ._FMT) diff --git a/blessings/tests/__init__.py b/blessings/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/blessings/tests/__init__.py diff --git a/blessings/tests/accessories.py b/blessings/tests/accessories.py new file mode 100644 index 0000000..8cb3f5b --- /dev/null +++ b/blessings/tests/accessories.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +"""Accessories for automated py.test runner.""" +# std +from __future__ import with_statement +import contextlib +import subprocess +import functools +import traceback +import termios +import random +import codecs +import curses +import sys +import pty +import os + +# local +from blessings import Terminal + +# 3rd +import pytest + +if sys.version_info[0] == 3: + text_type = str +else: + text_type = unicode # noqa + +TestTerminal = functools.partial(Terminal, kind='xterm-256color') +SEND_SEMAPHORE = SEMAPHORE = b'SEMAPHORE\n' +RECV_SEMAPHORE = b'SEMAPHORE\r\n' +all_xterms_params = ['xterm', 'xterm-256color'] +many_lines_params = [30, 100] +many_columns_params = [1, 10] +from blessings._binterms import binary_terminals +default_all_terms = ['screen', 'vt220', 'rxvt', 'cons25', 'linux', 'ansi'] +if os.environ.get('TEST_ALLTERMS'): + try: + available_terms = [ + _term.split(None, 1)[0] for _term in + subprocess.Popen(('toe', '-a'), + stdout=subprocess.PIPE, + close_fds=True) + .communicate()[0].splitlines()] + except OSError: + all_terms_params = default_all_terms +else: + available_terms = default_all_terms +all_terms_params = list(set(available_terms) - ( + set(binary_terminals) if not os.environ.get('TEST_BINTERMS') + else set())) or default_all_terms + + +class as_subprocess(object): + """This helper executes test cases in a child process, + avoiding a python-internal bug of _curses: setupterm() + may not be called more than once per process. + """ + _CHILD_PID = 0 + encoding = 'utf8' + + def __init__(self, func): + self.func = func + + def __call__(self, *args, **kwargs): + pid, master_fd = pty.fork() + if pid is self._CHILD_PID: + # child process executes function, raises exception + # if failed, causing a non-zero exit code, using the + # protected _exit() function of ``os``; to prevent the + # 'SystemExit' exception from being thrown. + try: + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + self.func(*args, **kwargs) + except Exception: + e_type, e_value, e_tb = sys.exc_info() + o_err = list() + for line in traceback.format_tb(e_tb): + o_err.append(line.rstrip().encode('utf-8')) + o_err.append(('-=' * 20).encode('ascii')) + o_err.extend([_exc.rstrip().encode('utf-8') for _exc in + traceback.format_exception_only( + e_type, e_value)]) + os.write(sys.__stdout__.fileno(), b'\n'.join(o_err)) + os.close(sys.__stdout__.fileno()) + os.close(sys.__stderr__.fileno()) + os.close(sys.__stdin__.fileno()) + if cov is not None: + cov.stop() + cov.save() + os._exit(1) + else: + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + exc_output = text_type() + decoder = codecs.getincrementaldecoder(self.encoding)() + while True: + try: + _exc = os.read(master_fd, 65534) + except OSError: + # linux EOF + break + if not _exc: + # bsd EOF + break + exc_output += decoder.decode(_exc) + + # parent process asserts exit code is 0, causing test + # to fail if child process raised an exception/assertion + pid, status = os.waitpid(pid, 0) + os.close(master_fd) + + # Display any output written by child process + # (esp. any AssertionError exceptions written to stderr). + exc_output_msg = 'Output in child process:\n%s\n%s\n%s' % ( + u'=' * 40, exc_output, u'=' * 40,) + assert exc_output == '', exc_output_msg + + # Also test exit status is non-zero + assert os.WEXITSTATUS(status) == 0 + + +def read_until_semaphore(fd, semaphore=RECV_SEMAPHORE, + encoding='utf8', timeout=10): + """Read file descriptor ``fd`` until ``semaphore`` is found. + + Used to ensure the child process is awake and ready. For timing + tests; without a semaphore, the time to fork() would be (incorrectly) + included in the duration of the test, which can be very length on + continuous integration servers (such as Travis-CI). + """ + # note that when a child process writes xyz\\n, the parent + # process will read xyz\\r\\n -- this is how pseudo terminals + # behave; a virtual terminal requires both carriage return and + # line feed, it is only for convenience that \\n does both. + outp = text_type() + decoder = codecs.getincrementaldecoder(encoding)() + semaphore = semaphore.decode('ascii') + while not outp.startswith(semaphore): + try: + _exc = os.read(fd, 1) + except OSError: # Linux EOF + break + if not _exc: # BSD EOF + break + outp += decoder.decode(_exc, final=False) + assert outp.startswith(semaphore), ( + 'Semaphore not recv before EOF ' + '(expected: %r, got: %r)' % (semaphore, outp,)) + return outp[len(semaphore):] + + +def read_until_eof(fd, encoding='utf8'): + """Read file descriptor ``fd`` until EOF. Return decoded string.""" + decoder = codecs.getincrementaldecoder(encoding)() + outp = text_type() + while True: + try: + _exc = os.read(fd, 100) + except OSError: # linux EOF + break + if not _exc: # bsd EOF + break + outp += decoder.decode(_exc, final=False) + return outp + + +@contextlib.contextmanager +def echo_off(fd): + """Ensure any bytes written to pty fd are not duplicated as output.""" + try: + attrs = termios.tcgetattr(fd) + attrs[3] = attrs[3] & ~termios.ECHO + termios.tcsetattr(fd, termios.TCSANOW, attrs) + yield + finally: + attrs[3] = attrs[3] | termios.ECHO + termios.tcsetattr(fd, termios.TCSANOW, attrs) + + +def unicode_cap(cap): + """Return the result of ``tigetstr`` except as Unicode.""" + try: + val = curses.tigetstr(cap) + except curses.error: + val = None + if val: + return val.decode('latin1') + return u'' + + +def unicode_parm(cap, *parms): + """Return the result of ``tparm(tigetstr())`` except as Unicode.""" + try: + cap = curses.tigetstr(cap) + except curses.error: + cap = None + if cap: + try: + val = curses.tparm(cap, *parms) + except curses.error: + val = None + if val: + return val.decode('latin1') + return u'' + + +@pytest.fixture(params=binary_terminals) +def unsupported_sequence_terminals(request): + """Terminals that emit warnings for unsupported sequence-awareness.""" + return request.param + + +@pytest.fixture(params=all_xterms_params) +def xterms(request): + """Common kind values for xterm terminals.""" + return request.param + + +@pytest.fixture(params=all_terms_params) +def all_terms(request): + """Common kind values for all kinds of terminals.""" + return request.param + + +@pytest.fixture(params=many_lines_params) +def many_lines(request): + """Various number of lines for screen height.""" + return request.param + + +@pytest.fixture(params=many_columns_params) +def many_columns(request): + """Various number of columns for screen width.""" + return request.param diff --git a/blessings/tests/test_core.py b/blessings/tests/test_core.py new file mode 100644 index 0000000..ce1e838 --- /dev/null +++ b/blessings/tests/test_core.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +"Core blessings Terminal() tests." + +# std +try: + from StringIO import StringIO +except ImportError: + from io import StringIO +import collections +import warnings +import platform +import locale +import sys +import imp +import os + +# local +from .accessories import ( + as_subprocess, + TestTerminal, + unicode_cap, + all_terms +) + +# 3rd party +import mock +import pytest + + +def test_export_only_Terminal(): + "Ensure only Terminal instance is exported for import * statements." + import blessings + assert blessings.__all__ == ('Terminal',) + + +def test_null_location(all_terms): + "Make sure ``location()`` with no args just does position restoration." + @as_subprocess + def child(kind): + t = TestTerminal(stream=StringIO(), force_styling=True) + with t.location(): + pass + expected_output = u''.join( + (unicode_cap('sc'), unicode_cap('rc'))) + assert (t.stream.getvalue() == expected_output) + + child(all_terms) + + +def test_flipped_location_move(all_terms): + "``location()`` and ``move()`` receive counter-example arguments." + @as_subprocess + def child(kind): + buf = StringIO() + t = TestTerminal(stream=buf, force_styling=True) + y, x = 10, 20 + with t.location(y, x): + xy_val = t.move(x, y) + yx_val = buf.getvalue()[len(t.sc):] + assert xy_val == yx_val + + child(all_terms) + + +def test_yield_keypad(): + "Ensure ``keypad()`` writes keyboard_xmit and keyboard_local." + @as_subprocess + def child(kind): + # given, + t = TestTerminal(stream=StringIO(), force_styling=True) + expected_output = u''.join((t.smkx, t.rmkx)) + + # exercise, + with t.keypad(): + pass + + # verify. + assert (t.stream.getvalue() == expected_output) + + child(kind='xterm') + + +def test_null_fileno(): + "Make sure ``Terminal`` works when ``fileno`` is ``None``." + @as_subprocess + def child(): + # This simulates piping output to another program. + out = StringIO() + out.fileno = None + t = TestTerminal(stream=out) + assert (t.save == u'') + + child() + + +def test_number_of_colors_without_tty(): + "``number_of_colors`` should return 0 when there's no tty." + @as_subprocess + def child_256_nostyle(): + t = TestTerminal(stream=StringIO()) + assert (t.number_of_colors == 0) + + @as_subprocess + def child_256_forcestyle(): + t = TestTerminal(stream=StringIO(), force_styling=True) + assert (t.number_of_colors == 256) + + @as_subprocess + def child_8_forcestyle(): + t = TestTerminal(kind='ansi', stream=StringIO(), + force_styling=True) + assert (t.number_of_colors == 8) + + @as_subprocess + def child_0_forcestyle(): + t = TestTerminal(kind='vt220', stream=StringIO(), + force_styling=True) + assert (t.number_of_colors == 0) + + child_0_forcestyle() + child_8_forcestyle() + child_256_forcestyle() + child_256_nostyle() + + +def test_number_of_colors_with_tty(): + "test ``number_of_colors`` 0, 8, and 256." + @as_subprocess + def child_256(): + t = TestTerminal() + assert (t.number_of_colors == 256) + + @as_subprocess + def child_8(): + t = TestTerminal(kind='ansi') + assert (t.number_of_colors == 8) + + @as_subprocess + def child_0(): + t = TestTerminal(kind='vt220') + assert (t.number_of_colors == 0) + + child_0() + child_8() + child_256() + + +def test_init_descriptor_always_initted(all_terms): + "Test height and width with non-tty Terminals." + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=StringIO()) + assert t._init_descriptor == sys.__stdout__.fileno() + assert (isinstance(t.height, int)) + assert (isinstance(t.width, int)) + assert t.height == t._height_and_width()[0] + assert t.width == t._height_and_width()[1] + + child(all_terms) + + +def test_force_styling_none(all_terms): + "If ``force_styling=None`` is used, don't ever do styling." + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, force_styling=None) + assert (t.save == '') + assert (t.color(9) == '') + assert (t.bold('oi') == 'oi') + + child(all_terms) + + +def test_setupterm_singleton_issue33(): + "A warning is emitted if a new terminal ``kind`` is used per process." + @as_subprocess + def child(): + warnings.filterwarnings("error", category=UserWarning) + + # instantiate first terminal, of type xterm-256color + term = TestTerminal(force_styling=True) + + try: + # a second instantiation raises UserWarning + term = TestTerminal(kind="vt220", force_styling=True) + except UserWarning: + err = sys.exc_info()[1] + assert (err.args[0].startswith( + 'A terminal of kind "vt220" has been requested') + ), err.args[0] + assert ('a terminal of kind "xterm-256color" will ' + 'continue to be returned' in err.args[0]), err.args[0] + else: + # unless term is not a tty and setupterm() is not called + assert not term.is_a_tty or False, 'Should have thrown exception' + warnings.resetwarnings() + + child() + + +def test_setupterm_invalid_issue39(): + "A warning is emitted if TERM is invalid." + # https://bugzilla.mozilla.org/show_bug.cgi?id=878089 + + # if TERM is unset, defaults to 'unknown', which should + # fail to lookup and emit a warning, only. + @as_subprocess + def child(): + warnings.filterwarnings("error", category=UserWarning) + + try: + term = TestTerminal(kind='unknown', force_styling=True) + except UserWarning: + err = sys.exc_info()[1] + assert err.args[0] == ( + "Failed to setupterm(kind='unknown'): " + "setupterm: could not find terminal") + else: + assert not term.is_a_tty and not term.does_styling, ( + 'Should have thrown exception') + warnings.resetwarnings() + + child() + + +def test_setupterm_invalid_has_no_styling(): + "An unknown TERM type does not perform styling." + # https://bugzilla.mozilla.org/show_bug.cgi?id=878089 + + # if TERM is unset, defaults to 'unknown', which should + # fail to lookup and emit a warning, only. + @as_subprocess + def child(): + warnings.filterwarnings("ignore", category=UserWarning) + + term = TestTerminal(kind='unknown', force_styling=True) + assert term.kind is None + assert term.does_styling is False + assert term.number_of_colors == 0 + warnings.resetwarnings() + + child() + + +@pytest.mark.skipif(platform.python_implementation() == 'PyPy', + reason='PyPy freezes') +def test_missing_ordereddict_uses_module(monkeypatch): + "ordereddict module is imported when without collections.OrderedDict." + import blessings.keyboard + + if hasattr(collections, 'OrderedDict'): + monkeypatch.delattr('collections.OrderedDict') + + try: + imp.reload(blessings.keyboard) + except ImportError as err: + assert err.args[0] in ("No module named ordereddict", # py2 + "No module named 'ordereddict'") # py3 + sys.modules['ordereddict'] = mock.Mock() + sys.modules['ordereddict'].OrderedDict = -1 + imp.reload(blessings.keyboard) + assert blessings.keyboard.OrderedDict == -1 + del sys.modules['ordereddict'] + monkeypatch.undo() + imp.reload(blessings.keyboard) + else: + assert platform.python_version_tuple() < ('2', '7') # reached by py2.6 + + +@pytest.mark.skipif(platform.python_implementation() == 'PyPy', + reason='PyPy freezes') +def test_python3_2_raises_exception(monkeypatch): + "Test python version 3.0 through 3.2 raises an exception." + import blessings + + monkeypatch.setattr('platform.python_version_tuple', + lambda: ('3', '2', '2')) + + try: + imp.reload(blessings) + except ImportError as err: + assert err.args[0] == ( + 'Blessings needs Python 3.2.3 or greater for Python 3 ' + 'support due to http://bugs.python.org/issue10570.') + monkeypatch.undo() + imp.reload(blessings) + else: + assert False, 'Exception should have been raised' + + +def test_IOUnsupportedOperation_dummy(monkeypatch): + "Ensure dummy exception is used when io is without UnsupportedOperation." + import blessings.terminal + import io + if hasattr(io, 'UnsupportedOperation'): + monkeypatch.delattr('io.UnsupportedOperation') + + imp.reload(blessings.terminal) + assert blessings.terminal.IOUnsupportedOperation.__doc__.startswith( + "A dummy exception to take the place of") + monkeypatch.undo() + imp.reload(blessings.terminal) + + +def test_without_dunder(): + "Ensure dunder does not remain in module (py2x InterruptedError test." + import blessings.terminal + assert '_' not in dir(blessings.terminal) + + +def test_IOUnsupportedOperation(): + "Ensure stream that throws IOUnsupportedOperation results in non-tty." + @as_subprocess + def child(): + import blessings.terminal + + def side_effect(): + raise blessings.terminal.IOUnsupportedOperation + + mock_stream = mock.Mock() + mock_stream.fileno = side_effect + + term = TestTerminal(stream=mock_stream) + assert term.stream == mock_stream + assert term.does_styling is False + assert term.is_a_tty is False + assert term.number_of_colors is 0 + + child() + + +def test_winsize_IOError_returns_environ(): + """When _winsize raises IOError, defaults from os.environ given.""" + @as_subprocess + def child(): + def side_effect(fd): + raise IOError + + term = TestTerminal() + term._winsize = side_effect + os.environ['COLUMNS'] = '1984' + os.environ['LINES'] = '1888' + assert term._height_and_width() == (1888, 1984, None, None) + + child() + + +def test_yield_fullscreen(all_terms): + "Ensure ``fullscreen()`` writes enter_fullscreen and exit_fullscreen." + @as_subprocess + def child(kind): + t = TestTerminal(stream=StringIO(), force_styling=True) + t.enter_fullscreen = u'BEGIN' + t.exit_fullscreen = u'END' + with t.fullscreen(): + pass + expected_output = u''.join((t.enter_fullscreen, t.exit_fullscreen)) + assert (t.stream.getvalue() == expected_output) + + child(all_terms) + + +def test_yield_hidden_cursor(all_terms): + "Ensure ``hidden_cursor()`` writes hide_cursor and normal_cursor." + @as_subprocess + def child(kind): + t = TestTerminal(stream=StringIO(), force_styling=True) + t.hide_cursor = u'BEGIN' + t.normal_cursor = u'END' + with t.hidden_cursor(): + pass + expected_output = u''.join((t.hide_cursor, t.normal_cursor)) + assert (t.stream.getvalue() == expected_output) + + child(all_terms) + + +def test_no_preferredencoding_fallback_ascii(): + "Ensure empty preferredencoding value defaults to ascii." + @as_subprocess + def child(): + with mock.patch('locale.getpreferredencoding') as get_enc: + get_enc.return_value = u'' + t = TestTerminal() + assert t._encoding == 'ascii' + + child() + + +def test_unknown_preferredencoding_warned_and_fallback_ascii(): + "Ensure a locale without a codecs incrementaldecoder emits a warning." + @as_subprocess + def child(): + with mock.patch('locale.getpreferredencoding') as get_enc: + with warnings.catch_warnings(record=True) as warned: + get_enc.return_value = '---unknown--encoding---' + t = TestTerminal() + assert t._encoding == 'ascii' + assert len(warned) == 1 + assert issubclass(warned[-1].category, UserWarning) + assert "fallback to ASCII" in str(warned[-1].message) + + child() + + +def test_win32_missing_tty_modules(monkeypatch): + "Ensure dummy exception is used when io is without UnsupportedOperation." + @as_subprocess + def child(): + OLD_STYLE = False + try: + original_import = getattr(__builtins__, '__import__') + OLD_STYLE = True + except AttributeError: + original_import = __builtins__['__import__'] + + tty_modules = ('termios', 'fcntl', 'tty') + + def __import__(name, *args, **kwargs): + if name in tty_modules: + raise ImportError + return original_import(name, *args, **kwargs) + + for module in tty_modules: + sys.modules.pop(module, None) + + warnings.filterwarnings("error", category=UserWarning) + try: + if OLD_STYLE: + __builtins__.__import__ = __import__ + else: + __builtins__['__import__'] = __import__ + try: + import blessings.terminal + imp.reload(blessings.terminal) + except UserWarning: + err = sys.exc_info()[1] + assert err.args[0] == blessings.terminal.msg_nosupport + + warnings.filterwarnings("ignore", category=UserWarning) + import blessings.terminal + imp.reload(blessings.terminal) + assert blessings.terminal.HAS_TTY is False + term = blessings.terminal.Terminal('ansi') + assert term.height == 24 + assert term.width == 80 + + finally: + if OLD_STYLE: + setattr(__builtins__, '__import__', original_import) + else: + __builtins__['__import__'] = original_import + warnings.resetwarnings() + import blessings.terminal + imp.reload(blessings.terminal) + + child() diff --git a/blessings/tests/test_formatters.py b/blessings/tests/test_formatters.py new file mode 100644 index 0000000..3184140 --- /dev/null +++ b/blessings/tests/test_formatters.py @@ -0,0 +1,403 @@ +# -*- coding: utf-8 -*- +"""Tests string formatting functions.""" +import curses +import mock + + +def test_parameterizing_string_args_unspecified(monkeypatch): + """Test default args of formatters.ParameterizingString.""" + from blessings.formatters import ParameterizingString, FormattingString + # first argument to tparm() is the sequence name, returned as-is; + # subsequent arguments are usually Integers. + tparm = lambda *args: u'~'.join( + arg.decode('latin1') if not num else '%s' % (arg,) + for num, arg in enumerate(args)).encode('latin1') + + monkeypatch.setattr(curses, 'tparm', tparm) + + # given, + pstr = ParameterizingString(u'') + + # excersize __new__ + assert str(pstr) == u'' + assert pstr._normal == u'' + assert pstr._name == u'<not specified>' + + # excersize __call__ + zero = pstr(0) + assert type(zero) is FormattingString + assert zero == u'~0' + assert zero('text') == u'~0text' + + # excersize __call__ with multiple args + onetwo = pstr(1, 2) + assert type(onetwo) is FormattingString + assert onetwo == u'~1~2' + assert onetwo('text') == u'~1~2text' + + +def test_parameterizing_string_args(monkeypatch): + """Test basic formatters.ParameterizingString.""" + from blessings.formatters import ParameterizingString, FormattingString + + # first argument to tparm() is the sequence name, returned as-is; + # subsequent arguments are usually Integers. + tparm = lambda *args: u'~'.join( + arg.decode('latin1') if not num else '%s' % (arg,) + for num, arg in enumerate(args)).encode('latin1') + + monkeypatch.setattr(curses, 'tparm', tparm) + + # given, + pstr = ParameterizingString(u'cap', u'norm', u'seq-name') + + # excersize __new__ + assert str(pstr) == u'cap' + assert pstr._normal == u'norm' + assert pstr._name == u'seq-name' + + # excersize __call__ + zero = pstr(0) + assert type(zero) is FormattingString + assert zero == u'cap~0' + assert zero('text') == u'cap~0textnorm' + + # excersize __call__ with multiple args + onetwo = pstr(1, 2) + assert type(onetwo) is FormattingString + assert onetwo == u'cap~1~2' + assert onetwo('text') == u'cap~1~2textnorm' + + +def test_parameterizing_string_type_error(monkeypatch): + """Test formatters.ParameterizingString raising TypeError""" + from blessings.formatters import ParameterizingString + + def tparm_raises_TypeError(*args): + raise TypeError('custom_err') + + monkeypatch.setattr(curses, 'tparm', tparm_raises_TypeError) + + # given, + pstr = ParameterizingString(u'cap', u'norm', u'cap-name') + + # ensure TypeError when given a string raises custom exception + try: + pstr('XYZ') + assert False, "previous call should have raised TypeError" + except TypeError as err: + assert (err.args[0] == ( # py3x + "A native or nonexistent capability template, " + "'cap-name' received invalid argument ('XYZ',): " + "custom_err. You probably misspelled a " + "formatting call like `bright_red'") or + err.args[0] == ( + "A native or nonexistent capability template, " + "u'cap-name' received invalid argument ('XYZ',): " + "custom_err. You probably misspelled a " + "formatting call like `bright_red'")) + + # ensure TypeError when given an integer raises its natural exception + try: + pstr(0) + assert False, "previous call should have raised TypeError" + except TypeError as err: + assert err.args[0] == "custom_err" + + +def test_formattingstring(monkeypatch): + """Test formatters.FormattingString""" + from blessings.formatters import FormattingString + + # given, with arg + pstr = FormattingString(u'attr', u'norm') + + # excersize __call__, + assert pstr._normal == u'norm' + assert str(pstr) == u'attr' + assert pstr('text') == u'attrtextnorm' + + # given, without arg + pstr = FormattingString(u'', u'norm') + assert pstr('text') == u'text' + + +def test_nullcallablestring(monkeypatch): + """Test formatters.NullCallableString""" + from blessings.formatters import (NullCallableString) + + # given, with arg + pstr = NullCallableString() + + # excersize __call__, + assert str(pstr) == u'' + assert pstr('text') == u'text' + assert pstr('text', 1) == u'' + assert pstr('text', 'moretext') == u'' + assert pstr(99, 1) == u'' + assert pstr() == u'' + assert pstr(0) == u'' + + +def test_split_compound(): + """Test formatters.split_compound.""" + from blessings.formatters import split_compound + + assert split_compound(u'') == [u''] + assert split_compound(u'a_b_c') == [u'a', u'b', u'c'] + assert split_compound(u'a_on_b_c') == [u'a', u'on_b', u'c'] + assert split_compound(u'a_bright_b_c') == [u'a', u'bright_b', u'c'] + assert split_compound(u'a_on_bright_b_c') == [u'a', u'on_bright_b', u'c'] + + +def test_resolve_capability(monkeypatch): + """Test formatters.resolve_capability and term sugaring """ + from blessings.formatters import resolve_capability + + # given, always returns a b'seq' + tigetstr = lambda attr: ('seq-%s' % (attr,)).encode('latin1') + monkeypatch.setattr(curses, 'tigetstr', tigetstr) + term = mock.Mock() + term._sugar = dict(mnemonic='xyz') + + # excersize + assert resolve_capability(term, 'mnemonic') == u'seq-xyz' + assert resolve_capability(term, 'natural') == u'seq-natural' + + # given, where tigetstr returns None + tigetstr_none = lambda attr: None + monkeypatch.setattr(curses, 'tigetstr', tigetstr_none) + + # excersize, + assert resolve_capability(term, 'natural') == u'' + + # given, where does_styling is False + def raises_exception(*args): + assert False, "Should not be called" + term.does_styling = False + monkeypatch.setattr(curses, 'tigetstr', raises_exception) + + # excersize, + assert resolve_capability(term, 'natural') == u'' + + +def test_resolve_color(monkeypatch): + """Test formatters.resolve_color.""" + from blessings.formatters import (resolve_color, + FormattingString, + NullCallableString) + + color_cap = lambda digit: 'seq-%s' % (digit,) + monkeypatch.setattr(curses, 'COLOR_RED', 1984) + + # given, terminal with color capabilities + term = mock.Mock() + term._background_color = color_cap + term._foreground_color = color_cap + term.number_of_colors = -1 + term.normal = 'seq-normal' + + # excersize, + red = resolve_color(term, 'red') + assert type(red) == FormattingString + assert red == u'seq-1984' + assert red('text') == u'seq-1984textseq-normal' + + # excersize bold, +8 + bright_red = resolve_color(term, 'bright_red') + assert type(bright_red) == FormattingString + assert bright_red == u'seq-1992' + assert bright_red('text') == u'seq-1992textseq-normal' + + # given, terminal without color + term.number_of_colors = 0 + + # excersize, + red = resolve_color(term, 'red') + assert type(red) == NullCallableString + assert red == u'' + assert red('text') == u'text' + + # excesize bold, + bright_red = resolve_color(term, 'bright_red') + assert type(bright_red) == NullCallableString + assert bright_red == u'' + assert bright_red('text') == u'text' + + +def test_resolve_attribute_as_color(monkeypatch): + """ Test simple resolve_attribte() given color name. """ + import blessings + from blessings.formatters import resolve_attribute + + resolve_color = lambda term, digit: 'seq-%s' % (digit,) + COLORS = set(['COLORX', 'COLORY']) + COMPOUNDABLES = set(['JOINT', 'COMPOUND']) + monkeypatch.setattr(blessings.formatters, 'resolve_color', resolve_color) + monkeypatch.setattr(blessings.formatters, 'COLORS', COLORS) + monkeypatch.setattr(blessings.formatters, 'COMPOUNDABLES', COMPOUNDABLES) + term = mock.Mock() + assert resolve_attribute(term, 'COLORX') == u'seq-COLORX' + + +def test_resolve_attribute_as_compoundable(monkeypatch): + """ Test simple resolve_attribte() given a compoundable. """ + import blessings + from blessings.formatters import resolve_attribute, FormattingString + + resolve_cap = lambda term, digit: 'seq-%s' % (digit,) + COMPOUNDABLES = set(['JOINT', 'COMPOUND']) + monkeypatch.setattr(blessings.formatters, + 'resolve_capability', + resolve_cap) + monkeypatch.setattr(blessings.formatters, 'COMPOUNDABLES', COMPOUNDABLES) + term = mock.Mock() + term.normal = 'seq-normal' + + compound = resolve_attribute(term, 'JOINT') + assert type(compound) is FormattingString + assert str(compound) == u'seq-JOINT' + assert compound('text') == u'seq-JOINTtextseq-normal' + + +def test_resolve_attribute_non_compoundables(monkeypatch): + """ Test recursive compounding of resolve_attribute(). """ + import blessings + from blessings.formatters import resolve_attribute, ParameterizingString + uncompoundables = lambda attr: ['split', 'compound'] + resolve_cap = lambda term, digit: 'seq-%s' % (digit,) + monkeypatch.setattr(blessings.formatters, + 'split_compound', + uncompoundables) + monkeypatch.setattr(blessings.formatters, + 'resolve_capability', + resolve_cap) + tparm = lambda *args: u'~'.join( + arg.decode('latin1') if not num else '%s' % (arg,) + for num, arg in enumerate(args)).encode('latin1') + monkeypatch.setattr(curses, 'tparm', tparm) + + term = mock.Mock() + term.normal = 'seq-normal' + + # given + pstr = resolve_attribute(term, 'not-a-compoundable') + assert type(pstr) == ParameterizingString + assert str(pstr) == u'seq-not-a-compoundable' + # this is like calling term.move_x(3) + assert pstr(3) == u'seq-not-a-compoundable~3' + # this is like calling term.move_x(3)('text') + assert pstr(3)('text') == u'seq-not-a-compoundable~3textseq-normal' + + +def test_resolve_attribute_recursive_compoundables(monkeypatch): + """ Test recursive compounding of resolve_attribute(). """ + import blessings + from blessings.formatters import resolve_attribute, FormattingString + + # patch, + resolve_cap = lambda term, digit: 'seq-%s' % (digit,) + monkeypatch.setattr(blessings.formatters, + 'resolve_capability', + resolve_cap) + tparm = lambda *args: u'~'.join( + arg.decode('latin1') if not num else '%s' % (arg,) + for num, arg in enumerate(args)).encode('latin1') + monkeypatch.setattr(curses, 'tparm', tparm) + monkeypatch.setattr(curses, 'COLOR_RED', 6502) + monkeypatch.setattr(curses, 'COLOR_BLUE', 6800) + + color_cap = lambda digit: 'seq-%s' % (digit,) + term = mock.Mock() + term._background_color = color_cap + term._foreground_color = color_cap + term.normal = 'seq-normal' + + # given, + pstr = resolve_attribute(term, 'bright_blue_on_red') + + # excersize, + assert type(pstr) == FormattingString + assert str(pstr) == 'seq-6808seq-6502' + assert pstr('text') == 'seq-6808seq-6502textseq-normal' + + +def test_pickled_parameterizing_string(monkeypatch): + """Test pickle-ability of a formatters.ParameterizingString.""" + from blessings.formatters import ParameterizingString, FormattingString + + # simply send()/recv() over multiprocessing Pipe, a simple + # pickle.loads(dumps(...)) did not reproduce this issue, + from multiprocessing import Pipe + import pickle + + # first argument to tparm() is the sequence name, returned as-is; + # subsequent arguments are usually Integers. + tparm = lambda *args: u'~'.join( + arg.decode('latin1') if not num else '%s' % (arg,) + for num, arg in enumerate(args)).encode('latin1') + + monkeypatch.setattr(curses, 'tparm', tparm) + + # given, + pstr = ParameterizingString(u'seqname', u'norm', u'cap-name') + + # multiprocessing Pipe implicitly pickles. + r, w = Pipe() + + # excersize picklability of ParameterizingString + for proto_num in range(pickle.HIGHEST_PROTOCOL): + assert pstr == pickle.loads(pickle.dumps(pstr, protocol=proto_num)) + w.send(pstr) + r.recv() == pstr + + # excersize picklability of FormattingString + # -- the return value of calling ParameterizingString + zero = pstr(0) + for proto_num in range(pickle.HIGHEST_PROTOCOL): + assert zero == pickle.loads(pickle.dumps(zero, protocol=proto_num)) + w.send(zero) + r.recv() == zero + + +def test_tparm_returns_null(monkeypatch): + """ Test 'tparm() returned NULL' is caught (win32 PDCurses systems). """ + # on win32, any calls to tparm raises curses.error with message, + # "tparm() returned NULL", function PyCurses_tparm of _cursesmodule.c + from blessings.formatters import ParameterizingString, NullCallableString + + def tparm(*args): + raise curses.error("tparm() returned NULL") + + monkeypatch.setattr(curses, 'tparm', tparm) + + term = mock.Mock() + term.normal = 'seq-normal' + + pstr = ParameterizingString(u'cap', u'norm', u'seq-name') + + value = pstr(u'x') + assert type(value) is NullCallableString + + +def test_tparm_other_exception(monkeypatch): + """ Test 'tparm() returned NULL' is caught (win32 PDCurses systems). """ + # on win32, any calls to tparm raises curses.error with message, + # "tparm() returned NULL", function PyCurses_tparm of _cursesmodule.c + from blessings.formatters import ParameterizingString, NullCallableString + + def tparm(*args): + raise curses.error("unexpected error in tparm()") + + monkeypatch.setattr(curses, 'tparm', tparm) + + term = mock.Mock() + term.normal = 'seq-normal' + + pstr = ParameterizingString(u'cap', u'norm', u'seq-name') + + try: + pstr(u'x') + assert False, "previous call should have raised curses.error" + except curses.error: + pass diff --git a/blessings/tests/test_keyboard.py b/blessings/tests/test_keyboard.py new file mode 100644 index 0000000..393b248 --- /dev/null +++ b/blessings/tests/test_keyboard.py @@ -0,0 +1,902 @@ +# -*- coding: utf-8 -*- +"Tests for keyboard support." +# std imports +import functools +import tempfile +try: + from StringIO import StringIO +except ImportError: + import io + StringIO = io.StringIO +import platform +import signal +import curses +import time +import math +import tty # NOQA +import pty +import sys +import os + +# local +from .accessories import ( + read_until_eof, + read_until_semaphore, + SEND_SEMAPHORE, + RECV_SEMAPHORE, + as_subprocess, + TestTerminal, + SEMAPHORE, + all_terms, + echo_off, + xterms, +) + +# 3rd-party +import pytest +import mock + +if sys.version_info[0] == 3: + unichr = chr + + +def test_char_is_ready_interrupted(): + "_char_is_ready() should not be interrupted with a signal handler." + pid, master_fd = pty.fork() + if pid is 0: + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + + # child pauses, writes semaphore and begins awaiting input + global got_sigwinch + got_sigwinch = False + + def on_resize(sig, action): + global got_sigwinch + got_sigwinch = True + + term = TestTerminal() + signal.signal(signal.SIGWINCH, on_resize) + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(raw=True): + assert term.keystroke(timeout=1.05) == u'' + os.write(sys.__stdout__.fileno(), b'complete') + assert got_sigwinch is True + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + read_until_semaphore(master_fd) + stime = time.time() + os.kill(pid, signal.SIGWINCH) + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'complete' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 1.0 + + +def test_char_is_ready_interrupted_nonetype(): + "_char_is_ready() should also allow interruption with timeout of None." + pid, master_fd = pty.fork() + if pid is 0: + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + + # child pauses, writes semaphore and begins awaiting input + global got_sigwinch + got_sigwinch = False + + def on_resize(sig, action): + global got_sigwinch + got_sigwinch = True + + term = TestTerminal() + signal.signal(signal.SIGWINCH, on_resize) + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(raw=True): + term.keystroke(timeout=1) + os.write(sys.__stdout__.fileno(), b'complete') + assert got_sigwinch is True + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + read_until_semaphore(master_fd) + stime = time.time() + time.sleep(0.05) + os.kill(pid, signal.SIGWINCH) + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'complete' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 1.0 + + +def test_char_is_ready_interrupted_interruptable(): + "_char_is_ready() may be interrupted when interruptable=False." + pid, master_fd = pty.fork() + if pid is 0: + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + + # child pauses, writes semaphore and begins awaiting input + global got_sigwinch + got_sigwinch = False + + def on_resize(sig, action): + global got_sigwinch + got_sigwinch = True + + term = TestTerminal() + signal.signal(signal.SIGWINCH, on_resize) + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(raw=True): + term.keystroke(timeout=1.05, interruptable=False) + os.write(sys.__stdout__.fileno(), b'complete') + assert got_sigwinch is True + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + read_until_semaphore(master_fd) + stime = time.time() + time.sleep(0.05) + os.kill(pid, signal.SIGWINCH) + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'complete' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +def test_char_is_ready_interrupted_nonetype_interruptable(): + """_char_is_ready() may be interrupted when interruptable=False with + timeout None.""" + pid, master_fd = pty.fork() + if pid is 0: + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + + # child pauses, writes semaphore and begins awaiting input + global got_sigwinch + got_sigwinch = False + + def on_resize(sig, action): + global got_sigwinch + got_sigwinch = True + + term = TestTerminal() + signal.signal(signal.SIGWINCH, on_resize) + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(raw=True): + term.keystroke(timeout=None, interruptable=False) + os.write(sys.__stdout__.fileno(), b'complete') + assert got_sigwinch is True + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + read_until_semaphore(master_fd) + stime = time.time() + time.sleep(0.05) + os.kill(pid, signal.SIGWINCH) + os.write(master_fd, b'X') + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'complete' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +def test_keystroke_input_no_kb(): + "keystroke_input() should not call tty.setcbreak() without keyboard." + @as_subprocess + def child(): + with tempfile.NamedTemporaryFile() as stream: + term = TestTerminal(stream=stream) + with mock.patch("tty.setcbreak") as mock_setcbreak: + with term.keystroke_input(): + assert not mock_setcbreak.called + assert term.keyboard_fd is None + child() + + +def test_notty_kb_is_None(): + "keyboard_fd should be None when os.isatty returns False." + # in this scenerio, stream is sys.__stdout__, + # but os.isatty(0) is False, + # such as when piping output to less(1) + @as_subprocess + def child(): + with mock.patch("os.isatty") as mock_isatty: + mock_isatty.return_value = False + term = TestTerminal() + assert term.keyboard_fd is None + child() + + +def test_raw_input_no_kb(): + "keystroke_input(raw=True) should not call tty.setraw() without keyboard." + @as_subprocess + def child(): + with tempfile.NamedTemporaryFile() as stream: + term = TestTerminal(stream=stream) + with mock.patch("tty.setraw") as mock_setraw: + with term.keystroke_input(raw=True): + assert not mock_setraw.called + assert term.keyboard_fd is None + child() + + +def test_char_is_ready_no_kb(): + "_char_is_ready() always immediately returns False without a keyboard." + @as_subprocess + def child(): + term = TestTerminal(stream=StringIO()) + stime = time.time() + assert term.keyboard_fd is None + assert term._char_is_ready(timeout=1.1) is False + assert (math.floor(time.time() - stime) == 1.0) + child() + + +def test_keystroke_0s_keystroke_input_noinput(): + "0-second keystroke without input; '' should be returned." + @as_subprocess + def child(): + term = TestTerminal() + with term.keystroke_input(): + stime = time.time() + inp = term.keystroke(timeout=0) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 0.0) + child() + + +def test_keystroke_0s_keystroke_input_noinput_nokb(): + "0-second keystroke without data in input stream and no keyboard/tty." + @as_subprocess + def child(): + term = TestTerminal(stream=StringIO()) + with term.keystroke_input(): + stime = time.time() + inp = term.keystroke(timeout=0) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 0.0) + child() + + +def test_keystroke_1s_keystroke_input_noinput(): + "1-second keystroke without input; '' should be returned after ~1 second." + @as_subprocess + def child(): + term = TestTerminal() + with term.keystroke_input(): + stime = time.time() + inp = term.keystroke(timeout=1) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 1.0) + child() + + +def test_keystroke_1s_keystroke_input_noinput_nokb(): + "1-second keystroke without input or keyboard." + @as_subprocess + def child(): + term = TestTerminal(stream=StringIO()) + with term.keystroke_input(): + stime = time.time() + inp = term.keystroke(timeout=1) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 1.0) + child() + + +def test_keystroke_0s_keystroke_input_with_input(): + "0-second keystroke with input; Keypress should be immediately returned." + pid, master_fd = pty.fork() + if pid is 0: + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + # child pauses, writes semaphore and begins awaiting input + term = TestTerminal() + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(): + inp = term.keystroke(timeout=0) + os.write(sys.__stdout__.fileno(), inp.encode('utf-8')) + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + os.write(master_fd, u'x'.encode('ascii')) + read_until_semaphore(master_fd) + stime = time.time() + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'x' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +def test_keystroke_keystroke_input_with_input_slowly(): + "0-second keystroke with input; Keypress should be immediately returned." + pid, master_fd = pty.fork() + if pid is 0: + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + # child pauses, writes semaphore and begins awaiting input + term = TestTerminal() + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(): + while True: + inp = term.keystroke(timeout=0.5) + os.write(sys.__stdout__.fileno(), inp.encode('utf-8')) + if inp == 'X': + break + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + os.write(master_fd, u'a'.encode('ascii')) + time.sleep(0.1) + os.write(master_fd, u'b'.encode('ascii')) + time.sleep(0.1) + os.write(master_fd, u'cdefgh'.encode('ascii')) + time.sleep(0.1) + os.write(master_fd, u'X'.encode('ascii')) + read_until_semaphore(master_fd) + stime = time.time() + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'abcdefghX' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +def test_keystroke_0s_keystroke_input_multibyte_utf8(): + "0-second keystroke with multibyte utf-8 input; should decode immediately." + # utf-8 bytes represent "latin capital letter upsilon". + pid, master_fd = pty.fork() + if pid is 0: # child + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + term = TestTerminal() + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(): + inp = term.keystroke(timeout=0) + os.write(sys.__stdout__.fileno(), inp.encode('utf-8')) + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + os.write(master_fd, u'\u01b1'.encode('utf-8')) + read_until_semaphore(master_fd) + stime = time.time() + output = read_until_eof(master_fd) + pid, status = os.waitpid(pid, 0) + assert output == u'Ʊ' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +@pytest.mark.skipif(os.environ.get('TRAVIS', None) is not None or + platform.python_implementation() == 'PyPy', + reason="travis-ci nor pypy handle ^C very well.") +def test_keystroke_0s_raw_input_ctrl_c(): + "0-second keystroke with raw allows receiving ^C." + pid, master_fd = pty.fork() + if pid is 0: # child + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + term = TestTerminal() + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + with term.keystroke_input(raw=True): + os.write(sys.__stdout__.fileno(), RECV_SEMAPHORE) + inp = term.keystroke(timeout=0) + os.write(sys.__stdout__.fileno(), inp.encode('latin1')) + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + # ensure child is in raw mode before sending ^C, + read_until_semaphore(master_fd) + os.write(master_fd, u'\x03'.encode('latin1')) + stime = time.time() + output = read_until_eof(master_fd) + pid, status = os.waitpid(pid, 0) + assert (output == u'\x03' or + output == u'' and not os.isatty(0)) + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +def test_keystroke_0s_keystroke_input_sequence(): + "0-second keystroke with multibyte sequence; should decode immediately." + pid, master_fd = pty.fork() + if pid is 0: # child + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(): + inp = term.keystroke(timeout=0) + os.write(sys.__stdout__.fileno(), inp.name.encode('ascii')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, u'\x1b[D'.encode('ascii')) + read_until_semaphore(master_fd) + stime = time.time() + output = read_until_eof(master_fd) + pid, status = os.waitpid(pid, 0) + assert output == u'KEY_LEFT' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +def test_keystroke_1s_keystroke_input_with_input(): + "1-second keystroke w/multibyte sequence; should return after ~1 second." + pid, master_fd = pty.fork() + if pid is 0: # child + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(): + inp = term.keystroke(timeout=3) + os.write(sys.__stdout__.fileno(), inp.name.encode('utf-8')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + read_until_semaphore(master_fd) + stime = time.time() + time.sleep(1) + os.write(master_fd, u'\x1b[C'.encode('ascii')) + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'KEY_RIGHT' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 1.0 + + +def test_esc_delay_keystroke_input_035(): + "esc_delay will cause a single ESC (\\x1b) to delay for 0.35." + pid, master_fd = pty.fork() + if pid is 0: # child + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(): + stime = time.time() + inp = term.keystroke(timeout=5) + measured_time = (time.time() - stime) * 100 + os.write(sys.__stdout__.fileno(), ( + '%s %i' % (inp.name, measured_time,)).encode('ascii')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + read_until_semaphore(master_fd) + stime = time.time() + os.write(master_fd, u'\x1b'.encode('ascii')) + key_name, duration_ms = read_until_eof(master_fd).split() + + pid, status = os.waitpid(pid, 0) + assert key_name == u'KEY_ESCAPE' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + assert 34 <= int(duration_ms) <= 45, duration_ms + + +def test_esc_delay_keystroke_input_135(): + "esc_delay=1.35 will cause a single ESC (\\x1b) to delay for 1.35." + pid, master_fd = pty.fork() + if pid is 0: # child + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(): + stime = time.time() + inp = term.keystroke(timeout=5, esc_delay=1.35) + measured_time = (time.time() - stime) * 100 + os.write(sys.__stdout__.fileno(), ( + '%s %i' % (inp.name, measured_time,)).encode('ascii')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + read_until_semaphore(master_fd) + stime = time.time() + os.write(master_fd, u'\x1b'.encode('ascii')) + key_name, duration_ms = read_until_eof(master_fd).split() + + pid, status = os.waitpid(pid, 0) + assert key_name == u'KEY_ESCAPE' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 1.0 + assert 134 <= int(duration_ms) <= 145, int(duration_ms) + + +def test_esc_delay_keystroke_input_timout_0(): + """esc_delay still in effect with timeout of 0 ("nonblocking").""" + pid, master_fd = pty.fork() + if pid is 0: # child + try: + cov = __import__('cov_core_init').init() + except ImportError: + cov = None + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.keystroke_input(): + stime = time.time() + inp = term.keystroke(timeout=0) + measured_time = (time.time() - stime) * 100 + os.write(sys.__stdout__.fileno(), ( + '%s %i' % (inp.name, measured_time,)).encode('ascii')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, u'\x1b'.encode('ascii')) + read_until_semaphore(master_fd) + stime = time.time() + key_name, duration_ms = read_until_eof(master_fd).split() + + pid, status = os.waitpid(pid, 0) + assert key_name == u'KEY_ESCAPE' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + assert 34 <= int(duration_ms) <= 45, int(duration_ms) + + +def test_keystroke_default_args(): + "Test keyboard.Keystroke constructor with default arguments." + from blessings.keyboard import Keystroke + ks = Keystroke() + assert ks._name is None + assert ks.name == ks._name + assert ks._code is None + assert ks.code == ks._code + assert u'x' == u'x' + ks + assert ks.is_sequence is False + assert repr(ks) in ("u''", # py26, 27 + "''",) # py33 + + +def test_a_keystroke(): + "Test keyboard.Keystroke constructor with set arguments." + from blessings.keyboard import Keystroke + ks = Keystroke(ucs=u'x', code=1, name=u'the X') + assert ks._name is u'the X' + assert ks.name == ks._name + assert ks._code is 1 + assert ks.code == ks._code + assert u'xx' == u'x' + ks + assert ks.is_sequence is True + assert repr(ks) == "the X" + + +def test_get_keyboard_codes(): + "Test all values returned by get_keyboard_codes are from curses." + from blessings.keyboard import ( + get_keyboard_codes, + CURSES_KEYCODE_OVERRIDE_MIXIN, + ) + exemptions = dict(CURSES_KEYCODE_OVERRIDE_MIXIN) + for value, keycode in get_keyboard_codes().items(): + if keycode in exemptions: + assert value == exemptions[keycode] + continue + assert hasattr(curses, keycode) + assert getattr(curses, keycode) == value + + +def test_alternative_left_right(): + "Test _alternative_left_right behavior for space/backspace." + from blessings.keyboard import _alternative_left_right + term = mock.Mock() + term._cuf1 = u'' + term._cub1 = u'' + assert not bool(_alternative_left_right(term)) + term._cuf1 = u' ' + term._cub1 = u'\b' + assert not bool(_alternative_left_right(term)) + term._cuf1 = u'seq-right' + term._cub1 = u'seq-left' + assert (_alternative_left_right(term) == { + u'seq-right': curses.KEY_RIGHT, + u'seq-left': curses.KEY_LEFT}) + + +def test_cuf1_and_cub1_as_RIGHT_LEFT(all_terms): + "Test that cuf1 and cub1 are assigned KEY_RIGHT and KEY_LEFT." + from blessings.keyboard import get_keyboard_sequences + + @as_subprocess + def child(kind): + term = TestTerminal(kind=kind, force_styling=True) + keymap = get_keyboard_sequences(term) + if term._cuf1: + assert term._cuf1 in keymap + assert keymap[term._cuf1] == term.KEY_RIGHT + if term._cub1: + assert term._cub1 in keymap + if term._cub1 == '\b': + assert keymap[term._cub1] == term.KEY_BACKSPACE + else: + assert keymap[term._cub1] == term.KEY_LEFT + + child(all_terms) + + +def test_get_keyboard_sequences_sort_order(xterms): + "ordereddict ensures sequences are ordered longest-first." + @as_subprocess + def child(): + term = TestTerminal(force_styling=True) + maxlen = None + for sequence, code in term._keymap.items(): + if maxlen is not None: + assert len(sequence) <= maxlen + assert sequence + maxlen = len(sequence) + child() + + +def test_get_keyboard_sequence(monkeypatch): + "Test keyboard.get_keyboard_sequence. " + import curses.has_key + import blessings.keyboard + + (KEY_SMALL, KEY_LARGE, KEY_MIXIN) = range(3) + (CAP_SMALL, CAP_LARGE) = 'cap-small cap-large'.split() + (SEQ_SMALL, SEQ_LARGE, SEQ_MIXIN, SEQ_ALT_CUF1, SEQ_ALT_CUB1) = ( + b'seq-small-a', + b'seq-large-abcdefg', + b'seq-mixin', + b'seq-alt-cuf1', + b'seq-alt-cub1_') + + # patch curses functions + monkeypatch.setattr(curses, 'tigetstr', + lambda cap: {CAP_SMALL: SEQ_SMALL, + CAP_LARGE: SEQ_LARGE}[cap]) + + monkeypatch.setattr(curses.has_key, '_capability_names', + dict(((KEY_SMALL, CAP_SMALL,), + (KEY_LARGE, CAP_LARGE,)))) + + # patch global sequence mix-in + monkeypatch.setattr(blessings.keyboard, + 'DEFAULT_SEQUENCE_MIXIN', ( + (SEQ_MIXIN.decode('latin1'), KEY_MIXIN),)) + + # patch for _alternative_left_right + term = mock.Mock() + term._cuf1 = SEQ_ALT_CUF1.decode('latin1') + term._cub1 = SEQ_ALT_CUB1.decode('latin1') + keymap = blessings.keyboard.get_keyboard_sequences(term) + + assert list(keymap.items()) == [ + (SEQ_LARGE.decode('latin1'), KEY_LARGE), + (SEQ_ALT_CUB1.decode('latin1'), curses.KEY_LEFT), + (SEQ_ALT_CUF1.decode('latin1'), curses.KEY_RIGHT), + (SEQ_SMALL.decode('latin1'), KEY_SMALL), + (SEQ_MIXIN.decode('latin1'), KEY_MIXIN)] + + +def test_resolve_sequence(): + "Test resolve_sequence for order-dependent mapping." + from blessings.keyboard import resolve_sequence, OrderedDict + mapper = OrderedDict(((u'SEQ1', 1), + (u'SEQ2', 2), + # takes precedence over LONGSEQ, first-match + (u'KEY_LONGSEQ_longest', 3), + (u'LONGSEQ', 4), + # wont match, LONGSEQ is first-match in this order + (u'LONGSEQ_longer', 5), + # falls through for L{anything_else} + (u'L', 6))) + codes = {1: u'KEY_SEQ1', + 2: u'KEY_SEQ2', + 3: u'KEY_LONGSEQ_longest', + 4: u'KEY_LONGSEQ', + 5: u'KEY_LONGSEQ_longer', + 6: u'KEY_L'} + ks = resolve_sequence(u'', mapper, codes) + assert ks == u'' + assert ks.name is None + assert ks.code is None + assert ks.is_sequence is False + assert repr(ks) in ("u''", # py26, 27 + "''",) # py33 + + ks = resolve_sequence(u'notfound', mapper=mapper, codes=codes) + assert ks == u'n' + assert ks.name is None + assert ks.code is None + assert ks.is_sequence is False + assert repr(ks) in (u"u'n'", "'n'",) + + ks = resolve_sequence(u'SEQ1', mapper, codes) + assert ks == u'SEQ1' + assert ks.name == u'KEY_SEQ1' + assert ks.code is 1 + assert ks.is_sequence is True + assert repr(ks) in (u"KEY_SEQ1", "KEY_SEQ1") + + ks = resolve_sequence(u'LONGSEQ_longer', mapper, codes) + assert ks == u'LONGSEQ' + assert ks.name == u'KEY_LONGSEQ' + assert ks.code is 4 + assert ks.is_sequence is True + assert repr(ks) in (u"KEY_LONGSEQ", "KEY_LONGSEQ") + + ks = resolve_sequence(u'LONGSEQ', mapper, codes) + assert ks == u'LONGSEQ' + assert ks.name == u'KEY_LONGSEQ' + assert ks.code is 4 + assert ks.is_sequence is True + assert repr(ks) in (u"KEY_LONGSEQ", "KEY_LONGSEQ") + + ks = resolve_sequence(u'Lxxxxx', mapper, codes) + assert ks == u'L' + assert ks.name == u'KEY_L' + assert ks.code is 6 + assert ks.is_sequence is True + assert repr(ks) in (u"KEY_L", "KEY_L") + + +def test_keypad_mixins_and_aliases(): + """ Test PC-Style function key translations when in ``keypad`` mode.""" + # Key plain app modified + # Up ^[[A ^[OA ^[[1;mA + # Down ^[[B ^[OB ^[[1;mB + # Right ^[[C ^[OC ^[[1;mC + # Left ^[[D ^[OD ^[[1;mD + # End ^[[F ^[OF ^[[1;mF + # Home ^[[H ^[OH ^[[1;mH + @as_subprocess + def child(kind): + term = TestTerminal(kind=kind, force_styling=True) + from blessings.keyboard import resolve_sequence + + resolve = functools.partial(resolve_sequence, + mapper=term._keymap, + codes=term._keycodes) + + assert resolve(unichr(10)).name == "KEY_ENTER" + assert resolve(unichr(13)).name == "KEY_ENTER" + assert resolve(unichr(8)).name == "KEY_BACKSPACE" + assert resolve(unichr(9)).name == "KEY_TAB" + assert resolve(unichr(27)).name == "KEY_ESCAPE" + assert resolve(unichr(127)).name == "KEY_DELETE" + assert resolve(u"\x1b[A").name == "KEY_UP" + assert resolve(u"\x1b[B").name == "KEY_DOWN" + assert resolve(u"\x1b[C").name == "KEY_RIGHT" + assert resolve(u"\x1b[D").name == "KEY_LEFT" + assert resolve(u"\x1b[U").name == "KEY_PGDOWN" + assert resolve(u"\x1b[V").name == "KEY_PGUP" + assert resolve(u"\x1b[H").name == "KEY_HOME" + assert resolve(u"\x1b[F").name == "KEY_END" + assert resolve(u"\x1b[K").name == "KEY_END" + assert resolve(u"\x1bOM").name == "KEY_ENTER" + assert resolve(u"\x1bOj").name == "KEY_KP_MULTIPLY" + assert resolve(u"\x1bOk").name == "KEY_KP_ADD" + assert resolve(u"\x1bOl").name == "KEY_KP_SEPARATOR" + assert resolve(u"\x1bOm").name == "KEY_KP_SUBTRACT" + assert resolve(u"\x1bOn").name == "KEY_KP_DECIMAL" + assert resolve(u"\x1bOo").name == "KEY_KP_DIVIDE" + assert resolve(u"\x1bOX").name == "KEY_KP_EQUAL" + assert resolve(u"\x1bOp").name == "KEY_KP_0" + assert resolve(u"\x1bOq").name == "KEY_KP_1" + assert resolve(u"\x1bOr").name == "KEY_KP_2" + assert resolve(u"\x1bOs").name == "KEY_KP_3" + assert resolve(u"\x1bOt").name == "KEY_KP_4" + assert resolve(u"\x1bOu").name == "KEY_KP_5" + assert resolve(u"\x1bOv").name == "KEY_KP_6" + assert resolve(u"\x1bOw").name == "KEY_KP_7" + assert resolve(u"\x1bOx").name == "KEY_KP_8" + assert resolve(u"\x1bOy").name == "KEY_KP_9" + assert resolve(u"\x1b[1~").name == "KEY_FIND" + assert resolve(u"\x1b[2~").name == "KEY_INSERT" + assert resolve(u"\x1b[3~").name == "KEY_DELETE" + assert resolve(u"\x1b[4~").name == "KEY_SELECT" + assert resolve(u"\x1b[5~").name == "KEY_PGUP" + assert resolve(u"\x1b[6~").name == "KEY_PGDOWN" + assert resolve(u"\x1b[7~").name == "KEY_HOME" + assert resolve(u"\x1b[8~").name == "KEY_END" + assert resolve(u"\x1b[OA").name == "KEY_UP" + assert resolve(u"\x1b[OB").name == "KEY_DOWN" + assert resolve(u"\x1b[OC").name == "KEY_RIGHT" + assert resolve(u"\x1b[OD").name == "KEY_LEFT" + assert resolve(u"\x1b[OF").name == "KEY_END" + assert resolve(u"\x1b[OH").name == "KEY_HOME" + assert resolve(u"\x1bOP").name == "KEY_F1" + assert resolve(u"\x1bOQ").name == "KEY_F2" + assert resolve(u"\x1bOR").name == "KEY_F3" + assert resolve(u"\x1bOS").name == "KEY_F4" + + child('xterm') diff --git a/blessings/tests/test_length_sequence.py b/blessings/tests/test_length_sequence.py new file mode 100644 index 0000000..0eac165 --- /dev/null +++ b/blessings/tests/test_length_sequence.py @@ -0,0 +1,342 @@ +# encoding: utf-8 +import itertools +import platform +import termios +import struct +import fcntl +import sys +import os +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +from .accessories import ( + all_terms, + as_subprocess, + TestTerminal, + many_columns, + many_lines, +) + +import pytest + + +def test_length_cjk(): + @as_subprocess + def child(): + term = TestTerminal(kind='xterm-256color') + + # given, + given = term.bold_red(u'コンニチハ, セカイ!') + expected = sum((2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1,)) + + # exercise, + assert term.length(given) == expected + + child() + + +def test_length_ansiart(): + @as_subprocess + def child(): + import codecs + from blessings.sequences import Sequence + term = TestTerminal(kind='xterm-256color') + # this 'ansi' art contributed by xzip!impure for another project, + # unlike most CP-437 DOS ansi art, this is actually utf-8 encoded. + fname = os.path.join(os.path.dirname(__file__), 'wall.ans') + lines = codecs.open(fname, 'r', 'utf-8').readlines() + assert term.length(lines[0]) == 67 # ^[[64C^[[34m▄▓▄ + assert term.length(lines[1]) == 75 + assert term.length(lines[2]) == 78 + assert term.length(lines[3]) == 78 + assert term.length(lines[4]) == 78 + assert term.length(lines[5]) == 78 + assert term.length(lines[6]) == 77 + child() + + +def test_sequence_length(all_terms): + """Ensure T.length(string containing sequence) is correct.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + # Create a list of ascii characters, to be separated + # by word, to be zipped up with a cycling list of + # terminal sequences. Then, compare the length of + # each, the basic plain_text.__len__ vs. the Terminal + # method length. They should be equal. + plain_text = (u'The softest things of the world ' + u'Override the hardest things of the world ' + u'That which has no substance ' + u'Enters into that which has no openings') + if t.bold: + assert (t.length(t.bold) == 0) + assert (t.length(t.bold(u'x')) == 1) + assert (t.length(t.bold_red) == 0) + assert (t.length(t.bold_red(u'x')) == 1) + assert (t.strip(t.bold) == u'') + assert (t.rstrip(t.bold) == u'') + assert (t.lstrip(t.bold) == u'') + assert (t.strip(t.bold(u' x ')) == u'x') + assert (t.strip(t.bold(u'z x q'), 'zq') == u' x ') + assert (t.rstrip(t.bold(u' x ')) == u' x') + assert (t.lstrip(t.bold(u' x ')) == u'x ') + assert (t.strip(t.bold_red) == u'') + assert (t.rstrip(t.bold_red) == u'') + assert (t.lstrip(t.bold_red) == u'') + assert (t.strip(t.bold_red(u' x ')) == u'x') + assert (t.rstrip(t.bold_red(u' x ')) == u' x') + assert (t.lstrip(t.bold_red(u' x ')) == u'x ') + assert (t.strip_seqs(t.bold) == u'') + assert (t.strip_seqs(t.bold(u' x ')) == u' x ') + assert (t.strip_seqs(t.bold_red) == u'') + assert (t.strip_seqs(t.bold_red(u' x ')) == u' x ') + + if t.underline: + assert (t.length(t.underline) == 0) + assert (t.length(t.underline(u'x')) == 1) + assert (t.length(t.underline_red) == 0) + assert (t.length(t.underline_red(u'x')) == 1) + assert (t.strip(t.underline) == u'') + assert (t.strip(t.underline(u' x ')) == u'x') + assert (t.strip(t.underline_red) == u'') + assert (t.strip(t.underline_red(u' x ')) == u'x') + assert (t.rstrip(t.underline_red(u' x ')) == u' x') + assert (t.lstrip(t.underline_red(u' x ')) == u'x ') + assert (t.strip_seqs(t.underline) == u'') + assert (t.strip_seqs(t.underline(u' x ')) == u' x ') + assert (t.strip_seqs(t.underline_red) == u'') + assert (t.strip_seqs(t.underline_red(u' x ')) == u' x ') + + if t.reverse: + assert (t.length(t.reverse) == 0) + assert (t.length(t.reverse(u'x')) == 1) + assert (t.length(t.reverse_red) == 0) + assert (t.length(t.reverse_red(u'x')) == 1) + assert (t.strip(t.reverse) == u'') + assert (t.strip(t.reverse(u' x ')) == u'x') + assert (t.strip(t.reverse_red) == u'') + assert (t.strip(t.reverse_red(u' x ')) == u'x') + assert (t.rstrip(t.reverse_red(u' x ')) == u' x') + assert (t.lstrip(t.reverse_red(u' x ')) == u'x ') + assert (t.strip_seqs(t.reverse) == u'') + assert (t.strip_seqs(t.reverse(u' x ')) == u' x ') + assert (t.strip_seqs(t.reverse_red) == u'') + assert (t.strip_seqs(t.reverse_red(u' x ')) == u' x ') + + if t.blink: + assert (t.length(t.blink) == 0) + assert (t.length(t.blink(u'x')) == 1) + assert (t.length(t.blink_red) == 0) + assert (t.length(t.blink_red(u'x')) == 1) + assert (t.strip(t.blink) == u'') + assert (t.strip(t.blink(u' x ')) == u'x') + assert (t.strip(t.blink(u'z x q'), u'zq') == u' x ') + assert (t.strip(t.blink_red) == u'') + assert (t.strip(t.blink_red(u' x ')) == u'x') + assert (t.strip_seqs(t.blink) == u'') + assert (t.strip_seqs(t.blink(u' x ')) == u' x ') + assert (t.strip_seqs(t.blink_red) == u'') + assert (t.strip_seqs(t.blink_red(u' x ')) == u' x ') + + if t.home: + assert (t.length(t.home) == 0) + assert (t.strip(t.home) == u'') + if t.clear_eol: + assert (t.length(t.clear_eol) == 0) + assert (t.strip(t.clear_eol) == u'') + if t.enter_fullscreen: + assert (t.length(t.enter_fullscreen) == 0) + assert (t.strip(t.enter_fullscreen) == u'') + if t.exit_fullscreen: + assert (t.length(t.exit_fullscreen) == 0) + assert (t.strip(t.exit_fullscreen) == u'') + + # horizontally, we decide move_down and move_up are 0, + assert (t.length(t.move_down) == 0) + assert (t.length(t.move_down(2)) == 0) + assert (t.length(t.move_up) == 0) + assert (t.length(t.move_up(2)) == 0) + + # other things aren't so simple, somewhat edge cases, + # moving backwards and forwards horizontally must be + # accounted for as a "length", as <x><move right 10><y> + # will result in a printed column length of 12 (even + # though columns 2-11 are non-destructive space + assert (t.length(u'x\b') == 0) + assert (t.strip(u'x\b') == u'') + + # XXX why are some terminals width of 9 here ?? + assert (t.length(u'\t') in (8, 9)) + assert (t.strip(u'\t') == u'') + assert (t.length(u'_' + t.move_left) == 0) + + if t.cub: + assert (t.length((u'_' * 10) + t.cub(10)) == 0) + + assert (t.length(t.move_right) == 1) + + if t.cuf: + assert (t.length(t.cuf(10)) == 10) + + # vertical spacing is unaccounted as a 'length' + assert (t.length(t.move_up) == 0) + assert (t.length(t.cuu(10)) == 0) + assert (t.length(t.move_down) == 0) + assert (t.length(t.cud(10)) == 0) + + # this is how manpages perform underlining, this is done + # with the 'overstrike' capability of teletypes, and aparently + # less(1), '123' -> '1\b_2\b_3\b_' + text_wseqs = u''.join(itertools.chain( + *zip(plain_text, itertools.cycle(['\b_'])))) + assert (t.length(text_wseqs) == len(plain_text)) + + child(all_terms) + + +def test_env_winsize(): + """Test height and width is appropriately queried in a pty.""" + @as_subprocess + def child(): + # set the pty's virtual window size + os.environ['COLUMNS'] = '99' + os.environ['LINES'] = '11' + t = TestTerminal(stream=StringIO()) + save_init = t._init_descriptor + save_stdout = sys.__stdout__ + try: + t._init_descriptor = None + sys.__stdout__ = None + winsize = t._height_and_width() + width = t.width + height = t.height + finally: + t._init_descriptor = save_init + sys.__stdout__ = save_stdout + assert winsize.ws_col == width == 99 + assert winsize.ws_row == height == 11 + + child() + + +@pytest.mark.skipif(platform.python_implementation() == 'PyPy', + reason='PyPy fails TIOCSWINSZ') +def test_winsize(many_lines, many_columns): + """Test height and width is appropriately queried in a pty.""" + @as_subprocess + def child(lines=25, cols=80): + # set the pty's virtual window size + val = struct.pack('HHHH', lines, cols, 0, 0) + fcntl.ioctl(sys.__stdout__.fileno(), termios.TIOCSWINSZ, val) + t = TestTerminal() + winsize = t._height_and_width() + assert t.width == cols + assert t.height == lines + assert winsize.ws_col == cols + assert winsize.ws_row == lines + + child(lines=many_lines, cols=many_columns) + + +@pytest.mark.skipif(platform.python_implementation() == 'PyPy', + reason='PyPy fails TIOCSWINSZ') +def test_Sequence_alignment(all_terms): + """Tests methods related to Sequence class, namely ljust, rjust, center.""" + @as_subprocess + def child(kind, lines=25, cols=80): + # set the pty's virtual window size + val = struct.pack('HHHH', lines, cols, 0, 0) + fcntl.ioctl(sys.__stdout__.fileno(), termios.TIOCSWINSZ, val) + t = TestTerminal(kind=kind) + + pony_msg = 'pony express, all aboard, choo, choo!' + pony_len = len(pony_msg) + pony_colored = u''.join( + ['%s%s' % (t.color(n % 7), ch,) + for n, ch in enumerate(pony_msg)]) + pony_colored += t.normal + ladjusted = t.ljust(pony_colored) + radjusted = t.rjust(pony_colored) + centered = t.center(pony_colored) + assert (t.length(pony_colored) == pony_len) + assert (t.length(centered.strip()) == pony_len) + assert (t.length(centered) == len(pony_msg.center(t.width))) + assert (t.length(ladjusted.strip()) == pony_len) + assert (t.length(ladjusted) == len(pony_msg.ljust(t.width))) + assert (t.length(radjusted.strip()) == pony_len) + assert (t.length(radjusted) == len(pony_msg.rjust(t.width))) + + child(kind=all_terms) + + +def test_sequence_is_movement_false(all_terms): + """Test parser about sequences that do not move the cursor.""" + @as_subprocess + def child_mnemonics_wontmove(kind): + from blessings.sequences import measure_length + t = TestTerminal(kind=kind) + assert (0 == measure_length(u'', t)) + # not even a mbs + assert (0 == measure_length(u'xyzzy', t)) + # negative numbers, though printable as %d, do not result + # in movement; just garbage. Also not a valid sequence. + assert (0 == measure_length(t.cuf(-333), t)) + assert (len(t.clear_eol) == measure_length(t.clear_eol, t)) + # various erases don't *move* + assert (len(t.clear_bol) == measure_length(t.clear_bol, t)) + assert (len(t.clear_eos) == measure_length(t.clear_eos, t)) + assert (len(t.bold) == measure_length(t.bold, t)) + # various paints don't move + assert (len(t.red) == measure_length(t.red, t)) + assert (len(t.civis) == measure_length(t.civis, t)) + if t.cvvis: + assert (len(t.cvvis) == measure_length(t.cvvis, t)) + assert (len(t.underline) == measure_length(t.underline, t)) + assert (len(t.reverse) == measure_length(t.reverse, t)) + for _num in range(t.number_of_colors): + assert (len(t.color(_num)) == measure_length(t.color(_num), t)) + assert (len(t.normal) == measure_length(t.normal, t)) + assert (len(t.normal_cursor) == measure_length(t.normal_cursor, t)) + assert (len(t.hide_cursor) == measure_length(t.hide_cursor, t)) + assert (len(t.save) == measure_length(t.save, t)) + assert (len(t.italic) == measure_length(t.italic, t)) + assert (len(t.standout) == measure_length(t.standout, t) + ), (t.standout, t._wont_move) + + child_mnemonics_wontmove(all_terms) + + +def test_sequence_is_movement_true(all_terms): + """Test parsers about sequences that move the cursor.""" + @as_subprocess + def child_mnemonics_willmove(kind): + from blessings.sequences import measure_length + t = TestTerminal(kind=kind) + # movements + assert (len(t.move(98, 76)) == + measure_length(t.move(98, 76), t)) + assert (len(t.move(54)) == + measure_length(t.move(54), t)) + assert not t.cud1 or (len(t.cud1) == + measure_length(t.cud1, t)) + assert not t.cub1 or (len(t.cub1) == + measure_length(t.cub1, t)) + assert not t.cuf1 or (len(t.cuf1) == + measure_length(t.cuf1, t)) + assert not t.cuu1 or (len(t.cuu1) == + measure_length(t.cuu1, t)) + assert not t.cub or (len(t.cub(333)) == + measure_length(t.cub(333), t)) + assert not t.cuf or (len(t.cuf(333)) == + measure_length(t.cuf(333), t)) + assert not t.home or (len(t.home) == + measure_length(t.home, t)) + assert not t.restore or (len(t.restore) == + measure_length(t.restore, t)) + assert not t.clear or (len(t.clear) == + measure_length(t.clear, t)) + + child_mnemonics_willmove(all_terms) diff --git a/blessings/tests/test_sequences.py b/blessings/tests/test_sequences.py new file mode 100644 index 0000000..f42a52c --- /dev/null +++ b/blessings/tests/test_sequences.py @@ -0,0 +1,549 @@ +# -*- coding: utf-8 -*- +"""Tests for Terminal() sequences and sequence-awareness.""" +# std imports +try: + from StringIO import StringIO +except ImportError: + from io import StringIO +import platform +import random +import sys +import os + +# local +from .accessories import ( + unsupported_sequence_terminals, + all_terms, + as_subprocess, + TestTerminal, + unicode_parm, + many_columns, + unicode_cap, +) + +# 3rd-party +import pytest +import mock + + +def test_capability(): + """Check that capability lookup works.""" + @as_subprocess + def child(): + # Also test that Terminal grabs a reasonable default stream. This test + # assumes it will be run from a tty. + t = TestTerminal() + sc = unicode_cap('sc') + assert t.save == sc + assert t.save == sc # Make sure caching doesn't screw it up. + + child() + + +def test_capability_without_tty(): + """Assert capability templates are '' when stream is not a tty.""" + @as_subprocess + def child(): + t = TestTerminal(stream=StringIO()) + assert t.save == u'' + assert t.red == u'' + + child() + + +def test_capability_with_forced_tty(): + """force styling should return sequences even for non-ttys.""" + @as_subprocess + def child(): + t = TestTerminal(stream=StringIO(), force_styling=True) + assert t.save == unicode_cap('sc') + + child() + + +def test_parametrization(): + """Test parameterizing a capability.""" + @as_subprocess + def child(): + assert TestTerminal().cup(3, 4) == unicode_parm('cup', 3, 4) + + child() + + +def test_height_and_width(): + """Assert that ``height_and_width()`` returns full integers.""" + @as_subprocess + def child(): + t = TestTerminal() # kind shouldn't matter. + assert isinstance(t.height, int) + assert isinstance(t.width, int) + + child() + + +def test_stream_attr(): + """Make sure Terminal ``stream`` is stdout by default.""" + @as_subprocess + def child(): + assert TestTerminal().stream == sys.__stdout__ + + child() + + +@pytest.mark.skipif(os.environ.get('TRAVIS', None) is not None, + reason="travis-ci does not have binary-packed terminals.") +def test_emit_warnings_about_binpacked(): + """Test known binary-packed terminals (kermit, avatar) emit a warning.""" + from blessings.sequences import _BINTERM_UNSUPPORTED_MSG + from blessings._binterms import binary_terminals + + @as_subprocess + def child(kind): + import warnings + warnings.filterwarnings("error", category=RuntimeWarning) + warnings.filterwarnings("error", category=UserWarning) + + try: + TestTerminal(kind=kind, force_styling=True) + except UserWarning: + err = sys.exc_info()[1] + assert (err.args[0] == _BINTERM_UNSUPPORTED_MSG.format(kind) or + err.args[0].startswith('Unknown parameter in ') or + err.args[0].startswith('Failed to setupterm(') + ), err + else: + assert 'warnings should have been emitted.' + warnings.resetwarnings() + + # any binary terminal should do. + child(binary_terminals[random.randrange(len(binary_terminals))]) + + +def test_unit_binpacked_unittest(): + """Unit Test known binary-packed terminals emit a warning (travis-safe).""" + import warnings + from blessings._binterms import binary_terminals + from blessings.sequences import (_BINTERM_UNSUPPORTED_MSG, + init_sequence_patterns) + warnings.filterwarnings("error", category=UserWarning) + term = mock.Mock() + term.kind = binary_terminals[random.randrange(len(binary_terminals))] + + try: + init_sequence_patterns(term) + except UserWarning: + err = sys.exc_info()[1] + assert err.args[0] == _BINTERM_UNSUPPORTED_MSG.format(term.kind) + else: + assert False, 'Previous stmt should have raised exception.' + warnings.resetwarnings() + + +def test_merge_sequences(): + """Test sequences are filtered and ordered longest-first.""" + from blessings.sequences import _merge_sequences + input_list = [u'a', u'aa', u'aaa', u''] + output_expected = [u'aaa', u'aa', u'a'] + assert (_merge_sequences(input_list) == output_expected) + + +def test_location_with_styling(all_terms): + """Make sure ``location()`` works on all terminals.""" + @as_subprocess + def child_with_styling(kind): + t = TestTerminal(kind=kind, stream=StringIO(), force_styling=True) + with t.location(3, 4): + t.stream.write(u'hi') + expected_output = u''.join( + (unicode_cap('sc'), + unicode_parm('cup', 4, 3), + u'hi', unicode_cap('rc'))) + assert (t.stream.getvalue() == expected_output) + + child_with_styling(all_terms) + + +def test_location_without_styling(): + """Make sure ``location()`` silently passes without styling.""" + @as_subprocess + def child_without_styling(): + """No side effect for location as a context manager without styling.""" + t = TestTerminal(stream=StringIO(), force_styling=None) + + with t.location(3, 4): + t.stream.write(u'hi') + + assert t.stream.getvalue() == u'hi' + + child_without_styling() + + +def test_horizontal_location(all_terms): + """Make sure we can move the cursor horizontally without changing rows.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=StringIO(), force_styling=True) + with t.location(x=5): + pass + expected_output = u''.join( + (unicode_cap('sc'), + unicode_parm('hpa', 5), + unicode_cap('rc'))) + assert (t.stream.getvalue() == expected_output), ( + repr(t.stream.getvalue()), repr(expected_output)) + + # skip 'screen', hpa is proxied (see later tests) + if all_terms != 'screen': + child(all_terms) + + +def test_vertical_location(all_terms): + """Make sure we can move the cursor horizontally without changing rows.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=StringIO(), force_styling=True) + with t.location(y=5): + pass + expected_output = u''.join( + (unicode_cap('sc'), + unicode_parm('vpa', 5), + unicode_cap('rc'))) + assert (t.stream.getvalue() == expected_output) + + # skip 'screen', vpa is proxied (see later tests) + if all_terms != 'screen': + child(all_terms) + + +def test_inject_move_x(): + """Test injection of hpa attribute for screen/ansi (issue #55).""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=StringIO(), force_styling=True) + COL = 5 + with t.location(x=COL): + pass + expected_output = u''.join( + (unicode_cap('sc'), + u'\x1b[{0}G'.format(COL + 1), + unicode_cap('rc'))) + assert (t.stream.getvalue() == expected_output) + assert (t.move_x(COL) == u'\x1b[{0}G'.format(COL + 1)) + + child('screen') + child('screen-256color') + child('ansi') + + +def test_inject_move_y(): + """Test injection of vpa attribute for screen/ansi (issue #55).""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=StringIO(), force_styling=True) + ROW = 5 + with t.location(y=ROW): + pass + expected_output = u''.join( + (unicode_cap('sc'), + u'\x1b[{0}d'.format(ROW + 1), + unicode_cap('rc'))) + assert (t.stream.getvalue() == expected_output) + assert (t.move_y(ROW) == u'\x1b[{0}d'.format(ROW + 1)) + + child('screen') + child('screen-256color') + child('ansi') + + +def test_inject_civis_and_cnorm_for_ansi(): + """Test injection of cvis attribute for ansi.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=StringIO(), force_styling=True) + with t.hidden_cursor(): + pass + expected_output = u''.join( + (unicode_cap('sc'), + u'\x1b[?25l\x1b[?25h', + unicode_cap('rc'))) + assert (t.stream.getvalue() == expected_output) + + child('ansi') + + +def test_zero_location(all_terms): + """Make sure ``location()`` pays attention to 0-valued args.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=StringIO(), force_styling=True) + with t.location(0, 0): + pass + expected_output = u''.join( + (unicode_cap('sc'), + unicode_parm('cup', 0, 0), + unicode_cap('rc'))) + assert (t.stream.getvalue() == expected_output) + + child(all_terms) + + +def test_mnemonic_colors(all_terms): + """Make sure color shortcuts work.""" + @as_subprocess + def child(kind): + def color(t, num): + return t.number_of_colors and unicode_parm('setaf', num) or '' + + def on_color(t, num): + return t.number_of_colors and unicode_parm('setab', num) or '' + + # Avoid testing red, blue, yellow, and cyan, since they might someday + # change depending on terminal type. + t = TestTerminal(kind=kind) + assert (t.white == color(t, 7)) + assert (t.green == color(t, 2)) # Make sure it's different than white. + assert (t.on_black == on_color(t, 0)) + assert (t.on_green == on_color(t, 2)) + assert (t.bright_black == color(t, 8)) + assert (t.bright_green == color(t, 10)) + assert (t.on_bright_black == on_color(t, 8)) + assert (t.on_bright_green == on_color(t, 10)) + + child(all_terms) + + +def test_callable_numeric_colors(all_terms): + """``color(n)`` should return a formatting wrapper.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + if t.magenta: + assert t.color(5)('smoo') == t.magenta + 'smoo' + t.normal + else: + assert t.color(5)('smoo') == 'smoo' + + if t.on_magenta: + assert t.on_color(5)('smoo') == t.on_magenta + 'smoo' + t.normal + else: + assert t.color(5)(u'smoo') == 'smoo' + + if t.color(4): + assert t.color(4)(u'smoo') == t.color(4) + u'smoo' + t.normal + else: + assert t.color(4)(u'smoo') == 'smoo' + + if t.on_green: + assert t.on_color(2)('smoo') == t.on_green + u'smoo' + t.normal + else: + assert t.on_color(2)('smoo') == 'smoo' + + if t.on_color(6): + assert t.on_color(6)('smoo') == t.on_color(6) + u'smoo' + t.normal + else: + assert t.on_color(6)('smoo') == 'smoo' + + child(all_terms) + + +def test_null_callable_numeric_colors(all_terms): + """``color(n)`` should be a no-op on null terminals.""" + @as_subprocess + def child(kind): + t = TestTerminal(stream=StringIO(), kind=kind) + assert (t.color(5)('smoo') == 'smoo') + assert (t.on_color(6)('smoo') == 'smoo') + + child(all_terms) + + +def test_naked_color_cap(all_terms): + """``term.color`` should return a stringlike capability.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + assert (t.color + '' == t.setaf + '') + + child(all_terms) + + +def test_formatting_functions(all_terms): + """Test simple and compound formatting wrappers.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + # test simple sugar, + if t.bold: + expected_output = u''.join((t.bold, u'hi', t.normal)) + else: + expected_output = u'hi' + assert t.bold(u'hi') == expected_output + # Plain strs for Python 2.x + if t.green: + expected_output = u''.join((t.green, 'hi', t.normal)) + else: + expected_output = u'hi' + assert t.green('hi') == expected_output + # Test unicode + if t.underline: + expected_output = u''.join((t.underline, u'boö', t.normal)) + else: + expected_output = u'boö' + assert (t.underline(u'boö') == expected_output) + + if t.subscript: + expected_output = u''.join((t.subscript, u'[1]', t.normal)) + else: + expected_output = u'[1]' + + assert (t.subscript(u'[1]') == expected_output) + + child(all_terms) + + +def test_compound_formatting(all_terms): + """Test simple and compound formatting wrappers.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + if any((t.bold, t.green)): + expected_output = u''.join((t.bold, t.green, u'boö', t.normal)) + else: + expected_output = u'boö' + assert t.bold_green(u'boö') == expected_output + + if any((t.on_bright_red, t.bold, t.bright_green, t.underline)): + expected_output = u''.join( + (t.on_bright_red, t.bold, t.bright_green, t.underline, u'meh', + t.normal)) + else: + expected_output = u'meh' + assert (t.on_bright_red_bold_bright_green_underline('meh') + == expected_output) + + child(all_terms) + + +def test_formatting_functions_without_tty(all_terms): + """Test crazy-ass formatting wrappers when there's no tty.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=StringIO(), force_styling=False) + assert (t.bold(u'hi') == u'hi') + assert (t.green('hi') == u'hi') + # Test non-ASCII chars, no longer really necessary: + assert (t.bold_green(u'boö') == u'boö') + assert (t.bold_underline_green_on_red('loo') == u'loo') + assert (t.on_bright_red_bold_bright_green_underline('meh') == u'meh') + + child(all_terms) + + +def test_nice_formatting_errors(all_terms): + """Make sure you get nice hints if you misspell a formatting wrapper.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + try: + t.bold_misspelled('hey') + assert not t.is_a_tty or False, 'Should have thrown exception' + except TypeError: + e = sys.exc_info()[1] + assert 'probably misspelled' in e.args[0] + try: + t.bold_misspelled(u'hey') # unicode + assert not t.is_a_tty or False, 'Should have thrown exception' + except TypeError: + e = sys.exc_info()[1] + assert 'probably misspelled' in e.args[0] + + try: + t.bold_misspelled(None) # an arbitrary non-string + assert not t.is_a_tty or False, 'Should have thrown exception' + except TypeError: + e = sys.exc_info()[1] + assert 'probably misspelled' not in e.args[0] + + if platform.python_implementation() != 'PyPy': + # PyPy fails to toss an exception, Why?! + try: + t.bold_misspelled('a', 'b') # >1 string arg + assert not t.is_a_tty or False, 'Should have thrown exception' + except TypeError: + e = sys.exc_info()[1] + assert 'probably misspelled' in e.args[0], e.args + + child(all_terms) + + +def test_null_callable_string(all_terms): + """Make sure NullCallableString tolerates all kinds of args.""" + @as_subprocess + def child(kind): + t = TestTerminal(stream=StringIO(), kind=kind) + assert (t.clear == '') + assert (t.move(1 == 2) == '') + assert (t.move_x(1) == '') + assert (t.bold() == '') + assert (t.bold('', 'x', 'huh?') == '') + assert (t.bold('', 9876) == '') + assert (t.uhh(9876) == '') + assert (t.clear('x') == 'x') + + child(all_terms) + + +def test_bnc_parameter_emits_warning(): + """A fake capability without target digits emits a warning.""" + import warnings + from blessings.sequences import _build_numeric_capability + + # given, + warnings.filterwarnings("error", category=UserWarning) + term = mock.Mock() + fake_cap = lambda *args: u'NO-DIGIT' + term.fake_cap = fake_cap + + # excersize, + try: + _build_numeric_capability(term, 'fake_cap', base_num=1984) + except UserWarning: + err = sys.exc_info()[1] + assert err.args[0].startswith('Unknown parameter in ') + else: + assert False, 'Previous stmt should have raised exception.' + warnings.resetwarnings() + + +def test_bna_parameter_emits_warning(): + """A fake capability without any digits emits a warning.""" + import warnings + from blessings.sequences import _build_any_numeric_capability + + # given, + warnings.filterwarnings("error", category=UserWarning) + term = mock.Mock() + fake_cap = lambda *args: 'NO-DIGIT' + term.fake_cap = fake_cap + + # excersize, + try: + _build_any_numeric_capability(term, 'fake_cap') + except UserWarning: + err = sys.exc_info()[1] + assert err.args[0].startswith('Missing numerics in ') + else: + assert False, 'Previous stmt should have raised exception.' + warnings.resetwarnings() + + +def test_padd(): + """ Test terminal.padd(seq). """ + @as_subprocess + def child(): + from blessings.sequences import Sequence + from blessings import Terminal + term = Terminal('xterm-256color') + assert Sequence('xyz\b', term).padd() == u'xy' + assert Sequence('xxxx\x1b[3Dzz', term).padd() == u'xzz' + + child() diff --git a/blessings/tests/test_wrap.py b/blessings/tests/test_wrap.py new file mode 100644 index 0000000..e5f7a15 --- /dev/null +++ b/blessings/tests/test_wrap.py @@ -0,0 +1,105 @@ +import platform +import textwrap +import termios +import struct +import fcntl +import sys + +from .accessories import ( + as_subprocess, + TestTerminal, + many_columns, + all_terms, +) + +import pytest + + +def test_SequenceWrapper_invalid_width(): + """Test exception thrown from invalid width""" + WIDTH = -3 + + @as_subprocess + def child(): + term = TestTerminal() + try: + my_wrapped = term.wrap(u'------- -------------', WIDTH) + except ValueError as err: + assert err.args[0] == ( + "invalid width %r(%s) (must be integer > 0)" % ( + WIDTH, type(WIDTH))) + else: + assert False, 'Previous stmt should have raised exception.' + del my_wrapped # assigned but never used + + child() + + +@pytest.mark.parametrize("kwargs", [ + dict(break_long_words=False, + drop_whitespace=False, + subsequent_indent=''), + dict(break_long_words=False, + drop_whitespace=True, + subsequent_indent=''), + dict(break_long_words=False, + drop_whitespace=False, + subsequent_indent=' '), + dict(break_long_words=False, + drop_whitespace=True, + subsequent_indent=' '), + dict(break_long_words=True, + drop_whitespace=False, + subsequent_indent=''), + dict(break_long_words=True, + drop_whitespace=True, + subsequent_indent=''), + dict(break_long_words=True, + drop_whitespace=False, + subsequent_indent=' '), + dict(break_long_words=True, + drop_whitespace=True, + subsequent_indent=' '), +]) +def test_SequenceWrapper(all_terms, many_columns, kwargs): + """Test that text wrapping matches internal extra options.""" + @as_subprocess + def child(term, width, kwargs): + # build a test paragraph, along with a very colorful version + term = TestTerminal() + pgraph = u' Z! a bc defghij klmnopqrstuvw<<>>xyz012345678900 ' + attributes = ('bright_red', 'on_bright_blue', 'underline', 'reverse', + 'red_reverse', 'red_on_white', 'superscript', + 'subscript', 'on_bright_white') + term.bright_red('x') + term.on_bright_blue('x') + term.underline('x') + term.reverse('x') + term.red_reverse('x') + term.red_on_white('x') + term.superscript('x') + term.subscript('x') + term.on_bright_white('x') + + pgraph_colored = u''.join([ + getattr(term, (attributes[idx % len(attributes)]))(char) + if char != u' ' else u' ' + for idx, char in enumerate(pgraph)]) + + internal_wrapped = textwrap.wrap(pgraph, width=width, **kwargs) + my_wrapped = term.wrap(pgraph, width=width, **kwargs) + my_wrapped_colored = term.wrap(pgraph_colored, width=width, **kwargs) + + # ensure we textwrap ascii the same as python + assert internal_wrapped == my_wrapped + + # ensure content matches for each line, when the sequences are + # stripped back off of each line + for line_no, (left, right) in enumerate( + zip(internal_wrapped, my_wrapped_colored)): + assert left == term.strip_seqs(right) + + # ensure our colored textwrap is the same paragraph length + assert (len(internal_wrapped) == len(my_wrapped_colored)) + + child(all_terms, many_columns, kwargs) diff --git a/blessings/tests/wall.ans b/blessings/tests/wall.ans new file mode 100644 index 0000000..081b4d2 --- /dev/null +++ b/blessings/tests/wall.ans @@ -0,0 +1,7 @@ +[64C[34m▄▓▄
+ [1;30m░░ [32;42m█▀▀[0;32m█[1;42m▀[0;32m█[10C[1m█[42m▀[0;32m████████[1;42m▀[0;32m█[1;42m▀▀█ █▀▀[0;32m█[1;42m▀[0;32m█[10C[1;42m█▀▀[0;32m█[1;42m▀[0;32m█[7C[1;30mxz[0m([1;30mimp[0m)
+ [34m▄▄▄ [1;32;42m▓[0;32m█████ [34m▄▄ [1;32;42m█▀▀[0;32m█[1;37;42m░ [0;32m▀▀▀▀▀ █████[1;42m▓ ▓[0;32m█████ [34m░[30;44m▓ [1;32;42m█▀▀[0;32m█[1;37;42m░ [32m▓[0;32m█████ [34m░[30;44m▓ [1;32;42m█▀▀[0;32m█[1;37;42m░ [34;44m░[0;34m▄ [1;30m░░
+ [0;34m▐▄[1;44m░▄[0;34m▀ [32m▀▀▀▀▀▀ [34m█[1;44m▓ [0;32m▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ [34m██ [32m▀▀▀▀▀ ▀▀▀▀▀▀ [34m██ [32m▀▀▀▀▀ [34m█[1;44m▄[0;34m▌▌▄▄
+ █[1;44m▓[0;34m██ [30;47m░[0;37m▓████ [34m▐ [37m████[1;47m░ ░[0m████ [1;34;44m▄[0;34m█ [37m████▓[30;47m░ ░[0;37m▓████ [1;34;44m█[0;34m█ [37m████[1;47m░ [0;30;47m░[0;37m▓████ [1;34;44m█[0;34m█ [37m████[1;47m░ [0;34m█[1;44m▐▄▄[0;34m█[1;44m░[0;10m
+ [0;34m██[1;44m▓[0;34m▀ [30;47m░[0;37m▓████ █ ████[1;47m░ ░[0m████ [34m▐ [37m████▓[30;47m░ ░[0;37m▓████ [1;34;44m▄[0;34m█▌[37m████[1;47m░ [0;30;47m░[0;37m▓████ [1;34;44m▄[0;34m█▌[37m████[1;47m░ [0;34m▌█████
+ ▀ [30;47m░[0;37m▓████▄█▄▄███ [1;47m░ ░ [0m███▄▄▄▄████▓[30;47m░ ░[0;37m▓████▄▄▄▄███ [1;47m░ [0;30;47m░[0;37m▓████▄▄▄▄███ [1;47m░ [0;34m▀▀▀[30;44m▓[0;10m
|