Python を使って、指定したファイルを選択した状態でエクスプローラを起動する

概要
----

Visual Studio Code の「エクスプローラーで表示する (Reveal in Explorer)」のように、
特定のファイルを選択した状態でエクスプローラを起動する方法を紹介します。
検証に使用した環境は、Windows10 + Python 3.8.3 です。


不充分な方法
------------

特定のフォルダを開いた状態でエクスプローラを起動するだけであれば、
以下のようなシンプルなコードで実現できます。

```py
import os

path = 'C:\\Users\\hoge\\Documents\\新しいフォルダー'
os.startfile(path)
```

しかし、この方法ではフォルダ内のファイルを選択しておくことはできません。

explorer.exe の /select オプションを使って

```py
import subprocess

file_path = 'C:\\Users\\hoge\\Documents\\新しいフォルダー\\test01.txt'
subprocess.run('explorer /select,{}'.format(file_path))
```

とすれば、指定したファイルを選択した状態でエクスプローラを起動できます。

しかしタスクマネージャで確認すると、
この方法で起動したエクスプローラは既存のものとは別のプロセスになっています。
分かった上で自分だけが使うのであれば問題ないかもしれませんが、
この機能を組み込んだツールを誰かに使ってもらう場合にはあまり望ましくないでしょう。
また、複数のファイルを選択しておくことができないという点にも注意が必要です。

なお、os.startfile で起動したエクスプローラには既存のプロセスが使われるようですが、
先に述べた通りファイルを選択しておくことはできません。

まとめると、上記の二つの方法のどちらを使っても

- 指定したフォルダを開いた状態でエクスプローラを起動する
- その際に、幾つかのファイルやフォルダを選択した状態にしておく
- 既存のエクスプローラのプロセスをそのまま使う

という条件を全て満たすことはできなさそうです。


解決策
------

以下のように [SHOpenFolderAndSelectItems](https://docs.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shopenfolderandselectitems) を呼び出すことで実現できます。
長いですが、ほとんどはこの関数のための下準備と後始末をしているだけです。
（エラーのチェック等は最低限しか行っていません）

```py
import os
import sys
import ctypes


CoInitialize = ctypes.windll.ole32.CoInitialize
CoInitialize.restype = ctypes.HRESULT
CoInitialize.argtypes = [ctypes.c_void_p]

CoUninitialize = ctypes.windll.ole32.CoUninitialize
CoUninitialize.restype = None
CoUninitialize.argtypes = None

ILCreateFromPathW = ctypes.windll.shell32.ILCreateFromPathW
ILCreateFromPathW.restype = ctypes.c_void_p
ILCreateFromPathW.argtypes = [ctypes.c_char_p]

SHOpenFolderAndSelectItems = ctypes.windll.shell32.SHOpenFolderAndSelectItems
SHOpenFolderAndSelectItems.restype = ctypes.HRESULT
SHOpenFolderAndSelectItems.argtypes = [
    ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_ulong]

ILFree = ctypes.windll.shell32.ILFree
ILFree.restype = None
ILFree.argtypes = [ctypes.c_void_p]


class CommonDirNameError(Exception):
    """共通するフォルダパスが求まらなかった場合の例外"""
    pass


def __get_common_dirname(paths):
    """指定されたパスに共通するフォルダパスを求めて返す"""
    dirnames = [os.path.normpath(os.path.dirname(p)) for p in paths]
    if len(set(dirnames)) != 1:
        raise CommonDirNameError()
    return dirnames[0]


def reveal(paths):
    """指定されたパスを選択した状態でエクスプローラを起動する"""
    abs_paths = [os.path.abspath(p) for p in paths if os.path.exists(p)]
    count = len(abs_paths)
    if not count:
        sys.stderr.write(
            'Error: Targets to reveal are not specified or not found.')
        return False
    # 共通するフォルダパスを求める
    try:
        dirname = __get_common_dirname(abs_paths)
    except CommonDirNameError:
        sys.stderr.write('Error: Multiple folders specified.')
        return False
    # フォルダの存在チェック
    if not os.path.isdir(dirname):
        template = 'Error: Folder not found: {}'
        sys.stderr.write(template.format(dirname))
        return False
    # 後で SHOpenFolderAndSelectItems を呼ぶために必要
    hr = CoInitialize(None)
    if hr:
        template = 'Error: CoInitialize returned 0x{:08X}'
        sys.stderr.write(template.format(hr & 0xFFFFFFFF))
        return False
    # 開くフォルダのパスを、アイテム識別子のリストに変換する
    b_dirname = dirname.encode('utf-16le')
    il_dirname = ILCreateFromPathW(b_dirname + b'\0')
    # 選択するファイルの各パスを、アイテム識別子のリストに変換する
    list_b_path = [p.encode('utf-16le') for p in abs_paths]
    list_il_path = [ILCreateFromPathW(b_path + b'\0') for b_path in list_b_path]
    ArrayType = ctypes.c_void_p * count
    targets = ArrayType(*list_il_path)
    # 指定したファイルを選択した状態でエクスプローラを起動する
    result = True
    hr = SHOpenFolderAndSelectItems(il_dirname, count, targets, 0)
    if hr:
        template = 'Error: SHOpenFolderAndSelectItems returned 0x{:08X}'
        sys.stderr.write(template.format(hr & 0xFFFFFFFF))
        result = False
    # 後始末
    for il_path in reversed(list_il_path):
        ILFree(il_path)
    ILFree(il_dirname)
    CoUninitialize()
    return result
```

使い方は、以下のように選択したいファイルやフォルダのパスをリストにして渡すだけです。
（パスが 1 つだけでもリストに入れて渡す必要があります）

```py
reveal([
    'C:\\Users\\hoge\\Documents\\新しいフォルダー\\test01.txt',
    'C:\\Users\\hoge\\Documents\\新しいフォルダー\\test03.txt'])
```


補足
----

元々は Maya 用に開発したツールのために調べたもので、
記事にするにあたって書き直しました。

Maya の Python は 2 系の場合と 3 系の場合があり、
2 系では上記のコードの文字列周りの処理を少し変える必要があります。

また、Maya では CoInitialize が S_FALSE を返してくることにも注意が必要です。
恐らく Maya 自身がどこかで CoInitialize を呼び出しているからだと思われますので、
上記のコードから CoInitialize 及び CoUninitialize の呼び出しを省けば動作するはずです。
（この場合、スクリプト冒頭の ole32 周りの設定も不要です）