【Python】【bat】フォルダ内のファイルに対して1つずつスクリプトを実行する方法

※2025/3/10 Pythonスクリプトを修正しました
## はじめに

Pythonファイル(.py)、バッチファイル(.bat/.cmd)、コンソールアプリ(.exe)などでファイルに対して何かしらの処理を行うスクリプトがある
↓

それらのスクリプトをフォルダ内のファイルに対して１つずつ同じ処理を行うようにする場合、毎度フォルダ内のファイルを列挙するコードを書くのは大変・・・
↓

バッチファイル(.bat/.cmd)などから呼び出して（なるべく簡単かつ汎用的に）使用できる、Pythonファイル(.py)を作成してみました

## 使用方法 (イメージ例)
例えば、あるフォルダ内の画像ファイル(jpg/png)に対して
①→②の順で処理したい場合は、下記のようなコードとなります
(dirfile pyがフォルダ内のファイルを検索するスクリプトです。コードは記事下部に記載しています)

①画像サイズ変更
②画像の拡張子変換(→ jpg) 
```bat
:: ■ [1] 画像サイズ変更 ::
call dirfile.py ^
--cmd "call imgshrink.py {input_filepath} --size {args}" ^
--input "C:\\input" ^
--output "C:\\output1" ^
--topdirectory ^
--filter "*.jpg" "*.png" ^
--args 800x600

:: ■ [2] 画像の拡張子変換 ::
call dirfile.py ^
--cmd "call imgtranslate.py {input_filepath} --format {args}" ^
--input "C:\\output1" ^
--output "C:\\output2" ^
--topdirectory ^
--filter "*.jpg" "*.png" ^
--args .jpg

```

## ポイント
- スクリプトに渡すコマンドを任意に変更できるように実装しました
- フォルダの検索結果（=ファイルパス）をコマンドに組み込めるようにしました
(--cmdの中身を変更：スクリプトごとにコマンドライン引数は異なるため)
```
--cmd "call imgshrink.py {input_filepath} --size {args}" ^
```
- フォルダの検索条件を指定できるようにしました
(スクリプトによっては対象ファイルが異なるため)
```
--input "C:\\output1" ^
--output "C:\\output2" ^
--topdirectory ^
--filter "*.jpg" "*.png" ^
```
- その他任意の変数を定義できるようにしました
  (--args 以外にも --args0 ~ --args9 まで使用可能)
```
--args .jpg
```

##  batファイル (スクリプト使用方法について)
### test.bat
- テスト用（説明用）
```bat
@echo off

:: ■ :: ~ :: は コメント

:: ■ batファイルの文字エンコードが"UTF-8"の場合は実行する ::
:: chcp 65001 > nul

:: ■ dirfile.py コマンドライン引数について ::
:: --cmd (必須。--cmd "~" → "~"を任意コマンドに変更して使用 ::
:: --input (必須。複数指定可) ::
:: --output (省略可。複数指定可。省略した場合 → --inputフォルダと同じ) ::
:: --topdirectory (省略可。省略した場合はサブフォルダのファイルも検索する) ::
:: --filter "*.txt" (省略可。複数指定可。省略した場合 → *.*) ::
:: --args, --args* (省略可。複数指定可)  ::

:: ■ dirfile.py を 使用してフォルダを走査してファイルを列挙 ::
:: (testのはC:\直下のtextを列挙してecho) ::
:: ^ は コマンドの途中で改行 ::
call dirfile.py --cmd ^
"^
echo input_filepath={input_filepath} ^
& echo input_dir={input_dir} ^
& echo input_filename={input_filename} ^
& echo input_ext={input_ext} ^
& echo output={output} ^
& echo output_dir={output_dir} ^
& echo args={args} ^
& echo args0={args0} ^
& echo args1={args1} ^
& echo args2={args2} ^
& echo args3={args3} ^
& echo args4={args4} ^
& echo args5={args5} ^
& echo args6={args6} ^
& echo args7={args7} ^
& echo args8={args8} ^
& echo args9={args9} ^
& echo. ^
" ^
--input "C:\\" "D:\\" ^
--output "C:\\" "D:\\" ^
--topdirectory ^
--filter "*.txt" ^
--args 0123456789 ^
--args0 0 ^
--args1 0 1 ^
--args2 0 1 2 ^
--args3 0 1 3 ^
--args4 0 1 4 ^
--args5 0 1 5 ^
--args6 0 1 6 ^
--args7 0 1 7 ^
--args8 0 1 8 ^
--args9 0 1 9

```

### test.bat 実行結果
- ファイル情報から分解して情報を取得できるように工夫しています
- test.batはecho結果のみですが、--cmdのコマンド内容を変更することで様々な処理ができます
```bat
input_filepath=C:\***.txt
input_dir=C:\
input_filename=***
input_ext=.txt
input_path=C:\***.txt
output=C:\
output_dir=C:\
args=0123456789
args0=0
args1=0 1
args2=0 1 2
args3=0 1 3
args4=0 1 4
args5=0 1 5
args6=0 1 6
args7=0 1 7
args8=0 1 8
args9=0 1 9
...
```

## Pythonスクリプト (フォルダ内のファイルに対して1つずつスクリプトを実行)
### dirfile.py
メイン処理
※2025/3/10 全体的に修正しました
```python
import os
import sys
import shlex
import subprocess
from pathlib import Path
from typing import List, Dict

def parse_arguments(args: List[str]) -> Dict[str, List[str]]:
    """
    コマンドライン引数を解析する。
    `--` で始まる引数をキー、それ以外を無名引数として管理する。

    :param args: コマンドライン引数のリスト
    :return: 引数の辞書（無名引数は "__unnamed" に格納される）
    """
    arguments = {"__unnamed": []}  # 無名引数を格納するリスト
    current_key = None  # 現在処理中のキー

    for arg in args:
        if arg.startswith("--"):
            # `--` で始まる場合、新しいオプションのキーとして扱う
            current_key = arg
            arguments.setdefault(current_key, [])  # 初めてのキーなら空のリストを作成
        elif current_key:
            # 直前の `--` オプションに対する値として追加
            arguments[current_key].append(arg)
        else:
            # オプションに属さない無名引数として格納
            arguments["__unnamed"].append(arg)

    return arguments

def clean_path(path: str) -> str:
    """
    パス文字列のクリーンアップ（余分なスペースやクォートを削除）。
    `--cmd` の部分はクォートを削除しないようにする。

    :param path: クリーンアップするパス
    :return: クリーンアップ後のパス
    """
    return path.strip() if path else ""  # 先頭・末尾のスペースを削除

def quote_path(path: str) -> str:
    """
    スペースを含むパスを適切にクォートする。

    :param path: パス文字列
    :return: クォートされたパス
    """
    return path
    # return f'"{path}"' if " " in path else path  # スペースがある場合のみ `"` で囲む

def execute_command(command: str):
    """
    シェルコマンドを実行し、出力を取得する。

    :param command: 実行するコマンド
    """
    print(f"Executing: {command}")  # 実行するコマンドを表示
    process = subprocess.Popen(
        command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
    )
    stdout, stderr = process.communicate()  # コマンドの実行結果を取得

    if stdout:
        print(stdout.strip())  # 標準出力を表示
    if stderr:
        print(stderr.strip(), file=sys.stderr)  # エラー出力を標準エラーに表示

    if process.returncode != 0:
        # コマンドが失敗した場合、エラーメッセージを出力して終了
        print(f"Error: Command execution failed with code {process.returncode}.", file=sys.stderr)
        sys.exit(process.returncode)

def main():
    """
    メイン処理：
    1. コマンドライン引数の解析
    2. 入力ディレクトリの検証
    3. 指定ディレクトリ内のファイルを検索
    4. ファイルごとにコマンドを組み立てて実行
    """
    args = parse_arguments(sys.argv[1:])  # コマンドライン引数を解析

    # 必須オプション `--cmd` と `--input` があるか確認
    if "--cmd" not in args or "--input" not in args:
        print("Error: --cmd and --input are required.", file=sys.stderr)
        sys.exit(1)

    command_template = args["--cmd"][0]  # コマンドのテンプレート
    input_dirs = args["--input"]  # 入力ディレクトリ（複数可）
    output_dirs = args.get("--output", input_dirs)  # 出力ディレクトリ（省略時は入力と同じ）
    filters = args.get("--filter", ["*.*"])  # 検索するファイルの拡張子（デフォルトは `*.*`）

    # `--topdirectory` がない、または `true` の場合はサブディレクトリを含める
    top_directory_only = "--topdirectory" not in args or not args["--topdirectory"] or args["--topdirectory"][0].lower() == "true"

    # 追加の引数 (`--args` や `--args0` ~ `--args9`) を取得
    extra_args = args.get("--args", [])
    numbered_args = {i: args.get(f"--args{i}", []) for i in range(10)}

    for input_dir in input_dirs:
        cleaned_input_dir = clean_path(input_dir)  # 入力ディレクトリのパスをクリーンアップ

        # 入力ディレクトリが存在しない場合はエラー
        if not os.path.isdir(cleaned_input_dir):
            print(f"Error: Input folder '{cleaned_input_dir}' not found.", file=sys.stderr)
            sys.exit(1)

        for output_dir in output_dirs:
            cleaned_output_dir = clean_path(output_dir)  # 出力ディレクトリのパスをクリーンアップ

            # 出力ディレクトリが存在しない場合は作成
            if not os.path.isdir(cleaned_output_dir):
                print(f"Creating output folder: {cleaned_output_dir}")
                os.makedirs(cleaned_output_dir, exist_ok=True)

            for file_filter in filters:
                # `rglob()` は再帰検索（サブディレクトリ含む）、`glob()` はトップディレクトリのみ
                search_pattern = Path(cleaned_input_dir).rglob(file_filter) if not top_directory_only else Path(cleaned_input_dir).glob(file_filter)

                for file_path in search_pattern:
                    cleaned_file_path = clean_path(str(file_path))  # ファイルパスをクリーンアップ
                    file_name = file_path.stem  # 拡張子なしのファイル名
                    file_ext = file_path.suffix  # 拡張子
                    input_dir_path = str(file_path.parent)  # 親ディレクトリ

                    # 追加引数をスペース区切りの文字列に変換
                    args_string = " ".join(map(clean_path, extra_args))

                    # コマンドテンプレートのプレースホルダーを実際の値に置換
                    command = command_template.format(
                        input=quote_path(cleaned_input_dir),
                        input_dir=quote_path(input_dir_path),
                        input_filename=quote_path(file_name),
                        input_ext=quote_path(file_ext),
                        input_filepath=quote_path(cleaned_file_path),
                        output=quote_path(cleaned_output_dir),
                        output_dir=quote_path(cleaned_output_dir),
                        args=args_string,
                        **{f"args{i}": " ".join(map(clean_path, numbered_args[i])) for i in range(10)}
                    )

                    # コマンドを実行
                    execute_command(command)

    print("Done.")  # すべての処理が完了したことを表示

if __name__ == "__main__":
    main()

```

## 最後に
もう少し良い方法があるかもしれませんが、Python以外でも作ってみたいと思います。