07 4月 2011

[Python] Hook stdout後 print會出現 UnicodeDecodeError

在Python裡,字串的表達方式分為兩種,一種是常見的ansi string:"abc",另一種則是unicode:u"abc",而OS在秀出文字到window上時,其實都會轉換到使用者設定的code page後才秀出來的。簡單來說,繁體中文有一個對應的code page(cp950),簡體中文也有個對應的code page(cp936),如果一個unicode的字串參考錯誤的code page轉換出來的字串就有可能會變成亂碼了。

如果有hook stdout的需求的話,通常會把sys.stdout指向自行定義的objecct身上,以下這個範例是實作一個HookStdOut,將output string重新導向stdout與debug message
import sys
class HookStdOut(object):
    def __init__(self, *argv, **argd):
        object.__init__(self)
        
    def write(self, s):
        from ctypes import windll
        if isinstance(s, unicode):
            windll.kernel32.OutputDebugStringW(s)
        else:
            windll.kernel32.OutputDebugStringA(s)
        sys.__stdout__.write(s)

sys.stdout = HookStdOut()
s = unichr(0x5927) + unichr(0x5BB6) + unichr(0x597D) #in cp950 (大, 家, 好)
try:
    print s
except:
    import traceback
    traceback.print_exc()

但是這個方法如果在print unicode時,就會有exception出現 UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-2: ordinal not in range(128)
這是因為Python在print unicode到stdout前,會試著把unicode以console code page做encode的動作,再write到stdout。而現在stdout是我們自行定義的HookStdOut,最後write到stdout時,只會以ascii的方式encode,然後就會出現UnicodeEncodeError了。

研究了一下Python 2.5的source code...
Python\sysmodule.c
PyObject *
_PySys_Init(void)
{
    //...
    sysout = PyFile_FromFile(stdout, "<stdout>", "w", _check_and_flush);
    //...
    if(isatty(_fileno(stdout)) && PyFile_Check(sysout)) {
        sprintf(buf, "cp%d", GetConsoleOutputCP());
        if (!PyFile_SetEncoding(sysout, buf))
            return NULL;
    }
    //...
}
Objects\fileobject.c
int
PyFile_WriteObject(PyObject *v, PyObject *f, int flags)
{
    PyObject *writer, *value, *args, *result;
    if (f == NULL) {
        PyErr_SetString(PyExc_TypeError, "writeobject with NULL file");
        return -1;
    }
    else if (PyFile_Check(f)) {
        FILE *fp = PyFile_AsFile(f);
#ifdef Py_USING_UNICODE
        PyObject *enc = ((PyFileObject*)f)->f_encoding;
        int result;
#endif
        if (fp == NULL) {
            err_closed();
            return -1;
        }
#ifdef Py_USING_UNICODE
                if ((flags & Py_PRINT_RAW) &&
            PyUnicode_Check(v) && enc != Py_None) {
            char *cenc = PyString_AS_STRING(enc);
            value = PyUnicode_AsEncodedString(v, cenc, "strict");
            if (value == NULL)
                return -1;
        } else {
            value = v;
            Py_INCREF(value);
        }
        result = PyObject_Print(value, fp, flags);
        Py_DECREF(value);
        return result;
#else
        return PyObject_Print(v, fp, flags);
#endif
    }
    //...
}
在Python default的sys.stdout,其實是一個FileObject,會以Windows API GetConsoleCP()將console code page 記錄在f_encoding這個變數裡,等到print時會call進PyFile_WriteObject(),如果要print的PyObject *v是PyUnicode的話,會先將PyUnicode以f_encoding做encode,然後再print出去。

而我們實作了一個HookStdOut,但是我們並沒有指定它的encoding是什麼,所以default是ascii,在嘗試print unicode時,就會出現exception了。所以,我們必須要在write()裡自己encode到正確的code page才不會有這個exception出現。

正確版的範例
import sys
class HookStdOut(object):
    def __init__(self, *argv, **argd):
        object.__init__(self)
        self.encoding = sys.getfilesystemencoding()
    def write(self, s):
        from ctypes import windll
        if isinstance(s, unicode):
            windll.kernel32.OutputDebugStringW(s)
            sys.__stdout__.write(s.encode(self.encoding)) #try to encode the unicode to default file system encoding
        else:
            windll.kernel32.OutputDebugStringA(s)
            sys.__stdout__.write(s)

sys.stdout = HookStdOut()
s = unichr(0x5927) + unichr(0x5BB6) + unichr(0x597D) #in cp950 (大, 家, 好)
try:
    print s
except:
    import traceback
    traceback.print_exc()


其實還有另一個方法可以避掉UnicodeEncodeError的exception,但是並不建議這麼做。
class HookStdOut(object):
    def __init__(self, *argv, **argd):
        object.__init__(self)
        
    def write(self, s):
        from ctypes import windll
        if isinstance(s, unicode):
            windll.kernel32.OutputDebugStringW(s)
        else:
            windll.kernel32.OutputDebugStringA(s)
        sys.__stdout__.write(s)

#reset default encoding!!!
reload(sys)
sys.setdefaultencoding(sys.getfilesystemencoding())

sys.stdout = HookStdOut()
s = unichr(0x5927) + unichr(0x5BB6) + unichr(0x597D) #in cp950 (大, 家, 好)
try:
    print s
except:
    import traceback
    traceback.print_exc()
由於在Python2.5的site.py將sys.setdefaultencoding remove掉了,所以必須要reload sys module,然後再setdefaultencoding一次才行。
但是參考一下這篇文章,setdefaultencoding is evil,我想,在小程式裡可能可以用setdefaultencoding的方式解掉,但如果是大型的project的話,並非所有的unicode會用相同的encoding,所以還是避免用這樣的方式比較好。


延伸閱讀

沒有留言: