ファイルを検索(Everythingを利用)(Windows,Python)

# ファイルを高速で検索する
ファイルを検索するとき、普通はルートから順に再帰的に検索をかけていきます。Pythonではそれに便利なglobと言うモジュールもあります。
# でも
遅っせえです。まあ、当たり前ですよね。あと、glob.globはちょっとアレな所があります。ちょっと色々ハマったし、WindowsだとPathlibしか使わん。なのかもしれません。
# 速くするにはどうすればいい？
一番簡単なのは、インデックスを作成してデータベース化してしまうことです。でも、たかが１アプリのためにわざわざそんな物作る理由も意味もありません。

# WindowsにはEverythingがある！
Everythingと言うソフトがあります。常駐してインデックスを作成しています。便利なソフトなので私は使っています。

https://www.voidtools.com/

このソフトでは、ETPサーバーと言うものを使うことができます。これはちょっと毛が生えたFTPです。
PythonからETPサーバーにアクセスすれば簡単に全ドライブ検索が実装できるんじゃなくね？
と、思ってweb検索してみたのですが、Javaでの実装しか無かったので、Pythonで作ってみました。

```Python:ETPlib.py
from dataclasses import dataclass
from ftplib import FTP
import os,os.path

FTP.encoding = "utf-8"

@dataclass(frozen = True)
class ETPServerinfo:
    host: str = 'localhost'
    port: int = 21
    user: str = 'anonymous'
    passwd: str = 'foo@bar'
    timeout: int = 500

@dataclass
class ETPQuery:
    query : str = ''
    case : bool = False #大小文字を区別
    whole_word : bool = False #単語にマッチ
    path : bool = False #パスにマッチ
    diacritics : bool = True #補助符号にマッチ
    regex : bool = False #正規表現検索を行う
    r_sort : str = 'NAME_ASCENDING'
    r_offset : int = 0
    r_count : int = 0xffffffff
    r_size_column : bool = False
    r_date_created_column : bool = False
    r_date_modified_column : bool = False
    r_attributes_column : bool = False
    r_path_column : bool = True
    r_file_list_filename_column : bool = False

class ETP:
    def __init__(self, server : ETPServerinfo):
        self.ftp = FTP()
        self.ftp.connect(host = server.host, port = server.port, timeout = server.timeout)
        self.ftp.set_pasv('true')
        self.ftp.login(user = server.user,passwd = server.passwd)
        self.ftp.cwd('/')
        c = self.ftp.sendcmd('FEAT')
        if c.find('EVERYTHING') == -1:
            raise ValueError('FTP not supported Everything command')

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self.ftp.quit()

    def close(self):
        self.ftp.quit()

    def _r_out(self, q : ETPQuery):
        _s = self._s
        self.ftp.sendcmd('EVERYTHING SORT ' + q.r_sort)
        self.ftp.sendcmd('EVERYTHING OFFSET ' + str(q.r_offset))
        self.ftp.sendcmd('EVERYTHING COUNT ' + str(q.r_count))
        self.ftp.sendcmd('EVERYTHING SIZE_COLUMN' + _s(q.r_size_column))
        self.ftp.sendcmd('EVERYTHING DATE_CREATED_COLUMN' + _s(q.r_date_created_column))
        self.ftp.sendcmd('EVERYTHING DATE_MODIFIED_COLUMN' + _s(q.r_date_modified_column))
        self.ftp.sendcmd('EVERYTHING ATTRIBUTES_COLUMN' + _s(q.r_attributes_column))
        self.ftp.sendcmd('EVERYTHING PATH_COLUMN' + _s(q.r_path_column))
        self.ftp.sendcmd('EVERYTHING FILE_LIST_FILENAME_COLUMN' + _s(q.r_file_list_filename_column))

    def _r_fmt(self):
        def cmp(s,cmp):
            if s.startswith(cmp):
                r = s[len(cmp)+1:]
                return int(r) if r.isdigit() else r
            else:
                return None

        def pj(path,fn):
            if path is not None:
                return os.path.join(path,fn)
            else:
                return None

        a = self.ftp.sendcmd('EVERYTHING QUERY').split('\n')
        r = []
        ri = {}
        cnt = None
        for i in a:
            if i.startswith('200-Query'):
                continue
            if i.startswith(' RESULT_COUNT'):
                cnt = int(i[14:])
            c = cmp( i, ' PATH')
            if c is not None:
                ri['Path'] = c
                continue
            c = cmp( i, ' ATTRIBUTES')
            if c is not None:
                ri['Attributes'] = c
                continue
            c = cmp( i, ' SIZE')
            if c is not None:
                ri['Size'] = c
                continue
            c = cmp( i, ' DATE_CREATED')
            if c is not None:
                ri['Date_Created'] = c
                continue
            c = cmp( i, ' DATE_MODIFIED')
            if c is not None:
                ri['Date_Modified'] = c
                continue
            f = cmp(i,' FOLDER')
            if f is not None:
                ri['isdir'] = True
                ri['isfile'] = False
                ri['Name'] = f
                ri['PathName'] = pj(ri['Path'],f)
                r.append(ri)
                ri = {}
            f = cmp(i,' FILE')
            if f is not None:
                ri['isdir'] = False
                ri['isfile'] = True
                ri['Name'] = f
                ri['PathName'] = pj(ri['Path'],f)
                r.append(ri)
                ri = {}
        return (cnt,r)

    def _s(self, p : bool):
        return ' 1' if p else ' 0'

    def _sendcmd(self, q: ETPQuery, fc: str):
        _s = self._s
        ec = 'EVERYTHING '+fc
        self.ftp.sendcmd(ec+'CASE' + _s(q.case))
        self.ftp.sendcmd(ec+'WHOLE_WORD' + _s(q.whole_word))
        self.ftp.sendcmd(ec+'PATH' + _s(q.path))
        self.ftp.sendcmd(ec+'DIACRITICS' + _s(q.diacritics))
        self.ftp.sendcmd(ec+'REGEX' + _s(q.regex))
        self.ftp.sendcmd(ec+'SEARCH ' + q.query)


    def query(self, q : ETPQuery):
        self._r_out( q )
        self._sendcmd( q, '')

        return self._r_fmt()

    def filter_query(self, q : ETPQuery):
        self._r_out( q )
        self._sendcmd( q, 'FILTER_')

        return self._r_fmt()

    def raw_cmd(self, q: ETPQuery, filtersw):
        self._r_out( q )
        if filtersw:
            self._sendcmd( q, 'FILTER_')
        else:
            self._sendcmd( q, '')


    def raw_query(self):
        return self.ftp.sendcmd('EVERYTHING QUERY')

""" SORT PARAM
'NAME_ASCENDING','NAME_DESCENDING',
'PATH_ASCENDING','PATH_DESCENDING',
'SIZE_ASCENDING','SIZE_DESCENDING',
'EXTENSION_ASCENDING','EXTENSION_DESCENDING',
'DATE_CREATED_ASCENDING','DATE_CREATED_DESCENDING',
'DATE_MODIFIED_ASCENDING','DATE_MODIFIED_DESCENDING',
'ATTRIBUTES_ASCENDING','ATTRIBUTES_DESCENDING',
'FILE_LIST_FILENAME_ASCENDING','FILE_LIST_FILENAME_DESCENDING'
"""

if __name__ == '__main__':
    with ETP(ETPServerinfo(port = 9021)) as f:
        c,r = f.query(ETPQuery(query = '*.log'))

        for i in r:
            print(i['PathName'])

        print(c,'件')

        print('='*20)

        f.raw_cmd(ETPQuery(query='python'),False)
        f.raw_cmd(ETPQuery(query='whl'),True)
        print(f.raw_query())

```

```Python:example.py
from ETPlib import ETPServerinfo,ETPQuery,ETP

with ETP(ETPServerinfo(port = 9021)) as f:
    c,r = f.query(ETPQuery(query='うまぴょい'))

print(r)
```
まず、Everythingの設定を変更して下さい。
![2022-04-22 21_46_00-Everything オプション.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570715/64dee4c8-28d7-de82-34e8-0eab7a99ec42.png)
ETP/FTPサーバーを有効(E) にチェックを入れて、必要に応じてポート番号、ユーザー名、パスワードを設定して下さい。
図ではポート番号を標準の21から9021に変更しています。

# 使い方
`from ETPlib import ETPServerinfo,ETPQuery,ETP` でimportしてください。
`server = ETPServerinfo ( host= ホスト名 , port= ポート番号 , user= ユーザー名 , passwd= パスワード , timeout = タイムアウト)`
のように設定します。初期値として
```
host = 'localhost'
port = 21
user = 'anonymous'
passwd = 'foo@bar'
timeout = 500
```
が設定されているので、ローカルホストでの使用では特に変更する設定は無いと思います。ETPServerinfoはイミュータブルです。宣言以降値の変更はできません。

`etp = ETP( server )`
で接続します。
server はこの後使用しないので、複数回接続するのでなければサンプルのように
`etp = ETP( ETPServerinfo( .... ))` のように使用しても問題はありません。

ETPQueryを引数として検索をします。
`q = ETPQuery()`
以下のプロパティがあります。
・検索に関するオプション
```
ETPQuery.query : str # 検索する文字列

ETPQuery.case : bool = False # 大文字小文字を区別するか
ETPQuery.whole_word : bool = False #単語にマッチ
ETPQuery.path : bool = False #パスにマッチ
ETPQuery.diacritics : bool = True #補助符号にマッチ
ETPQuery.regex : bool = False #正規表現検索を行う
```
・結果表示に関するオプション
```
ETPQuery.r_sort : str = 'NAME_ASCENDING' # 結果をどういう順序でソートするか。ソース中の SORT PARAM のコメント参照
ETPQuery.r_offset : int = 0 # 結果を何番目から表示するか
ETPQuery.r_count : int = 0xffffffff # 結果を幾つ表示するか
ETPQuery.r_size_column : bool = False # 結果にサイズを含めるか
ETPQuery.r_date_created_column : bool = False # 結果に作成日を含めるか
ETPQuery.r_date_modified_column : bool = False # 結果に更新日を含めるか
ETPQuery.r_attributes_column : bool = False # 結果に属性を含めるか
ETPQuery.r_path_column : bool = True # 結果にファイルパスを含めるか
ETPQuery.r_file_list_filename_column : bool = False # 使用しません
```
結果表示は基本変更する必要はありません。ローカル検索を前提にしているので、各種ファイル情報は直接調べれば良いと言う判断からです。変更する場合、ETPQuery.r_pathだけは注意してください。これをFalseにすると、ファイルがどの場所にあるかわからなくなります。

`c , r = etp.query( q : ETPQuery ) `で検索します。cはヒット数、rはファイル情報のリストです。
ファイル情報には
```
r[n]['Name'] #ファイル名
r[n]['Path'] #ファイルのあるパス ※ETPQuery.pathがFalseだと存在しません
r[n]['PathName'] #フルパス名 ※ETPQuery.pathがFalseだと存在しません
r[n]['isdir'] #ディレクトリならTrue
r[n]['isfile'] #ファイルならTrue
```
・ETPQuery.r_....で、結果を追加している場合、
```
r[n]['Size'] #ファイルサイズ
r[n]['Attribites'] #属性
r[n]['Date_Created'] #ファイル作成日(int)
r[n]['Date_Modified'] #ファイル更新日(int)
が含まれます。
```

一度検索をした後に、
`c , r = etp.filter_query( q : ETPQuery )` で絞り込み検索ができます。

件数が多くなると、リストへ変換するのに時間がかかります。そういう場合はraw_cmd,raw_queryを使ってください。

`etp.raw_cmd( q : ETPQuery , filter = bool )` で結果を表示せずに検索だけを行います。filter が Trueなら絞り込み検索です
`etp.raw_query()` で結果をとりだします。この結果は文字列です

`etp.close()` で切断します

# ダメなところ
エラーチェックがザルです。
ETPサーバーかどうかのチェックはかろうじてしていますが、それ以外のエラーは全く考慮していません。