【Python】prompt_toolkitとjsonで対話型JSON検索ツールを作ってみた

【Python】prompt_toolkitとjsonで対話型JSON検索ツールを作ってみた

公開:
CodeCampが提供するDX人材育成が可能なプログラミングやITが学べる公開講座

ターミナルでJSONの中身を検索語で絞り込みたくなったので、prompt_toolkitjsonを中心に対話型のJSON検索ツールを作りました。検索処理はsearch_recordsという1本の関数に集約し、引数指定のsearchと補完・履歴付きのinteractiveから同じ関数を呼ぶ構成です。

サンプルJSONを作るinitも同梱したため、記事の手順だけで最後まで追体験できます。手元では合計4コマンドを実行し、すべて終了コード0で動きました。

prompt_toolkitの基本概念、要件定義、実装、動作確認までを順番に学べる構成です。動画は目次から確認したい場面へ移動でき、本文だけでも手順と考え方が完結します。

【Python】prompt_toolkitとjsonで対話型JSON検索ツールを作ってみた|フルカリキュラム
動画の内容をテキストで確認する

オープニング。prompt_toolkitとjsonを使って対話型JSON検索CLIを作るカリキュラムを始めます。概要紹介。

JSONファイルから必要なレコードを検索するprompt_toolkitとjsonの役割を知る 入力補完と履歴を使う対話ツールを実装する 検索語を変えながら結果を確認する 具体的にやること。

サンプルJSONを作り、読み込み対象を用意する 検索語に部分一致するレコードを抽出する 一致件数と一致フィールドを表示する 対話モードで検索語を繰り返し入力するTab補完と履歴呼び出しで入力を助ける 実装環境・必須アプリ。

OS:Windows 11 Pro Python:3.13.3シェル:PowerShell 5.1使用アプリ:コードエディターとターミナル 追加パッケージ:prompt_toolkit 3.0.53 json:Python標準ライブラリ prompt_toolkitとjsonモジュールとは。

prompt_toolkit:補完と履歴を備えた対話入力ライブラリjson:JSONとPythonのデータを相互に変換する標準ライブラリ prompt_toolkitで作るJSON検索CLIの要点。

JSONの形をレコードの並びにそろえる 値を文字列へ変換して部分一致で探す 検索と表示を共通化して両モードから使う 補完候補と履歴ファイルを入力欄に渡す 対話型JSON検索CLIの要件定義。

サンプルJSONに10件のレコードを保存する コーヒーの検索で3件のレコードを表示する 商品名・カテゴリ・数値の値で検索できる 同じ対話セッションで検索語を変えられるTabで補完し、上矢印で履歴を呼び出せるexitまたはquitを入力して終了できる INTRO: Monaco Editorで対話型JSON検索CLIを実装。

コードを1行ずつ入力し、補完と自動インデントを使いながら実行結果を確認します。LINE 001: モジュールの説明文。このファイル全体の役割を示すdocstringです。

prompt_toolkitとjsonを組み合わせて対話型のJSON検索ツールを作ることを説明しています。LINE 003: argparseの読み込み。コマンドライン引数を解析するためのargparseモジュールを読み込んでいます。

この後のCLI組み立て処理で使われます。LINE 004: jsonの読み込み。JSONファイルの読み込みや書き出しを行うためのjsonモジュールを読み込んでいます。

データの変換全般で利用します。LINE 005: osの読み込み。ファイルの存在確認やパス操作を行うためのosモジュールを読み込んでいます。

ファイル関連の処理で使われます。LINE 007: PromptSessionの読み込み。対話的な入力受付を行うPromptSessionクラスをprompt_toolkitから読み込んでいます。

対話モードの中心となる機能です。LINE 008: WordCompleterの読み込み。入力補完の候補を扱うWordCompleterクラスを読み込んでいます。

フィールド名や値をタブ補完するために使われます。LINE 009: FileHistoryの読み込み。入力履歴をファイルに保存するFileHistoryクラスを読み込んでいます。

過去に入力した検索語を上下キーで呼び出せるようにします。LINE 013: サンプルデータの開始。動作確認用のサンプルレコード一覧を定義するリストの先頭です。

この後に商品情報を表す辞書が並びます。LINE 014: サンプル商品1。コーヒー豆の商品情報を1件分の辞書として定義しています。

id、名前、カテゴリ、価格、タグを持つ構造です。LINE 015: 商品1の続き。14行目の辞書の続きで、価格とタグの情報を記述しています。

改行して見やすく1件分の情報を完成させています。LINE 016: サンプル商品2。ドリップコーヒーメーカーの商品情報を辞書として定義しています。

カテゴリは家電になっています。LINE 017: 商品2の続き。16行目の辞書の続きで、価格とタグを記述して2件目の商品情報を完成させています。

LINE 018: サンプル商品3。静音メカニカルキーボードの商品情報を辞書として定義しています。カテゴリはPC周辺機器です。

LINE 019: 商品3の続き。18行目の辞書の続きで、価格とタグを記述して3件目の商品情報を完成させています。LINE 020: サンプル商品4。

ワイヤレスキーボードの商品情報を辞書として定義しています。同じくPC周辺機器カテゴリです。LINE 021: 商品4の続き。

20行目の辞書の続きで、価格とタグを記述して4件目の商品情報を完成させています。LINE 022: サンプル商品5。ゲルインクボールペンの商品情報を辞書として定義しています。

カテゴリは文房具です。LINE 023: 商品5の続き。22行目の辞書の続きで、価格とタグを記述して5件目の商品情報を完成させています。

LINE 024: サンプル商品6。A5リングノートの商品情報を辞書として定義しています。文房具カテゴリの2件目です。

LINE 025: 商品6の続き。24行目の辞書の続きで、価格とタグを記述して6件目の商品情報を完成させています。LINE 026: サンプル商品7。

アイスコーヒーの商品情報を辞書として定義しています。カテゴリは食品で1件目のコーヒー豆と同じ分類です。LINE 027: 商品7の続き。

26行目の辞書の続きで、価格とタグを記述して7件目の商品情報を完成させています。LINE 028: サンプル商品8。USB-Cハブの商品情報を辞書として定義しています。

PC周辺機器カテゴリの追加商品です。LINE 029: 商品8の続き。28行目の辞書の続きで、価格とタグを記述して8件目の商品情報を完成させています。

LINE 030: サンプル商品9。デスクマットの商品情報を辞書として定義しています。こちらもPC周辺機器カテゴリです。

LINE 031: 商品9の続き。30行目の辞書の続きで、価格とタグを記述して9件目の商品情報を完成させています。LINE 032: サンプル商品10。

蛍光マーカーの商品情報を辞書として定義しています。文房具カテゴリの最後の商品です。LINE 033: 商品10の続き。

32行目の辞書の続きで、価格とタグを記述して10件目の商品情報を完成させています。LINE 034: サンプルデータの終了。13行目から続いていたサンプルレコードのリスト定義を閉じています。

ここまでで10件分の商品データが揃います。LINE 036: 終了ワードの定義。対話モードを終了させる入力語をタプルとして定義しています。

exitまたはquitと入力すると終了する仕組みに使われます。LINE 037: 履歴ファイル名の定義。入力履歴を保存するファイルの既定名を定数として定義しています。

対話モード起動時にこの名前が使われます。LINE 038: 補完候補の上限定義。入力補完の候補として表示する単語数の上限を定数で定義しています。

候補が多すぎて重くならないようにする役割です。RUN 1/9: サンプルデータと定数を確かめる。商品10件のサンプルと、終了語や履歴ファイル名などの定数までを入力しました。

件数と中身をprintで表示して、後の処理で使う値がそろっているか確認します。CHECK 1/9: 途中実行に成功。商品10件のサンプルと、終了語や履歴ファイル名などの定数までを入力しました。

件数と中身をprintで表示して、後の処理で使う値がそろっているか確認します。RETURN 01: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 042: レコード読み込み関数の定義。JSONファイルを読み込んでレコードのリストに整える関数load_recordsを定義しています。この後で内容を読み取り処理を書いていきます。

LINE 043: 関数の説明文。load_records関数の役割を説明するdocstringです。JSONファイルを読み込み辞書のリストに揃えることを示しています。

LINE 044: ファイルを開く。指定されたパスのファイルをUTF-8で読み込みモードで開いています。withを使うことで処理後に自動でファイルが閉じられます。

LINE 045: JSONの解析。開いたファイルの内容をjson.loadで解析し、Pythonのオブジェクトに変換しています。結果はリストや辞書として得られます。

LINE 047: リスト形式かどうかの判定。読み込んだデータがすでにリストかどうかを判定しています。リストであればそのままレコード一覧として扱えます。

LINE 048: リストをそのまま返す。データがリストだった場合に、そのままレコードのリストとして返しています。追加の変換は不要な形式です。

LINE 049: 辞書形式かどうかの判定。データが辞書だった場合の処理に進むための条件分岐です。レコードが別のキーの下に格納されている可能性を考慮しています。

LINE 050: 候補キーの走査。records、items、dataという候補キーを順番に確認するループです。よく使われるキー名を想定しています。

LINE 051: キーに対応する値の取得。辞書からそのキーに対応する値を取り出しています。存在しなければNoneが得られます。

LINE 052: 値がリストかどうかの判定。取り出した値がリストであるかどうかを確認しています。リストであればそれをレコード一覧とみなします。

LINE 053: 見つかったリストを返す。候補キーの中でリストが見つかった場合に、それをレコードのリストとして返しています。LINE 054: 単一データを1件のリストに変換。

候補キーのいずれにも該当しなかった場合、その辞書自体を1件のレコードとみなしてリストに包んで返しています。LINE 055: 不正な形式へのエラー。データがリストにも辞書にも当てはまらない場合に、エラーメッセージ付きで例外を発生させています。

想定外のJSON形式を防ぎます。LINE 058: フィールド収集関数の定義。レコード全体からフィールド名を集める関数collect_fieldsを定義しています。

この後の処理でフィールド一覧を組み立てます。LINE 059: 関数の説明文。collect_fields関数の役割を説明するdocstringです。

フィールド名を出現順に集めることを示しています。LINE 060: フィールド一覧の初期化。収集したフィールド名を格納するための空リストを用意しています。

この後のループで要素を追加していきます。LINE 061: レコードごとのループ。渡されたレコードの一覧を1件ずつ順に処理するためのループです。

LINE 062: 辞書以外の除外判定。レコードが辞書でない場合を検出する条件です。想定外のデータ型を後続処理から除外するために使います。

LINE 063: 次のレコードへスキップ。辞書でないレコードだった場合に、このレコードの処理を飛ばして次のループへ進みます。LINE 064: キーごとのループ。

レコードの辞書に含まれる各キーを順番に取り出すループです。LINE 065: 重複チェック。そのキーがまだfieldsリストに含まれていないかどうかを確認しています。

重複したフィールド名を防ぐための条件です。LINE 066: フィールド名の追加。未登録のキーだった場合に、fieldsリストへそのキー名を追加しています。

出現順を保ったまま蓄積されます。LINE 067: フィールド一覧の返却。すべてのレコードを調べ終えた後に、集まったフィールド名のリストを呼び出し元へ返しています。

RUN 2/9: JSONの読み込みとフィールド収集を確かめる。load_recordsとcollect_fieldsが書けました。recordsキーを持つ辞書形式のJSONを一時ファイルへ書き出し、レコード件数とフィールド名を取り出せるか確認します。

CHECK 2/9: 途中実行に成功。load_recordsとcollect_fieldsが書けました。recordsキーを持つ辞書形式のJSONを一時ファイルへ書き出し、レコード件数とフィールド名を取り出せるか確認します。

RETURN 02: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 071: 既存ファイル説明関数の定義。

書き出し前のファイル状態を文字列で説明する関数describe_existingを定義しています。init処理での状態表示に使われます。LINE 072: 関数の説明文。

describe_existing関数の役割を説明するdocstringです。書き出し前のファイル状態を返すことを示しています。LINE 073: ファイル存在の確認。

指定されたパスにファイルが存在しないかどうかを確認しています。存在しない場合の分岐に使われます。LINE 074: 未作成時のメッセージ。

ファイルが存在しない場合に「ファイルなし」という文字列を返しています。まだサンプルが作られていない状態を示します。LINE 075: 読み込み処理の開始。

既存ファイルの内容を読み込む処理をtry節で囲んでいます。読み込みに失敗した場合に備える構造です。LINE 076: 既存レコード件数の表示文。

既存ファイルを読み込んでレコード件数を取得し、その件数を含む説明文字列を作って返しています。LINE 077: 読み込みエラーの捕捉。JSONとして不正な内容やファイルアクセスエラーが起きた場合を捕捉する例外処理です。

LINE 078: 読み込み失敗時のメッセージ。既存ファイルはあるもののJSONとして読み込めなかった場合に、その旨を示す文字列を返しています。LINE 081: サンプル作成関数の定義。

サンプルJSONを指定パスへ書き出す関数create_sampleを定義しています。この後の処理でファイルへの書き出しを行います。LINE 082: 関数の説明文。

create_sample関数がサンプルJSONを指定パスへ書き出す処理であることを示すdocstringです。関数の役割を明示し、後続処理の理解を助けます。LINE 083: 書き出し先ディレクトリの特定。

引数pathを絶対パスに変換し、そのディレクトリ部分をdirectoryに取得します。相対パス指定でも正しくフォルダを作成できるようにする準備です。LINE 084: ディレクトリの作成。

os.makedirsでdirectoryを再帰的に作成します。exist_ok=Trueにより、既にディレクトリが存在してもエラーにならず処理を続行できます。LINE 085: 書き込み用ファイルを開く。

指定したpathを書き込みモードでUTF-8エンコーディングにより開き、json_fileとしてwithブロックで扱います。処理後は自動的にファイルが閉じられます。LINE 086: サンプルデータをJSONとして書き出す。

json.dumpでSAMPLE_RECORDSをjson_fileへ出力します。ensure_ascii=Falseで日本語をそのまま出力し、indent=2で読みやすい整形を行います。LINE 087: 末尾に改行を追加。

書き出したJSONの末尾に改行文字を書き込み、一般的なテキストファイルの慣習に合わせています。LINE 088: 書き出し先パスを返す。呼び出し元へ書き出したファイルのpathを返し、後続処理でこのパスを使って読み込みなどが行えるようにします。

RUN 3/9: サンプルJSONの書き出しを確かめる。describe_existingとcreate_sampleが完成しました。一時ディレクトリへ書き出し、作成前と作成後で状態の説明が変わることを確認します。

CHECK 3/9: 途中実行に成功。describe_existingとcreate_sampleが完成しました。一時ディレクトリへ書き出し、作成前と作成後で状態の説明が変わることを確認します。

RETURN 03: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 092: 文字列化関数の定義。

検索処理で値を統一的に扱うため、任意の値を文字列に変換するto_text関数を定義しています。LINE 093: 関数の説明文。to_textが検索用に値を文字列化し、数値やリストもJSON文字列として扱うことを説明するdocstringです。

LINE 094: 文字列かどうかの判定。valueが既にstr型であるかをisinstanceで確認し、変換の要否を分岐する条件です。LINE 095: 文字列はそのまま返す。

valueが文字列であればそのまま返し、余計な変換を行わないようにします。LINE 096: 非文字列をJSON文字列化。数値やリスト、辞書などstr以外の値をjson.dumpsでJSON文字列に変換して返します。

ensure_ascii=Falseにより日本語もそのまま表現されます。LINE 099: 一致フィールド抽出関数の定義。レコード内で検索語を含むフィールド名を調べるmatched_fields関数を定義しています。

LINE 100: 関数の説明文。matched_fieldsがレコードのうち検索語を含むフィールド名を返す関数であることを説明するdocstringです。LINE 101: 検索語の小文字化。

queryを小文字に変換してneedleに格納し、大文字小文字を区別しない検索を実現します。LINE 102: 辞書以外のレコードの判定。recordが辞書型でない場合の処理を分岐する条件です。

想定外のデータ形式にも対応します。LINE 103: 非辞書レコードの一致判定。recordを文字列化して小文字比較し、needleを含む場合は"value"というフィールド名のリストを返し、含まない場合は空リストを返します。

LINE 104: 辞書の各フィールドを走査。record.items()で各キーと値を取り出し、条件に合うキーのみを集めるリスト内包表記の開始部分です。LINE 105: 値に検索語が含まれるか判定。

各値をto_textで文字列化し小文字化した上でneedleを含むかを判定し、条件を満たすキーだけをリストへ抽出します。LINE 108: 検索全体を行う関数の定義。検索語でレコード全体を絞り込むsearch_records関数を定義しています。

LINE 109: 関数の説明文。search_recordsが検索語に部分一致したレコードと一致フィールドの組を返すことを説明するdocstringです。LINE 110: 結果リストの初期化。

検索結果を格納する空のresultsリストを用意します。この後のループで一致したレコードを追加していきます。LINE 111: 全レコードの順次处理。

recordsの各要素を順に取り出し、後の行で検索語とのマッチングを行うためのループです。LINE 112: 一致フィールドの取得。matched_fieldsを呼び出し、各レコードについて検索語を含むフィールドのリストをfieldsに取得します。

LINE 113: 一致の有無を判定。fieldsが空でなければ、すなわち一致するフィールドがあれば次の処理に進む条件です。LINE 114: 結果への追加。

一致したレコードとfieldsのタプルをresultsリストに追加します。LINE 115: 検索結果リストを返す。ループで集めたresultsを呼び出し元へ返し、表示処理へ渡します。

RUN 4/9: 検索語コーヒーで一致を数える。to_textからsearch_recordsまでがそろいました。サンプルデータを直接渡して、一致件数と一致フィールドが取れるか確認します。

CHECK 4/9: 途中実行に成功。to_textからsearch_recordsまでがそろいました。サンプルデータを直接渡して、一致件数と一致フィールドが取れるか確認します。

RETURN 04: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 119: 結果表示関数の定義。

検索結果をターミナルへ表示するprint_results関数を定義しています。LINE 120: 関数の説明文。print_resultsが検索語・一致件数・一致レコードを出力することを説明するdocstringです。

LINE 121: 検索語の表示。入力された検索語queryを「検索語: ○○」という形式で先頭に表示します。LINE 122: 一致件数と全件数の表示。

一致したresultsの件数と全レコード数recordsを並べて表示し、検索の全体像を伝えます。LINE 123: 結果がない場合の分岐。resultsが空の場合の分岐条件です。

この後の行で一致なしを伝えて早期リターンする処理につながります。LINE 124: 一致なしメッセージの表示。検索結果が空のときに、一致するレコードがないことをユーザーへ知らせるメッセージを表示します。

LINE 125: 処理の早期終了。一致件数がゼロの場合はこれ以降の一覧表示処理を行わずに関数を終了します。LINE 126: 結果の一覧を反復。

results内の各レコードと一致フィールドの組を、1から始まる番号付きで順番に取り出します。LINE 127: レコード本体の出力。取り出した番号とともに、レコードをJSON文字列に変換してそのまま表示します。

LINE 128: 一致フィールドの出力。検索語が一致したフィールド名をカンマ区切りで整形し、レコードの下に表示します。RUN 5/9: 検索結果の表示を確かめる。

print_resultsを書き終えました。一致するときと一致しないときの2通りを呼び出し、出力の並びとメッセージを見比べます。CHECK 5/9: 途中実行に成功。

print_resultsを書き終えました。一致するときと一致しないときの2通りを呼び出し、出力の並びとメッセージを見比べます。RETURN 05: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 132: 補完候補作成関数の定義。レコード一覧を受け取り、入力補完に使う候補語のリストを作成する関数を定義します。

LINE 133: 関数の説明コメント文字列。この関数がフィールド名と文字列値から補完候補を組み立てることを説明するドキュメント文字列です。LINE 134: 終了語を候補の初期値に設定。

exitやquitといった終了用のキーワードを、補完候補リストの初期要素として用意します。LINE 135: レコードごとの反復開始。渡された全レコードを1件ずつ処理するためのループを開始します。

LINE 136: 辞書以外のレコードを判定。レコードが辞書型でない場合は、フィールド情報を取り出せないため後続処理をスキップする条件です。LINE 137: 該当レコードの読み飛ばし。

辞書型でないレコードについては補完候補の追加を行わず、次のレコードの処理に進みます。LINE 138: キーと値のペアを反復。レコード内の各フィールドについて、キー名と対応する値を順番に取り出します。

LINE 139: キー名を候補に追加。フィールド名を文字列に変換し、補完候補のリストへ追加します。LINE 140: 値が文字列かどうかを判定。

フィールドの値が文字列型であるかを確認し、該当する場合のみ候補追加の処理に進みます。LINE 141: 文字列値を候補に追加。文字列型の値をそのまま補完候補のリストへ追加します。

LINE 142: 値がリストかどうかを判定。フィールドの値がリスト型である場合に、要素を候補として扱うための分岐です。LINE 143: リスト要素を候補へ展開。

リストの各要素を文字列に変換したものを、条件を満たす場合に候補リストへまとめて追加します。LINE 144: 要素の型を限定する条件。文字列・整数・浮動小数点数の要素だけを対象とすることで、扱いにくい型を候補から除外します。

LINE 145: 候補の重複除去と件数制限。集めた候補語を重複なく並べ替えたうえで、最大件数までに絞り込みます。LINE 146: 補完オブジェクトの生成開始。

整えた候補リストをもとに、大文字小文字を区別しない補完機能を持つWordCompleterを作り始めます。LINE 147: 補完動作の設定。文全体を対象とした補完や、途中の文字列にも一致する補完を有効にする設定を渡して補完機能を仕上げます。

RUN 6/9: 補完候補の中身を確かめる。build_completerが完成しました。サンプルデータから候補を組み立て、候補数と先頭の並び、終了語が含まれるかを確認します。

CHECK 6/9: 途中実行に成功。build_completerが完成しました。サンプルデータから候補を組み立て、候補数と先頭の並び、終了語が含まれるかを確認します。

RETURN 06: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 151: 対話モード関数の定義。

JSONファイルのパスと履歴ファイルパスを受け取り、対話的な検索を行う関数を定義します。LINE 152: 関数の説明コメント文字列。検索語を繰り返し受け取りながら一致レコードを表示し続ける関数であることを説明しています。

LINE 153: 検索対象レコードの読み込み。指定されたパスのJSONファイルを読み込み、対話中に検索するレコード一覧を用意します。LINE 154: 履歴パス未指定の判定。

履歴ファイルのパスが指定されていない場合に、デフォルトの場所を決定する処理へ進む条件です。LINE 155: 対象ファイルのディレクトリ取得。読み込み対象のJSONファイルの絶対パスから、その親ディレクトリを求めます。

LINE 156: デフォルト履歴パスの組み立て。対象ファイルと同じディレクトリに、定数で定義した履歴ファイル名を結合したパスを作成します。LINE 158: プロンプトセッションの生成開始。

対話的な入力受付を行うPromptSessionオブジェクトの作成を開始します。LINE 159: 入力履歴の設定。決定した履歴ファイルパスを使い、入力履歴を保存・読み込みするFileHistoryを設定します。

LINE 160: 補完機能の設定。読み込んだレコードから作成した補完候補をセッションに組み込み、入力時の補完を可能にします。LINE 161: 入力中補完の有効化。

文字入力の途中でも補完候補を表示するオプションを有効にして設定を完了します。LINE 162: セッション生成の完了。これまでの設定をもとにPromptSessionオブジェクトの生成処理を閉じます。

LINE 164: 起動時の見出し表示。対話型JSON検索モードが始まったことを示す見出しを画面に表示します。LINE 165: 対象ファイルと件数の表示。

検索対象のファイルパスと読み込んだレコード件数をまとめて表示し、利用者に読み込み状況を伝えます。LINE 166: 利用可能フィールドの一覧表示。collect_fieldsで集めたフィールド名をカンマ区切りにして表示し、どの項目で検索できるかを示します。

LINE 167: 履歴ファイルの場所を表示。検索履歴を保存しているファイルのパスを表示し、履歴の保存先を利用者に知らせます。LINE 168: 操作方法の案内表示。

Tab補完や上下キーでの履歴呼び出し、終了方法をまとめて案内するメッセージを表示します。LINE 170: 対話ループの開始。終了操作があるまで検索語の入力と結果表示を繰り返す無限ループを開始します。

LINE 171: 入力受付の開始。例外発生時の処理を行うためtryブロックで検索語の入力受付を囲みます。LINE 172: 検索語の入力受付。

PromptSessionのprompt機能で検索語を1行入力させ、補完や履歴を使えるようにします。LINE 173: 入力中断の捕捉。Ctrl-Dによる終了やCtrl-Cによる中断が発生した場合の例外をまとめて捕捉します。

LINE 174: 終了メッセージの表示。入力が中断された際に終了することを利用者に伝えるメッセージを表示します。LINE 175: ループの終了。

breakでwhileループを抜け、対話モードを終了させます。LINE 177: 入力文字列の整形。入力された文字列の前後の空白を取り除き、検索語として扱えるように整えます。

LINE 178: 空入力の判定。検索語が空文字列かどうかを判定します。LINE 179: 空入力時の再入力。

検索語が空の場合は何もせずcontinueで次の入力待ちに戻ります。LINE 180: 終了ワードの判定。入力を小文字にしてEXIT_WORDSに含まれるかを調べ、終了指示かどうかを判定します。

LINE 181: 終了メッセージの表示。終了ワードが入力された場合に終了することを知らせるメッセージを表示します。LINE 182: ループの終了。

終了ワード入力時にbreakでwhileループを抜け、対話モードを終える処理です。LINE 184: 検索と結果表示の実行。search_recordsで検索語に一致するレコードを取得し、print_resultsで結果をまとめて表示します。

LINE 188: initコマンドの処理定義。サブコマンドinitに対応する処理としてcommand_init関数を定義しています。LINE 189: 関数の説明。

サンプルJSONを作成し、作成前後の状態を表示する関数であることを示す説明です。LINE 190: 処理内容の見出し表示。サンプルJSON作成処理が始まったことを示す見出しを画面に表示します。

LINE 191: 作成前の状態表示。describe_existingで既存ファイルの有無や中身の状態を調べ、作成前の状況として表示します。LINE 192: サンプルJSONの書き出し。

create_sampleを呼び出して指定パスにサンプルデータを書き出し、実際に使われたパスを受け取ります。LINE 193: 書き出し結果の読み込み。書き出したJSONファイルをload_recordsで読み込み直し、作成後の内容を確認できるようにします。

LINE 194: 書き出し先の表示。サンプルJSONを書き出したファイルパスを画面に表示します。LINE 195: 作成後の件数表示。

読み込み直したレコードの件数を表示し、正しく書き出せたことを確認できるようにします。LINE 196: フィールド一覧の表示。作成したサンプルJSONに含まれるフィールド名の一覧をカンマ区切りで表示します。

LINE 197: 先頭レコード表示の見出し。これから先頭2件のレコードを表示することを示す見出しを表示します。LINE 198: 先頭2件の取り出し。

recordsの先頭から2件をスライスで取り出し、1件ずつ処理するループを開始します。LINE 199: レコードのJSON表示。取り出した各レコードをjson.dumpsで文字列化し、字下げして画面に表示します。

LINE 202: searchコマンドの処理定義。サブコマンドsearchに対応する処理としてcommand_search関数を定義しています。LINE 203: 関数の説明。

コマンドライン引数で受け取った検索語を使い、1回だけ検索を行う関数であることを示す説明です。LINE 204: 検索対象ファイルの読み込み。引数で指定されたパスからJSONファイルを読み込み、レコードのリストとしてrecordsに格納します。

以降の検索処理はこのrecordsを対象に行います。LINE 205: 見出しの表示。引数指定検索モードであることを示す見出し文字列をターミナルに出力します。

LINE 206: 対象ファイルパスの表示。検索対象として読み込んだファイルのパスをf文字列で整形して表示します。LINE 207: 検索実行と結果表示。

search_recordsで検索語に一致したレコードを求め、その結果をprint_resultsに渡してまとめて表示します。RUN 7/9: searchコマンドの処理を単体で動かす。command_searchまで入力できました。

サンプルJSONを書き出してから、argparse.Namespaceで引数を組み立てて呼び出し、検索結果の表示を確認します。CHECK 7/9: 途中実行に成功。command_searchまで入力できました。

サンプルJSONを書き出してから、argparse.Namespaceで引数を組み立てて呼び出し、検索結果の表示を確認します。RETURN 07: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 210: 対話モード用ハンドラの定義。argparseから渡されたargsを受け取り、対話型検索を起動するための関数を定義します。LINE 211: 関数の役割説明。

この関数が対話モードを起動する処理であることを説明するdocstringです。LINE 212: 対話モードの起動。args.pathとargs.historyを渡してrun_interactive関数を呼び出し、対話型の検索セッションを開始します。

LINE 216: パーサ構築関数の定義。サブコマンドを持つ引数パーサを組み立てるbuild_parser関数を定義します。この関数はCLI全体の構造を作ります。

LINE 217: 関数の役割説明。この関数がサブコマンド付きの引数パーサを作成することを説明するdocstringです。LINE 218: ArgumentParserの生成開始。

argparse.ArgumentParserのインスタンス生成を開始し、コマンドライン引数解析の土台を用意します。LINE 219: ツールの説明文設定。ArgumentParserのdescription引数に、このツール全体の説明文を設定します。

LINE 220: サブパーサの追加。parserにサブコマンドを追加できるようにsubparsersオブジェクトを生成し、dest属性にコマンド名を格納し必須指定にします。LINE 222: initサブコマンドの生成開始。

サンプルJSON作成用のinitサブコマンドをsubparsersに追加するための呼び出しを開始します。LINE 223: initコマンド名とヘルプ設定。サブコマンド名を"init"とし、ヘルプメッセージにサンプルJSON作成用であることを示す説明を設定します。

LINE 224: initの--path引数追加(1)。initサブコマンドに--pathオプションを追加し、デフォルト値としてsample_products.jsonを設定します。LINE 225: initの--pathヘルプ設定。

--pathオプションのヘルプメッセージとして、書き出し先のJSONファイルであることを説明します。LINE 226: initハンドラの登録。init_parserのset_defaultsでhandler属性にcommand_init関数を紐づけ、initコマンド実行時に呼び出す処理を指定します。

LINE 228: searchサブコマンドの生成開始。引数指定検索用のsearchサブコマンドをsubparsersに追加するための呼び出しを開始します。LINE 229: searchコマンド名とヘルプ設定。

サブコマンド名を"search"とし、1回だけ検索を行うコマンドであることを示すヘルプメッセージを設定します。LINE 230: searchの--path引数追加(1)。searchサブコマンドに--pathオプションを必須として追加し、検索対象ファイルの指定を要求します。

LINE 231: searchの--pathヘルプ設定。--pathオプションのヘルプメッセージとして、検索対象のJSONファイルであることを説明します。LINE 232: --query引数の追加。

検索語を受け取る--queryオプションを必須として追加し、ヘルプメッセージに検索語であることを示します。LINE 233: searchハンドラの登録。search_parserのset_defaultsでhandler属性にcommand_search関数を紐づけ、searchコマンド実行時の処理を指定します。

LINE 235: interactiveサブコマンドの生成開始。対話型検索用のinteractiveサブコマンドをsubparsersに追加するための呼び出しを開始します。LINE 236: interactiveコマンド名とヘルプ設定。

サブコマンド名を"interactive"とし、補完と履歴付きで対話検索するコマンドであることを示すヘルプを設定します。LINE 237: interactiveの--path引数追加(1)。interactiveサブコマンドに--pathオプションを必須として追加し、検索対象ファイルの指定を要求します。

LINE 238: interactiveの--pathヘルプ設定。--pathオプションのヘルプメッセージとして、検索対象のJSONファイルであることを説明します。LINE 239: --history引数の追加。

履歴ファイルのパスを指定する--historyオプションを追加し、デフォルト値をNoneに設定します。LINE 240: --historyヘルプ設定。--historyオプションのヘルプメッセージとして、履歴ファイルのパスであることを説明します。

LINE 241: interactiveハンドラの登録。interactive_parserのset_defaultsでhandler属性にcommand_interactive関数を紐づけ、interactiveコマンド実行時の処理を指定します。LINE 243: パーサオブジェクトの返却。

組み立てが完了したparserオブジェクトを呼び出し元に返し、main関数などで利用できるようにします。RUN 8/9: サブコマンドの解析結果を確かめる。build_parserが完成しました。

searchコマンドの引数を解析させて、コマンド名と検索語、紐づいた処理関数の名前を表示します。CHECK 8/9: 途中実行に成功。build_parserが完成しました。

searchコマンドの引数を解析させて、コマンド名と検索語、紐づいた処理関数の名前を表示します。RETURN 08: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 246: main関数の定義。プログラムのエントリーポイントとなるmain関数を定義します。この関数がコマンドライン実行時の起点になります。

LINE 247: main関数の役割説明。main関数がコマンドライン全体を解析し、対応するサブコマンドの処理を実行する入り口であることを示しています。LINE 248: 引数パーサの構築と解析。

build_parserでサブコマンド付きの引数パーサを作成し、parse_argsで実際のコマンドライン引数を解析してargsに格納します。LINE 249: 対応するハンドラの実行。

解析結果のargsに設定されたhandler属性(command_init、command_search、command_interactiveのいずれか)をargs自体を渡して呼び出し、選択されたコマンドの処理を実行します。RUN 9/9: initとsearchを続けて実行する。mainまで書き終えました。

コマンドラインを差し替えてinitとsearchを続けて呼び出し、サンプル作成から検索までが1本の流れでつながることを確認します。CHECK 9/9: 途中実行に成功。mainまで書き終えました。

コマンドラインを差し替えてinitとsearchを続けて呼び出し、サンプル作成から検索までが1本の流れでつながることを確認します。RETURN 09: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 252: スクリプト直接実行の判定。このファイルがモジュールとしてインポートされたのではなく、直接実行されたときにだけ以降の処理を行うための定型的な条件分岐です。LINE 253: main関数の呼び出し。

直接実行時にmain関数を呼び出し、コマンドライン引数の解析から各コマンド処理の実行までの一連の流れを開始します。実行1/5: サンプルJSONを作成する。initコマンドで動作確認用のサンプルJSONを書き出します。

作成前の状態、レコード件数、フィールド名、先頭2件までが表示されます。サンプルJSONを作成する。initコマンドで動作確認用のサンプルJSONを書き出します。

作成前の状態、レコード件数、フィールド名、先頭2件までが表示されます。検索語コーヒーで絞り込む。searchコマンドに検索語コーヒーを渡し、一致件数と一致したフィールド名を確認します。

カテゴリ名の文房具で絞り込む。商品名ではなくカテゴリだけが一致する検索語を試し、一致フィールドがcategoryになることを確かめます。対話モードで検索語を変える。

interactiveを起動し、USBに続いて価格の12800を検索します。起動し直さずに検索語を変え、それぞれの一致レコードを確認できます。Tab補完と履歴で検索する。

ワイヤレスまで入力してTabで商品名を補完します。続いて上矢印で直前の検索語を呼び出し、同じ商品を再検索できます。学習内容のまとめ。

PromptSessionで繰り返し検索語を受け取るWordCompleterとFileHistoryで補完と履歴を使うjson.loadで読み込み、値を文字列にそろえて検索するsearchとinteractiveで同じ検索・表示処理を使う 一致件数と一致フィールドから検索結果を確認する エンディング。

Python研修はCodeCampでご確認ください。

prompt_toolkitとjsonモジュールとは

今回使用する主要なライブラリについて、役割と使い分けを順番に確認します。

対話入力を担うprompt_toolkitの役割

prompt_toolkitは、ターミナルでの1行入力に補完や履歴を追加できるPythonの対話入力ライブラリです。標準のinput関数だけで作ると、Tabキーによる候補表示や上下キーでの履歴呼び出しを自前で用意する必要があります。PromptSessionを使えば、履歴の保存先と補完候補を引数で渡すだけで、同じ設定のまま何度でも入力を受け取れました。

今回のツールでは対話モードだけがprompt_toolkitに依存し、検索処理そのものは標準ライブラリで完結させています。

対話モードのrun_interactiveでprompt_toolkitに任せている入力機能は次のとおりです

  • PromptSessionで検索語の入力を繰り返し受け取る
  • FileHistoryで入力履歴をファイルへ残す
  • WordCompleterへ候補リストを渡してTab補完する
  • complete_while_typingで入力中も候補を表示する
  • ignore_caseで大文字小文字を区別せず候補を探す
  • match_middleで語の途中に一致する候補も出す
  • EOFErrorを捕まえてCtrl-Dでも終了する

JSONを読み書きする標準ライブラリのjson

jsonは、Pythonのリストや辞書とJSON形式の文字列を相互に変換する標準ライブラリです。追加インストールなしでimportでき、ファイルからの読み込みはjson.load、書き出しはjson.dumpが担当します。

今回のツールでは、サンプルJSONの作成と検索対象の読み込みに加えて、数値やリストを検索用の文字列へそろえる場面でもjson.dumpsを使いました。日本語をそのまま保存するensure_ascii=Falseのように、引数の指定だけで出力の見え方も調整できます。

サンプル作成から検索までの各場面でjsonモジュールに任せている処理は次のとおりです

  • json.loadで検索対象のファイルを読み込む
  • json.dumpでサンプルJSONを書き出す
  • indent=2で階層を見やすく整える
  • ensure_ascii=Falseで日本語をそのまま保存する
  • json.dumpsで数値やリストを検索用の文字列に変える
  • isinstanceでリストと辞書の形を見分ける
さらに詳しく学べるPython研修の詳細はこちら

Python・prompt_toolkitで開発する場合の環境構築

この記事のセットアップ手順と掲載コードは、Windows 11 Pro、PowerShell 5.1、Python 3.13.3、prompt_toolkit 3.0.53で動作確認しています。仮想環境を有効化せず、その中のPythonを直接指定するため、以下のコマンドはPowerShellとコマンドプロンプト(cmd)の両方で使えます。

python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install prompt_toolkit

macOS・Linuxでは仮想環境内のPythonパスが異なります。今回の動作確認環境とは異なるため、以下は環境差分を補う参考手順です。

python3 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install prompt_toolkit
  • PyPIではprompt_toolkitとprompt-toolkitのどちらの表記でも導入できますが、コードでimportするモジュール名はprompt_toolkitです。
  • interactiveサブコマンドは端末で実行してください。Tab補完と上下キーの履歴は端末の入出力を前提にしています。
  • 履歴ファイル.json_search_historyは検索対象JSONと同じディレクトリに作られます。置き場所は--historyで変更できます。
さらに詳しく学べるPython研修の詳細はこちら

対話型JSON検索CLIの要件定義

目的は、prompt_toolkitとjsonで対話型のJSON検索ツールを作り、サンプルJSONに対する検索語の部分一致で一致件数と一致フィールドを確認できるようにすることです。

対象者として、Pythonの基本文法を終えて、標準ライブラリのjsonとprompt_toolkitでターミナル向けの対話型ツールを組み立てる流れを学びたい人を想定しています。

完成物は、prompt_toolkitとjsonを使い、initでサンプルを用意し、searchとinteractiveで一致レコード・件数・フィールド名を確認できる対話型JSON検索CLIです。

実装へ入る前に、機能・品質・受け入れ条件を分けて確認します。

機能要件

  • initサブコマンドでサンプルJSONを書き出す
  • interactiveサブコマンドで検索語を繰り返し受け取る
  • Tabキーでフィールド名と値から候補を補完する
  • 上下キーで履歴ファイルの入力履歴を呼び出す
  • 書き出し前のファイル状態を作成前として表示する
  • 作成後のレコード件数とフィールド名を表示する
  • searchサブコマンドで引数の検索語を1回だけ検索する
  • 一致したレコードをJSON1行で通し番号付きに表示する
  • 検索語を含む値のフィールド名を表示する
  • 一致件数と全件数を並べて表示する
  • exitまたはquitの入力で対話モードを終了する
  • 一致0件のときは専用メッセージを表示する

非機能要件

  • 外部ライブラリはprompt_toolkitのみ利用
  • JSONとCLIは標準ライブラリのjsonとargparseで構成
  • JSONの読み書きをUTF-8に統一
  • ensure_ascii=Falseで日本語をそのまま保存
  • 配列、配列を含む辞書、単一の辞書を受け付ける
  • 配列でも辞書でもないトップレベル値はValueErrorを送出
  • サンプルJSONの書き出し先の親ディレクトリを作成する
  • 補完候補を重複排除して500語までに制限
  • 検索語と値を小文字化して大文字小文字を無視
  • Ctrl-DとCtrl-Cを捕捉して終了メッセージを表示
  • 履歴ファイルの既定位置は対象JSONと同じディレクトリ
  • initでは指定先の既存ファイルを上書きする

実装方針

今回はprompt_toolkitとjsonの基本動作を追いやすくするため、対話型JSON検索CLI本体を1つのPythonファイルへまとめます。

入力、判定、結果表示の役割を分け、実行結果を確認しながら機能を積み上げます。

対話型JSON検索CLIを安全に組み立てるための実装方針は次のとおりです。

  • 外部ライブラリはprompt_toolkitのみ利用
  • JSONとCLIは標準ライブラリのjsonとargparseで構成
  • JSONの読み書きをUTF-8に統一
  • ensure_ascii=Falseで日本語をそのまま保存
  • 配列、配列を含む辞書、単一の辞書を受け付ける
  • 配列でも辞書でもないトップレベル値はValueErrorを送出
  • サンプルJSONの書き出し先の親ディレクトリを作成する
  • 補完候補を重複排除して500語までに制限
  • 検索語と値を小文字化して大文字小文字を無視
  • Ctrl-DとCtrl-Cを捕捉して終了メッセージを表示
  • 履歴ファイルの既定位置は対象JSONと同じディレクトリ
  • initでは指定先の既存ファイルを上書きする

完成と判断する条件

  1. initの実行後にレコード10件と表示される
  2. 書き出したJSONにid・name・category・price・tagsが並ぶ
  3. searchでコーヒーを検索すると一致3件になる
  4. 一致フィールドにnameとtagsが表示される
  5. 12800で検索するとpriceが一致フィールドになる
  6. interactiveの起動時に対象ファイルと全件数が表示される
  7. exitの入力で対話モードが終了する
  8. Tab補完と履歴から呼び出した同じ検索語で同じ結果が表示される
さらに詳しく学べるPython研修の詳細はこちら

対話型JSON検索CLIを作る際の重要ポイント

このツールの中心は、レコードの配列と検索語を受け取って一致した組を返すsearch_recordsです。引数指定のsearchと対話モードのinteractiveは、入力の受け取り方こそ違うものの、最後に呼ぶ関数は同じでした。判定はmatched_fieldsが担当し、値を文字列にそろえてから小文字同士で部分一致を確かめます。

表示もprint_resultsへ切り出したため、検索語と件数、一致フィールドの並びはどちらのコマンドでも同じ形で読めました。

検索処理とコマンド構成で押さえておきたい実装上の要点は次のとおりです

  • matched_fieldsが一致したフィールド名を集める
  • search_recordsがレコードと一致フィールドの組を返す
  • print_resultsが検索語と件数と明細を出力する
  • add_subparsersでinit・search・interactiveを登録する
  • set_defaultsで各コマンドの処理関数を紐づける
  • mainがargs.handlerを呼び出して処理を振り分ける
  • searchとinteractiveが同じsearch_recordsを使う

search_recordsへ一致判定を集約する設計

search_recordsは、レコードの配列と検索語を受け取り、一致したレコードと一致フィールドの組をリストで返します。判定そのものはmatched_fieldsに任せ、空でないときだけ結果へ追加する短い関数でした。

この関数をsearchとinteractiveの両方から呼ぶため、条件の追加や修正は1か所を直せば両方へ反映されます。入力の受け取りと検索処理を分けておくと、後から機能を足すときにも迷いにくくなりました。

検索処理を1本にまとめたことで得られる動きは次のとおりです

  • searchとinteractiveで同じ一致結果になる
  • 一致フィールド名を結果と一緒に受け取れる
  • 大文字小文字を無視した部分一致で比較する
  • 辞書以外のレコードはvalueという名前で扱う

argparseによるサブコマンドの登録手順

コマンドの受け口はargparseで組み立てました。add_subparsersへdestとrequired=Trueを渡しているため、サブコマンドを書き忘れた実行はその場でエラーになります。

各サブパーサにはset_defaultsでhandlerを登録し、mainではargs.handler(args)を呼ぶだけにしました。コマンドが増えても、パーサの登録と処理関数を1組足すだけで対応できます。

3つのサブコマンドで受け付ける引数は次のとおりです

  • initは--pathが任意で既定はsample_products.json
  • searchは--pathと--queryが必須
  • interactiveは--pathが必須で--historyが任意
  • set_defaultsで各コマンドの処理関数を紐づける

print_resultsが出力する検索結果の読み方

print_resultsは、検索語、一致件数と全件数、一致した各レコードの順に出力します。表示を1つの関数へ切り出したので、引数指定でも対話モードでも同じ並びで結果を読めました。

各レコードはjson.dumpsで1行にまとめ、その下へ一致フィールド名を並べています。どの項目が検索語を含んでいたのかが分かるため、絞り込みの手応えを見ながら検索語を変えられます。

print_resultsが出力する行の並びは次のとおりです

  • 1行目に検索語をそのまま表示
  • 2行目に一致件数と全件数を表示
  • 一致レコードを角括弧付きの通し番号で表示
  • 各レコードの下に一致フィールド名をカンマ区切りで表示
  • 一致がない場合は検索語・件数に続いてメッセージを表示する
さらに詳しく学べるPython研修の詳細はこちら

json_search_tui.pyの3サブコマンド設計

公開する入口はargparseのサブコマンドで、initsearchinteractiveの3つに絞りました。入口が少ないほど--helpを読むだけで使い方が伝わります。

initはサンプルJSONを書き出す担当です。書き出す前と後の状態を出力するので、新規作成なのか上書きなのかを目で追えます。

search--queryで受け取った検索語をそのまま共通関数へ渡します。対話モードに入らず1回だけ調べたいときや、実行例を記録に残したいときに便利でした。

このセクションの用語

argparse
コマンドラインの引数やオプションを解析する標準ライブラリです。--helpの表示も自動で用意されます。
サブコマンド
1つのコマンドの後ろに続けて機能を選ぶ仕組みです。initsearchがこれに当たります。
サブコマンド 役割 主なオプション
init サンプルJSONを指定パスへ書き出し、作成前後の状態を表示する --path
search 引数で渡した検索語で一致レコードを表示する --path / --query
interactive 補完と履歴付きのプロンプトで検索語を繰り返し入力する --path

Python研修一覧はこちら

目的に合うPython研修を一覧形式から探したい方は、ぜひご利用ください。

Python研修を比較する

Java研修一覧はこちら

目的に合うJava研修を一覧形式から探したい方は、ぜひご利用ください。

Java研修を比較する

PHP研修一覧はこちら

目的に合うPHP研修を一覧形式から探したい方は、ぜひご利用ください。

PHP研修を比較する

新入社員研修

目的に合う新入社員研修を一覧形式から探したい方は、ぜひご利用ください。

新入社員研修を比較する

全ての研修からも探したい方はこちら

Pythonで対話型JSON検索CLIの完成コード

ここからはjson_search_tui.pyの中身を、押さえておきたい部分だけ抜き出して解説します。ファイルは定数、JSONの読み込み、サンプル作成、検索、コマンド定義の順に並んでいます。

検索の中心はsearch_recordsです。レコード1件ずつをjson.dumpsで文字列化し、検索語が含まれるかどうかだけで一致を判定しました。

読み込み側のload_recordsは、入力のゆらぎを吸収する担当になります。配列でも辞書でも受け取り、最後はレコードのリストにそろえて返します。

このセクションの用語

json.dumps
PythonのオブジェクトをJSON形式の文字列へ変換する関数です。今回はレコードを検索用の文字列にするために使います。
isinstance
値が指定した型かどうかを判定する組み込み関数です。リストか辞書かの場合分けに使えます。
f文字列
文字列の前にfを付け、{}の中に変数や式を埋め込める書き方です。
searchとinteractiveが共有する検索処理 load_recordsでJSONを読むsearch_recordsでレコードを順に調べるmatched_fieldsで各フィールドの値を確認するto_textで検索用の文字列にそろえる小文字にそろえて部分一致で絞る一致レコードとフィールド名を返す
searchとinteractiveが共有する検索処理
"""prompt_toolkitとjsonで作る対話型JSON検索ツール。"""

import argparse
import json
import os

from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory


# サンプルJSONの定義
SAMPLE_RECORDS = [
    {"id": 1, "name": "深煎りコーヒー豆 200g", "category": "食品",
     "price": 1280, "tags": ["コーヒー", "焙煎"]},
    {"id": 2, "name": "ドリップコーヒーメーカー", "category": "家電",
     "price": 8800, "tags": ["コーヒー", "キッチン"]},
    {"id": 3, "name": "静音メカニカルキーボード", "category": "PC周辺機器",
     "price": 12800, "tags": ["キーボード", "静音"]},
    {"id": 4, "name": "ワイヤレスキーボード 60%", "category": "PC周辺機器",
     "price": 6980, "tags": ["キーボード", "無線"]},
    {"id": 5, "name": "ゲルインクボールペン 5本組", "category": "文房具",
     "price": 550, "tags": ["筆記具"]},
    {"id": 6, "name": "A5リングノート 3冊組", "category": "文房具",
     "price": 720, "tags": ["ノート"]},
    {"id": 7, "name": "アイスコーヒー 無糖 1L", "category": "食品",
     "price": 380, "tags": ["コーヒー", "飲料"]},
    {"id": 8, "name": "USB-Cハブ 7ポート", "category": "PC周辺機器",
     "price": 4980, "tags": ["USB", "拡張"]},
    {"id": 9, "name": "デスクマット ブラック", "category": "PC周辺機器",
     "price": 2480, "tags": ["デスク"]},
    {"id": 10, "name": "蛍光マーカー 6色セット", "category": "文房具",
     "price": 640, "tags": ["筆記具", "マーカー"]},
]

EXIT_WORDS = ("exit", "quit")
HISTORY_FILENAME = ".json_search_history"
MAX_COMPLETION_WORDS = 500


# JSONの読み込み
def load_records(path):
    """JSONファイルを読み込み、レコード(辞書)のリストにそろえる。"""
    with open(path, encoding="utf-8") as json_file:
        data = json.load(json_file)

    if isinstance(data, list):
        return data
    if isinstance(data, dict):
        for key in ("records", "items", "data"):
            value = data.get(key)
            if isinstance(value, list):
                return value
        return [data]
    raise ValueError("レコードの配列を含むJSONファイルを指定してください")


def collect_fields(records):
    """レコード全体に登場するフィールド名を出現順で集める。"""
    fields = []
    for record in records:
        if not isinstance(record, dict):
            continue
        for key in record:
            if key not in fields:
                fields.append(key)
    return fields


# サンプルJSONの作成
def describe_existing(path):
    """書き出し前のファイル状態を説明する文字列を返す。"""
    if not os.path.exists(path):
        return "ファイルなし"
    try:
        return f"既存ファイルあり(レコード{len(load_records(path))}件)"
    except (ValueError, OSError):
        return "既存ファイルあり(JSONとして読み込めません)"


def create_sample(path):
    """サンプルJSONを指定パスへ書き出す。"""
    directory = os.path.dirname(os.path.abspath(path))
    os.makedirs(directory, exist_ok=True)
    with open(path, "w", encoding="utf-8") as json_file:
        json.dump(SAMPLE_RECORDS, json_file, ensure_ascii=False, indent=2)
        json_file.write("\n")
    return path


# 検索処理(対話モードと引数指定モードの共通部分)
def to_text(value):
    """検索用に値を文字列化する。数値やリストもJSON文字列にする。"""
    if isinstance(value, str):
        return value
    return json.dumps(value, ensure_ascii=False)


def matched_fields(record, query):
    """レコードのうち検索語を含むフィールド名を返す。"""
    needle = query.lower()
    if not isinstance(record, dict):
        return ["value"] if needle in to_text(record).lower() else []
    return [key for key, value in record.items()
            if needle in to_text(value).lower()]


def search_records(records, query):
    """検索語に部分一致したレコードと一致フィールドの組を返す。"""
    results = []
    for record in records:
        fields = matched_fields(record, query)
        if fields:
            results.append((record, fields))
    return results


# 検索結果の表示
def print_results(query, records, results):
    """検索語・一致件数・一致レコードをターミナルへ出力する。"""
    print(f"検索語: {query}")
    print(f"一致: {len(results)}件 / 全{len(records)}件")
    if not results:
        print("  一致するレコードはありません")
        return
    for index, (record, fields) in enumerate(results, start=1):
        print(f"[{index}] {json.dumps(record, ensure_ascii=False)}")
        print(f"    一致フィールド: {', '.join(fields)}")


# 補完候補の作成
def build_completer(records):
    """フィールド名と文字列の値から入力補完の候補を組み立てる。"""
    words = list(EXIT_WORDS)
    for record in records:
        if not isinstance(record, dict):
            continue
        for key, value in record.items():
            words.append(str(key))
            if isinstance(value, str):
                words.append(value)
            elif isinstance(value, list):
                words.extend(str(item) for item in value
                             if isinstance(item, (str, int, float)))
    candidates = sorted(set(words))[:MAX_COMPLETION_WORDS]
    return WordCompleter(candidates, ignore_case=True, sentence=True,
                         match_middle=True)


# 対話モード(prompt_toolkitのPromptSession)
def run_interactive(path, history_path=None):
    """検索語を繰り返し受け取り、一致レコードを表示し続ける。"""
    records = load_records(path)
    if history_path is None:
        base_dir = os.path.dirname(os.path.abspath(path))
        history_path = os.path.join(base_dir, HISTORY_FILENAME)

    session = PromptSession(
        history=FileHistory(history_path),
        completer=build_completer(records),
        complete_while_typing=True,
    )

    print("=== 対話型JSON検索 ===")
    print(f"対象ファイル: {path}(全{len(records)}件)")
    print(f"フィールド: {', '.join(collect_fields(records))}")
    print(f"履歴ファイル: {history_path}")
    print("Tabで補完、上下キーで履歴、exitまたはCtrl-Dで終了します")

    while True:
        try:
            raw_input_text = session.prompt("検索> ")
        except (EOFError, KeyboardInterrupt):
            print("終了します")
            break

        query = raw_input_text.strip()
        if not query:
            continue
        if query.lower() in EXIT_WORDS:
            print("終了します")
            break

        print_results(query, records, search_records(records, query))


# コマンドごとの処理
def command_init(args):
    """サンプルJSONを作成し、作成前後の状態を表示する。"""
    print("=== サンプルJSONの作成 ===")
    print(f"作成前: {describe_existing(args.path)}")
    path = create_sample(args.path)
    records = load_records(path)
    print(f"書き出し: {path}")
    print(f"作成後: レコード{len(records)}件")
    print(f"フィールド: {', '.join(collect_fields(records))}")
    print("先頭2件:")
    for record in records[:2]:
        print(f"  {json.dumps(record, ensure_ascii=False)}")


def command_search(args):
    """引数で受け取った検索語で1回だけ検索する。"""
    records = load_records(args.path)
    print("=== 引数指定の検索 ===")
    print(f"対象ファイル: {args.path}")
    print_results(args.query, records, search_records(records, args.query))


def command_interactive(args):
    """対話モードを起動する。"""
    run_interactive(args.path, args.history)


# CLIの組み立て
def build_parser():
    """サブコマンド付きの引数パーサを作る。"""
    parser = argparse.ArgumentParser(
        description="JSONファイルを検索する対話型ツール")
    subparsers = parser.add_subparsers(dest="command", required=True)

    init_parser = subparsers.add_parser(
        "init", help="動作確認用のサンプルJSONを作成する")
    init_parser.add_argument("--path", default="sample_products.json",
                             help="書き出し先のJSONファイル")
    init_parser.set_defaults(handler=command_init)

    search_parser = subparsers.add_parser(
        "search", help="引数で指定した検索語で1回だけ検索する")
    search_parser.add_argument("--path", required=True,
                               help="検索対象のJSONファイル")
    search_parser.add_argument("--query", required=True, help="検索語")
    search_parser.set_defaults(handler=command_search)

    interactive_parser = subparsers.add_parser(
        "interactive", help="補完と履歴付きで対話検索する")
    interactive_parser.add_argument("--path", required=True,
                                    help="検索対象のJSONファイル")
    interactive_parser.add_argument("--history", default=None,
                                    help="履歴ファイルのパス")
    interactive_parser.set_defaults(handler=command_interactive)

    return parser


def main():
    """コマンドラインを解析し、対応する処理を実行する。"""
    args = build_parser().parse_args()
    args.handler(args)


if __name__ == "__main__":
    main()

コード全文は上の折り畳みに入れてあるので、全部を上から読む必要はありません。ここでは特に重要な部分だけを抜き出して、何をしているのか順番に見ていきます。

prompt_toolkitの3つのimport

from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory

対話プロンプト本体、単語補完、履歴保存の3部品だけを取り込みます。この3つがそろえば、補完と履歴が効く入力欄を数行で組み立てられます。

SAMPLE_RECORDSで検索対象を用意

SAMPLE_RECORDS = [
    {"id": 1, "name": "深煎りコーヒー豆 200g", "category": "食品",
     "price": 1280, "tags": ["コーヒー", "焙煎"]},

idname、価格、タグの配列を持つ辞書を並べた定数です。文字列と数値と配列が混ざっているので、型を問わない検索の練習台になります。

EXIT_WORDSと補完語数の定数

EXIT_WORDS = ("exit", "quit")
HISTORY_FILENAME = ".json_search_history"
MAX_COMPLETION_WORDS = 500

終了語、履歴ファイル名、補完候補の上限をファイル先頭にまとめました。数値や文字列をコードの奥に埋め込まないので、変更が1か所で済みます。

load_recordsでJSONを配列にそろえる

    with open(path, encoding="utf-8") as json_file:
        data = json.load(json_file)

    if isinstance(data, list):
        return data

encoding="utf-8"を明示して開き、json.loadでPythonのリストや辞書へ変換します。トップレベルが配列ならそのまま返すだけで済みます。

recordsキーを探すフォールバック

    if isinstance(data, dict):
        for key in ("records", "items", "data"):
            value = data.get(key)
            if isinstance(value, list):
                return value
        return [data]

辞書で来た場合はrecordsitemsdataの順に配列を探します。見つからなければ辞書1件だけのリストとして扱い、それ以外の型は例外で知らせる作りです。

collect_fieldsで表示用のフィールド名を集める

    fields = []
    for record in records:
        if not isinstance(record, dict):
            continue
        for key in record:
            if key not in fields:
                fields.append(key)
    return fields

全レコードのキーを、重複を除いて出現順で集めます。initの作成結果やinteractiveの開始時に、検索対象のフィールド一覧を表示するために使います。

describe_existingで作成前後を見せる

    if not os.path.exists(path):
        return "ファイルなし"
    try:
        return f"既存ファイルあり(レコード{len(load_records(path))}件)"
    except (ValueError, OSError):
        return "既存ファイルあり(JSONとして読み込めません)"

initが状態変化を出力するための説明文を返す小さな関数です。読めないJSONが置かれていても落ちないように、例外をまとめて捕まえています。


Python基礎・実践(Django)

企業・法人向けのPython研修では、基礎から応用まで体系的に学べます。

Python研修の詳細

DX社員研修

企業・法人向けのDX研修では、実務に繋がるリスキリングでITレベルを向上させます。

DX研修の詳細

Javaエンジニア育成研修

企業・法人向けのJavaエンジニア育成研修では、Javaの基礎から応用まで確実に習得できます。

Java研修の詳細

新卒・新入社員向け研修

企業・法人に新入社員・新卒社員に向けたプログラミング研修を提供しています。

新入社員研修の詳細

コードキャンプのIT研修を全て見る

Pythonで対話型JSON検索CLIのエラー対処

ここからは一般に遭遇しやすい実行エラーを整理します。今回の4コマンドはすべて正常終了しましたが、パスやJSONの形が変われば同じコードでも例外が飛びます。

多いのは環境の取り違えと、ファイルパスの取り違えです。導入状況はpython -m pip listで確かめ、パスは絶対パスで渡すと切り分けが早くなります。

このセクションの用語

例外
実行中に問題が起きたときに発生する通知です。処理を止めてエラーの内容を伝えます。
トレースバック
例外が起きるまでの呼び出し経路を並べた出力です。原因の行を探す手掛かりになります。
エラー例 原因 対処
ModuleNotFoundError: No module named 'prompt_toolkit' 実行中のPython環境にprompt_toolkitが入っていない、または別環境で導入した 使用中の環境で導入し直し、python -m pip listで入っているか確認する
FileNotFoundError: [Errno 2] No such file or directory: 'sample_products.json' initを実行していない、または--pathの場所が違う 先にinitでサンプルを作り、--pathへ実際の保存先を指定する
json.decoder.JSONDecodeError: Expecting value JSONが壊れている(末尾のカンマ、シングルクォート、空ファイルなど) エディタでJSONを整形し、json.loadが通る形に直してから読み込ませる
ValueError: レコードの配列を含むJSONファイルを指定してください トップレベルが文字列・数値・真偽値・nullのいずれか 配列またはオブジェクトをJSONのトップレベルに置く
PermissionError: [Errno 13] Permission denied 書き込み権限のない場所へサンプルや履歴ファイルを出そうとした 書き込めるフォルダを選び、サンプルは--path、履歴は--historyで保存先を指定する

対話型JSON検索CLIで注意したい点

作っている途中で引っかかりやすいのは、JSONの形と履歴ファイルの置き場所です。トップレベルが配列でない場合、load_recordsrecordsitemsdataのキーを順に探しに行く仕組みです。

履歴はFileHistoryがファイルへ保存します。書き込み権限のある保存先が必要で、場所は--historyで指定できます。

対話モードの終了方法も先に知っておくと安心でした。exitquitを入力すればループが終わる作りです。ポイントとしては、JSONの構造・履歴ファイル・終了操作と補完操作の押さえどころになります。

POINT

JSON構造:配列や単一オブジェクト

履歴ファイル:書き込み権限を確認

終了操作:exitまたはquit入力

補完表示:Tabキーで候補

さらに詳しく学べるPython研修の詳細はこちら

WordCompleterとFileHistoryで補完と履歴

対話モードの使い心地を決めるのは補完と履歴です。PromptSessioncompleterhistoryを渡すだけで、Tabキーの候補表示と上下キーの履歴呼び出しが有効になります。

WordCompleterignore_casesentencematch_middleを有効にして組み立てました。大文字小文字を無視し、入力途中の文でも語の途中からでも候補が並びます。

履歴はFileHistoryにファイル名を渡す方式です。ツールを閉じても前回の検索語が残るので、語を少しずつ変えて試す作業が軽くなりました。この節で押さえるポイントは、補完と履歴の2つの引数です。

このセクションの用語

PromptSession
prompt_toolkitの入力欄本体です。補完や履歴の設定をまとめて保持し、繰り返し入力を受け付けます。
WordCompleter
あらかじめ渡した単語リストから候補を出す補完クラスです。
FileHistory
入力履歴をファイルへ保存するクラスです。次回の起動でも履歴を引き継げます。
match_middle
WordCompleterの設定項目で、単語の途中に一致する候補も表示するようにします。

参考:

©prompt_toolkit公式ドキュメント

prompt_toolkit is a library for building powerful interactive command line applications in Python.

POINT

語中一致:途中からでも検索

大小無視:大文字小文字を区別せず

履歴保存:前回の検索語を再利用

さらに詳しく学べるPython研修の詳細はこちら

対話型JSON検索CLIの動作確認

実際にjson_search_tui.pyを動かしました。実行したのはinitが1回とsearchが3回の合計4コマンドで、すべて終了コード0で完了しています。

最初にinitでサンプルJSONを/tmp/sample_products.jsonへ作成しました。作成前と作成後の状態が並んで出力されるため、何が変わったのかが読み取れます。

続いて検索語をコーヒーキーボード文房具と入れ替えてsearchを3回実行しました。同じファイルに対して--queryだけを差し替えた形です。

キャプチャに残したのはinitsearchの4件です。対話モードも同じsearch_recordsを呼ぶ設計なので、interactiveでは検索語を打ち替えながら同じ判定を繰り返せます。

このセクションの用語

終了コード
コマンドが終わるときに返す番号です。0は正常終了を表します。
絶対パス
/tmp/sample_products.jsonのように、先頭から辿れる完全なファイル位置の書き方です。

今回実行した4つのコマンドを、上から順に並べます。

  1. python json_search_tui.py init --path /tmp/sample_products.json
  2. python json_search_tui.py search --path /tmp/sample_products.json --queryコーヒー
  3. python json_search_tui.py search --path /tmp/sample_products.json --queryキーボード
  4. python json_search_tui.py search --path /tmp/sample_products.json --query文房具
python json_search_tui.py init --path /tmp/sample_products.jsonの実行結果(終了コード0)
python json_search_tui.py init --path /tmp/sample_products.jsonの実行結果(終了コード0)
python json_search_tui.py search --path /tmp/sample_products.json --queryコーヒーの実行結果(終了コード0)
python json_search_tui.py search --path /tmp/sample_products.json --queryコーヒーの実行結果(終了コード0)
python json_search_tui.py search --path /tmp/sample_products.json --queryキーボードの実行結果(終了コード0)
python json_search_tui.py search --path /tmp/sample_products.json --queryキーボードの実行結果(終了コード0)
python json_search_tui.py search --path /tmp/sample_products.json --query文房具の実行結果(終了コード0)
python json_search_tui.py search --path /tmp/sample_products.json --query文房具の実行結果(終了コード0)
さらに詳しく学べるPython研修の詳細はこちら

対話型JSON検索CLIの活用例

このツールの強みは、JSONの中身をよく知らなくても検索を始められるところです。フィールド名と文字列値が補完候補になるので、キー名を思い出しながら探せます。

同じsearch_recordsを引数指定でも呼べるため、手元での調査とスクリプトからの呼び出しを同じ挙動で使い分けられました。

このセクションの用語

マスタデータ
商品や社員のように、業務の基準となる一覧データです。
スキーマ
データがどんなキーと型で構成されるかを示す構造の決まりです。
使える場面 具体的な使い方
APIレスポンスの調査 保存したレスポンスJSONを--pathに渡し、interactiveでキー名を補完させながら値を打ち替えて該当レコードだけ表示する
設定ファイルの棚卸し 複数サービスの設定をrecordsキーの配列にまとめ、環境名やホスト名で検索して設定の抜けを目で確認する
マスタデータの確認 商品や社員のマスタを対話モードで開き、カテゴリ名を上下キーで呼び出しながら順に絞り込む
障害対応時のログ確認 JSON形式のログ配列を読み込み、エラーコードや利用者IDを検索語にして関連レコードを拾い出す
手順書やスクリプトへの組み込み searchサブコマンドを手順書に書き、誰が実行しても同じ検索語で同じ判定になる形で共有する
さらに詳しく学べるPython研修の詳細はこちら

対話型JSON検索CLI開発のまとめ

prompt_toolkitjsonの組み合わせで、対話型のJSON検索ツールを1ファイルにまとめました。検索の本体はsearch_recordsだけなので、searchinteractiveの結果がずれません。

手元ではinitsearchの合計4コマンドを実行し、いずれも終了コード0で終わりました。サンプル作成から検索語の打ち替えまで、記事の中だけで追体験できる構成です。

次の一歩としては、補完候補の絞り込みや一致箇所の見せ方の改善が候補になります。まずは自分の手元のJSONを--pathに渡し、キー名の補完が効くところから試してみてください。

さらに詳しく学べるPython研修の詳細はこちら

参考にした一次情報

  1. ^ prompt_toolkit: Asking for input (prompts). https://python-prompt-toolkit.readthedocs.io/en/master/pages/asking_for_input.html, (参照26-09-05).
  2. ^ prompt_toolkit: Reference(Completion / History). https://python-prompt-toolkit.readthedocs.io/en/master/pages/reference.html, (参照26-09-05).
  3. ^ Python標準ライブラリjson — JSONエンコーダおよびデコーダ. https://docs.python.org/ja/3/library/json.html, (参照26-09-05).
  4. ^ Python標準ライブラリargparse — コマンドラインオプション、引数、サブコマンドのパーサー. https://docs.python.org/ja/3/library/argparse.html, (参照26-09-05).

※内容は執筆時点のものです。ライブラリやサイトの仕様は変わる可能性があるため、公式ドキュメントもあわせてご確認ください。

ブログに戻る

コメントを残す

コメントは公開前に承認される必要があることにご注意ください。

企業・法人向けのIT・プログラミング・生成AI研修を探す、比較する - IT・プログラミングを知って学べるコネクトメディア CodeCampが提供するDX人材育成が可能なプログラミングやITが学べる公開講座 - IT・プログラミングを知って学べるコネクトメディア コードキャンプが提供する無料で学べるプログラミングスクール講座 - IT・プログラミングを知って学べるコネクトメディア コードキャンプDX人材育成研修 - IT・プログラミングを知って学べるコネクトメディア 3.5日の研修で、年間1,600時間の削減効果が見込まれる。東京きらぼしフィナンシャルグループのDX人材育成事例 - IT・プログラミングを知って学べるコネクトメディア 配属3ヶ月で30%の生産性向上を実現するいよぎんコンピュータサービスの新人研修に迫る - IT・プログラミングを知って学べるコネクトメディア 金融業界の業務効率化を加速するニッセイアセットマネジメントの生成AI×GAS活用研修事例 - IT・プログラミングを知って学べるコネクトメディア 【製造業のDX人材育成事例】デジタル人材の即戦力化を実現する、日本ガイシ株式会社の異動者向オンボーディング研修 - ITやプログラミングを知って学べるコネクトメディア フューチャーアーキテクト株式会社が実現した新入社員向けIT研修プログラムでタスクフォース制度が主体的な学びと成長を生み出す - IT・プログラミングを知って学べるコネクトメディア コードキャンプDX人材育成研修 - IT・プログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/【IT新入社員研修】オンラインとオフラインの最適バランスを実現したFutureOneの導入事例 - IT・プログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/【新入社員研修】柔軟なハイブリッド型Java研修で実現した新卒20名の成長と成果|サークレイス株式会社 - ITやプログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/現場により近いところにデジタルを根付かせるDX基礎講座研修|株式会社ブリヂストン - ITやプログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/業務の効率化・DX推進に向けたIT人材育成への第一歩|株式会社カナエ - ITやプログラミングを知って学べるコネクトメディア 企業・法人向けのIT・プログラミング研修 - ITやプログラミングを知って学べるコネクトメディア

What's New

新着記事

対象者別で探す

子供(小学生・中学生・高校生)向け
プログラミング教室検索する

子供(小学生・中学生・高校生)がロボットやプログラミング言語を学ぶことができるオフラインからオンラインスクールを検索、比較することが可能です。

子供(小学生・中学生・高校生)
プログラミング教室検索する

ITやプログラムなどの
最新情報を検索する

日々、新しいITやプログラミング言語の情報が流れていきますが、特定の情報を時系列でニュースやコラムを確認することができます。

ITやプログラムなどの
最新情報を検索する