yukiのブログ

作ったものなど

フォルダのサイズを表示するプログラム作った

この記事はIS17er Advent Calendarの18日目が空いていたので、「無いよりはなんかあった方がいいだろう」と思ってその日のうちに書かれたものです。
17日目の記事はこちら

Windows標準のエクスプローラはフォルダのサイズを表示してくれないので、目立たない名前のフォルダが数ギガバイトも食ってたりしても気づきくくて厄介です。
そこでフォルダのサイズを表示するプログラムを書きました。

[2017/1/13追記]
githubリポジトリ作りました。
github.com
以下の記事は2016/12/18時点でのプログラムについての記事となります。

参考資料

プログラムを作るのに使ったモジュールや関数はAutomate the Boring Stuff with Pythonに載っていたものが多いです。この本はアルゴリズムやデータ構造のことなど考えずに、「pythonでなんかやろう!」というコンセプトのもと書かれた本で、プログラミングやpythonの入門(≠ 情報科学の入門)として最適だと思います。オンラインでなら無料で読めるので気になった方はぜひ。

動かす

python3の標準モジュールだけを使っているのでpythonがあれば動きます。WindowsUbuntuで動くことを確認しましたが、Macではわかりません。
記事の一番下にあるコードをコピペして、FolderSizeExplorer.pyなどの名前で保存した後

python3 FolderSizeExplorer.py

とすれば、現在のフォルダの中身を探索してサイズ順に表示します。
そのあとはフォルダの名前を入れればフォルダを移動でき、qと入力すれば終了できます。

pオプションで最初に探索するフォルダを変更できます。フォルダの内容が多い場合には探索に時間がかかります(自分の環境では100GB使用済みのCドライブ全体を探索すると1分程度かかりました)。見えるファイル全てにアクセスしているのでエラーがたくさん出ますが、問題ありません。

> python .\FolderSizeExplorer.py -p ~
target folder : C:\Users\iiyuk
No saved shelve found.
Collectiong imformation about C:\Users\iiyuk. This may take some time...
Permission denied : C:\Users\iiyuk\AppData\Local\Application Data
Permission denied : C:\Users\iiyuk\AppData\Local\History
.
.
.
Permission denied : C:\Users\iiyuk\NetHood
Permission denied : C:\Users\iiyuk\PrintHood
Permission denied : C:\Users\iiyuk\Recent
Permission denied : C:\Users\iiyuk\SendTo
Permission denied : C:\Users\iiyuk\Templates
Permission denied : C:\Users\iiyuk\スタート メニュー
You can reduce errors by running script as administrator.
------------Contents of C:\Users\iiyuk------------
100.00%   34155.90MB All contents
 44.82%   15309.09MB .\Documents
 20.13%    6875.94MB .\OneDrive
 15.41%    5262.00MB .\AppData
 12.50%    4269.39MB .\Pictures
  5.49%    1874.84MB .\Downloads
  1.32%     451.81MB .\.PyCharm2016.2
  0.22%      75.02MB .\.vscode
  0.05%      18.62MB Files in this folder
  0.05%      16.00MB .\.nuget
  0.01%       3.08MB .\3D Objects
  0.00%       0.05MB .\.ipython
  0.00%       0.04MB .\.matplotlib
  0.00%       0.02MB .\.pylint.d
  0.00%       0.01MB .\Desktop
  0.00%       0.01MB .\Favorites
  0.00%       0.00MB .\Searches
  0.00%       0.00MB .\Links
  0.00%       0.00MB .\Videos
  0.00%       0.00MB .\Music
  0.00%       0.00MB .\Contacts
  0.00%       0.00MB .\Saved Games
  0.00%       0.00MB .\.ssh
  0.00%       0.00MB .\.config
  0.00%       0.00MB .\Templates
Enter folder name. (or q to quit) :

qを押して終了するときに、探索の結果を保存することができます。保存しておけば次回同じフォルダを表示しようとしたときにロードできます。

コードの中身

osモジュール

pythonのosモジュールにはディレクトリを操作する関数が豊富にあります。今回のコードで使ったものの一部を上げると

os.getcwd()           # cwd(現在のフォルダ)を返す
os.chdir(path)        # cwdを変更
os.listdir()          # cwdの中身をリストで返す
os.path.exists(path)  # あるパスが存在するかどうかを返す
os.path.sep           # パスのセパレーター("\"か"/")
os.path.abspath(path) # pathの絶対パスを返す
os.path.islink(path)  # pathがシンボリックリンクかどうかを返す

などです。どの関数もOSに依存することなく動くので便利です。
osモジュールを使えば、フォルダの情報を保持するクラスを以下のように簡単に書けます。

class FolderInfo():
    def __init__(self, path):
        self.path = path     # フォルダのパス(絶対パスを想定)
        self.size = 0        # フォルダのサイズ
        self.size_files = 0  # フォルダにあるファイルのサイズの合計
        self.sub_dirs = {}   # フォルダにあるフォルダのFolderInfoを入れる
        for content in os.listdir(self.name):
            # この関数内ではcwdを変更しないので絶対パスに変換
            content = os.path.join(self.name, content)
            elif os.path.isfile(content):
                # contentがファイルのときの処理
                self.size += os.path.getsize(content)
                self.size_files += os.path.getsize(content)
            else:
                # contentがフォルダのときの処理
                basename = os.path.basename(content)
                self.sub_dirs[basename] = FolderInfo(content)
                self.size += self.sub_dirs[basename].size

あとはこれにエラー処理や対話処理、探索結果の保存処理なんかを書けば完成です(簡単に書けるとは言ってない)。

shelveモジュール

作ったオブジェクトの保存/読みだしがすぐできて便利なので使いました。便利なので特にいうこともありません。

    def load_shelve(self, key):
        slv = shelve.open(os.path.join(self.first_cwd, "savedShelve"))
        self.info = slv[key]
        slv.close()

    def save_shelve(self):
        slv = shelve.open(os.path.join(self.first_cwd, "savedShelve"))
        slv[self.info.path] = self.info
        slv.close()

シンボリックリンク

エラー処理はtry-exceptでどうにかなるのが多かったのですが、シンボリックリンク絡みのエラ-にはかなり悩みました。
シンボリックリンクとはあるフォルダ/ファイルを別のフォルダ/ファイルに見せかける機能で、linuxで多用されています。
ただ別の場所を指しているだけならいいのですが、リンクの張り方によっては無限ループが生じます。
無限ループの分かりやすい例がbash on Windowsの中にあります。フォルダの中のフォルダがそのフォルダ自身を指していて、以下のようなことができます。bash on Windowsをいれているなら試せるはずです。

~$ cd /lib/recovery-mode/
/lib/recovery-mode$ ls
l10n.sh  options  recovery-menu  recovery-mode
/lib/recovery-mode$ cd recovery-mode
/lib/recovery-mode/recovery-mode$ ls
l10n.sh  options  recovery-menu  recovery-mode
/lib/recovery-mode/recovery-mode$ cd recovery-mode
/lib/recovery-mode/recovery-mode/recovery-mode$ cd recovery-mode
/lib/recovery-mode/recovery-mode/recovery-mode/recovery-mode$ cd recovery-mode
/lib/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode$ cd recovery-mode
/lib/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode$ cd recovery-mode
/lib/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode$ cd recovery-mode
/lib/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode$ cd recovery-mode
/lib/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode/recovery-mode$
.
.
.

シンボリックリンクで無限ループができているディレクトリに上のプログラムを使うと無限に再帰してしまい上手くいきません。

どうしたものかと悩みましたが、そもそもシンボリックリンクは別の場所にあるフォルダ/ファイルを指しているわけで、これらのフォルダ/ファイルのサイズをいちいち数えていると、同じフォルダ/ファイルのサイズが2回以上数えられてしまうことになり全体のサイズを実際よりも大きく見積もってしまいます。つまりシンボリックリンク先のフォルダ/ファイルのサイズは数えなくていいのです。

[12/19追記]

というわけでシンボリックリンクをスキップすればOKです。シンボリックリンクかどうかはos.path.islink()で判定できます。

for content in os.listdir(self.path):
  .
  .
  if os.path.islink(content):
    # シンボリックリンクは無視
    continue
  .
  .
  .


コードとは直接関係ありませんが、pythonシンボリックリンクで変な挙動をするので書いておきます。(コメントでの指摘ありがとうございます)

まずはbash on Windowsでの通常の挙動です。

# pythonコンソール on bash on Windows
>>> import os
>>> os.path.islink("/bin/sh")   # "/bin/sh"はシンボリックリンクか?
True                            # "/bin/sh"はシンボリックリンクである。
>>> os.path.realpath("/bin/sh") # シンボリックリンク"/bin/sh"が指すパスは何か?
'/bin/dash'                     # シンボリックリンク"/bin/sh"は"bin/dash"を指している。

次にWindowsでの変な挙動です。

# pythonコンソール on Windows
>>> import os
>>> os.path.islink("C:\\Users\\All users")   # "C:\\Users\\All users"はシンボリックリンクか?
True                                         # "C:\\Users\\All users"はシンボリックリンクである。
>>> os.path.realpath("C:\\Users\\All users") # シンボリックリンク"C:\\Users\\All users"が指すパスは何か?
'C:\\Users\\All users'                       # シンボリックリンク"C:\\Users\\All users"は"C:\\Users\\All users"を指している(?)。

os.path.realpath("C:\\Users\\All users")は本当は"C:\ProgramData"を返すべきですがそうなっていません。
これはpythonのバグみたいです。このページによると、このバグは2010年に報告されまだ直ってないようです...。


問題点

  • GUIが無い。
  • フォルダの探索が遅い。
  • エラーがまだ残ってるような気がする。
  • 開けてないフォルダ/ファイルが割とある。
  • すでに保存してあるフォルダ下のフォルダを開くように設定しても探索が始まってしまう。
  • フォルダの内容が変わったときに、探索結果を更新できない(一から作り直す必要がある)。
  • 保存してある探索結果を読み込むのが遅い。
  • メモリ使用量が多い(100GBの探索結果を読み込むと133MBぐらい食う)。

感想

ですます調で文章を書くのは疲れる。

コード

コピペ用のコードです。コピペして動くように多少変更しています。

# -*- coding: utf-8 -*-

import os
import argparse
import shelve


class FolderInfo():

    def __init__(self, path):
        self.path = path     # フォルダの絶対パス
        self.size = 0        # フォルダのサイズ
        self.size_files = 0  # フォルダにあるファイルのサイズの合計
        self.sub_dirs = {}   # フォルダにあるフォルダのFolderInfoを入れる
        try:
            for content in os.listdir(self.path):
                # この関数内ではcwdを変更しないので絶対パスに変換
                content = os.path.join(self.path, content)
                if os.path.islink(content):
                    # シンボリックリンクなら無視
                    continue
                if os.path.isfile(content):
                    # contentがファイルのとき
                    self.size += os.path.getsize(content)
                    self.size_files += os.path.getsize(content)
                else:
                    # contentがフォルダのとき
                    basename = os.path.basename(content)
                    self.sub_dirs[basename] = FolderInfo(content)
                    self.size += self.sub_dirs[basename].size
        except PermissionError:
            print('Permission denied : ' + self.path)
        except FileNotFoundError:
            print('File not found : ' + self.path)
        except NotADirectoryError:
            print('Not a directory : ' + self.path)
        except OSError:
            print('Smething wrong  : ' + self.path)


class FileSizeExplorer():
    """
    FolderInfoの内容を表示する。
    """

    def __init__(self):
        self.first_cwd = os.getcwd()
        self.args = self.make_perser().parse_args()
        self.cwd = self.clear_path(self.args.path)
        self.info = None
        if os.path.exists(self.cwd):
            self.prologue()
            self.loop()
            self.epilogue()
        else:
            print(self.cwd + "does not exist.")

    @staticmethod
    def shelve_exists(key, shelve_path):
        """
        pathにsavedShelveがあり、かつその中にkeyというkeyがあるかどうかを返す
        """
        if os.path.exists(os.path.join(shelve_path, "savedShelve.dat")):
            slv = shelve.open(os.path.join(shelve_path, "savedShelve"))
            ans = key in slv.keys()
            slv.close()
            return ans
        elif os.path.exists(os.path.join(shelve_path, "savedShelve")):
            slv = shelve.open(os.path.join(shelve_path, "savedShelve"))
            ans = key in slv.keys()
            slv.close()
            return ans
        else:
            return False

    def load_shelve(self, key):
        """
        savedShelveからkeyを読みだし、self.infoに代入する
        """
        slv = shelve.open(os.path.join(self.first_cwd, "savedShelve"))
        self.info = slv[key]
        slv.close()
        return

    def save_shelve(self):
        """
        あとから読み出せるようにshelveに保存する。
        保存先はself.first_cwd/saved_shelve
        """
        slv = shelve.open(os.path.join(self.first_cwd, "savedShelve"))
        slv[self.info.path] = self.info
        slv.close()

    @staticmethod
    def make_perser():
        """
        パーサーを作って、パーサーを返す。
        返り値に直接parse_arg()することが前提になっている。
        """
        parser = argparse.ArgumentParser(description="Show folder size.")
        parser.add_argument("-p", dest="path", default="." + os.path.sep,
                            required=False, action="store",
                            help="Configure folder path. Default : ." +
                            os.path.sep)
        return parser

    @staticmethod
    def clear_path(path):
        """
        pathを完全なパス(曖昧さのないパス)にして返す。
        """
        path = os.path.expanduser(path)
        path = os.path.expandvars(path)
        return os.path.abspath(path)

    def prologue(self):
        """
        開始時のプロンプト。
        self.infoを設定する。
        """
        print("target folder : " + self.cwd)
        while True:
            if self.shelve_exists(self.cwd, os.getcwd()):
                response = input(
                    "Saved shelve found. Load this shelve? [y/n] : ")
                if response == "y":
                    print("loading...")
                    self.load_shelve(self.cwd)
                    return
                elif response == "n":
                    break
            else:
                print("No saved shelve found.")
                break
        print("Collectiong imformation about %s."
              " This may take some time..." % self.cwd)
        self.info = FolderInfo(self.cwd)
        print("You can reduce errors by running script as administrator.")

    def epilogue(self):
        """
        終了時のプロンプト。
        self.infoをセーブする。
        """
        while True:
            response = input("Save result?[y/n] : ")
            if response == "y":
                print("saving...")
                self.save_shelve()
                print("Results saved.")
                print("quitting...")
                break
            elif response == "n":
                print("quitting...")
                break

    def get_info(self, path):
        """
        pathフォルダの情報(そのフォルダのサイズとサブフォルダのサイズ)を返す。
        """
        temp_size = self.info.size
        temp_size_files = self.info.size_files
        temp_sub_dirs = self.info.sub_dirs
        rel_path = path[len(self.info.path):]
        # rel_path == ""のときpath = self.info.path
        if rel_path != "":
            if rel_path[0] == os.path.sep:
                rel_path = rel_path[1:]
            for path in rel_path.split(os.path.sep):
                temp_size = temp_sub_dirs[path].size
                temp_size_files = temp_sub_dirs[path].size_files
                temp_sub_dirs = temp_sub_dirs[path].sub_dirs

        result = {}
        result[temp_size_files] = "Files in this folder"
        for sub_dir in temp_sub_dirs.values():
            result[sub_dir.size] = "." + os.path.sep + \
                os.path.basename(sub_dir.path)

        return result, temp_size

    def show_info(self):
        """
        self.infoとself.cwdに基づいてフォルダの内容を表示する。
        """
        result, total_size = self.get_info(self.cwd)
        print(("Contents of " + self.cwd).center(50, "-"))
        print("100.00%% %10.2fMB All contents" %
              (total_size / 1048576))
        for size, name in sorted(result.items(), reverse=True):
            print("%6.2f%% %10.2fMB %s" %
                  (size / total_size * 100, size / 1048576, name))

    def loop(self):
        """
        メインループ
        """
        os.chdir(self.cwd)
        self.show_info()
        while True:
            key = input(
                "Enter folder name. (or q to quit) : ")
            if key == "":
                continue
            elif key == "q":
                break
            else:
                key = self.clear_path(key)
                if os.path.exists(key):
                    if self.info.path in key:
                        self.cwd = key
                        os.chdir(self.cwd)
                    else:
                        print("Information about " + key + " does not exist.")
                        continue
                else:
                    print("Path " + key + " does not exist.")
                    continue
            self.show_info()

FileSizeExplorer()