Windows な「Check whether a path is valid」もしくはサニタイズ

至極基本的なことなのに今更気付く、なんてのも日常のひとつ。

Windows でファイル名に使える文字種の話そのものは、同じように「いまさらだが」ネタとして書いたことがある。そこで書いたのとは少しだけ違った見え方がする問題に出くわしてみたので、一応別ネタとして書いておこうかと。

そもそもは、「ファイルを別ファイルシステムからコピー」みたいなことをすることがまずは問題を起こすわけよね、「Unix で管理してたファイルを Windows にコピーしてくる」みたいなね。こういう「OS またはファイルシステムの違い」に起因する問題というのが、真っ先に思いつくエラーなわけなんだけれど、そうではないところに起因する「よくお目にかかる問題」があるわけよ。そう、「テキストに基づいてファイル名を決めたい」ケースね。たとえば以下:

1 <html lang="ja">
2   <head>
3   <meta charset="utf-8">
4   </head>
5   <body>
6     <img src="/path/to/img.jpg" title="ひげ~きしょうねん!?">
7   </body>
8 </html>

この img をダウンロードする際に、「img.jpg」ではなくて、title に基いて決めたい、なんてことは考えてもいいだろう。この場合、title として「許される文字種」は、当たり前だが「utf-8 が許容する全て」である。だから、これを「ファイル名」として採用するにあたっては「プラットフォームもしくはファイルシステムの制約に引っかからないように変形してあげる」必要がある。

これがさぁ、やってみて初めて気付いたんだけれど、まずは「Japanese Edition Windows な cp932」に収まらない文字が問題を起こすことが多いので、思わずこうしちゃったわけよ:

1 def sanitize_filename(fn):
2     # python 3.x 前提
3     fn = fn.encode("cp932", errors="replace").decode("cp932")
4     # ...
5     return fn

これにより、cp932 に収まらないものを置換できてトラブル回避になる…、わけないのよね、これ。なぜなら「replace」はよりによって「?」に置き換えてしまうから。「cp932 に収まらないものを置換」処理のいかんに関わらず、いずれにしても「ファイル名として許されるように」しなければならないのだが、でもこれは気分悪いでしょ:

1 def sanitize_filename(fn):
2     fn = fn.encode("cp932", errors="replace").decode("cp932")
3     # ...
4     return fn.replace("?", "_")

pathvalidate を使うつもりなら:

1 import pathvalidate
2 
3 
4 def sanitize_filename(fn):
5     fn = fn.encode("cp932", errors="replace").decode("cp932")
6     # ...
7     return pathvalidate.sanitize_filename(fn)

てな感じだが、処理結果が気に入らない場合でも pathvalidate は読みやすいソースなので、自分で書く処理のベースにはしやすいだろう。

なお、そもそも「errors=”replace”」にこだわる必要もなくて、「errors=”xmlcharrefreplace”」であれば「変換した結果「?」になっちゃう問題」は起こらない:

1 def sanitize_filename(fn):
2     fn = fn.encode("cp932", errors="xmlcharrefreplace").decode("cp932")
3     # ...
4     return fn

無論「変換する前から「?」がいた」場合をはじめ、Unicode にまつわる問題とは独立した「ファイル名として不正文字」問題は起こるので、どちらにせよ pathvalidate 相当の処理は必要である。pathvalidate に依存したくないなら、まぁきっとこんな感じであろう:

 1 import re
 2 
 3 
 4 def sanitize_filename(fn):
 5     fn = fn.encode("cp932", errors="replace").decode("cp932")
 6     #fn = fn.encode("cp932", errors="xmlcharrefreplace").decode("cp932")
 7     # ...
 8     invp = "".join([chr(c) for c in range(0, ord(" "))])  # invisible
 9     invp += chr(127)
10     invp += ':*?"<>|\\/'  # printable but is invalid
11     invalc_rgx = re.compile("[{}]".format(
12         "".join([re.escape(c) for c in invp])))
13     #
14     return invalc_rgx.sub("-", fn)

なお、 pathvalidate はパスの長さの問題と、「バカな Windows のキーワード(NUL、CON、など)」問題にも関与してくれるので、そこまで問題にするなら pathvalidate を使うか、それをベースに処理を書くといい。(というか pathvalidate も ワイド文字拡張 については知らないフリしてるみたいで、どっちみち不完全ではあるんだけどさ。)