見出し画像

Pythonで整理整頓を「半」自動化。すると、ファイルがすっきりへの道が拓けた

Mac Fanの雑誌を読んでいると、「ファイルの整理」の仕方が載っていたので、その雑誌を参考に、Pythonで自動化できないかと考えてきました。

どういったルールを自動化したのか?

というと、

残しておくかもしれないけど、しばらくはいらないのを「MyTrush」というフォルダに入れ、

「MyTrush」フォルダにおいて、プロジェクトのフォルダを1年使わなかったら、捨てる

というルールですね。
たとえば、捨てきれないフォルダやファイルがあるとします。私はデスクトップにおいて、プロジェクトをフォルダ単位で分けているのですが、
プロジェクトのフォルダ内に、

「MyTrush」

というフォルダを作っておいて、あらかじめ決めておいた保存期間を過ぎると、ダウンロードに捨てていくということです。そして、事前にAutomatorで組んでおいたアプリにより、Macを再起動かけた後に自動的にゴミ箱へ捨てるシステムを構築しています。
Automatorの記事の以下の記事から↓

あとはMacを再起動した場合、

「ゴミ箱に捨てる」動作

を行うことで、ファイルをすっきりさせるシステムを自動的に組めないかと思い立ったわけです。Visual StudioCodeなどのエディタや、Python実行環境があることが条件にはなりますが、今から、Pythonでたった 「40行ぐらいのコードで実行可能 なので、みなさんも試してみてはいかがでしょうか?以下のコードが全文となります。これから一つずつわかりやすく解説するように心がけていきますので、よろしくおねがいします!!

import glob
import os,shutil,re,sys
import datetime
'''
①
'''
baseShellregex='**/MyTrush/**'
iCloudLocs=[os.path.expanduser(f'~/Library/Mobile Documents/com~apple~CloudDocs/{baseShellregex}'),os.path.expanduser(f'~/Desktop/{baseShellregex}'),os.path.expanduser(f'~/Documents/{baseShellregex}')]
FILES=[]
now=datetime.datetime.now()
print(f"[{__file__}:{now.strftime('%Y-%m-%d:%H:%M:%S')}]")
for index_num,iCloudLoc in enumerate(iCloudLocs):
    sys.stdout.write(f'{len(iCloudLocs)}件中{index_num+1}件目ファイル検索中…')
    if index_num<len(iCloudLocs)-1:
        sys.stdout.write('\r')
    FILES.extend(glob.glob(iCloudLoc,recursive=True))#候補を徐々にくっつける。
sys.stdout.write('検索完了😊\n')
'''
②
'''
#MyTrushは削除しないようにフィルタをかける。
dueDays=365
def extractFiles(file,dueDays,now):#抽出条件の関数。
    if re.match(os.path.expanduser(r'^.*/MyTrush/$'),file):
        return False
    elapsedDays=(now-datetime.datetime.fromtimestamp(os.path.getatime(file))).days
    return True if elapsedDays>dueDays else False
FILES=[file for file in FILES if extractFiles(file,dueDays,now)]
#1年になっているがデフォルトで設定可能。変えるときはオプション設定を入れる。
'''
③
'''
ignoredFiles=[]
error_Num=0
for index_num,file in enumerate(FILES):
    try:
        shutil.move(file,os.path.expanduser('~/Downloads/'))
    except FileNotFoundError as e:#親ディレクトリが削除されてる場合は、ファイルが見つからない場合が想定される
        ignoredFiles.append(file)
        error_Num+=1
    finally:
        sys.stdout.write(f'{index_num+1}件目削除完了…')
        if error_Num>0:
            sys.stdout.write(f'内無視:{error_Num}件')
        if index_num<len(FILES)-1:
            sys.stdout.write('\r')
if len(ignoredFiles)==0 or len(FILES)==0:
    print('処理完了。')
    sys.exit(0)
print('処理完了。以下のファイル・ディレクトリは親ディレクトリが削除されてるため、無視されました。')
print('===============')
for ignoredFile in ignoredFiles:
    print(ignoredFile)

コード①の解説:ファイル検索モジュール「glob」はめっちゃ優秀

まずは、①のコードの動作から見ていきましょう。①のコードは以下のコードとなります。

'''
①
'''
baseShellregex='**/MyTrush/**'
iCloudLocs=[os.path.expanduser(f'~/Library/Mobile Documents/com~apple~CloudDocs/{baseShellregex}'),os.path.expanduser(f'~/Desktop/{baseShellregex}'),os.path.expanduser(f'~/Documents/{baseShellregex}')]
FILES=[]
now=datetime.datetime.now()
print(f"[{__file__}:{now.strftime('%Y-%m-%d:%H:%M:%S')}]")
for index_num,iCloudLoc in enumerate(iCloudLocs):
    sys.stdout.write(f'{len(iCloudLocs)}件中{index_num+1}件目ファイル検索中…')#…①-1
    if index_num<len(iCloudLocs)-1:#…①-1
        sys.stdout.write('\r')#…①-1
    FILES.extend(glob.glob(iCloudLoc,recursive=True))#ここはextend関数でないと、2次元配列となってしまう🥺
sys.stdout.write('検索完了😊\n')

簡単にいうと、変数iCloudLocsに入ってる配列にglobモジュールで検索した結果をすべて、

extend関数(append関数を使ってしまうと、この場合、配列と配列がごちゃまぜ状態になります。)を使って、「FILES」と呼ばれる変数にすべて入れていく

コードとなります。globモジュールで多くのファイルやフォルダを検索すると

「検索に時間がかかることがある」

ので、①-1のような進捗メッセージを入れることで、

「ちゃんとロードが進んでいるかな…」

という不安を軽減させる工夫を行っています。

        sys.stdout.write('\r')#…①-1

また、①-1に出てくる「\r」は出力されてる行の先頭に来る命令を持った文字、すなわち**エスケープシーケンス(文字)**と呼ばれるものです。これにより、

「3件目中1件目完了」

というふうに1行で表示されるので、進捗メッセージを混乱させないようにも配慮しています。

⚠注意⚠
事前にMyTrushのフォルダを作っておきましょう。でないと、ファイルやフォルダを探してくれません。

つまり、簡単にどういう動作を繰り返しているのかをいうと、以下のコードの要領で、for文にまとめています。
以下のコードでは例として簡単に、「Desktop」フォルダを検索するコードを書いています。

iCloudLoc=os.path.expanduser('~/Desktop/**/MyTrush/**')
FILES=glob.glob(iCloudLoc,recursive=True))
print(FILES)
#MyTrushに入っているファイル・フォルダが表示される。

とファイルとフォルダが一度に検索をかけられたことがわかります。
ちなみに、ファイルのパスはos.path.expanduser関数を使うことで、

「ホームディレクトリ(~)」を可視化

しています。ですので、ログインさえしていれば、どんなユーザーであっても、ホームディレクトリのアドレスを自動的に補完できるようになっているのです。

globモジュールに入れる文字列は「シェル形式」の正規表現

docs.python.orgによると、globモジュールのglob関数について調べてみると、以下のように書いてあります。

pathname can be either absolute (like /usr/src/Python-1.5/Makefile) or relative (like ../../Tools//.gif), and can contain shell-style wildcards.

「glob — Unix 形式のパス名のパターン展開 — Python 3.10.6 ドキュメント」より

よって、

シェル形式の正規表現≠文字列の正規表現

となりますので、注意が必要です!!私も以前混同したために、備忘録としてこのNoteに残すことにしました。

glob関数に、文字列の正規表現を入れたファイルのパスを入れてしまったがために、エラーや誤動作が起きても原因がわからずに、

「何時間もスクリプトができあがらない…🥺」

と悩んでいたことがあったからです。

**(アスタリスク2つ)は階層のレベル関係なく検索してくれる!!

まずは「*(アスタリスク1つ)」を見ていきましょう!!まず、globモジュールにおいて、「*」を使うと、1つの階層において、あらゆる

「フォルダとファイル」

を検索することを意味します。ですので、

「いくつもの階層」

のファイルを列挙するわけではないので、注意しましょう。
「*(アスタリスク1つ)」に対し、「**(アスタリスク2つ)」はいくつもの階層をさかのぼって、

「ファイルの階層のレベル関係」

なく、検索してくれます。ちなみに「**(アスタリスク2つ)」はPython3.5からの対応となっていますので、注意しましょう!!

コード②解説:Pythonの内包表記を使ってでMyTrushのフォルダは抜こう

それでは、②のコードに解説に移ります。②のコードは以下のコードとなります。

'''
②
'''
#MyTrushは削除しないようにフィルタをかける。
baseShellregex='**/MyTrush/**'
dueDays=365
def extractFiles(file,dueDays,now):#抽出条件の関数。
    if re.match(os.path.expanduser(r'^.*/MyTrush/$'),file):
        return False
    elapsedDays=(now-datetime.datetime.fromtimestamp(os.path.getatime(file))).days
    return True if elapsedDays>dueDays else False
FILES=[file for file in FILES if extractFiles(file,dueDays,now)]
#1年になっているがデフォルトで設定可能。変えるときはオプション設定を入れる。

ここでは正規表現を使い、MyTrushのフォルダ本体を除く条件において、

「リストの内包表記」

を使うことで、

  • MyTrush以外のフォルダ

  • アクセス日時が1年過ぎたファイル・フォルダ

を抽出し、FILESと呼ばれる変数に配列をおさめています。では、リストの内包表記は

どのように書いていくのか?

を次に解説していきたいとも思います。

リストの内包表記ってどうやって書くの?

リストの内包表記はPythonの独自の書き方の一つなので、いまいちよくわからないですよね🤔
リストの内包表記の書き方は以下の通りになります。

[(要素) for (要素) in (配列) (if (条件関数))]

要するに、in文における「配列」の中から、

条件関数に基づいた要素の集まり(配列)

を作りなさいという命令文となります。ちなみに、条件関数の戻り値を

要素が必要な場合▶True
要素がいらない場合▶False

としなければなりません。要するに、filter関数における条件関数と同じ書き方となっています。
※if文による条件に沿った抽出が必要ない場合は、入れなくても、問題ありません。

内包表記で要素を抽出するには関数を定義する必要がある🤔

では内包表記を書く前準備として、条件関数を設定しましょう。
内包表記はPythonの独自の記述方法ですが、覚えておくと、処理が早く済むので覚えておいてソンはないでしょう☺️
関数の作り方は以下のNoteを見てくださいね〜😉↓
【Python】関数を使ってまとまった処理をパーツにしてしまおう|きぃ|note

def extractFiles(file,dueDays,now):#抽出条件の関数。
    if re.match(os.path.expanduser(r'^.*/MyTrush/$'),file):
        return False #…①
    elapsedDays=(now-datetime.datetime.fromtimestamp(os.path.getatime(file))).days #…②
    return True if elapsedDays>dueDays else False #…②

文字列の正規表現でMyTrushのフォルダは除いておこう

上の関数extractFilesでは、まず、MyTrushのフォルダを除く文を①に書いています。こちらは、文字列の正規表現で書きました。
混乱のないようにもう一度言いますが、まちがっても

「シェル形式」の正規表現ではないので、注意してください!!

なので、下のコードの文字列の正規表現の意味はどんな意味になるかと言うと、

re.match(r'^.*/MyTrush/$')

そもそも、re.matchの「re」はreモジュールなので、re.matchは、reモジュールにおけるmatch関数のことを指します🤔
では、

r'^.*/MyTrush/$'

はどんな意味になるかと言うと、まず、

  • 「^」・・・文頭

  • 「.」・・・1文字

  • 「*」・・・何回も検索させる。(不明な文字「.」の前につける)

  • 「$」・・・文末

という意味になりますので、

「.*」は文字の数を問わない

という意味になります。なので、整理すると

r'^.*/MyTrush/$'

の意味は、

「文頭から文末にかけて、MyTrushのフォルダであるかどうか」

を抜き出す意味になり、match関数の意味を考慮すると、

「MyTrushのフォルダ本体を示すパターンの文字列がマッチしていたら、1個目以上の文字でもTrueを返せ」

という意味になります。
Pythonでは文字列の正規表現を使うときは、raw文字列である「r」をクオーテーションの頭につけておきましょう。そうすることでエスケープシーケンスの情報から読み替えるのではなく、

1文字1文字をそのまま

で読み込んでくれます。ちなみにraw文字列でなければ、エラーを吐くことがあるので、注意が必要です。
もっと、正規表現の意味について、詳しく知りたい方は、文字列の正規表現の記事を書いていますので、以下の記事を参考にしてくださいね〜😉

経過日数によって、ファイルの抽出条件をさらに限定しよう

elapsedDays=(now-datetime.datetime.fromtimestamp(os.path.getatime(file))).days #…②-1
    return True if elapsedDays>dueDays else False #…②-2

では、次の②-1を見ていきましょう。まず、現在の時刻(now=datetime.datetime.now())をnowの変数に格納しておきました。次に関数で、os.path.getatime()でファイルにおける最後のアクセスした時刻を割り出し、datetime.datetime.fromtimestamp()の関数を使って、UNIX時間からdatetime型に変換します。その上で、

現在の時刻ーアクセス日時

を引き、days変数を「日数」に表示させることにより、

「経過日数」

を割り出しています。②−2では、Pythonの三項演算子を使って、日数単位で設定された保存期間を経過すると、Trueを返し、そうでない場合は、Falseを返すようにしています。
ちなみに、三項演算子の書き方は、

[真:Trueの場合] if [条件式] else [偽:Falseの場合]

となりますので、プログラミング言語で使われるような一般的な三項演算子の書き方とは書き方が違うので、注意しておきましょう。
こういう処理をやっておかないと、アクセスした日時が過ぎてないのに、MyTrushのフォルダごともってかれてしまい、もう一度MyTrushのフォルダを作らなくてはいけません🤔(手作業が大変ですよね💦)ですので、あらかじめ内包表記を使って

  • MyTrushのフォルダ以外

  • アクセス日時が1年を超えている

ファイルやフォルダを抜き出してから、ファイルの操作を行うことによって、

移動の不要なファイルやフォルダまで移動される心配

がなくなるというわけです。

コード③の解説:ファイル・フォルダをshutilモジュールで移動させよう

次は、コード③の解説に移ります。

'''
③
'''
ignoredFiles=[]
error_Num=0
for index_num,file in enumerate(FILES):
    try:
        shutil.move(file,os.path.expanduser('~/Downloads/'))
    except FileNotFoundError as e:#親ディレクトリが削除されてる場合は、ファイルが見つからない場合が想定される
        ignoredFiles.append(file)
        error_Num+=1
    finally:
        sys.stdout.write(f'{index_num+1}件目削除完了…')
        if error_Num>0:
            sys.stdout.write(f'内無視:{error_Num}件')
        if index_num<len(FILES)-1:
            sys.stdout.write('\r')
if len(ignoredFiles)==0 or len(FILES)==0:
    print('処理完了。')
    sys.exit(0)
print('処理完了。以下のファイル・ディレクトリは親ディレクトリが削除されてるため、無視されました。')
print('===============')
for ignoredFile in ignoredFiles:
    print(ignoredFile)

次でようやく、ファイルをダウンロードフォルダに移動するコード書いていきます。さて、ファイル操作の必要なフォルダやファイルがそろったら、いよいよ、ファイル操作を実行します。(この瞬間めっちゃドキドキですね🤔💦)
ちなみに、ファイルの移動はshutil.move関数を使いました。この関数は

「フォルダやファイルを移動する」

関数となります。使い方は、

shutil.move([移動元のファイルやフォルダ],[移動先のフォルダ])

となります。この関数の便利なところは、

「ファイルもフォルダも移動」

してくれることです。

ただし、移動元のファイルが見つからない場合、

FileNotFoundErrorの例外を吐く

ので、try構文で、想定されるエラーのコードを囲み、except文で想定されるエラーの処理を書く必要があります。

 try:
 		shutil.move(file,os.path.expanduser('~/Downloads/'))
 except FileNotFoundError as e:#親ディレクトリが削除されてる場合は、ファイルが見つからない場合が想定される
 		ignoredFiles.append(file)
      error_Num+=1
 finally:
      sys.stdout.write(f'{index_num+1}件目削除完了…')
      if error_Num>0:
			sys.stdout.write(f'内無視:{error_Num}件')
      if index_num<len(FILES)-1:
			sys.stdout.write('\r')

ちなみに、finally構文を使っていますが、例外が起こる起こらない関係なく、削除完了の進捗メッセージを出したいためにあえてつけました。
このように、親フォルダ削除によって、

子フォルダやファイルに隠れたファイル・フォルダが見つからないエラーが想定されうる

ので、try〜finallyの例外構文を使って、

「エラーによる実行停止を回避する」

わけです。また、近々例外について詳しい記事をNoteにあげようかなと考えています☺️

まとめ

これで一つ整理整頓のルールの自動化ができたというわけですが、まだまだルールの自動化に改善の余地があるように感じます🤔しかしながら、近年、ディープラーニングによる仕分けができるみたいですので、もっとMacに効率よく整理してもらうために、これからもプログラミングで自動化をやっていこうと思います!!

この記事書いてて思ったこと

でも、Macだったら、Automatorで簡単にできるじゃん…🥺
よしっ!!気を取り直して、Automatorの記事もまた上げるぞっ!!
ななっ、10000文字近い!!
僕が今まで作ったNoteの記事ではじめてじゃないのかな🤔

この記事が気に入ったらサポートをしてみませんか?