プログラムでおかえしできるかな

Python、フェイジョア、日常のあれこれでお返し、元SEの隠居生活。

ファイル内文字列検索アプリの作り方【Python】

このエントリーをはてなブックマークに追加

Windows 環境で文字コードが UTF-8 のテキストファイルを対象に文字列を検索するアプリを作成しました。
コマンドプロンプトで実行し、結果を標準出力します。
文字コードが Shift-JIS のファイルと混在していても両方とも検索できます。
大文字・小文字の一致、正規表現、サブフォルダの検索、除外するフォルダの指定、含めるフォルダの指定ができます。

アプリの作り方を考え方、処理に分けて説明します。
コードも掲載します。

▽アプリの画面

アプリはお使いいただけます 📖 ファイル内文字列検索アプリfind-us-str【フリー】 🔗
アプリの画面や機能などはこちらの記事で確認できます。

本アプリのコードについて説明します。
使用しているプログラミング言語は Python です。

目次

◆動作時のコマンドプロンプト画面

検索結果

◆技術的な特徴と考え方

本アプリの技術的な特徴は次の通りです。

  • 例外を使用して対象ファイルを選別
    • エンコードを UTF-8 にしてファイルを読込
    • 例外ならエンコードを Shift-JIS にして再読込
    • さらに例外ならファイルは対象外とする
  • 結果出力にエスケープシーケンスを用いて検索文字列に色付け
  • エスケープシーケンスの付加は正規表現の置換で実装

また、大まかな処理の流れは次の通りです。

  1. 対象ファイルの抽出
    pathlib.glob() メソッドで対象ファイルを抽出
    対象パスによって抽出方法を切替え

    • ファイル名指定
    • フォルダ名指定
    • ワイルドカード指定
  2. 対象ファイルのパスに対し除外するフォルダ、含めるフォルダを考慮して絞り込み
    パスをフォルダのリストに分解して内包表記の条件で絞り込み

  3. ファイルごとに文字列の検索

    1. encoding を指定してファイルを読み込み
      対象は、UTF-8 と Shift-JIS
    2. 例外 UnicodeDecodeError が出ないときだけ文字列検索
      • 一致した行だけを内包表記の条件で抽出
      • 一致した部分の前後にエスケープ文字を挿入
    3. 結果を出力

◆対象ファイルの抽出

◇考え方

pathlib.glob() メソッド(以下、glob 関数)で対象ファイルを抽出します。
glob 関数を使用するには指定されたパスで Path オブジェクトを生成します。
これで検索の起点となるフォルダが決定します。
後は指定されたパスの種類によって glob 関数の呼び方を切替えます。

パスの種類は、ファイル、フォルダ、ワイルドカード(ファイルでもフォルダでもない)です。
is_file()、is_dir() メソッドで判定します。

パスの種類がフォルダ、ワイルドカードの場合は glob 関数を使います。
オプション指定で検索にサブフォルダを含む場合、glob 関数のパターンに **/ を付加します。
パスの種類がワイルドカードの場合は Path オブジェクトのファイル名を glob 関数のパターンに指定します。
パスの種類がファイルの場合は、glob 関数を使用せず Path オブジェクトをリストにして返します。
これは glob 関数の戻り値(Path オブジェクトのイテレータ)と同じに扱えるようにするためです。

オプション指定で対象ファイルのパスに対して除外するフォルダ、含めるフォルダが指定された場合、
glob() 関数の結果得られるファイルに対し、内包表記の条件を使用して絞り込みます。
条件には、ファイルのパスをフォルダごとに分解し、分割したフォルダのいずれかが指定されているかどうかを記述します。
ファイルのパスは、Path オブジェクトから文字列で取得し、パスの区切り文字でフォルダごとに分割します。

.venv や .vscode フォルダなどをまとめて除外できるように、除外するフォルダ(含むフォルダも)の指定では正規表現での指定をサポートします。

オプション指定で正規表現を使用するかどうかを指定できますが、内部的には常に正規表現を使用します。
そのため、正規表現を使用しない場合、検索文字列をエスケープして正規表現で使用する特殊文字をそのまま扱えるようにします。
また、ファイルごとに正規表現を使った検索を行うため、正規表現オブジェクトを事前に用意し効率化します。
更に、大文字と小文字を区別するかどうかのオプションは、正規表現オブジェクトのコンストラクタのフラグ機能で実装します。

◇処理

  1. Path オブジェクトを作成 (Path)
  2. 正規表現を使用しない場合のエスケープ処理 (escape)
  3. 大文字小文字を区別するかどうかのフラグを設定
  4. 正規表現オブジェクトを作成 (compile)
    文字列検索用はフラグを付加、
    フォルダ指定用は常に大文字小文字を区別しないフラグを付加
    例外発生時はメッセージを出力して終了
  5. サブフォルダを検索するかどうかのフラグ設定
  6. 対象ファイルを抽出 (glob)(パスの種類で切り分け)
  7. 対象ファイルを絞り込み
    • 内包表記の条件で除外するフォルダ、含めるフォルダを判定 (str, splite, fullmatch)
    • 絞り込まれたファイルごとに文字列の抽出と出力 (find_text)

◇コード

def find_text_in_dir(target_path:str, keyword:str, subdir:bool, is_icase:bool, 
                     is_regx:bool, exculde_dir:str, include_dir:str, plain_text:bool):
    """
    フォルダ内のファイルを取得してファイル内の文字列を検索して出力
    ファイルの抽出にはpathlib.glob()を使用
    文字列検索には正規表現を使用。オプションで正規表現未使用の場合、特殊文字をエスケープする

    Args:
        target_path(str):   ファイル名、フォルダ名、ワイルドカード
        keyword(str):       検索文字列
        subdir(bool):       サブフォルダも検索
        is_icase(bool):     大文字と小文字を区別しない
        is_regx(bool):      正規表現を使用
        exclude_dir(str):   除外するフォルダ
        include_dir(str):   含めるフォルダ
        plain_text(bool):   装飾しない出力
    """
    p = Path(target_path)
    
    # 正規表現初期化(繰り返し使用するのでcompile()する)
    # 正規表現未使用の場合、特殊文字をエスケープ
    if not is_regx:
        keyword = re.escape(keyword)
        exculde_dir = re.escape(exculde_dir)
        include_dir = re.escape(include_dir)
    
    # 大文字と小文字を区別するかどうかは正規表現のフラグで対応
    f_re = 0
    if is_icase: f_re = re.IGNORECASE

    # 正規表現を使用する場合、記述に誤りがあるときは例外
    try:
        regk = re.compile(rf"({keyword})", flags=f_re)  # 置換で色付けするためグループ化
        rege = re.compile(rf"{exculde_dir}", flags=re.IGNORECASE)  # 常に大文字と小文字を区別しない
        regi = re.compile(rf"{include_dir}", flags=re.IGNORECASE)  # 常に大文字と小文字を区別しない
    except re.error as e:
        print(f"正規表現の記述誤り:'{keyword}' {e}")
        return
    
    # 検索範囲
    wc = ''
    if subdir: wc = '**/'       # サブフォルダも検索する場合glob()で再帰的検索にする
    
    # 対象ファイルの抽出
    if p.is_file():     # ファイル名指定
        files = [p]
    elif p.is_dir():    # フォルダ名指定
        files = p.glob(wc + '*')
    else:               # ワイルドカード指定
        files = p.parent.glob(wc + p.name)

    # ファイルごとに文字列の抽出と出力
    # 内包表記で対象のファイルを選別し、find_text()を実行
    [find_text(file_path, regk, plain_text) for file_path in files
        if ((not exculde_dir or 
             not [dir for dir in str(file_path).split(os.sep) if rege.fullmatch(dir)]) and 
             (not include_dir or 
              [dir for dir in str(file_path).split(os.sep) if regi.fullmatch(dir)]))]

◆文字列の検索と出力

テキストファイルの読み方、ファイル内検索の仕方、標準出力の仕方について説明します。

◇テキストファイルだけを処理対象とする考え方

本アプリの一番の目的は、文字コードが UTF-8 のファイルを検索することです。
Python でテキストファイルを読み込む場合、エンコーディングを指定します。
デフォルトではプラットフォーム依存となり Windows の場合、Shift-JIS になります。
したがってエンコーディングに UTF-8 を指定して UTF-8 のファイルを読み込みます。
この時、エンコーディングできないデータがあると例外が発生します。
その場合、エンコーディングを Shift-JIS に変えて再読み込みします。
更に例外が発生した場合、これら以外の文字コードかテキストファイルではないと考え読み飛ばします。

Python は例外の使用を推奨しています。
処理したい対象以外を例外ではじくとすっきりしたコードになると思います。
例えば、エンコーディングの問題も事前に文字コードが何かを判断して処理しようとすると、文字コードの判断に多くの労力を使うことになります。
例外を使えば本アプリのようなコーディングにできます。

◇ファイル内検索

文字列の検索は in 演算子でもできますが、ここでは結果出力で色付けすることを考慮して正規表現の search() メソッドを使用します。

テキストファイルを読み込んで行ごとの文字列のリストにします。
内包表記の条件を使用して行内に検索文字列が存在する行を抽出します。
あわせて出力用に行番号を保持します。

検索文字列に一致した部分を色を変えて出力します。
色を変えるにはエスケープシーケンスで一致部分を挟みます。
その位置にエスケープシーケンスを挿入するのではなく、
エスケープシーケンスで挟んだ文字列に置換することで対応します。
正規表現のグループとその参照を利用して実装します。

◇標準出力に色付け

標準出力する文字列に色付けする方法を説明します。

print() メソッドで出力する文字列にエスケープシーケンスを使用すると文字に色付けができます。

  • 基本:「色付け開始コード」+文字列+「色付け終了コード」を print() メソッドで出力
  • 色付け開始コード:エスケープシーケンスで \033[ + 色 + m
  • 色付け終了コード:エスケープシーケンスで \033[0m
  • エスケープシーケンス:\033[ + 内容 + m
    \033 は8進数、16進なら \x1b
  • 主な色、装飾:
    黒:30、赤:31、緑:32、黄:33、青:34、マゼンタ:35、シアン:36、白:37、
    デフォルトに戻す:39、太字:1、下線:4、背景と反転:7
  • 本アプリの使用例
    • 赤色文字エスケープシーケンス :"\033[31m"
    • 色付け終了エスケープシーケンス:"\033[0m"
    • 下線エスケープシーケン    :"\033[4m"

注意事項

  • Windows のコマンドプロンプトではエスケープシーケンスをデフォルトで処理しません。
    以下を実行して処理するようにします。1
    os.system('')
  • エスケープシーケンスを含んだ変数を正規表現で扱う場合、 Raw 文字列記法を使用します。
  • コマンドプロンプトへの出力をリダイレクトしてファイルに出力する場合、デフォルトでは Shift-JIS で出力するのでエンコードエラーになることがあります。
    回避するために UTF-8 で出力します。
    対応は次のコードを追加します。
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')

◇処理

  1. 標準出力を UTF-8 に設定
  2. 出力時に使用する修飾用エスケープシーケンスの設定
  3. エンコーディングを UTF-8 にしてファイルを読む
    • 読めたら内容(行のリスト)を取得 (readlines)
    • PermissionError 例外が発生したら終了
    • UnicodeDecodeError 例外が発生したら
      エンコーディングを Shift-JIS にしてファイルを読む
      • 読めたら内容を取得 (readlines)
      • UnicodeDecodeError 例外が発生したら終了
  4. 行のリストから改行文字を削除 (strip)
  5. 検索文字列を含む行だけを抽出
    • 内包表記の条件で行内に検索文字列が存在するか判定
    • 判定には正規表現を使用 (search)
    • 行番号が取れるようにする (enumerate)
    • 検索文字列を正規表現で置換し前後に修飾用のエスケープ文字を付加 (sub)
  6. 出力
    1. ファイル名を出力。下線を付ける
    2. 各行を出力。行番号を4桁分取って出力

◇コード

import sys, io
# 出力をリダイレクトしたときのエンコードエラー回避
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')

COLOR_RED = "\033[31m"      # 赤色文字エスケープシーケンス
COLOR_END = "\033[0m"       # 色付け終了エスケープシーケンス
COLOR_UDL = "\033[4m"       # 下線エスケープシーケンス

def find_text(file_path:Path, regx:re.Pattern, plain_text:bool):
    """
    ファイル内の文字列を検索して出力
    一致する行の内容と行番号を出力
    一致する行がある場合、ファイル名を出力
    一致部分を赤色で出力

    Args:
        file_path(Path):    対象ファイル
        regx(re.Pattern):   コンパイル済み正規表現オブジェクト
        plain_text(bool):   装飾しない出力
    """
    # print(file_path)
    # return

    # 修飾用文字の設定
    if plain_text:  # 修飾なしは空文
        cr = ce = cu = ''
    else:           # 修飾ありはエスケープ文字
        cr = COLOR_RED  # 赤
        ce = COLOR_END  # 終了
        cu = COLOR_UDL  # 下線

    try:
        with open(file_path, encoding="utf-8") as f:
            data_lines = f.readlines()
    except PermissionError:     # 参照権限がない場合は飛ばす
        return
    except UnicodeDecodeError:  # UTF-8でない場合はShift-JISで試す
        try:
            with open(file_path, encoding="shift-jis") as f:
                data_lines = f.readlines()
        except UnicodeDecodeError:  # UTF-8でもShift-JISでもない場合は飛ばす
            return

    # 各行から改行文字を除いてリスト化
    lines_no_lf = [line.strip() for line in data_lines]
    # 検索文字列を含んだ行だけをリスト化
        # リスト内は行番号と内容のタプル
        # 一致部分の色を赤に変更するためエスケープシーケンスを追加
        # \1は正規表現で一致部分の内容
    lines = [(i, regx.sub(rf"{cr}\1{ce}", line))
             for i, line in enumerate(lines_no_lf, 1) if regx.search(line)]
    # 出力
    if lines:
        print(f"\n{cu}{file_path}{ce}")     # ファイル名の出力
    for i, v in lines:
        print(f"{i:>4}: {v}")               # 一致行の出力。行番号に4桁確保

◆ヘルプの作成

本アプリはコマンドラインから実行します。
実行時に引数でオプションを指定します。
それらの使い方をヘルプで提供します。

ヘルプを作るために argparse モジュールを使用します。
argparse モジュールは少しの手間でユーザーフレンドリーなヘルプを提供してくれます。
argparse をインポートして使用します。

手順としては、始めに ArgumentParser クラスのインスタンスを作成します。
今回、生成される説明文に改行を指定して見やすくなるようにします。
そのためにコンストラクタで formatter_class 引数に RawTextHelpFormatter を指定します。
これで add_argument() メソッドの help 引数に改行を含めて機能させることができます。
次に add_argument() メソッドでオプションを定義します。

◇処理

  1. ArgumentParserクラスのインスタンスを作成 (ArgumentParser)
  2. コマンドライン引数を定義 (add_argument)
    • 位置引数:オプション名を指定
      • keyword  :検索文字列
      • target_path:検索対象のパス
    • オプション:フラグ名とオプション名を指定
      • subdir   :サブフォルダの検索。action で論理値に
      • ignore_case:大文字と小文字の区別。action で論理値に
      • regx    :正規表現の使用。action で論理値に
      • exclude_dir:除外するフォルダ
      • include_dir:含むフォルダ
      • plain_text :装飾しない出力。action で論理値に
  3. コマンドライン引数を解析 (parse_args)

◇コード

▽該当タグの特定と時間の抽出、タイマーの起動

    # コマンドライン引数の定義
    import argparse
    paser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)

    paser.add_argument('keyword', help='検索文字列')
    paser.add_argument('target_path', help='検索対象のパス(ファイルorフォルダorワイルドカード)\n例:file.txt、dir、dir\\*.py')
    paser.add_argument('-s', '--subdir', action='store_true', help='サブフォルダも検索')
    paser.add_argument('-i', '--ignore_case', action='store_true', help='大文字小文字を同一視')
    paser.add_argument('-r', '--regx', action='store_true', help='正規表現を使用(対象:検索文字列、除外するフォルダ、含めるフォルダ)')
    paser.add_argument('--exclude_dir', default='', metavar= 'dir', help='除外するフォルダ')
    paser.add_argument('--include_dir', default='', metavar= 'dir', help='含めるフォルダ')
    paser.add_argument('-p', '--plain_text', action='store_true', help='装飾しない出力')

    args = paser.parse_args()

    print(args)

◇ヘルプの出力

usage: find-us-str [-h] [-s] [-i] [-r] [--exclude_dir dir] [--include_dir dir] [-p]
                   keyword target_path

positional arguments:
  keyword            検索文字列
  target_path        検索対象のパス(ファイルorフォルダorワイルドカード)
                     例:file.txt、dir、dir\*.py

options:
  -h, --help         show this help message and exit
  -s, --subdir       サブフォルダも検索
  -i, --ignore_case  大文字小文字を同一視
  -r, --regx         正規表現を使用(対象:検索文字列、除外するフォルダ、含めるフォルダ)
  --exclude_dir dir  除外するフォルダ
  --include_dir dir  含めるフォルダ
  -p, --plain_text   装飾しない出力

◆使用した標準関数

◇Path.glob()

【Path.glob()】現在のパスが表すディレクトリ内で相対 pattern に一致する (あらゆる種類の) すべてのファイルを yield します

  • 【構文】Path.glob(pattern, *, case_sensitive=None, recurse_symlinks=False)
  • よく使う使い方(サンプル)
    • p.glog("**/*.txt"):拡張子がtxtの任意のファイル(サブフォルダ内も含む)
    • p.glog("**/*"):任意のフォルダとファイル(サブフォルダ内も含みカレントフォルダは除く)
  • 引数
    • pattern:検索パターン。次のワイルドカードをサポート
      • **:全てのセグメント(ファイル、フォルダ)全体
      • *:一つのセグメント(ファイル、フォルダ)全体
      • *:長さ0文字以上の任意の文字列
      • ?:任意の一文字
      • []:括弧内の文字列のどれか一文字
    • case_sensitive:大文字小文字の区別(デフォルトはOS依存)
    • recurse_symlinks:シンボリックリンク 2 をたどるかどうか。デフォルトは False
  • 戻り値:Path オブジェクトのイテレータ

◇Path.is_file()

【Path.is_file()】パスが一般ファイルで存在するかどうか

  • 【構文】is_file(*, follow_symlinks=True)
  • 引数
    • follow_symlinks:シンボリックリンクをたどるかどうか。デフォルトは True
  • 戻り値:Bool

◇Path.is_dir()

【Path.is_dir()】パスがフォルダで存在するかどうか。

  • 【構文】is_dir(*, follow_symlinks=True)
  • 引数
    • follow_symlinks:シンボリックリンクをたどるかどうか。デフォルトは True
  • 戻り値:Bool

◇re.compile()

【re.compile()】正規表現パターンをコンパイルし正規表現オブジェクトを返します

  • 【構文】re.compile(pattern, flags=0)
  • 引数
    • pattern:正規表現パターン
    • flags:式の動作を変更するフラグ
      本アプリでは re.IGNORECASE を使用
  • 戻り値:正規表現オブジェクト
  • 例外:re.error:記述誤り
    ※Ver 3.13 から PatternError に。error も後方互換性のエイリアスとして残る

◇re.escape()

【re.escape()】pattern 中の特殊文字をエスケープします

  • 【構文】re.escape(pattern)
  • 引数
    • pattern:正規表現パターン
  • 戻り値:文字列

◇Pattern.fullmatch()

【Pattern.fullmatch()】string 全体が正規表現にマッチするか判定

  • 【構文】Pattern.fullmatch(string[, pos[, endpos]])
  • 引数
    • string:評価文字列
    • pos, endpos:pos から endpos - 1 の文字に対してだけ探す
  • 戻り値:Match オブジェクト

◇Pattern.search()

【Pattern.search()】string 全体を走査して正規表現にマッチするか判定

  • 【構文】Pattern.search(string[, pos[, endpos]])
  • 引数
    • string:評価文字列
    • pos, endpos:pos から endpos - 1 の文字に対してだけ探す
  • 戻り値:一致した最初の一を示す Match オブジェクト

◇Pattern.sub()

【Pattern.sub()】string 中に出現する最も左の重複しないパターンを置換します

  • 【構文】Pattern.sub(repl, string, count=0)
  • 引数
    • repl:置換文字列(バックスラッシュエスケープが処理される)or関数
    • string:評価文字列
    • count:pos から endpos - 1 の文字に対してだけ探す
  • 戻り値:一致した最初の一を示す Match オブジェクト

◇ArgumentParser.add_argument()

【ArgumentParser.add_argument()】コマンドライン引数の定義を追加

  • 【構文】ArgumentParser.add_argument(name or flags..., *[, action][, nargs][, const][, default][, type][, choices][, required][, help][, metavar][, dest][, deprecated])
  • 主な引数
    • name or flags:オプション名 or フラグ名(- はじまり)とオプション名(-- はじまり)
    • キーワード引数:フラグ名(- はじまり)と引数名(-- はじまり)を指定
      例:位置引数:"foo"、オプション引数:"-f", "--foo"
    • action:オプション指定された時のアクション
      • "store":保存(デフォルト)
      • "sotre_true":True を保存
        オプションが指定されなかったときは False を保存
      • "store_false":False を保存
        オプションが指定されなかったときは True を保存
    • default:引数が存在しなかった場合の値
    • help:引数の説明
    • metavar:使用法表示で使われるオプションの引数の名前

◆ソースの取得

全体のソースはこちらから取得できます。

◇更新情報

  • 1.0.1:2025-03-01:リダイレクトで出力したときのエンコードエラーを回避
  • 1.0.0:2024-08-26:初期リリース

◆さいごに

自分用に作ったプログラムですが、思いのほか勉強になりました。
例外を使う良い例になっているのではないかと思います。
開けなかったファイルを開けなかったと表示しなければ、バイナリファイルがあっても大量の出力が出ないので実用的かなと思っています。

検索文字列の色付けも結果の見栄えをよくしていると思います。
色付けだけでなく下線も引けるとわかり、ファイル名に下線を付けたのも自分では気に入っています。

アプリ名は findstr と区別できるように UTF-8 と Sjift-JIS の頭文字を付加してみました。
少しでも名前に意味があると思い出せる気がします。


あわせて読みたい 📖 連続動作するタイマーの作り方【Python】 🔗

◇ご注意

本記事は次のバージョンの下で動作した内容を基に記述しています。

  • Python 3.12.8

◇免責事項

ご利用に際しては、『免責事項』をご確認ください。
お気づきの点がございましたら『お問い合わせ』からお問い合わせください。

◆参考

投稿:

  1. エスケープシーケンスの有効化:ANSI escape code wont work on python interpreter - Stack Overflow
  2. コンピュータのディスク上で扱うファイルやディレクトリを、本来の位置にファイルを残しつつそれとは別の場所に置いたり別名を付けてアクセスする手段である。複製とは違い、実体がないこと、ソフトリンクで開いたファイルへの操作が実物のファイルにも反映されること、ファイルサイズが小さいのが特徴。
    実際には、各種OSによって名称も異なっており、それぞれ、

    - Microsoft Windows - ショートカット
    - macOS - エイリアス
    - UNIX - シンボリックリンク
    - NTFSを搭載したWindows(Windows XPなど) - ジャンクションWikipedia