IMAP4と日本語のメールボックス

少し前に Yahoo!メールが IMAP4 から読めるようになったので、Python の標準ライブラリに収録されている imaplib を試しているが日本語で問題が発生した。

普通のメールソフトでフォルダに相当するものを IMAP4 はメールボックスと呼んでいる。メールボックスの名前として使える文字は ASCII の一部だけで、残りは Modified UTF-7 という方法で符号化しなければならない。しかし現在の Python にはこの実装がついてこない。

事案5305: imaplib は内部メールボックス名をサポートすべき」という文書から

  • Twisted がすでに Modified UTF-7 を実装しているが、
  • それには軽度なバグがある(ということを日本人らしき人が指摘している)

ということがわかった。自分は Python3 の実装が欲しかったので、前述のバグの改修も含めて移植してみた。

# Python 3.2.2
import codecs
import base64

def fix_base64(s):
    pad = b'=' * (((~len(s)) + 1) & 3)
    return s + pad

def modified_unbase64(s):
    s = fix_base64(s)
    return base64.b64decode(s, b'+,').decode('utf_16_be')

def modified_base64(s):
    enc = base64.b64encode(s.encode('utf_16_be'), b'+,')
    return enc.rstrip(b'=')

def decoder(s, errors=None):
    assert isinstance(s, bytes) or isinstance(s, memoryview)

    r = []
    decode = []

    for i in bytes(s):
        if i == ord(b'&') and not decode:
            decode.append(i)
        elif i == ord(b'-') and decode:
            if len(decode) == 1:
                r.append('&')
            else:
                r.append(modified_unbase64(bytes(decode[1:])))
            del decode[:]
        elif decode:
            decode.append(i)
        else:
            r.append(chr(i))

    if decode:
        r.append(modified_unbase64(bytes(r[1:])))
    return ''.join(r), len(s)

def encoder(s, erros=None):
    ret = bytearray()
    _in = []
    
    for c in s:
        if '\x20' <= c <= '\x7E':
            if _in:
                ret.extend(b'&' + modified_base64(''.join(_in)) + b'-')
                del _in[:]
            if c == '&':
                ret += b'&-'
            else:
                ret += c.encode()
        else:
            _in.append(c)

    if _in:
        ret.extend(b'&' + modified_base64(''.join(_in)) + b'-')

    return bytes(ret), len(ret)

class StreamReader(codecs.StreamReader):
    def decode(self, s, errors='strict'):
        return decoder(s)

class StreamWriter(codecs.StreamWriter):
    def encode(self, s, errors='strict'):
        return encoder(s)

_codecInfo = codecs.CodecInfo(encoder, decoder, StreamReader, StreamWriter)

def imap4_utf_7(name):
    if name == 'imap4-utf-7':
        return _codecInfo

codecs.register(imap4_utf_7)

以上のコードを実行すると、かなり簡易ではあるものの一応 imap4-utf-7 というエンコーディングが使用可能になる。以下は適当なメールボックスを作って削除する例。

import imaplib
import getpass
import re
import sys

def test():
    conn = imaplib.IMAP4_SSL('imap.mail.yahoo.co.jp')
    password = getpass.getpass('password:')
    try:
        conn.login('/* ここにアカウント名を入れる */', password)
        create_mailbox(conn, 'やっほーyahoo')
        list_mailbox(conn)
        print('-' * 20)
        delete_mailbox(conn, 'やっほーyahoo')
        list_mailbox(conn)
    except conn.error as err:
        print('IMAP Error:', err, file=sys.stderr)
    finally:
        conn.logout()

def list_mailbox(conn):
    re_response = re.compile(br'\((?P<flags>.*?)\) "(?P<delimiter>.*)" (?P<name>.*)')
    typ, lines = conn.list()
    for i in lines:
        m = re_response.match(i)
        if m:
            flags, delim, name = m.groups()
            if name.startswith(b'"') and name.endswith(b'"'):
                name = name[1:-1]
            print('-', name.decode('imap4-utf-7'))
        else:
            print('Response Parsing Error:', i, file=sys.stderr)

def create_mailbox(conn, name):
    conn.create(name.encode('imap4-utf-7'))

def delete_mailbox(conn, name):
    conn.delete(name.encode('imap4-utf-7'))


if __name__ == '__main__':
    test()

""" 結果:
- Bulk Mail
- Draft
- Inbox
- Sent
- Trash
- やっほーyahoo
- 保存
--------------------
- Bulk Mail
- Draft
- Inbox
- Sent
- Trash
- 保存
"""

^Z の意味

マイクロソフトの「Cランタイム・ライブラリ」に \x1D を読み込ませると、そこで入力が止まってしまうという話を少し前にした。

古い本「C言語を256倍使うための本」を読んでいたらその理由が書いてあった。

だが^Zは, ファイルサイズをクラスタ単位でしか管理していなかったCP/M時代の遺物で、ファイルサイズがバイト単位で管理されているDOSでは不要なもののはずだ. リダイレクト“>>”を使う場合にも, ^Zの存在は邪魔であり, 現時点では^Zはなくした方がよいと考えられる.

これがヒントになり色々調べてみると、WikipediaCP/Mに詳細が書いてあった。

File size was specified as the number of 128-byte records (directly corresponding to disk sectors on 8-inch drives) occupied by a file on the disk. There was no generally supported way of specifying byte-exact file sizes. The current size of a file was maintained in the file's file control block (FCB) by the operating system. Since many application programs (such as text editors) prefer to deal with files as sequences of characters rather than as sequences of records, by convention text files were terminated with a control-Z character (ASCII SUB, hexadecimal 1A). Determining the end of a text file therefore involved examining the last record of the file to locate the terminating control-Z. This also meant that inserting a control-Z character into the middle of a file usually had the effect of truncating the text contents of the file.
ファイルのサイズは、ディスク上で128バイト(8インチ・ドライブのディスク・セクターに相当)のレコードがいくつ占有するかという数字で指定される。正確なファイルサイズを指定する方法はない。ファイルサイズはOSが管理するファイル中のFSB(ファイル・コントロール・ブロック)で管理される。テキスト・エディタのようなアプリケーションはファイルをレコード単位ではなく文字単位で扱う傾向にあるので、習慣としてテキストファイルは contrl-z 文字(ASCIIのSUB, 16進数で1a)で終端されることとなった。というわけで、テキストファイルの終わりは最終レコードの control-z で判断されるうることなった。またファイル中に control-z を挿入することは、ファイルの中身を切り詰めることを意味した。

http://en.wikipedia.org/wiki/CP/M#File_system

へぇ〜。人体に尻尾の痕跡が残ってるみたいな話だな。

そういえば似たような話で、ExcelLotus 1-2-3閏年バグが再現されているとかいう話もあったっけ。

.pycファイルを作成させない方法メモ

Python に Bオプションを付けるか、環境変数 PYTHONDONTWRITEBYTECODE を作ると .pycファイルが作成されなくなる。

具体的には

python -B foo.py

のように実行するか、

:: Windows cmd の場合
set PYTHONDONTWRITEBYTECODE=1
foo.py

# Linux bash の場合
export PYTHONDONTWRITEBYTECODE=1
./foo.py

のように事前に環境変数を設定しておく。

そもそも .pyc ファイルとは?

Python で書かれたモジュールを import すると自動的に .pycファイルが作成される。

例えば

import bar

とやると bar.py と同じディレクトリに bar.pyc というファイルができる。

このファイルは bar.py をコンパイルして得られたバイトコードを保存したものだ。つまり一種のキャッシュである。モジュールは他のファイルからも import される可能性が高いので、こうしておけばコンパイルする時間を節約できる。

人によってはこの動作が邪魔という人もいる。例えば、普段は Python 2.7 を使っているのだが、一時的に 2.6 を使うといった場合だ。.pycファイルはバージョン間の互換性がないので、他のバージョンの Pythonスクリプトを実行するとキャッシュが上書きされてしまう。

Python 3.2 から新しい仕組みができた

Python 3.2 から .pycファイルは __pycache__ というディレクトリに、バージョン番号付きで保存されるようになったので、キャッシュが衝突したり、フォルダーがごちゃごちゃになったりすることはなくなった。


collections.Counterの使用例

だいぶ前に defaultdict について書いたけど、出現回数のカウントは collections.Counter クラスの方がシンプルに書ける。

# Python 3.2.2
from collections import Counter
c = Counter('abrakadabra')
for k, v in c.items():
    print(k, '----', v)
# 結果:
#   a ---- 5
#   r ---- 2
#   b ---- 2
#   k ---- 1
#   d ---- 1

Counter クラスは dict を継承しているので、基本的な使い勝手は辞書と同じである。

c = Counter(red=3, green=4, blue=5)
print(c)  # Counter({'blue': 5, 'green': 4, 'red': 3})
c.update(['red', 'red', 'green', 'blue', 'blue', 'blue'])
print(c)  # Counter({'blue': 8, 'green': 5, 'red': 5})

さらに Counter クラスでは「数え上げた後」のことも考えられている。例えば Counter 同士を足したり、

storeA = Counter(pencil=10, eraser=5, ruler=3)
storeB = Counter(pencil=3, eraser=1)
# 在庫の合計を求める
whole = storeA + storeB
print(whole)  # 結果: Counter({'pencil': 13, 'eraser': 6, 'ruler': 3})

Counter 同士を引いたり、

stock = Counter(pencil=10, eraser=5, ruler=3)
sales = Counter(pencil=3, eraser=1)
# 売れ残った数を求める
rest = stock - sales
print(rest)  # 結果: Counter({'pencil': 7, 'eraser': 4, 'ruler': 3})

論理和を求めたりできる。

mon = Counter(car=3, bike=2, tricycle=1)
tue = Counter(car=2, bike=4)
wed = Counter(tricycle=3)
# 必要最低数を求める
least_demand = mon | tue | wed
print(least_demand)  # 結果: Counter({'bike': 4, 'car': 3, 'tricycle': 3})

他にも使用例が思い浮かばないような機能がたくさんある。

winregのREG_DWORDの扱いがちょっと

Python にはレジストリを扱うために winreg というライブラリがついてくるけれど、REG_DWORD の扱いに少し問題があることがわかった。

問題1:大きな整数が正しく読み込めない

例えば 4294967173 という大きな数を読もうとすると、-123 になってしまう。


# CPython 3.2.1 for Windows x86
from winreg import *
with OpenKey(HKEY_CURRENT_USER, r'Software\__test__') as key:
    data, typ = QueryValueEx(key, 'big_one')
    print(data)  # -123

DWORD 型というのは要するに unsigned long のことだ。しかし winreg の内部では、これを signed として扱っている箇所があるので、大きな数字を読み込もうとすると負数になってしまう現象が発生する。

問題2:大きな整数を書き込むと奇妙な場所で例外が出る

signed long に収まらない整数を書き込むと、いかなる値であっても全て 0xFFFFFFFF にされてしまう。さらに悪いことに、これ以降いくつかの関数で必ず不可解な例外が発生するようになる。

from winreg import *
with OpenKey(HKEY_CURRENT_USER, r'Software\__test__', access=KEY_SET_VALUE) as key:
    SetValueEx(key, 'big_one2', 0, REG_DWORD, 0x80000000)
try:
    max(1, 2, 3)  # something harmeless
except OverflowError:
    print('whoa!?')

ソースを眺めてみる限り、データが 0xFFFFFFFF になるのは、PyLong_AsLong() の戻り値 -1unsigned として解釈しているから。変な場所で例外が発生するのは、PyLong_AsLong() の例外を放置しているからだと思う。

というわけで

誰か英語が得意な人がいたら報告しておいてください。 報告されました。

"\x1a" で入力が止まってしまう

Windowsで "\x1a" を含むテキストファイルをCPythonに読ませると、途中で読み込みが停止してしまうようだね。

# CPython 2.7.2 on Win32
with open('a.txt', 'wb') as fout:
    fout.write("abc\x1adef")

with open('a.txt', 'r') as fin:
    print fin.read()  # 出力: abc

そもそも、C言語の fgets() レベルで読み込みが止まる。これはCランタイム ライブラリ(CRT)の仕様なのか?

#include <stdio.h>

int main(void)
{
    FILE *fp;
    char sbuf[256];

    fp = fopen("a.txt", "r");
    if (NULL == fp) {
        fputs("IOError\n", stderr);
        return 1;
    }

    if (fgets(sbuf, 256, fp)) {
        fputs(sbuf, stdout);  /*  出力: abc  */
    }

    fclose(fp);
    return 0;
}

"\x1a" は Ctrl-z を押すと発行され、ファイルやユーザー入力の終わりを表す。なので、まったく納得できない動作ではないのだが、DebianPythonでは問題にならないんだよな(Ctrl-d に相当する "\x04" を含んでいても問題なく読める)。

おもしろいことに、ファイルを universal newline mode で開くと、問題なく全て読むことができる。

with open('a.txt', 'U') as fin:
    print fin.read()

これは、U フラグを指定すると、内部的にはバイナリモードでファイルが開かれるからだと思われる。その場合、改行コードの処理はCRTではなくCPythonの方で行われる。この方法で問題を回避するのが一番かんたんかな。

WriteFileでWCHARを書き込む方法とか

ワイド文字列を WriteFile() でファイルに書き出してみる。

#define UNICODE

#include <windows.h>
#include <stdio.h>

void test(HANDLE file)
{
    WCHAR s[] = L"あいうえお";
    DWORD written;
    WriteFile(file, s, 5 * sizeof(WCHAR), &written, NULL);
}

int main()
{
    HANDLE file = CreateFile(L"a.txt", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    if (INVALID_HANDLE_VALUE != file) {
        test(file);
        CloseHandle(file);
    } else {
        puts("IOError");
        return 1;
    }
    return 0;
}

書き込んだファイルをエディタで開くと文字化けしている。文字コードを UTF-16LE に設定して開くとちゃんと見えるので、単にByte Order Mark (BOM) が付いていないだけだとわかる。

こうすればエディタがきちんと自動判定できるようになる。

void test(HANDLE file)
{
    WCHAR s[] = L"\xFEFFあいうえお";
    DWORD written;
    WriteFile(file, s, 6 * sizeof(WCHAR), &written, NULL);
}

以上「WriteFile WCHAR | LPWSTR | PWSTR | wchar_t」とかでググっても出てこないのでメモしておく。


ちなみに OPEN_ALWAYS でファイルに追記する場合、BOM を書き込むかどうか判定する必要が出てくるけど、それはこうすればいいかな。

#define UNICODE

#include <windows.h>
#include <stdio.h>

int main()
{
    HANDLE file = CreateFile(L"c.txt", GENERIC_WRITE, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    if (INVALID_HANDLE_VALUE == file) {
        puts("IOError");
        return 1;
    }

    if (GetLastError() != ERROR_ALREADY_EXISTS) {
        puts("新規ファイルです。");
        // BOMを書き込む
        char bom[2] = {0xFF, 0xFE};
        DWORD written;
        WriteFile(file, bom, 2, &written, NULL);
    } else {
        puts("既存のファイルを開きました。");
        // ファイルの最後までシーク
        SetFilePointer(file, 0, NULL, FILE_END);
    }
    
    // 現在時刻を追記
    WCHAR tm[128];
    int cch = GetTimeFormat(LOCALE_USER_DEFAULT, 0, NULL, L"hh':'mm':'ss'\r\n'", tm, 128);
    if (cch > 0) {
        DWORD written;
        // cch には \0 も含まれるので
        cch --;
        WriteFile(file, tm, cch * sizeof(WCHAR), &written, NULL);
    }
    CloseHandle(file);
    
    return 0;
}

このように CreateFile() が成功していても GetLastError() を使うことがある。