diff --git a/doc/source/whatsnew/v0.19.2.txt b/doc/source/whatsnew/v0.19.2.txt index f4a45a6938a95..5236ebb38ae63 100644 --- a/doc/source/whatsnew/v0.19.2.txt +++ b/doc/source/whatsnew/v0.19.2.txt @@ -43,3 +43,7 @@ Bug Fixes - Explicit check in ``to_stata`` and ``StataWriter`` for out-of-range values when writing doubles (:issue:`14618`) +- BUG in clipboard (linux, python2) with unicode and separator (:issue:`13747`) +- BUG: clipboard functions windows 10 py3.5 (:issue:`14362`) +- BUG: test_clipboard fails (:issue:`12807`) +- to_clipboard is no longer Excel compatible (:issue:`12529`) diff --git a/pandas/io/clipboard.py b/pandas/io/clipboard.py index 6f6f1366a6732..3c7ac528d83fd 100644 --- a/pandas/io/clipboard.py +++ b/pandas/io/clipboard.py @@ -1,6 +1,6 @@ """ io on the clipboard """ from pandas import compat, get_option, option_context, DataFrame -from pandas.compat import StringIO +from pandas.compat import StringIO, PY2 def read_clipboard(sep='\s+', **kwargs): # pragma: no cover @@ -18,6 +18,14 @@ def read_clipboard(sep='\s+', **kwargs): # pragma: no cover ------- parsed : DataFrame """ + encoding = kwargs.pop('encoding', 'utf-8') + + # only utf-8 is valid for passed value because that's what clipboard + # supports + if encoding is not None and encoding.lower().replace('-', '') != 'utf8': + raise NotImplementedError( + 'reading from clipboard only supports utf-8 encoding') + from pandas.util.clipboard import clipboard_get from pandas.io.parsers import read_table text = clipboard_get() @@ -78,6 +86,12 @@ def to_clipboard(obj, excel=None, sep=None, **kwargs): # pragma: no cover - Windows: - OS X: """ + encoding = kwargs.pop('encoding', 'utf-8') + + # testing if an invalid encoding is passed to clipboard + if encoding is not None and encoding.lower().replace('-', '') != 'utf8': + raise ValueError('clipboard only supports utf-8 encoding') + from pandas.util.clipboard import clipboard_set if excel is None: excel = True @@ -87,8 +101,12 @@ def to_clipboard(obj, excel=None, sep=None, **kwargs): # pragma: no cover if sep is None: sep = '\t' buf = StringIO() - obj.to_csv(buf, sep=sep, **kwargs) - clipboard_set(buf.getvalue()) + # clipboard_set (pyperclip) expects unicode + obj.to_csv(buf, sep=sep, encoding='utf-8', **kwargs) + text = buf.getvalue() + if PY2: + text = text.decode('utf-8') + clipboard_set(text) return except: pass diff --git a/pandas/io/tests/test_clipboard.py b/pandas/io/tests/test_clipboard.py index 6c5ee6fcd22ba..3ce714274e2dc 100644 --- a/pandas/io/tests/test_clipboard.py +++ b/pandas/io/tests/test_clipboard.py @@ -9,16 +9,16 @@ from pandas import read_clipboard from pandas import get_option from pandas.util import testing as tm -from pandas.util.testing import makeCustomDataframe as mkdf, disabled +from pandas.util.testing import makeCustomDataframe as mkdf +from pandas.util.clipboard.exceptions import PyperclipException try: - import pandas.util.clipboard # noqa -except OSError: - raise nose.SkipTest("no clipboard found") + DataFrame({'A': [1, 2]}).to_clipboard() +except PyperclipException: + raise nose.SkipTest("clipboard primitives not installed") -@disabled class TestClipboard(tm.TestCase): @classmethod @@ -52,6 +52,9 @@ def setUpClass(cls): # Test for non-ascii text: GH9263 cls.data['nonascii'] = pd.DataFrame({'en': 'in English'.split(), 'es': 'en español'.split()}) + # unicode round trip test for GH 13747, GH 12529 + cls.data['utf8'] = pd.DataFrame({'a': ['µasd', 'Ωœ∑´'], + 'b': ['øπ∆˚¬', 'œ∑´®']}) cls.data_types = list(cls.data.keys()) @classmethod @@ -59,13 +62,14 @@ def tearDownClass(cls): super(TestClipboard, cls).tearDownClass() del cls.data_types, cls.data - def check_round_trip_frame(self, data_type, excel=None, sep=None): + def check_round_trip_frame(self, data_type, excel=None, sep=None, + encoding=None): data = self.data[data_type] - data.to_clipboard(excel=excel, sep=sep) + data.to_clipboard(excel=excel, sep=sep, encoding=encoding) if sep is not None: - result = read_clipboard(sep=sep, index_col=0) + result = read_clipboard(sep=sep, index_col=0, encoding=encoding) else: - result = read_clipboard() + result = read_clipboard(encoding=encoding) tm.assert_frame_equal(data, result, check_dtype=False) def test_round_trip_frame_sep(self): @@ -115,3 +119,16 @@ def test_read_clipboard_infer_excel(self): exp = pd.read_clipboard() tm.assert_frame_equal(res, exp) + + def test_invalid_encoding(self): + # test case for testing invalid encoding + data = self.data['string'] + with tm.assertRaises(ValueError): + data.to_clipboard(encoding='ascii') + with tm.assertRaises(NotImplementedError): + pd.read_clipboard(encoding='ascii') + + def test_round_trip_valid_encodings(self): + for enc in ['UTF-8', 'utf-8', 'utf8']: + for dt in self.data_types: + self.check_round_trip_frame(dt, encoding=enc) diff --git a/pandas/util/clipboard.py b/pandas/util/clipboard.py deleted file mode 100644 index 02da0d5b8159f..0000000000000 --- a/pandas/util/clipboard.py +++ /dev/null @@ -1,266 +0,0 @@ -# Pyperclip v1.5.15 -# A cross-platform clipboard module for Python. -# By Al Sweigart al@inventwithpython.com - -# Usage: -# import pyperclip -# pyperclip.copy('The text to be copied to the clipboard.') -# spam = pyperclip.paste() - -# On Windows, no additional modules are needed. -# On Mac, this module makes use of the pbcopy and pbpaste commands, which -# should come with the os. -# On Linux, this module makes use of the xclip or xsel commands, which should -# come with the os. Otherwise run "sudo apt-get install xclip" or -# "sudo apt-get install xsel" -# Otherwise on Linux, you will need the gtk or PyQt4 modules installed. -# The gtk module is not available for Python 3, and this module does not work -# with PyGObject yet. - - -# Copyright (c) 2015, Albert Sweigart -# All rights reserved. -# -# BSD-style license: -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# * Neither the name of the pyperclip nor the -# names of its contributors may be used to endorse or promote products -# derived from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY Albert Sweigart "AS IS" AND ANY -# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL Albert Sweigart BE LIABLE FOR ANY -# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -# flake8: noqa - -import platform -import os -from subprocess import call, Popen, PIPE - -PY2 = '2' == platform.python_version_tuple()[0] -text_type = unicode if PY2 else str - - -class NoClipboardProgramError(OSError): - pass - - -def _pasteWindows(): - CF_UNICODETEXT = 13 - d = ctypes.windll - d.user32.OpenClipboard(0) - handle = d.user32.GetClipboardData(CF_UNICODETEXT) - data = ctypes.c_wchar_p(handle).value - d.user32.CloseClipboard() - return data - - -def _copyWindows(text): - GMEM_DDESHARE = 0x2000 - CF_UNICODETEXT = 13 - d = ctypes.windll # cdll expects 4 more bytes in user32.OpenClipboard(0) - if not isinstance(text, text_type): - text = text.decode('mbcs') - - d.user32.OpenClipboard(0) - - d.user32.EmptyClipboard() - hCd = d.kernel32.GlobalAlloc(GMEM_DDESHARE, - len(text.encode('utf-16-le')) + 2) - pchData = d.kernel32.GlobalLock(hCd) - ctypes.cdll.msvcrt.wcscpy(ctypes.c_wchar_p(pchData), text) - d.kernel32.GlobalUnlock(hCd) - d.user32.SetClipboardData(CF_UNICODETEXT, hCd) - d.user32.CloseClipboard() - - -def _pasteCygwin(): - CF_UNICODETEXT = 13 - d = ctypes.cdll - d.user32.OpenClipboard(0) - handle = d.user32.GetClipboardData(CF_UNICODETEXT) - data = ctypes.c_wchar_p(handle).value - d.user32.CloseClipboard() - return data - - -def _copyCygwin(text): - GMEM_DDESHARE = 0x2000 - CF_UNICODETEXT = 13 - d = ctypes.cdll - if not isinstance(text, text_type): - text = text.decode('mbcs') - d.user32.OpenClipboard(0) - d.user32.EmptyClipboard() - hCd = d.kernel32.GlobalAlloc(GMEM_DDESHARE, - len(text.encode('utf-16-le')) + 2) - pchData = d.kernel32.GlobalLock(hCd) - ctypes.cdll.msvcrt.wcscpy(ctypes.c_wchar_p(pchData), text) - d.kernel32.GlobalUnlock(hCd) - d.user32.SetClipboardData(CF_UNICODETEXT, hCd) - d.user32.CloseClipboard() - - -def _copyOSX(text): - p = Popen(['pbcopy', 'w'], stdin=PIPE, close_fds=True) - p.communicate(input=text.encode('utf-8')) - - -def _pasteOSX(): - p = Popen(['pbpaste', 'r'], stdout=PIPE, close_fds=True) - stdout, stderr = p.communicate() - return stdout.decode('utf-8') - - -def _pasteGtk(): - return gtk.Clipboard().wait_for_text() - - -def _copyGtk(text): - global cb - cb = gtk.Clipboard() - cb.set_text(text) - cb.store() - - -def _pasteQt(): - return str(cb.text()) - - -def _copyQt(text): - cb.setText(text) - - -def _copyXclip(text): - p = Popen(['xclip', '-selection', 'c'], stdin=PIPE, close_fds=True) - p.communicate(input=text.encode('utf-8')) - - -def _pasteXclip(): - p = Popen(['xclip', '-selection', 'c', '-o'], stdout=PIPE, close_fds=True) - stdout, stderr = p.communicate() - return stdout.decode('utf-8') - - -def _copyXsel(text): - p = Popen(['xsel', '-b', '-i'], stdin=PIPE, close_fds=True) - p.communicate(input=text.encode('utf-8')) - - -def _pasteXsel(): - p = Popen(['xsel', '-b', '-o'], stdout=PIPE, close_fds=True) - stdout, stderr = p.communicate() - return stdout.decode('utf-8') - - -def _copyKlipper(text): - p = Popen(['qdbus', 'org.kde.klipper', '/klipper', - 'setClipboardContents', text.encode('utf-8')], - stdin=PIPE, close_fds=True) - p.communicate(input=None) - - -def _pasteKlipper(): - p = Popen(['qdbus', 'org.kde.klipper', '/klipper', - 'getClipboardContents'], stdout=PIPE, close_fds=True) - stdout, stderr = p.communicate() - return stdout.decode('utf-8') - - -# Determine the OS/platform and set the copy() and paste() functions -# accordingly. -if 'cygwin' in platform.system().lower(): - _functions = 'Cygwin' # for debugging - import ctypes - paste = _pasteCygwin - copy = _copyCygwin -elif os.name == 'nt' or platform.system() == 'Windows': - _functions = 'Windows' # for debugging - import ctypes - paste = _pasteWindows - copy = _copyWindows -elif os.name == 'mac' or platform.system() == 'Darwin': - _functions = 'OS X pbcopy/pbpaste' # for debugging - paste = _pasteOSX - copy = _copyOSX -elif os.name == 'posix' or platform.system() == 'Linux': - # Determine which command/module is installed, if any. - xclipExists = call(['which', 'xclip'], - stdout=PIPE, stderr=PIPE) == 0 - - xselExists = call(['which', 'xsel'], - stdout=PIPE, stderr=PIPE) == 0 - - xklipperExists = ( - call(['which', 'klipper'], stdout=PIPE, stderr=PIPE) == 0 and - call(['which', 'qdbus'], stdout=PIPE, stderr=PIPE) == 0 - ) - - gtkInstalled = False - try: - # Check it gtk is installed. - import gtk - gtkInstalled = True - except ImportError: - pass - - if not gtkInstalled: - # Check for either PyQt4 or PySide - qtBindingInstalled = True - try: - from PyQt4 import QtGui - except ImportError: - try: - from PySide import QtGui - except ImportError: - qtBindingInstalled = False - - # Set one of the copy & paste functions. - if xclipExists: - _functions = 'xclip command' # for debugging - paste = _pasteXclip - copy = _copyXclip - elif xklipperExists: - _functions = '(KDE Klipper) - qdbus (external)' # for debugging - paste = _pasteKlipper - copy = _copyKlipper - elif gtkInstalled: - _functions = 'gtk module' # for debugging - paste = _pasteGtk - copy = _copyGtk - elif qtBindingInstalled: - _functions = 'PyQt4 module' # for debugging - app = QtGui.QApplication([]) - cb = QtGui.QApplication.clipboard() - paste = _pasteQt - copy = _copyQt - elif xselExists: - # TODO: xsel doesn't seem to work on Raspberry Pi (my test Linux - # environment). Putting this as the last method tried. - _functions = 'xsel command' # for debugging - paste = _pasteXsel - copy = _copyXsel - else: - raise NoClipboardProgramError('Pyperclip requires the gtk, PyQt4, or ' - 'PySide module installed, or either the ' - 'xclip or xsel command.') -else: - raise RuntimeError('pyperclip does not support your system.') - -# pandas aliases -clipboard_get = paste -clipboard_set = copy diff --git a/pandas/util/clipboard/__init__.py b/pandas/util/clipboard/__init__.py new file mode 100644 index 0000000000000..358c9b5f8035a --- /dev/null +++ b/pandas/util/clipboard/__init__.py @@ -0,0 +1,110 @@ +""" +Pyperclip + +A cross-platform clipboard module for Python. (only handles plain text for now) +By Al Sweigart al@inventwithpython.com +BSD License + +Usage: + import pyperclip + pyperclip.copy('The text to be copied to the clipboard.') + spam = pyperclip.paste() + + if not pyperclip.copy: + print("Copy functionality unavailable!") + +On Windows, no additional modules are needed. +On Mac, the module uses pbcopy and pbpaste, which should come with the os. +On Linux, install xclip or xsel via package manager. For example, in Debian: +sudo apt-get install xclip + +Otherwise on Linux, you will need the gtk or PyQt4 modules installed. + +gtk and PyQt4 modules are not available for Python 3, +and this module does not work with PyGObject yet. +""" +__version__ = '1.5.27' + +# flake8: noqa + +import platform +import os +import subprocess +from .clipboards import (init_osx_clipboard, + init_gtk_clipboard, init_qt_clipboard, + init_xclip_clipboard, init_xsel_clipboard, + init_klipper_clipboard, init_no_clipboard) +from .windows import init_windows_clipboard + +# `import PyQt4` sys.exit()s if DISPLAY is not in the environment. +# Thus, we need to detect the presence of $DISPLAY manually +# and not load PyQt4 if it is absent. +HAS_DISPLAY = os.getenv("DISPLAY", False) +CHECK_CMD = "where" if platform.system() == "Windows" else "which" + + +def _executable_exists(name): + return subprocess.call([CHECK_CMD, name], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0 + + +def determine_clipboard(): + # Determine the OS/platform and set + # the copy() and paste() functions accordingly. + if 'cygwin' in platform.system().lower(): + # FIXME: pyperclip currently does not support Cygwin, + # see https://github.com/asweigart/pyperclip/issues/55 + pass + elif os.name == 'nt' or platform.system() == 'Windows': + return init_windows_clipboard() + if os.name == 'mac' or platform.system() == 'Darwin': + return init_osx_clipboard() + if HAS_DISPLAY: + # Determine which command/module is installed, if any. + try: + import gtk # check if gtk is installed + except ImportError: + pass + else: + return init_gtk_clipboard() + + try: + import PyQt4 # check if PyQt4 is installed + except ImportError: + pass + else: + return init_qt_clipboard() + + if _executable_exists("xclip"): + return init_xclip_clipboard() + if _executable_exists("xsel"): + return init_xsel_clipboard() + if _executable_exists("klipper") and _executable_exists("qdbus"): + return init_klipper_clipboard() + + return init_no_clipboard() + + +def set_clipboard(clipboard): + global copy, paste + + clipboard_types = {'osx': init_osx_clipboard, + 'gtk': init_gtk_clipboard, + 'qt': init_qt_clipboard, + 'xclip': init_xclip_clipboard, + 'xsel': init_xsel_clipboard, + 'klipper': init_klipper_clipboard, + 'windows': init_windows_clipboard, + 'no': init_no_clipboard} + + copy, paste = clipboard_types[clipboard]() + + +copy, paste = determine_clipboard() + +__all__ = ["copy", "paste"] + + +# pandas aliases +clipboard_get = paste +clipboard_set = copy \ No newline at end of file diff --git a/pandas/util/clipboard/clipboards.py b/pandas/util/clipboard/clipboards.py new file mode 100644 index 0000000000000..182a685f956e6 --- /dev/null +++ b/pandas/util/clipboard/clipboards.py @@ -0,0 +1,136 @@ +# flake8: noqa + +import sys +import subprocess +from .exceptions import PyperclipException + +EXCEPT_MSG = """ + Pyperclip could not find a copy/paste mechanism for your system. + For more information, please visit https://pyperclip.readthedocs.org """ +PY2 = sys.version_info[0] == 2 +text_type = unicode if PY2 else str + + +def init_osx_clipboard(): + def copy_osx(text): + p = subprocess.Popen(['pbcopy', 'w'], + stdin=subprocess.PIPE, close_fds=True) + p.communicate(input=text.encode('utf-8')) + + def paste_osx(): + p = subprocess.Popen(['pbpaste', 'r'], + stdout=subprocess.PIPE, close_fds=True) + stdout, stderr = p.communicate() + return stdout.decode('utf-8') + + return copy_osx, paste_osx + + +def init_gtk_clipboard(): + import gtk + + def copy_gtk(text): + global cb + cb = gtk.Clipboard() + cb.set_text(text) + cb.store() + + def paste_gtk(): + clipboardContents = gtk.Clipboard().wait_for_text() + # for python 2, returns None if the clipboard is blank. + if clipboardContents is None: + return '' + else: + return clipboardContents + + return copy_gtk, paste_gtk + + +def init_qt_clipboard(): + # $DISPLAY should exist + from PyQt4.QtGui import QApplication + + app = QApplication([]) + + def copy_qt(text): + cb = app.clipboard() + cb.setText(text) + + def paste_qt(): + cb = app.clipboard() + return text_type(cb.text()) + + return copy_qt, paste_qt + + +def init_xclip_clipboard(): + def copy_xclip(text): + p = subprocess.Popen(['xclip', '-selection', 'c'], + stdin=subprocess.PIPE, close_fds=True) + p.communicate(input=text.encode('utf-8')) + + def paste_xclip(): + p = subprocess.Popen(['xclip', '-selection', 'c', '-o'], + stdout=subprocess.PIPE, close_fds=True) + stdout, stderr = p.communicate() + return stdout.decode('utf-8') + + return copy_xclip, paste_xclip + + +def init_xsel_clipboard(): + def copy_xsel(text): + p = subprocess.Popen(['xsel', '-b', '-i'], + stdin=subprocess.PIPE, close_fds=True) + p.communicate(input=text.encode('utf-8')) + + def paste_xsel(): + p = subprocess.Popen(['xsel', '-b', '-o'], + stdout=subprocess.PIPE, close_fds=True) + stdout, stderr = p.communicate() + return stdout.decode('utf-8') + + return copy_xsel, paste_xsel + + +def init_klipper_clipboard(): + def copy_klipper(text): + p = subprocess.Popen( + ['qdbus', 'org.kde.klipper', '/klipper', 'setClipboardContents', + text.encode('utf-8')], + stdin=subprocess.PIPE, close_fds=True) + p.communicate(input=None) + + def paste_klipper(): + p = subprocess.Popen( + ['qdbus', 'org.kde.klipper', '/klipper', 'getClipboardContents'], + stdout=subprocess.PIPE, close_fds=True) + stdout, stderr = p.communicate() + + # Workaround for https://bugs.kde.org/show_bug.cgi?id=342874 + # TODO: https://github.com/asweigart/pyperclip/issues/43 + clipboardContents = stdout.decode('utf-8') + # even if blank, Klipper will append a newline at the end + assert len(clipboardContents) > 0 + # make sure that newline is there + assert clipboardContents.endswith('\n') + if clipboardContents.endswith('\n'): + clipboardContents = clipboardContents[:-1] + return clipboardContents + + return copy_klipper, paste_klipper + + +def init_no_clipboard(): + class ClipboardUnavailable(object): + def __call__(self, *args, **kwargs): + raise PyperclipException(EXCEPT_MSG) + + if PY2: + def __nonzero__(self): + return False + else: + def __bool__(self): + return False + + return ClipboardUnavailable(), ClipboardUnavailable() diff --git a/pandas/util/clipboard/exceptions.py b/pandas/util/clipboard/exceptions.py new file mode 100644 index 0000000000000..615335f3a58da --- /dev/null +++ b/pandas/util/clipboard/exceptions.py @@ -0,0 +1,12 @@ +# flake8: noqa +import ctypes + + +class PyperclipException(RuntimeError): + pass + + +class PyperclipWindowsException(PyperclipException): + def __init__(self, message): + message += " (%s)" % ctypes.WinError() + super(PyperclipWindowsException, self).__init__(message) diff --git a/pandas/util/clipboard/windows.py b/pandas/util/clipboard/windows.py new file mode 100644 index 0000000000000..956d5b9d34025 --- /dev/null +++ b/pandas/util/clipboard/windows.py @@ -0,0 +1,152 @@ +# flake8: noqa +""" +This module implements clipboard handling on Windows using ctypes. +""" +import time +import contextlib +import ctypes +from ctypes import c_size_t, sizeof, c_wchar_p, get_errno, c_wchar +from .exceptions import PyperclipWindowsException + + +class CheckedCall(object): + def __init__(self, f): + super(CheckedCall, self).__setattr__("f", f) + + def __call__(self, *args): + ret = self.f(*args) + if not ret and get_errno(): + raise PyperclipWindowsException("Error calling " + self.f.__name__) + return ret + + def __setattr__(self, key, value): + setattr(self.f, key, value) + + +def init_windows_clipboard(): + from ctypes.wintypes import (HGLOBAL, LPVOID, DWORD, LPCSTR, INT, HWND, + HINSTANCE, HMENU, BOOL, UINT, HANDLE) + + windll = ctypes.windll + + safeCreateWindowExA = CheckedCall(windll.user32.CreateWindowExA) + safeCreateWindowExA.argtypes = [DWORD, LPCSTR, LPCSTR, DWORD, INT, INT, + INT, INT, HWND, HMENU, HINSTANCE, LPVOID] + safeCreateWindowExA.restype = HWND + + safeDestroyWindow = CheckedCall(windll.user32.DestroyWindow) + safeDestroyWindow.argtypes = [HWND] + safeDestroyWindow.restype = BOOL + + OpenClipboard = windll.user32.OpenClipboard + OpenClipboard.argtypes = [HWND] + OpenClipboard.restype = BOOL + + safeCloseClipboard = CheckedCall(windll.user32.CloseClipboard) + safeCloseClipboard.argtypes = [] + safeCloseClipboard.restype = BOOL + + safeEmptyClipboard = CheckedCall(windll.user32.EmptyClipboard) + safeEmptyClipboard.argtypes = [] + safeEmptyClipboard.restype = BOOL + + safeGetClipboardData = CheckedCall(windll.user32.GetClipboardData) + safeGetClipboardData.argtypes = [UINT] + safeGetClipboardData.restype = HANDLE + + safeSetClipboardData = CheckedCall(windll.user32.SetClipboardData) + safeSetClipboardData.argtypes = [UINT, HANDLE] + safeSetClipboardData.restype = HANDLE + + safeGlobalAlloc = CheckedCall(windll.kernel32.GlobalAlloc) + safeGlobalAlloc.argtypes = [UINT, c_size_t] + safeGlobalAlloc.restype = HGLOBAL + + safeGlobalLock = CheckedCall(windll.kernel32.GlobalLock) + safeGlobalLock.argtypes = [HGLOBAL] + safeGlobalLock.restype = LPVOID + + safeGlobalUnlock = CheckedCall(windll.kernel32.GlobalUnlock) + safeGlobalUnlock.argtypes = [HGLOBAL] + safeGlobalUnlock.restype = BOOL + + GMEM_MOVEABLE = 0x0002 + CF_UNICODETEXT = 13 + + @contextlib.contextmanager + def window(): + """ + Context that provides a valid Windows hwnd. + """ + # we really just need the hwnd, so setting "STATIC" + # as predefined lpClass is just fine. + hwnd = safeCreateWindowExA(0, b"STATIC", None, 0, 0, 0, 0, 0, + None, None, None, None) + try: + yield hwnd + finally: + safeDestroyWindow(hwnd) + + @contextlib.contextmanager + def clipboard(hwnd): + """ + Context manager that opens the clipboard and prevents + other applications from modifying the clipboard content. + """ + # We may not get the clipboard handle immediately because + # some other application is accessing it (?) + # We try for at least 500ms to get the clipboard. + t = time.time() + 0.5 + success = False + while time.time() < t: + success = OpenClipboard(hwnd) + if success: + break + time.sleep(0.01) + if not success: + raise PyperclipWindowsException("Error calling OpenClipboard") + + try: + yield + finally: + safeCloseClipboard() + + def copy_windows(text): + # This function is heavily based on + # http://msdn.com/ms649016#_win32_Copying_Information_to_the_Clipboard + with window() as hwnd: + # http://msdn.com/ms649048 + # If an application calls OpenClipboard with hwnd set to NULL, + # EmptyClipboard sets the clipboard owner to NULL; + # this causes SetClipboardData to fail. + # => We need a valid hwnd to copy something. + with clipboard(hwnd): + safeEmptyClipboard() + + if text: + # http://msdn.com/ms649051 + # If the hMem parameter identifies a memory object, + # the object must have been allocated using the + # function with the GMEM_MOVEABLE flag. + count = len(text) + 1 + handle = safeGlobalAlloc(GMEM_MOVEABLE, + count * sizeof(c_wchar)) + locked_handle = safeGlobalLock(handle) + + ctypes.memmove(c_wchar_p(locked_handle), c_wchar_p(text), count * sizeof(c_wchar)) + + safeGlobalUnlock(handle) + safeSetClipboardData(CF_UNICODETEXT, handle) + + def paste_windows(): + with clipboard(None): + handle = safeGetClipboardData(CF_UNICODETEXT) + if not handle: + # GetClipboardData may return NULL with errno == NO_ERROR + # if the clipboard is empty. + # (Also, it may return a handle to an empty buffer, + # but technically that's not empty) + return "" + return c_wchar_p(handle).value + + return copy_windows, paste_windows diff --git a/setup.py b/setup.py index 351d2b39ce6aa..3e7cc1b5ec019 100755 --- a/setup.py +++ b/setup.py @@ -643,7 +643,8 @@ def pxd(name): 'pandas.io.tests.parser', 'pandas.io.tests.sas', 'pandas.stats.tests', - 'pandas.msgpack' + 'pandas.msgpack', + 'pandas.util.clipboard' ], package_data={'pandas.io': ['tests/data/legacy_hdf/*.h5', 'tests/data/legacy_pickle/*/*.pickle',