ファイルをアップロードするための Python CGI スクリプト - 2

2008.9.5 - 2008.9.9 (鈴)

1. はじめに

本稿は,前回の Python CGI スクリプトの改訂と,Python 3.0 への移植の試みについて述べる。

upload-200902.tar.bz2 におさめた改訂版 CGI スクリプト upload は,前回同様,サーバ上のアップロード用フォルダの内容をリスティングし,ユーザからの ファイルのアップロードを受け付ける。 Mac OS X ほか一般の Unix の Web サーバ上の Python 2.* 処理系で動作する。 Python 2.3.52.5.22.6b3 で動作を確認した。 設定方法と使用方法は前回と変わらない。 sitecustomize.py による sys.setdefaultencoding の設定 (Python & IronPython 入門) はしてもしなくても,どちらでもよい。

前回述べた設定方法を簡単にまとめる。

  1. アップロード用フォルダとログファイルを用意する。場所と名前は upload スクリプトの FOLDERcgi.logfile で変更できる。 Web サーバが CGI スクリプトを実行するときの権限で扱えるようにしておく (下記では,とりあえず誰でもフォルダとログファイルを自由に扱えるように chmod 777666 で設定している)
    $ mkdir /tmp/uploads
    $ chmod 777 /tmp/uploads
    $ touch /tmp/logfile
    $ chmod 666 /tmp/uploads
    
  2. upload スクリプトの CHARSET を,サーバの OS がファイル名に使用しているエンコーディングの名前に一致させる。 Mac OS X 等は 'UTF-8' のままでよい。 他の典型的な候補として 'EUC-JP' がある。
  3. upload スクリプトを,Web サーバの CGI スクリプト用フォルダ (Mac OS X では /Library/WebServer/CGI-Executables/) に置く。

Web ブラウザ (図は Camino) からの利用例を前回から再掲する。 ブラウザで http://localhost/cgi-bin/upload を開くと, ファイル名の入力フィールドと UPLOAD ボタン,そしてアップロード用フォルダの現在の一覧が表示される。 図は ピクチャ 2.png というファイルをアップロードした場面である。 「received: ピクチャ 2.png」というメッセージは ピクチャ 2.png をサーバが受け取ったことを意味する。 アップロード用フォルダの一覧では,今受け取ったファイルが背景色を白色にして強調される。


2. 前回からの変更点

前回からの変更点を diff コマンドの出力で示す。

2c2
< # -*- mode: python -*-                                                H19.10/10
---
> # -*- mode: python -*-                                                H20.9/2
10c10
<     import popen2
---
>     import unicodedata
12,15c12,14
<         (rf, wf) = popen2.popen2('/usr/bin/iconv -f UTF-8-MAC -t UTF-8')
<         wf.write(s)
<         wf.close()
<         return rf.read()
---
>         u = s.decode('utf-8')
>         n = unicodedata.normalize('NFC', u)
>         return n.encode('utf-8')
33c32
<                 wf = file(wf_pname, 'wb')
---
>                 wf = open(wf_pname, 'wb')

Mac は,Unicode ファイル名をファイル・システムに記録するとき, 濁音をかな本体と濁点に分解し, 字上符付きアルファベットをアルファベット本体と字上符に分解する Normalization Form D (NFD) への正規化を行う。 Mac 上で os.listdir(path) を使ってディレクトリ path にあるファイル名のリストを UTF-8 で得たとき,その名前は NFD で正規化されている。

一方,ブラウザからアップロードされたファイル名は,分解せずに合成文字を使う Normalization Form C (NFC) で表現されている。 また,ブラウザによっては NFC による Unicode データしか満足に表示しない。 したがって,ファイル名の一致を調べたり,HTML にファイル名を展開するときは, NFC に正規化すると都合がよい。

前回のスクリプトは,/usr/bin/iconv コマンドを子プロセスとして動かすことによって NFC への正規化を実現した。 同コマンドによる呼称では,NFD による UTF-8 は (Mac 用の UTF-8 として) UTF-8-MAC と呼ばれ, NFC による UTF-8 は単に UTF-8 と呼ばれる。 UTF-8-MAC から UTF-8 への変換を行うように iconv に引数を与える。

しかし,Unicode の正規化処理は,外部コマンドに頼らなくても Python 標準モジュール unicodedatanormalize 関数で行える。 unicodedata.normalize の正規化処理はモジュールに内蔵されたテーブルを参照するから,プロセスを起動するよりも効率良い。

今回のスクリプトは,UTF-8 文字列 ss.decode('utf-8') で Unicode 文字列 u にデコードした後, n = unicodedata.normalize('NFC', u) によって, NFC に正規化した Unicode 文字列 n を得る。 そして,それを n.encode('utf-8') で再び UTF-8 文字列にエンコードする。

3. Python 3.0 の問題点

upload スクリプトは Python 3.0 (正確には,執筆時現在の最新公開版である Python 3.0b3 ) では動作しない。

Python 3.0 の仕様に合わせるためのトリビアルな修正作業は Python 3.0 付属の 2to3 ユーティリティで一括して行える。

$ 2to3 -w upload
RefactoringTool: Skipping implicit fixer: buffer
RefactoringTool: Skipping implicit fixer: idioms
RefactoringTool: Skipping implicit fixer: ws_comma
--- upload (original)
+++ upload (refactored)
@@ -22,7 +22,7 @@
     form = cgi.FieldStorage()
     wf_name = None
     msg = '---'
-    if form.has_key('file'):
+    if 'file' in form:
         f = form['file']
         if f.filename:
             try:
@@ -34,48 +34,48 @@
                     shutil.copyfileobj(f.file, wf)
                 finally:
                     wf.close()
-                os.chmod(wf_pname, 0666)
+                os.chmod(wf_pname, 0o666)
……以下略……

この例のように 2to3-w オプションを与えると,修正の差分が表示されるだけでなく, 修正を適用した結果が元のファイル名 (ここでは upload) で作られる。 修正前のファイルは (ここでは upload.bak に) 改名されて保存される。

しかし,このように修正をし,さらにファイルの先頭行にあるインタープリタのパス名を Python 3.0 の実行ファイルに置き換えても,CGI スクリプトとして正常に動作しない。 アップロード用フォルダに1個でもファイルが存在しているとき, Web サーバのエラーログ (/var/log/httpd/error_log など) に下記のようなエラーが記録される。

Traceback (most recent call last):
  File "/Library/WebServer/CGI-Executables/upload", line 81, in <module>
    main()
  File "/Library/WebServer/CGI-Executables/upload", line 64, in main
    fname = normalize(fname)
  File "/Library/WebServer/CGI-Executables/upload", line 12, in normalize
    u = s.decode('utf-8')
AttributeError: 'str' object has no attribute 'decode'

Python 3.0 の文字列は Unicode 文字列であり,os.listdir(path) は (Mac では NFD 形式の) Unicode 文字列のリストを返す。 したがって,normalize(s) 関数が正規化処理の前後で UTF-8 のデコード/エンコードをする必要はない。次のように修正する。

@@ -9,9 +9,7 @@
 if sys.platform == 'darwin':
     import unicodedata
     def normalize(s):
-        u = s.decode('utf-8')
-        n = unicodedata.normalize('NFC', u)
-        return n.encode('utf-8')
+        return unicodedata.normalize('NFC', s)
 else:
     def normalize(s):
         return s

ここまでの修正は十分に合理的である。問題はここからである。

アップロード用フォルダに非 ASCII ファイル名のファイル (例えば いろは.txt) があると,次のようなエラーが記録される。

Traceback (most recent call last):
  File "/Library/WebServer/CGI-Executables/upload3", line 79, in <module>
    main()
  File "/Library/WebServer/CGI-Executables/upload3", line 67, in main
    print('<td><tt>', cgi.escape(fname), '</tt></td>', end=' ')
  File "/Users/suzuki/config/tmp/lib/python3.0/io.py", line 1486, in write
    b = encoder.encode(s)
  File "/Users/suzuki/config/tmp/lib/python3.0/encodings/ascii.py", line 22, in 
encode
    return codecs.ascii_encode(input, self.errors)[0]
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-2: ordin
al not in range(128)

CGI スクリプトが HTML テキスト等を書き込むのは,標準出力に対してである。 Python 3.0 で標準出力にあたるオブジェクト sys.stdout は,バイナリ・モードではなく,Unicode 文字列からバイト列への変換を伴うテキスト・モードで起動時にオープンされる。 変換で使われるエンコーディングは起動時に locale から決定され, そしてその locale は環境変数 LC_ALLLANG から決定される。 この過程は完全に自動化されており,Python スクリプト内部から明示的に振舞を変更する正当な手段を利用者は持たない。

結果として,Mac OS X 10.4 標準の Apache のように,LC_ALL 等の環境変数を設定せずに CGI スクリプトを起動する Web サーバでは,Python 3.0 の CGI スクリプトの標準出力は (日本語文字を表示する手段がほとんど封じられているという意味で) 日本人にとって最も有用性が低い ascii エンコーディングに固定される。

下記の内容のファイル sitecustomize.py を,Python 3.0 をインストールした場所の lib/python3.0/site-packages/ におけば, CGI スクリプトとして起動された場合でも locale を強制的に en_US.UTF-8 にすることができ,したがって標準出力のエンコーディングを utf-8 にできる。

import os
os.environ['LC_ALL'] = 'en_US.UTF-8'

あるいは下記のように Python 用の環境変数 PYTHONIOENCODING を設定してもよい。

import os
os.environ['PYTHONIOENCODING'] = 'UTF-8'

ただし,CGI スクリプト中でこれを実行しても,その時点では既に標準出力オブジェクトが構築済みだから,効果はない。

この状態で,例えば a b c \n の4バイトからなるファイル abc.txt をアップロードすると,ブラウザ上に ERROR OCCURRED: abc.txt と表示され,サーバ上に abc.txt がサイズ 0 のファイルとして作られる。 このとき,スクリプトのログファイル (初期設定では /tmp/logfile) には

Thu Sep  4 17:00:51 2008: exception: can't write str to binary stream

のように記録される。 これはサーバのアップロード用フォルダにスクリプトがバイナリ・モードで書込み用にオープンした abc.txt に,Unicode 文字列のアップロード内容が書き込まれて発生した例外である。

これは標準モジュール cgi がアップロード内容をバイナリ・データとして扱わない点に問題がある。

当然予想されるとおり,画像などのバイナリ・ファイルをアップロードした場合は cgi.FieldStorage() でエラーが発生する。 そのとき,/var/log/httpd/error_log などには次のように記録される。

Traceback (most recent call last):
  File "/Library/WebServer/CGI-Executables/upload", line 79, in <module>
    main()
  File "/Library/WebServer/CGI-Executables/upload", line 20, in main
    form = cgi.FieldStorage()
  File "/Users/ling/lib/python3.0/cgi.py", line 530, in __init__
    self.read_multi(environ, keep_blank_values, strict_parsing)
  File "/Users/ling/lib/python3.0/cgi.py", line 650, in read_multi
    parser.feed(self.fp.read())
  File "/Users/ling/lib/python3.0/io.py", line 1719, in read
    decoder.decode(self.buffer.read(), final=True))
  File "/Users/ling/lib/python3.0/codecs.py", line 300, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf8' codec can't decode byte 0x80 in position 188: unexpec
ted code byte

おそらく,広汎に有用な設計方針は,ファイルの内容そのものはあくまでバイト透過的に扱い, StringIO などメモリ上の内部データとしても (ファイル・システムに準じて) バイト文字列として保持し, テキストとしてアクセスするとき,テキスト・モードのストリームとしてラップできる,あるいは 逆に,必要に応じてラップをバイパスしてバイナリ・モードでアクセスできる,というものだろう。 しかし,現時点の Python 3.0 には,そのような方針は無い。 せめて,バイナリとして扱うのか,テキストとして扱うのかを 利用者が自由に選択できればよいが,従来からの標準ライブラリは 必ずしもそのための十分なインタフェースを用意しない。

Python は,環境変数から自動的に標準入出力のエンコーディングを 決定するなど,非 ASCII 文字を含むデータのために決して単純ではない処理を行っている。 問題は,その処理だけですべての場合を尽くしたとして (現実にそのようなデータを必要とする利用者が 例外的な状況に置かれたときに) スクリプト内部からその決定過程に関与する手段を用意しなかったことである。 Python 2.* まではバイト文字列を基本としていたため,例外的な状況でも最低限の逃げ道が失われることはなかった。 しかし,Unicode テキストを基本とする Python 3.0 では,この設計上の選択が欠陥として表面化する。

4. Python 3.0 への対策

本節では CGI スクリプトを Python 3.0 で動作させるための対策を論ずる。 正攻法かどうかはともかく,ここで議論する方法は sitecustomize.py などの大域的な設定ファイル等を使わず,CGI スクリプトだけで完結する点で実用的である。

下記に示すように Python 3.0 の open 関数は 第1引数としてファイル名の文字列のかわりにファイル記述子の整数をとることができる。 エンコーディング等も指定できる。

>>> help(open)
Help on class OpenWrapper in module io:

class OpenWrapper(builtins.object)
 |  open(file, mode='r', buffering=None, encoding=None, errors=None, newline=Non
e, closefd=True)
 |  
 |  Open file and return a stream. If the file cannot be opened, an IOError is
 |      raised.
 |  
 |      file is either a string giving the name (and the path if the file
 |      isn't in the current working directory) of the file to be opened or an
 |      integer file descriptor of the file to be wrapped. (If a file
 |      descriptor is given, it is closed when the returned I/O object is
 |      closed, unless closefd is set to False.)
 |  
 |      mode is an optional string that specifies the mode in which the file
 |      is opened. It defaults to 'r' which means open for reading in text
……以下略……

対話セッションで実験してみよう。

sys.stdout のファイル記述子を fileno メソッドで得る。 その値を第1引数として新しくファイル・オブジェクトを作成し,それへの参照で sys.stdout をすげかえる。 新しいファイル・オブジェクトでは encoding='ascii', errors='xmlcharrefreplace' として,非 ASCII 文字を XML や HTML で使われるような文字参照で置き換えて出力する。 下記に例を示す。

>>> import sys
>>> sys.stdout = open(sys.stdout.fileno(), "w", encoding='ascii', errors='xmlcha
rrefreplace')
>>> "いろは"
'&#12356;&#12429;&#12399;'
>>> 

この方法を CGI スクリプトに応用する。

標準出力 sys.stdoutencoding を上記の実験のように 'ascii' として errors='xmlcharrefreplace' で文字参照を使うようにしてもよいが,ここでは単に CHARSET に一致させる。 改行文字として従来は Unix の '\n' を使ってきたが,HTTP の本則にあわせて '\r\n' を使う。

標準入力 sys.stdin は,本来はバイナリ・モード 'rb' にしたいが,cgi モジュールがテキスト・モードを仮定しているため, 次善の策として encoding='latin-1' とする。 これにより,0 から 255 までの各バイト値がそれぞれそのまま 0 から 255 までの文字コードの Unicode 文字として入力される。 newline='' は入力時の改行文字の変換を防ぐ。

非 ASCII ファイル名を含むメッセージをログ出力できるように,cgi.logfpencoding=CHARSET で陽にオープンしておく。 ログ出力関数 cgi.log は,初回呼出し時,cgi.logfp が未設定ならば cgi.logfile をファイル名としてログ・ファイルを追記オープンする。 あらかじめ cgi.logfp を設定しておけば,そのファイル・オブジェクトがそのまま使われる。

*** 6,11 ****
--- 6,15 ----
  CHARSET = 'UTF-8'                     # to be configured
  cgi.logfile = '/tmp/logfile'          # to be configured
  
+ sys.stdout = open(sys.stdout.fileno(), 'w', encoding=CHARSET, newline='\r\n')
+ sys.stdin = open(sys.stdin.fileno(), 'r', encoding='latin-1', newline='')
+ cgi.logfp = open(cgi.logfile, 'a', encoding=CHARSET)
+ 
  if sys.platform == 'darwin':
      import unicodedata
      def normalize(s):

上書きした sys.stdin の設定に従い,ブラウザからアップロードされたファイルの内容だけでなく名前も cgi モジュール中で 'latin-1' として解釈される。 しかし,Content-typecharsetCHARSET 値を指定しているから,ファイル名は CHARSET 値で解釈する必要がある。 そこでファイル名 f.filename に対し,.encode('latin-1').decode(CHARSET) を行う。 つまり,いったん 'latin-1' としてバイト文字列にした後,改めて CHARSET で解釈して Unicode 文字列を得る。

アップロードされたファイル f.file の内容をローカルに書き込むためのファイル wf は,本来はバイナリ・モードにしたいが,cgi モジュールが f.file をテキスト・モードで与えるから, (shutil.copyfileobj をそのまま使うために) encoding='latin-1' のテキスト・モードで開く。 これにより,0 から 255 までの文字コードの Unicode 文字がそれぞれそのまま 0 から 255 までのバイト値として出力される。 newline='' は出力時の改行文字の変換を防ぐ。

f.file からは,もともと標準入力から 'latin-1' で読み取られた Unicode 文字列が得られる。 wf がそれを 'latin-1' で書き込むことにより,バイト透過性が達成される。

*** 24,37 ****
          f = form['file']
          if f.filename:
              try:
!                 wf_name = os.path.basename(f.filename)
                  wf_name = ntpath.basename(wf_name) # for Windows IE 6
                  wf_pname = os.path.join(FOLDER, wf_name)
!                 wf = open(wf_pname, 'wb')
!                 try:
                      shutil.copyfileobj(f.file, wf)
-                 finally:
-                     wf.close()
                  os.chmod(wf_pname, 0o666)
                  msg = 'received: <b>%s</b>' % cgi.escape(wf_name)
                  cgi.log('%s: received: %s',
--- 28,40 ----
          f = form['file']
          if f.filename:
              try:
!                 wf_name = f.filename.encode('latin-1').decode(CHARSET)
!                 wf_name = os.path.basename(wf_name)
                  wf_name = ntpath.basename(wf_name) # for Windows IE 6
                  wf_pname = os.path.join(FOLDER, wf_name)
!                 with open(wf_pname, 'w',
!                           encoding='latin-1', newline='') as wf:
                      shutil.copyfileobj(f.file, wf)
                  os.chmod(wf_pname, 0o666)
                  msg = 'received: <b>%s</b>' % cgi.escape(wf_name)
                  cgi.log('%s: received: %s',

試行した限りでは,この CGI スクリプトを使ってバイト透過的にファイルをアップロードできる。 ただし,つねにファイル末尾の改行が欠けるという誤動作をおこす。 改行文字として \n を使うテキスト・ファイルでは,末尾の \n 1バイトを除いて全く同じ内容がアップロードされる。 同様に,改行文字として \r\n を使うテキスト・ファイルでは,末尾の \r\n 2バイトを除いて全く同じ内容がアップロードされる。 ファイルの途中に出現する改行文字はそのまま転送される。 したがって,末尾が改行で終わらないファイルならば完全に同じ内容がアップロードされる。

この誤動作は cgi モジュール由来の f.file が末尾の改行を返さないのが直接の原因だが,詳細は不明である。

5. おわりに

ファイルのアップロードとフォルダの一覧を行う Python CGI スクリプトを改訂し,外部コマンドへの依存性をなくすとともに,Python 3.0b3 への移植をとおして Python 3.0 の問題点を明らかにした。

標準入出力が不適切なエンコーディングのテキスト・モードで開かれる場合があること, そのエンコーディングの決定過程にスクリプト内部から直接関与する方法がないこと, 標準ライブラリの提供するインタフェースに,テキスト・モードとバイナリ・モードの選択手段が 十分に与えられていないことが,その問題点である。

しかし,さらに,生のファイル記述子に新しくファイル・オブジェクトをまとわせて元の標準入出力を置き換えること,および バイナリ・モードを latin-1 のテキスト・モードで代用することで,これらの問題を少なくとも暫定的には解決できることを示した。


前回


Copyright (c) 2008 OKI Software Co., Ltd.