Otsuhachi’s diary

プログラミングのことや覚書などその他いろいろ

当ブログの内容を使用したり参考にした場合に生じた問題の責任は負いかねます。

Pythonでフォルダの変更を監視・連番リネームするライブラリ

何日か前に作って公開するか悩んでた奴の紹介。
c:直下でやったらえらい目にあったなどの苦情は懸念されるが、このブログ頭にデカデカ表記している文言とそもそもブログ見に来てるやつそんなにいないから大丈夫を頼りに公開に踏み込む。

機能はタイトル通りフォルダ内の変更を監視・連番リネームができる。
この連番リネームが上記c:直下など、ソフトを動かすのに必要なファイルまでリネームしてしまう可能性があるので、絶対に自分で変更対象のファイルがどんなものなのか把握しているフォルダでのみ使用するように。
一応、管理者権限のある状態で実行できないようにしているのでc:直下なんかだとPermissionErrorしてくれると思うが試す勇気はない!

例えば画像ファイルや、メモ書きなんかしか入っていないフォルダで使うことを想定してのライブラリ。

完成品は既にgitに上がっているので、駆け足で紹介。

実装についてもそこで確認してもらえれば。
コードは完全にWindows用のもの。
明確にWindowsでしか使えないのは以下の権限確認部分のみなので、そこを消すか別OSの権限確認処理に差し替えれば動く……かも。

バージョン2022.8.12にアップデートしました。
この更新でリネーム処理が高速化する……はず。
pip install -U git+https://github.com/Otsuhachi/OtsuFolderSerialRenamer.git#egg=otsufolserren

OtsuFolderSerialRenamer
│  LICENSE
│  Pipfile
│  Pipfile.lock
│  README.md
│  setup.py
│
└─otsufolserren
    │  cfg.py
    │  funcs.py
    │  orders.py
    │  __init__.py
    │
    ├─monitoring
    │      classes.py
    │      __init__.py
    │
    └─serial_renamer
            classes.py
            __init__.py

otsufolserren直下にある__init__.pyにある以下のコードが権限確認部分。

import ctypes

if ctypes.windll.shell32.IsUserAnAdmin() == 1:
    msg = f'このライブラリは重要なファイル構成を破壊する恐れがあるため、管理者権限で実行することはできません。'
    raise PermissionError(msg)
del ctypes

余力があればGUIで使いやすくしたりして、このブログで紹介していきたい。

以下動作確認用のコードの説明。
テキストの中身とファイル名がバラバラ1のテストフォルダを実行ディレクトリと同じフォルダに作成し、そのフォルダのファイルを削除、追加、改名など弄ってからr, ro, cなど試したい操作を標準入力することで確認ができる。
今回のテストでは.txtファイル以外を想定していないので、注意2
eで終了。

import random
import shutil
import time

from pathlib import Path
from typing import Iterable

from otsufolserren import ORDER_CTIME, FolderSerialRenamer, get_fm
from otsutil import setup_path
from otsuvalidator import VInt

ROOT = Path('otsufolserren_test_dir')
CACHE = Path('otsufolserren_test_cache')


def create_test(sample_number: int = 10) -> None:
    """テストフォルダを初期化します。

    Args:
        sample_number (int, optional): テストフォルダ内に生成するファイル数です。
    """
    if ROOT.exists():
        for p in ROOT.iterdir():
            if p.is_file():
                p.unlink()
            else:
                shutil.rmtree(p)
    sample_number = VInt(0).validate(sample_number)
    digit = len(str(sample_number))
    for i, n in enumerate(random.sample(range(1, sample_number + 1), sample_number)):
        i += 1
        if i > 1:
            time.sleep(0.3)
        n = f'{n:0{digit}d}'
        i = f'{i:0{digit}d}'
        with open(setup_path(ROOT / f'{n}.txt'), 'w', encoding='utf-8') as f:
            f.write(f'This file is No.{i}.')


def remove_test() -> None:
    """テストフォルダを除去します。
    """
    if ROOT.exists():
        shutil.rmtree(ROOT)
    if CACHE.exists():
        shutil.rmtree(CACHE)


def rfiles() -> Iterable[Path]:
    """テストフォルダ直下のファイルを返します。

    Raises:
        FileNotFoundError: テストフォルダが存在しない場合に投げられます。

    Returns:
        filter[Path]: テストフォルダ直下のファイルです。
    """
    if not ROOT.exists():
        raise FileNotFoundError(ROOT)
    return filter(Path.is_file, ROOT.iterdir())


def show_files() -> None:
    """テストフォルダ内のフォルダを読み込み以下の形式で出力します。

    <ファイル名>: <ファイルの内容>
    """
    try:
        for file in rfiles():
            try:
                with open(file, 'r', encoding='utf-8') as f:
                    line = f.read()
            except:
                continue
            print(f'{file.name}: {line}')
    except:
        pass


def ask_prompt(question: str, *answer: str) -> str:
    """質問に対して特定の回答以外なら再回答を促し、回答結果を返します。

    Args:
        question (str): 質問です。

    Raises:
        ValueError: answerが指定されていない場合に投げられます。

    Returns:
        str: answerのいずれかです。
    """
    if not answer:
        msg = '1つ以上answerを指定してください。'
        raise ValueError(msg)
    answer_ = set(map(lambda x: x.lower(), answer))
    while True:
        ans = input(question).lower()
        if ans in answer_:
            return ans


def main():
    create_test()
    fm = get_fm(ROOT, 'f', cache_dir=CACHE)
    help_str = 'c: 更新を確認します。\nr: リネームを実行します。\nro: only_when_changeをFalseにしてリネームを実行します。\nntmp: name_templateを変更します。\nfiles: ファイルとその中のテキストを表示します。\nh: このヘルプを再表示します。\ne: このスクリプトを終了します。'
    with FolderSerialRenamer(fm, 'td-', ORDER_CTIME) as fsr:
        print(help_str)
        while True:
            cmd = ask_prompt('実行する操作を指定してください。> ', 'c', 'r', 'ro', 'ntmp', 'files', 'h', 'e')
            if cmd == 'c':
                if fsr:
                    l, diff = fsr.get_difference()
                    print(f'{l}件の変更を検出しました。')
                    if ask_prompt('表示しますか?(y/n)> ', 'y', 'n') == 'y':
                        for d in diff:
                            print(d)
            elif cmd in ('r', 'ro'):
                preview = fsr.get_preview()
                fsr.rename(only_when_change=cmd == 'r', preview=preview)
                preview = {x: y for x, y in preview.items() if str(x.resolve()) != str(y.resolve())}
                for b, a in preview.items():
                    print(b, '-->', a)
            elif cmd == 'ntmp':
                ntmp = input('新しいname_templateを入力してください。> ')
                fsr.name_template = ntmp
            elif cmd == 'files':
                show_files()
            elif cmd == 'h':
                print(help_str)
            elif cmd == 'e':
                break
    remove_test()


if __name__ == "__main__":
    main()

  1. テキストの中身が本来のファイルの生成順。

  2. filesでファイル名と生成番号の一致を確認できるようにしたかったため。