common prefix を持たない tar/zip アーカイブの展開がダルいのよ

いやん、寄り道し過ぎ。

この話にちょっと似た話。

tar/zip なアーカイブを作る流儀に、必ずルートを持つ派とそうでない派がいるでしょう? あるいは「無頓着」というのも流儀か。要するに Hoge-2.1.3-source.zip が Hoge-2.1.3-source をルートに持つか持たないか。Hoge-2.1.3-source でなくても Hoge でもいいが、とにかく「common prefix」を持つ場合と持たない場合がある、よね。

このツリーをアーカイブする際に、Hoge-2.1.3-source を省くかどうかの話。
1 Hoge-2.1.3-source/src
2 Hoge-2.1.3-source/src/hoge.c
3 Hoge-2.1.3-source/src/makefile
4 Hoge-2.1.3-source/doc
5 Hoge-2.1.3-source/doc/man
6 Hoge-2.1.3-source/doc/man/man1
7 Hoge-2.1.3-source/doc/man/man1/hoge.1

省く流儀にももちろん正当な理由があることは認めつつも。つまり「好きな場所にそのまま展開すればいーんだぜ」みたいなことよね。バイナリの配布なんかの場合はそんなのの方がいい場合も確かにある。

けど個人的にはこういったアーカイブはある特定のフォルダ(たとえば「c:/Users/myname/__WORK」とか」にまとめて置いて、そこで展開することが多いので、フラットなのは非常に迷惑な場合の方が多くて。だって展開してしまったあと「これはどこ由来だ?」について真剣に悩まねばならなくなるからね。

で、これが多くても一日一回、みたいな頻度ならまぁ「t で開いてみて、common prefix なしなら -C を付けて」でも(ウザいとは感じたとしても)別に死にはしないわな:

1 me@host: ~$ tar ztvf Hoge-2.1.3-source.tar.gz
2    ...
3 me@host: ~$ mkdir Hoge-2.1.3-source ; tar ztvf Hoge-2.1.3-source.tar.gz -C Hoge-2.1.3-source

みたいにね。

なんだけど、今ちょっととある理由で「ソースツリーのサンプル」をたくさんかき集めてるのね。正確にはそのソースアーカイブ中の特定のファイル形式なんだけど、とにかくいっぱい入手して、展開を繰り返してるのな。どうも SourceForge 流儀は「フラット派」が多いようで、かなり鬱陶しい。

というわけで、さすがに特に mkdir がダルいこともあり、もうスクリプトにしといた方がいいか、と。結構時間かかったかな、多分1時間とちょっとか。「苦労」は何にもしてないけれど、毎日使うものでもないモジュールだし、あと偶然「壊れたアーカイブ」を持ってたもんで、その扱いに少し時間喰ってた。こんなな:

unarchive.py
 1 #! /bin/env python
 2 # -*- coding: utf-8 -*-
 3 from __future__ import unicode_literals  # 2021-07-04追記参照
 4 
 5 import tarfile
 6 import zipfile
 7 import argparse
 8 import re
 9 import os
10 from os.path import commonprefix, basename, splitext
11 from os.path import join as path_join
12 
13 
14 def main(args):
15     if args.exclude:
16         exrgx = re.compile(args.exclude)
17         def is_exclude_target(s):
18             return exrgx.search(s)
19     else:
20         is_exclude_target = lambda s: False
21 
22     #
23     targ = args.archive
24     m = re.match(r"^(.*)(\.tar\.[xgb]z2{0,1})|(\.tgz)$", basename(targ))
25     # tar
26     if m:
27         root = m.group(1)
28         with tarfile.open(targ, 'r') as fi:
29             have_no_root = not commonprefix([inf.name for inf in fi.getmembers()])
30             root = root if have_no_root else ""
31             for inf in fi.getmembers():
32                 if is_exclude_target(inf.name):
33                     continue
34                 if args.action == "tv":
35                     print(inf.name)
36                 elif args.action.startswith("x"):
37                     if args.action.endswith("v"):
38                         print(path_join(root, inf.name).replace(os.sep, "/"))
39                     fi.extract(inf, root)
40     # zip
41     elif targ.lower().endswith(".zip"):
42         root = splitext(basename(targ))[0]
43         with zipfile.ZipFile(targ, 'r') as fi:
44             have_no_root = not commonprefix([inf.filename for inf in fi.infolist()])
45             root = root if have_no_root else ""
46             for name in fi.namelist():
47                 if is_exclude_target(name):
48                     continue
49                 if args.action == "tv":
50                     print(name)
51                 elif args.action.startswith("x"):
52                     if args.action.endswith("v"):
53                         print(path_join(root, name).replace(os.sep, "/"))
54                     fi.extract(name, root)
55 
56 
57 if __name__ == '__main__':
58     parser = argparse.ArgumentParser()
59     parser.add_argument("action", choices=["t", "x", "tv", "xv"])
60     parser.add_argument("archive")
61     parser.add_argument("--exclude",
62                         type=str,
63                         default="",
64                         help="exclude patten as regular expression")
65     args = parser.parse_args()
66 
67     try:
68         open(args.archive)  # check existing in advance.
69         main(args)
70     except (EOFError, IOError) as e:
71         # if corrupted:
72         #     EOFError on Python 3.x, IOError with CRC checking failure on Python 2.7
73         if args.archive not in str(e):
74             raise IOError(e, ": " + repr(args.archive))
75         raise

common prefix を持つ場合はそのまま展開、持たない場合はアーカイブ名をルートにして展開する。「このファイルだけ展開しろや」はやってないけど、「このパターンのものはいらん」は付けといた。

実はとんでもないソース配布を見つけちゃってなぁ。なんと、「.git 丸ごと」をアーカイブに含めて SourceForge でソース配布物として公開してるバカがいる。230MB だとぉ? 「.git」も「.hg」もこれは(cvs や svn のような「D」の付かない VCS と違って)レポジトリそのもの。すなわち「コミットするたびに成長していく」。どうするつもりなんだか、1年後には GB 超えなんてしてもおかしくねーぞ。てわけで「除外」は今すぐに必要だったのだ。

tar については Python の tarfile モジュールが対応してるものについて扱えるけど、2.7 と 3.x ではサポートされる範囲が違うと思う。確か 2.7 では xz は扱えなかった気がする。

インターフェイスは tar の使い方を熟知している人向け、ですよ。tar は Unix 初心者にはわかりにくいコマンドの代表みたいなところもあるけど、逆に使い始めれば日常的に使うわけで、その慣れたものに似てた方がいいとワタシは思ったんだね。

あと属性の表示をサボってる。いらんでしょ別に、と「ワタシは」思ったんで。


2021-07-04追記:
どうにもワタシが「日本語なんか含まないのが楽」として原則避けてしまうもんで、真っ先に気付かないことが多いんだよね、こういうの。日本語含むとダメなんだわ、特に zip。

まず、「レガシーな zip」を展開する際は、現状、python 2.7 でしか正しく展開出来ない。レガシーな、というのは、エントリのエンコードが utf-8 でないもの。日本語圏のものは当然 cp932。新しい仕様の zip はエンコーディングが utf-8 固定だが、レガシーなものは「各々のロケール圏でそれぞれ」という、まぁヒドいといえばヒドい仕様なわけね、けれども python 2.7 の zipfile では展開出来ていた。ただし。

これはほんとにワタシが一切日本語を含む zip でテストしなかったのが悪いんだが、「from __future__ import unicode_literals」は外さないとダメ。これを外せば、2.7 で動く。

が。「2.7だとぅ?」という反応は無論大正解。2.7 は「捨て去り、抹殺せねばならぬ」のだ。けれども、てこと。当然これは公式に問題だと認められている。issue40172issue41928など。直接の原因となっているコードはすぐに特定できる。zipfile 内で「filename.decode('cp437')」してる箇所が二箇所ある。これを「filename.decode('cp932')」に変えれば、とりあえず「日本語のもの」は正しく扱えるようになる。むろんこの決め打ちは間違いだが、同じように「cp437」決め打ちも間違いなので、罪の大きさは全く同じ。とりあえず個人用途としてよければいい、てことなら、書き換えとけばいい。