【Python】TextualとSQLiteで在庫を検索・更新できる在庫管理TUIアプリを作ってみた
公開:
PythonのTextualとSQLiteで、ターミナル上で在庫を検索・更新できる在庫管理TUIアプリを作るを題材に、実際にPythonコードを作って検証しました。実行結果は「CLIツールを5回のコマンドで実行し、全て正常(終了コード0)に動作した」です。
Textualの基本概念、要件定義、実装、動作確認までを順番に学べる構成です。動画は目次から確認したい場面へ移動でき、本文だけでも手順と考え方が完結します。
動画の内容をテキストで確認する
オープニング。TextualとSQLiteを使って在庫管理TUIを作るカリキュラムを始めます。概要紹介。
TextualとSQLiteの役割と使い方を学ぶ 在庫管理TUIの要件を整理する 完成コードと実行結果を確認する 最後に実コマンドとファイル状態で完成挙動を確かめる 具体的にやること。
initサブコマンドでサンプル在庫10件を投入するsearchサブコマンドでSKU・商品名・カテゴリを部分一致検索する --queryを省略した場合はinputで検索語を受け取るupdateサブコマンドで指定SKUの在庫数を書き換える 更新前後の在庫数を並べたメッセージを表示する 実装環境・必須アプリ。
OS:Windows 11 Pro Python:3.13.3シェル:PowerShell 5.1必須アプリ:コードエディター、ターミナル、エクスプローラー パッケージ:pip、textual PythonのTUIフレームワーク『Textual』とSQLiteとは。
Textual:Textualは、ターミナルの中にボタンや表を並べた画面を組み立てられるPython製のTUIフレームワークSQLite:SQLiteは、1つのファイルにテーブルを保存できる組み込み型のデータベースエンジン TextualとSQLiteで作る在庫管理TUIの要点。
検索と更新はTUIとCLIで同じ関数を共有するrun_search_cliが結果行と件数メッセージを組で返すrun_update_cliが更新後に同じSKUで再検索する 在庫管理TUIの要件定義。
initで在庫テーブルを初期化しました: 10件と表示されるsearchで検索語 文房具 の在庫: 4件が出力されるupdateでSTA-1002の在庫数が8から30へ変わるlistで登録済みの在庫: 10件が表示される 未登録SKUの更新で登録がない旨のメッセージが出る5回のコマンド実行がすべて終了コード0で完了する INTRO: Monaco Editorで在庫管理TUIを実装。
コードを1行ずつ入力し、補完と自動インデントを使いながら実行結果を確認します。LINE 001: モジュールDocstring冒頭。このモジュール全体の役割を一文で説明するdocstringの先頭行です。
TextualとSQLiteを組み合わせた在庫管理TUIアプリであることを示しています。LINE 003: アプリの主な用途。ターミナル上で在庫を検索し、在庫数を更新するというアプリの主要な機能をdocstringで説明しています。
LINE 004: textual非依存の設計方針(前半)。textualパッケージが使えない環境でも動作するよう設計されていることを説明する行です。init・search・update・listの各サブコマンドが対象であることを示しています。
LINE 005: textual非依存の設計方針(後半)。各サブコマンドがSQLiteへの直接アクセスだけで処理を完結できる設計になっていることを補足説明しています。LINE 006: docstring終端。
モジュール冒頭のdocstringを閉じる行です。ここまでがアプリ全体の説明文になります。LINE 008: 将来の型注釈構文を有効化。
将来のPythonで標準となる型注釈の書き方を先取りして使えるようにするimportです。list[dict]のような記法をこのファイルで使うために必要です。LINE 010: コマンドライン引数解析の準備。
コマンドライン引数を扱うためのargparseモジュールを読み込んでいます。init・search・update・listなどのサブコマンドを定義する際に使用します。LINE 011: SQLite操作の準備。
SQLiteデータベースを操作するための標準ライブラリsqlite3を読み込んでいます。在庫データの保存や検索に利用します。LINE 012: 文字幅計算の準備。
全角・半角文字の幅を判定するunicodedataモジュールを読み込んでいます。日本語を含む一覧表示の桁揃えに使用します。LINE 013: パス操作の準備。
ファイルパスを扱いやすくするPathクラスを読み込んでいます。DBファイルの存在確認や作成に利用します。LINE 017: textual読み込みの試行開始。
textualパッケージの読み込みを試みるtryブロックの開始行です。導入されていない環境でもエラーで停止しないようにしています。LINE 018: Textualアプリの基底クラス読み込み。
TUIアプリの土台となるAppクラスと画面構成を定義するComposeResultを読み込んでいます。LINE 019: レイアウト用コンテナの読み込み。部品を横並び・縦並びに配置するためのHorizontalとVerticalコンテナを読み込んでいます。
LINE 020: 画面部品(ウィジェット)の読み込み。ボタンや入力欄、テーブルなど画面に表示する各種ウィジェットをまとめて読み込んでいます。LINE 022: textual利用可能フラグをTrueに設定。
textualの読み込みに成功したことを示すフラグをTrueに設定しています。この値を見てtuiサブコマンドが使えるかどうかを判定します。LINE 023: モジュール未導入時の例外捕捉。
textualパッケージが存在しない場合に発生するModuleNotFoundErrorを捕まえる行です。LINE 024: textual利用可能フラグをFalseに設定。textualが読み込めなかったことを示すフラグをFalseに設定しています。
この場合はtuiサブコマンド以外の機能で動作を続けます。LINE 029: サンプル在庫データの開始。初期投入用のサンプル在庫データを保持するリストSEED_ITEMSの定義を開始しています。
LINE 030: 文房具データ(黒ボールペン)。SKU・商品名・カテゴリ・在庫数からなる1件目のサンプルデータで、黒の油性ボールペンを24本登録する内容です。LINE 031: 文房具データ(赤ボールペン)。
赤の油性ボールペンを8本として登録する2件目のサンプルデータです。LINE 032: 文房具データ(蛍光マーカー)。黄色の蛍光マーカーを15本として登録する3件目のサンプルデータです。
LINE 033: 文房具データ(水性ボールペン)。青の水性ボールペンを11本として登録する4件目のサンプルデータです。LINE 034: 用紙データ(コピー用紙)。
A4サイズのコピー用紙500枚入りを40セット登録する5件目のサンプルデータです。LINE 035: 用紙データ(付箋)。75mmサイズの付箋5冊パックを12個登録する6件目のサンプルデータです。
LINE 036: 電材データ(乾電池)。単三アルカリ乾電池20本入りを6個登録する7件目のサンプルデータです。LINE 037: 電材データ(USBケーブル)。
1m長のUSB-Cケーブルを18本登録する8件目のサンプルデータです。LINE 038: 事務用品データ(クリアファイル)。A4サイズのクリアファイル100枚入りを33個登録する9件目のサンプルデータです。
LINE 039: 事務用品データ(デスクマット)。700x500サイズのデスクマットを4枚登録する10件目のサンプルデータです。LINE 040: サンプル在庫データの終了。
SEED_ITEMSリストの定義を閉じる行です。ここまでの10件が初期化時にDBへ投入されます。RUN 1/8: サンプル在庫データの定義を確認する。
SEED_ITEMSへSKU、商品名、カテゴリ、在庫数の4項目を持つ在庫が並んだ状態です。件数と先頭の中身を表示し、タプルの並び順が意図どおりか確かめます。CHECK 1/8: 途中実行に成功。
SEED_ITEMSへSKU、商品名、カテゴリ、在庫数の4項目を持つ在庫が並んだ状態です。件数と先頭の中身を表示し、タプルの並び順が意図どおりか確かめます。RETURN 01: エディターへ戻りました。
入力済みのコードを維持したまま、次の実装へ進みます。LINE 042: テーブル作成SQLの開始。itemsテーブルを作成するSQL文を複数行文字列として定義し始めています。
LINE 043: テーブル存在確認付き作成文。itemsという名前のテーブルが存在しない場合にのみ作成するCREATE TABLE文の本体です。LINE 044: SKU列の定義。
商品を一意に識別するsku列を主キーとして定義しています。LINE 045: 商品名列の定義。商品名を保存するname列を必須項目として定義しています。
LINE 046: カテゴリ列の定義。商品のカテゴリを保存するcategory列を必須項目として定義しています。LINE 047: 在庫数列の定義。
在庫数を整数で保存するstock列を必須項目として定義しています。LINE 048: テーブル定義の括弧閉じ。カラム定義部分の括弧を閉じ、CREATE TABLE文の構造を完結させています。
LINE 049: テーブル作成SQLの終了。CREATE_SQL文字列の定義を閉じる行です。この文字列はDB初期化時にそのまま実行されます。
LINE 054: DB接続関数の定義開始。指定したパスのSQLiteデータベースへ接続する関数connectを定義しています。DBファイルの用意からテーブル作成までをまとめて行います。
LINE 055: DBパスのPathオブジェクト化。文字列で渡されたDBパスをPathオブジェクトに変換し、以降の親ディレクトリ確認をしやすくしています。LINE 056: 親ディレクトリの要否確認。
DBファイルの親ディレクトリがカレント以外の場所を指しているかどうかを判定している行です。LINE 057: 保存先ディレクトリの作成。必要な親ディレクトリが存在しなければ、途中の階層も含めて自動的に作成しています。
LINE 058: SQLiteへの接続。指定したパスのSQLiteデータベースファイルへ実際に接続し、接続オブジェクトを取得しています。LINE 059: 行取得方式の設定。
取得したレコードを列名でアクセスできるようにするため、row_factoryにsqlite3.Rowを設定しています。LINE 060: トランザクションブロックの開始。with文でconnを使い、テーブル作成処理を1つのトランザクションとしてまとめています。
LINE 061: 在庫テーブルの作成実行。事前に定義したCREATE_SQLを実行し、itemsテーブルが存在しなければ作成しています。LINE 062: 接続オブジェクトの返却。
準備が整った接続オブジェクトを呼び出し元に返し、以降のクエリ実行に使えるようにしています。RUN 2/8: DBへ接続してテーブルが作られるか確認する。connect関数までを入力した段階です。
一時ディレクトリのDBファイルへ接続し、itemsテーブルが自動で作られることを確認します。CHECK 2/8: 途中実行に成功。connect関数までを入力した段階です。
一時ディレクトリのDBファイルへ接続し、itemsテーブルが自動で作られることを確認します。RETURN 02: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 065: DB初期化関数の定義開始。DBを用意しサンプル在庫を投入して全件を返す関数init_dbを定義しています。initサブコマンドから呼び出されます。
LINE 066: 関数の説明文(docstring)。この関数がDBを用意しサンプル在庫を投入して全件を返す処理であることを説明するdocstringです。LINE 067: DBパスのPathオブジェクト化。
resetオプションで既存ファイルを削除するかどうかを判定するため、DBパスをPathオブジェクトに変換しています。LINE 068: リセット条件の判定。resetフラグが立っていて、かつ対象のDBファイルが既に存在するかどうかを確認しています。
LINE 069: 既存DBファイルの削除。リセット指定がある場合に、既存のDBファイルを削除して作り直す準備をしています。LINE 070: DBへの接続。
connect関数を呼び出してDBへ接続し、テーブルが用意された状態の接続オブジェクトを取得しています。LINE 071: 投入処理のトランザクション開始。with文でconnを使い、サンプルデータの投入をひとつのトランザクションとして扱っています。
LINE 072: サンプルデータの一括挿入。executemanyを使って複数件のサンプル在庫データを一度にitemsテーブルへ挿入しています。LINE 073: 挿入SQL文の内容。
既に同じSKUが存在する場合は無視するINSERT OR IGNORE文を、挿入先の列指定とともに定義しています。LINE 074: 挿入対象データの指定。先ほど定義したSEED_ITEMSリストを挿入するデータとしてexecutemanyに渡しています。
LINE 075: 一括挿入呼び出しの括弧閉じ。executemanyの引数指定を締めくくり、一括挿入処理の呼び出しを完結させています。LINE 076: 全件取得クエリの開始。
itemsテーブルから全件を取得するSELECT文の実行を開始しています。LINE 077: 全件取得のSQL内容。sku・name・category・stockの各列をsku順に並べて取得するSQL文の内容です。
LINE 078: 取得結果の全件取り出し。fetchallを呼び出して、クエリ結果をすべてリストとして取り出しています。LINE 079: DB接続のクローズ。
一連の処理が終わったため、開いていたDB接続を明示的に閉じています。LINE 080: 辞書形式での結果返却。取得した各行を辞書に変換したリストとして呼び出し元に返し、扱いやすい形にしています。
RUN 3/8: サンプル在庫の投入結果を確認する。init_dbでサンプル在庫を投入し、返ってくる全件の件数を確認します。同じDBへ二度実行しても件数が増えない点も合わせて見ます。
CHECK 3/8: 途中実行に成功。init_dbでサンプル在庫を投入し、返ってくる全件の件数を確認します。同じDBへ二度実行しても件数が増えない点も合わせて見ます。
RETURN 03: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 085: 在庫検索関数の定義開始。
SKU・商品名・カテゴリの部分一致で在庫を検索する関数search_itemsを定義しています。LINE 086: 検索用のDB接続。connect関数を呼び出してDBへ接続し、検索クエリを実行できる状態にしています。
LINE 087: 検索語をLIKE用に整形。入力された検索語の前後の空白を取り除き、SQLのLIKE検索で使う「%文字列%」という部分一致パターンを作成しています。この変数はこのあとの検索クエリでそのまま利用されます。
LINE 088: 検索SQLの実行開始。connで取得したデータベース接続に対して、在庫テーブルからデータを取り出すSQLの実行を開始しています。次の行以降で具体的なSELECT文が続きます。
LINE 089: 取得する列の指定。itemsテーブルからSKU・商品名・カテゴリ・在庫数の4つの列を取得することを指定しています。検索結果の一覧表示に必要な情報だけを選んでいます。
LINE 090: 部分一致の検索条件。SKU・商品名・カテゴリのいずれかに検索語が部分一致する行だけを絞り込む条件を指定しています。3つのプレースホルダーに同じ検索パターンが割り当てられます。
LINE 091: 検索結果の並び順指定。検索結果をSKUの昇順で並べるよう指定しています。これにより一覧が常に見やすい順番で表示されます。
LINE 092: プレースホルダーへの値渡し。SQL文中の3つの疑問符に、先ほど作成した検索パターンをそれぞれ渡しています。同じ値を3回使うことで3列すべてに同じ検索語を適用しています。
LINE 093: 検索結果の全件取得。実行したSQLの結果を全件まとめて取得し、rows変数に格納しています。この結果は次の行以降で利用されます。
LINE 094: データベース接続を閉じる。検索処理が終わったので、開いていたデータベース接続を閉じてリソースを解放しています。LINE 095: 検索結果を辞書のリストに変換。
取得した各行を辞書形式に変換し、リストとして呼び出し元に返しています。辞書にすることで列名でアクセスしやすくなります。RUN 4/8: 検索語ごとの絞り込み件数を確認する。
search_itemsを入力し終えた状態です。カテゴリ名や商品名の一部、空文字を渡して、部分一致の結果件数が変わることを確かめます。CHECK 4/8: 途中実行に成功。
search_itemsを入力し終えた状態です。カテゴリ名や商品名の一部、空文字を渡して、部分一致の結果件数が変わることを確かめます。RETURN 04: エディターへ戻りました。
入力済みのコードを維持したまま、次の実装へ進みます。LINE 100: 在庫更新関数の定義。指定したSKUの在庫数を更新するための関数を定義しています。
DBパス・SKU・新しい在庫数を受け取り、更新結果を辞書で返します。LINE 101: データベースへ接続。connect関数を使ってデータベースへの接続を確立しています。
この接続を使って以降の検索や更新を行います。LINE 102: 更新前の在庫数を取得。指定されたSKUの現在の在庫数を取得しています。
更新前の値として、あとで結果に含めるために保持しています。LINE 103: 対象SKUの存在確認。指定したSKUが在庫テーブルに存在するかどうかを確認しています。
存在しない場合は次の行以降で早期に処理を終えます。LINE 104: 未登録時に接続を閉じる。SKUが見つからなかった場合に、これ以上処理を続ける必要がないためデータベース接続を閉じています。
LINE 105: 未登録時の結果を返す。対象のSKUが存在しなかったことを示す辞書を返しています。更新は行われず、beforeとafterはどちらもNoneになります。
LINE 106: 更新処理のトランザクション開始。withブロックによってトランザクションを開始し、この中で行うSQL実行が正常に終わればまとめてコミットされるようにしています。LINE 107: 在庫数の更新SQL実行。
指定したSKUの在庫数を新しい値に更新するUPDATE文を実行しています。入力値を整数に変換してから渡すことで型のずれを防いでいます。LINE 108: 更新後の在庫数を再取得。
更新が反映された後の在庫数を改めて取得しています。この値はあとで更新前の値と比較して表示するために使われます。LINE 109: データベース接続を閉じる。
更新前後の値の取得が終わったので、データベース接続を閉じてリソースを解放しています。LINE 110: 更新結果の辞書を返す開始。更新結果をまとめた辞書を作成し、呼び出し元へ返す処理を開始しています。
以降の行で辞書の各項目が定義されます。LINE 111: 対象SKUを結果に含める。更新対象となったSKUを結果の辞書に含めています。
呼び出し元がどの商品を更新したか分かるようにしています。LINE 112: 更新成功フラグを設定。更新が正常に行われたことを示すTrueを結果に含めています。
呼び出し元はこのフラグで成功・失敗を判定できます。LINE 113: 更新前の在庫数を結果に格納。先ほど取得しておいた更新前の在庫数を結果の辞書に格納しています。
更新前後の変化を利用者に伝えるために使われます。LINE 114: 更新後の在庫数を結果に格納。更新後に再取得した在庫数を結果の辞書に格納しています。
これにより呼び出し元は最新の在庫数を確認できます。LINE 115: 結果辞書の定義終了。更新結果を表す辞書リテラルの定義を閉じています。
この辞書がupdate_stock関数の戻り値になります。RUN 5/8: 在庫数の更新前後の値を確認する。update_stockが返す辞書を表示し、更新前後の在庫数がそろって返ることを確認します。
存在しないSKUを渡した場合の戻り値も見比べます。CHECK 5/8: 途中実行に成功。update_stockが返す辞書を表示し、更新前後の在庫数がそろって返ることを確認します。
存在しないSKUを渡した場合の戻り値も見比べます。RETURN 05: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 120: CLI用検索処理の定義。ターミナルから直接呼び出すための検索処理をまとめた関数を定義しています。TUIを使わずに検索を実行できる経路を提供します。
LINE 121: 関数の説明文。この関数がTUIの検索欄と同等の処理をCLIから直接実行し、検索結果とステータスメッセージを返すことを説明しています。LINE 122: 検索処理の呼び出し。
search_items関数を呼び出して、指定した検索語に合致する在庫データを取得しています。取得結果はrowsに格納されます。LINE 123: 表示用ラベルの作成。
検索語が空だった場合には「(すべて)」という文字列を使い、そうでなければ入力された検索語をそのまま表示用ラベルとして使います。LINE 124: 検索結果メッセージの作成。検索語と該当件数を組み合わせて、ユーザーに表示するステータスメッセージを作成しています。
LINE 125: 検索結果を辞書で返す。検索結果の行データとステータスメッセージをまとめて辞書として返しています。呼び出し元はこの辞書を使って画面に表示します。
LINE 128: CLI用更新処理の定義。ターミナルから直接呼び出すための在庫数更新処理をまとめた関数を定義しています。TUIを使わずに更新を実行できる経路を提供します。
LINE 129: 関数の説明文。この関数がTUIの更新欄と同等の処理をCLIから直接実行し、更新後の結果行とステータスメッセージを返すことを説明しています。LINE 130: 在庫更新処理の呼び出し。
update_stock関数を呼び出して、指定したSKUの在庫数を実際に更新しています。戻り値の辞書はresultに格納されます。LINE 131: 更新成功可否の判定。
更新が成功したかどうかをresult辞書のupdatedキーで判定しています。この結果によって次に表示するメッセージが変わります。LINE 132: 更新成功メッセージの作成開始。
更新が成功した場合に表示するメッセージ文字列の作成を開始しています。次の行で更新前後の値を含めた文言が組み立てられます。LINE 133: 更新前後の値を含めたメッセージ。
更新前と更新後の在庫数を矢印でつないだ分かりやすいメッセージを作成しています。ユーザーはこの表示で変化を確認できます。LINE 134: メッセージ文字列の定義終了。
更新成功時のメッセージ文字列の定義を閉じています。この文字列がmessage変数に代入されます。LINE 135: 更新失敗時の分岐。
SKUが存在せず更新に失敗した場合の処理へ分岐しています。LINE 136: 未登録SKUのメッセージ作成。指定されたSKUが登録されていないことを伝えるメッセージを作成しています。
ユーザーに入力ミスなどに気づいてもらうためのものです。LINE 137: 更新後の在庫を再検索。更新したSKUで再度検索を行い、最新の状態を含む一覧を取得しています。
これにより画面表示を最新の状態に更新できます。LINE 138: 更新結果を辞書で返す。検索結果の行データと更新結果のメッセージをまとめて辞書として返しています。
呼び出し元はこの辞書を使って画面に表示します。RUN 6/8: CLI用ヘルパーのステータス文を確認する。検索と更新のヘルパーまで入力した段階です。
画面へ出すステータス文と、更新後に再検索した行数を確認します。CHECK 6/8: 途中実行に成功。検索と更新のヘルパーまで入力した段階です。
画面へ出すステータス文と、更新後に再検索した行数を確認します。RETURN 06: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 143: textual利用可否での分岐。textualパッケージが読み込めた場合のみ、以降のTUIアプリのクラス定義を行うようにしています。未導入の環境でもエラーにならないようにする工夫です。
LINE 145: TUIアプリクラスの定義。Textualのアプリクラスを継承して、在庫管理TUIアプリ全体を表すInventoryAppクラスを定義しています。LINE 146: クラスの説明文。
このクラスが検索欄・結果テーブル・更新欄を画面上に並べた在庫管理用のTUIであることを説明しています。LINE 148: アプリタイトルの設定。TUI画面の上部に表示されるタイトル文字列を「在庫管理TUI」に設定しています。
LINE 149: サブタイトルの設定。タイトルの下に表示される補足情報として、使用している技術構成を示すサブタイトルを設定しています。LINE 151: CSSスタイルの定義開始。
TUI画面の見た目を調整するためのCSSスタイル定義を開始しています。以降の行で各要素の余白やサイズが指定されます。LINE 162: CSSスタイルの定義終了。
画面レイアウト用のCSS文字列の定義を閉じています。この文字列全体がアプリの見た目を決めるスタイルシートとして使われます。LINE 164: キー操作の割り当て。
「q」キーを押すとアプリを終了する操作に割り当てています。ユーザーはこのキー操作でいつでもTUIを終了できます。LINE 166: 初期化メソッドの定義。
InventoryAppのインスタンスを作成する際に呼ばれる初期化メソッドを定義しています。データベースのパスを受け取ります。LINE 167: 親クラスの初期化呼び出し。
Textualの基底クラスであるAppの初期化処理を呼び出しています。これによりTUIアプリとしての基本的な機能が正しく準備されます。LINE 168: DBパスの保持。
受け取ったデータベースのファイルパスをインスタンス変数として保存しています。以降の検索や更新の処理でこの値が使われます。LINE 169: 検索結果の初期化。
直近の検索結果を保持するための変数を、空のリストで初期化しています。画面表示や再検索の際に利用されます。LINE 170: ステータスメッセージの初期化。
画面下部に表示するステータスメッセージを保持する変数を、空文字で初期化しています。LINE 172: 画面構成メソッドの定義。TUI画面にどのウィジェットをどう配置するかを定義するcomposeメソッドを定義しています。
以降の行で各部品がyieldされます。LINE 173: ヘッダーの表示。画面上部にタイトルなどを表示するHeaderウィジェットを配置しています。
LINE 174: 本体エリアの開始。検索欄や結果テーブルなどをまとめて縦方向に並べるためのVerticalコンテナを開始しています。id指定によりCSSでスタイルを適用できます。
LINE 175: 検索行の開始。検索用の入力欄と検索ボタンを横並びに配置するためのHorizontalコンテナを開始しています。LINE 176: 検索用入力欄の配置。
SKU・商品名・カテゴリで検索するための入力欄を配置しています。プレースホルダー文字列でどんな値を入力すべきか案内しています。LINE 177: 検索ボタンの配置。
「検索」という文字が表示されたボタンを配置しています。クリックされると検索処理が実行される仕組みです。LINE 178: 結果テーブルの配置。
検索結果を一覧表示するためのDataTableウィジェットを配置しています。この後の処理でこのテーブルに行データが追加されます。LINE 179: 更新行の開始。
在庫数更新に使う入力欄とボタンを横並びに配置するためのHorizontalコンテナを開始しています。LINE 180: SKU入力欄の配置。更新対象のSKUを入力するための入力欄を配置しています。
プレースホルダーで何を入力すべきか示しています。LINE 181: 在庫数入力欄の配置。新しい在庫数を入力するためのInputウィジェットを配置し、idにstock-inputを設定しています。
この値はrun_updateメソッドで在庫数として読み取られます。LINE 182: 更新ボタンの配置。在庫数を更新するためのButtonウィジェットを配置し、idにbtn-updateを設定しています。
variantにsuccessを指定することで緑系の強調表示になります。LINE 183: ステータス表示欄の配置。検索結果や更新結果のメッセージを表示するためのStaticウィジェットを配置し、idにstatusを設定しています。
初期値は空文字で、set_statusメソッドから更新されます。LINE 184: フッターの表示。画面下部にキー操作のヒントなどを表示するFooterウィジェットをyieldしています。
composeメソッド全体の最後に配置することで画面全体の構成が完成します。LINE 186: 起動時処理の定義開始。アプリ起動直後に一度だけ呼び出されるon_mountメソッドを定義しています。
ここでテーブルの初期設定と初期データの表示を行います。LINE 187: 結果テーブルの取得。idがresult-tableのDataTableウィジェットをquery_oneで取得し、変数tableに代入しています。
この後の行でこのテーブルに対する設定を行います。LINE 188: 行選択モードの設定。テーブルのcursor_typeにrowを指定し、セル単位ではなく行単位でカーソル選択できるようにしています。
これによりon_data_table_row_selectedが正しく動作します。LINE 189: テーブル列の定義。add_columnsメソッドでSKU・商品名・カテゴリ・在庫数の4つの見出しをテーブルに追加しています。
この列構成に合わせて後の行データが表示されます。LINE 190: 初期データの読み込み。load_rowsメソッドを空文字で呼び出し、全件の在庫データをテーブルに表示しています。
起動直後にユーザーが最初から在庫一覧を確認できるようにしています。LINE 191: 起動時メッセージの表示。set_statusメソッドを呼び出し、検索方法を案内するメッセージをステータス欄に表示しています。
ユーザーが最初にどう操作すればよいか分かるようにしています。LINE 195: 検索結果反映メソッドの定義。クエリを受け取ってテーブルを描き直すload_rowsメソッドを定義しています。
戻り値として取得した在庫行のリストを返します。LINE 196: 検索処理の実行。search_items関数を呼び出し、指定されたqueryに一致する在庫データをデータベースから取得しています。
取得結果は変数rowsに格納されます。LINE 197: 結果テーブルの取得。idがresult-tableのDataTableウィジェットをquery_oneで取得し、変数tableに代入しています。
この後の行でテーブルの中身を書き換える準備をします。LINE 198: テーブル内容のクリア。clearメソッドを呼び出し、テーブルに表示されている既存の行をすべて削除しています。
新しい検索結果を表示する前に画面をまっさらな状態に戻します。LINE 199: 取得行に対する繰り返し処理。検索で取得したrowsの各要素を1件ずつrow変数に取り出し、テーブルへ追加する処理を繰り返しています。
LINE 200: テーブルへの行追加。add_rowメソッドを呼び出し、SKU・商品名・カテゴリ・在庫数の値をテーブルの1行として追加しています。この行の続きでkey引数の指定が行われます。
LINE 201: 行データとキーの指定。表示する各列の値と、在庫数を文字列に変換した値、さらに行選択時に使うkeyとしてSKUを指定しています。このkeyはon_data_table_row_selectedで利用されます。
LINE 202: add_row呼び出しの終端。200行目から続くadd_rowメソッド呼び出しの引数指定を閉じています。この処理により1件分の在庫データがテーブルの1行として表示されます。
LINE 203: 最新結果の保持。取得した在庫行のリストをインスタンス変数last_rowsに代入し、直近の検索結果として保持しています。他のメソッドから参照できるようにする役割があります。
LINE 204: 検索結果の返却。load_rowsメソッドの戻り値として、取得した在庫行のリストrowsを返しています。呼び出し元のrun_searchなどでこの結果を利用できます。
LINE 206: ステータス更新メソッドの定義。messageを受け取り、画面下部のステータス表示を更新するset_statusメソッドを定義しています。他の複数のメソッドから共通して呼び出されます。
LINE 207: 最新メッセージの保持。引数で受け取ったmessageをインスタンス変数last_statusに代入し、直近のステータスメッセージとして保持しています。LINE 208: ステータス表示の更新。
idがstatusのStaticウィジェットをquery_oneで取得し、updateメソッドで表示内容をmessageに書き換えています。これにより画面下部のメッセージが即座に反映されます。LINE 214: 検索実行メソッドの定義。
検索ボタン押下やEnterキー入力に応じて呼び出されるrun_searchメソッドを定義しています。検索欄の値を取得して結果を反映する処理をまとめています。LINE 215: 検索語の取得。
idがsearch-inputのInputウィジェットの値を取得し、前後の空白をstripで取り除いてqueryに代入しています。ユーザーが入力した検索語をここで受け取ります。LINE 216: 検索結果の反映。
load_rowsメソッドをqueryを引数に呼び出し、テーブルの表示を検索結果に更新しています。戻り値の行数は変数rowsに格納されます。LINE 217: 検索語ラベルの整形。
queryが空文字の場合は表示用に(すべて)というラベルを使い、それ以外はそのままqueryをlabelに設定しています。ステータスメッセージの見やすさを保つための処理です。LINE 218: 検索結果メッセージの表示。
set_statusメソッドを呼び出し、検索語と該当件数を含むメッセージをステータス欄に表示しています。ユーザーに検索結果の件数を伝える役割があります。LINE 222: 更新実行メソッドの定義。
更新ボタン押下やEnterキー入力に応じて呼び出されるrun_updateメソッドを定義しています。在庫数を更新し、結果をテーブルとステータスに反映する処理をまとめています。LINE 223: SKU入力値の取得。
idがsku-inputのInputウィジェットの値を取得し、stripで空白を除去してskuに代入しています。更新対象の商品を特定するための値です。LINE 224: 在庫数入力値の取得。
idがstock-inputのInputウィジェットの値を取得し、stripで空白を除去してraw_stockに代入しています。この後の行で数値として妥当かを検証します。LINE 225: 入力値の妥当性チェック。
skuが空文字でないか、かつraw_stockが数字だけで構成されているかをisdigitで確認しています。どちらか一方でも条件を満たさなければ後続の更新処理を行いません。LINE 226: 入力不備時のメッセージ表示。
入力値が不正な場合に、SKUと0以上の在庫数を入力するよう促すメッセージをset_statusで表示しています。ユーザーへ再入力を促す役割があります。LINE 227: 処理の中断。
returnにより、入力不備の場合はここでrun_updateメソッドの処理を終了しています。以降の更新処理が実行されないようにするための分岐です。LINE 228: 在庫数更新処理の実行。
update_stock関数を呼び出し、指定されたskuの在庫数をraw_stockを整数変換した値に更新しています。更新結果は辞書としてresultに格納されます。LINE 229: 更新成功判定。
resultのupdatedキーがTrueかどうかを確認し、更新が成功した場合の処理へ分岐しています。LINE 230: 更新成功メッセージの組み立て開始。更新前後の在庫数を含む成功メッセージの組み立てを開始しています。
丸括弧により複数行にわたるf文字列の記述を可能にしています。LINE 231: 更新前後の値を含むメッセージ生成。skuと更新前の在庫数result['before']、更新後の在庫数result['after']を組み合わせたメッセージ文字列を作成し、messageに代入しています。
LINE 232: メッセージ組み立ての終端。230行目から続くメッセージ文字列の記述を閉じています。ここで完成したmessageが後続のステータス表示に使われます。
LINE 233: 更新失敗時の分岐。result['updated']がFalseの場合、つまり指定されたSKUが存在しなかった場合の処理へ分岐しています。LINE 234: 未登録時のメッセージ生成。
指定されたskuが登録されていない旨のメッセージを組み立て、messageに代入しています。存在しないSKUへの更新操作をユーザーに伝えます。LINE 235: テーブル表示の更新。
検索欄に入力されている値を取得し、その検索語でload_rowsを呼び出してテーブルの表示を最新の在庫状態に更新しています。更新後の在庫数が画面に反映されます。LINE 236: 更新結果メッセージの表示。
set_statusメソッドを呼び出し、229行目からの処理で組み立てたmessageをステータス欄に表示しています。更新の成否をユーザーに伝える最終処理です。LINE 240: ボタン押下イベントの処理定義。
ボタンが押された際に呼び出されるon_button_pressedメソッドを定義しています。引数eventから押されたボタンを判定します。LINE 241: 検索ボタンの判定。
押されたボタンのidがbtn-searchかどうかをevent.button.idで確認しています。検索ボタンが押された場合の分岐です。LINE 242: 検索処理の呼び出し。
検索ボタンが押されたと判定された場合に、run_searchメソッドを呼び出して検索処理を実行しています。LINE 243: 更新ボタンの判定。押されたボタンのidがbtn-updateかどうかをelif文で確認しています。
更新ボタンが押された場合の分岐です。LINE 244: 更新処理の呼び出し。更新ボタンが押されたと判定された場合に、run_updateメソッドを呼び出して在庫数の更新処理を実行しています。
LINE 246: 入力欄でのEnter押下イベント処理定義。Input欄でEnterキーが押された際に呼び出されるon_input_submittedメソッドを定義しています。引数eventからどの入力欄で発生したかを判定します。
LINE 247: 検索欄でのEnter判定。Enterが押された入力欄のidがsearch-inputかどうかをevent.input.idで確認しています。検索欄で発生した場合の分岐です。
LINE 248: 検索処理の呼び出し。検索欄でEnterが押されたと判定された場合に、run_searchメソッドを呼び出して検索処理を実行しています。LINE 249: 更新欄でのEnter判定。
Enterが押された入力欄のidがsku-inputまたはstock-inputかどうかをinを使って確認しています。どちらの欄でも同じ更新処理につなげるための分岐です。LINE 250: 更新処理の呼び出し。
SKU欄または在庫数欄でEnterが押されたと判定された場合に、run_updateメソッドを呼び出して在庫数の更新処理を実行しています。LINE 252: テーブル行選択イベントの処理定義。データテーブルの行が選択された際に呼び出されるon_data_table_row_selectedメソッドを定義しています。
選択された行のキー情報を利用します。LINE 253: 選択行のSKU取得。選択された行に紐付けられたキー情報からrow_key.valueを取り出し、変数skuに代入しています。
この値は行追加時にkey引数として設定したSKUです。LINE 254: SKU値の存在確認。取得したskuが空でないかを確認しています。
有効な値が取得できた場合のみ次の行の処理を実行するための分岐です。LINE 255: SKU入力欄への反映。idがsku-inputのInputウィジェットのvalueに、選択した行のskuを文字列として設定しています。
選択操作だけで更新対象のSKUを入力欄に自動反映できるようにしています。LINE 260: 表示幅計算関数の定義。文字列の表示幅を計算する_display_width関数を定義しています。
全角文字と半角文字で幅が異なることを考慮した桁揃えに利用されます。LINE 261: 文字ごとの幅合計計算。文字列内の各文字についてeast_asian_widthを判定し、全角相当のWまたはFであれば2、それ以外は1として合計をsumで計算し返しています。
ターミナル上での見た目の桁数を正確に求めるための処理です。LINE 264: 文字列パディング関数の定義。指定した幅に満たない文字列へ半角スペースを補う_pad関数を定義しています。
表形式の出力を桁揃えするために利用されます。LINE 265: 不足分スペースの付加。widthから_display_widthで求めた実際の表示幅を差し引き、不足分だけ半角スペースを文字列の末尾に追加して返しています。
maxを使うことで幅が超過している場合にスペースが負にならないようにしています。LINE 268: 在庫一覧整形関数の定義。検索や一覧表示の結果rowsを、表形式の文字列に整形するformat_rows関数を定義しています。
標準出力で見やすく在庫データを表示するために使われます。LINE 269: 見出し行リストの作成開始。出力する文字列の各行を格納するリストlinesを作成し、1行目に見出し行を追加しています。
この後の行で区切り線が追加されます。LINE 270: 区切り線の追加。見出し行の下に表示する区切り線として、ハイフンを62個繰り返した文字列をlinesに追加しています。
見出しとデータ行を視覚的に区切る役割があります。LINE 271: 区切り線の作成。ハイフンを62個並べた文字列を作り、一覧表示の見出しの下に引く区切り線として使います。
LINE 272: 見出し行リストの終わり。見出し行と区切り線を格納したリストの定義を閉じています。この後、各行のデータがこのリストに追加されていきます。
LINE 273: 在庫データの繰り返し処理。引数で受け取った在庫データを1件ずつ順番に取り出し、下の処理で表示用の文字列に変換していきます。LINE 274: 行データの追加開始。
1件分の在庫データを整形した文字列としてlinesリストに追加する処理を開始しています。LINE 275: SKU・商品名の整形。SKUと商品名をそれぞれ指定した幅で揃えて、縦棒区切りで並べた文字列を作っています。
LINE 276: カテゴリ・在庫数の整形。カテゴリを幅揃えし、在庫数は右寄せで5桁分の幅を確保して1行分の表示文字列を完成させています。LINE 277: 追加処理の終わり。
1件分の在庫情報を整形した文字列の作成を締めくくり、linesリストへの追加を完了させます。LINE 278: 整形結果を結合して返す。linesリストの各行を改行文字でつなげて1つの文字列にまとめ、呼び出し元に返しています。
RUN 7/8: 標準出力用の桁揃えを確認する。format_rowsを入力した直後です。全角文字を含む商品名でも列がずれないか、見出し行の表示幅と合わせて確かめます。
CHECK 7/8: 途中実行に成功。format_rowsを入力した直後です。全角文字を含む商品名でも列がずれないか、見出し行の表示幅と合わせて確かめます。
RETURN 07: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 283: パーサー生成関数の定義。
コマンドライン引数を解析するためのargparse.ArgumentParserを組み立てる関数の始まりです。LINE 284: 共通オプションの器を用意。各サブコマンドで共有する引数をまとめるための、ヘルプ表示を持たない補助パーサーを作成しています。
LINE 285: DBパス引数の追加。どのサブコマンドでも使う「--db」オプションを共通パーサーに追加し、省略時はinventory.dbを使うようにしています。LINE 287: メインパーサーの作成。
アプリ全体の説明文を持つ、実際にコマンドライン全体を解析するためのArgumentParserを作成しています。LINE 288: サブコマンド機能の有効化。init・search・update・listなどのサブコマンドを受け付けられるようにし、必ずいずれかの指定が必要な設定にしています。
LINE 290: initサブコマンドの登録。「init」というサブコマンドを共通オプション付きで登録し、DB初期化用のヘルプ文言を設定しています。LINE 291: resetオプションの追加。
initサブコマンドに「--reset」フラグを追加し、指定すると既存DBを削除して作り直せるようにしています。LINE 293: searchサブコマンドの登録。「search」というサブコマンドを共通オプション付きで登録し、在庫検索用のヘルプ文言を設定しています。
LINE 294: queryオプション追加の開始。検索語を指定するための「--query」オプションをsearchサブコマンドに追加する処理を始めています。LINE 295: queryオプション名の指定。
検索語を受け取るためのオプション名として「--query」を指定しています。LINE 296: queryの初期値設定。「--query」が指定されなかった場合の初期値としてNoneを設定し、後続処理で未入力かどうかを判定できるようにしています。
LINE 297: queryのヘルプ文言。「--query」オプションの説明文を設定し、省略時はターミナルからその場で入力を受け取ることを利用者に伝えています。LINE 298: queryオプション追加の終わり。
「--query」オプションの追加処理を締めくくり、searchサブコマンドへの登録を完了させます。LINE 300: updateサブコマンドの登録。「update」というサブコマンドを共通オプション付きで登録し、在庫数更新用のヘルプ文言を設定しています。
LINE 301: skuオプションの追加。更新対象を指定するための「--sku」オプションをupdateサブコマンドに必須項目として追加しています。LINE 302: stockオプションの追加。
更新後の在庫数を指定するための「--stock」オプションを整数型かつ必須項目としてupdateサブコマンドに追加しています。LINE 304: listサブコマンドの登録。「list」というサブコマンドを共通オプション付きで登録し、登録済み在庫を一覧表示する機能として設定しています。
LINE 305: tuiサブコマンドの登録。「tui」というサブコマンドを共通オプション付きで登録し、在庫管理TUIを起動する機能として設定しています。LINE 306: パーサーを返す。
ここまで組み立てたparserオブジェクトを呼び出し元へ返し、コマンドライン解析に使えるようにしています。RUN 8/8: サブコマンドの引数解析を確認する。build_parserまで入力した状態です。
searchとupdateの引数を解析させ、コマンド名や--dbの既定値が期待どおり取れるか確認します。CHECK 8/8: 途中実行に成功。build_parserまで入力した状態です。
searchとupdateの引数を解析させ、コマンド名や--dbの既定値が期待どおり取れるか確認します。RETURN 08: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 311: main関数の定義。プログラム実行時に最初に呼び出されるmain関数の始まりで、この中でサブコマンドごとの処理を振り分けます。LINE 312: 引数の解析実行。
build_parserで組み立てたパーサーを使って実際のコマンドライン引数を解析し、結果をargsに格納しています。LINE 313: DBパスの取り出し。解析結果からDBファイルパスを取り出し、以降の各サブコマンド処理で共通して使えるようにdb_path変数へ保存しています。
LINE 315: initコマンドの判定。指定されたサブコマンドが「init」であるかどうかを判定し、該当する場合は続く初期化処理へ進みます。LINE 316: DB初期化の実行。
init_db関数を呼び出してDBを準備し、サンプル在庫を投入した上で全件のデータを取得しています。LINE 317: DBパスの表示。操作対象となったDBファイルのパスを画面に表示し、利用者がどのDBを扱っているか確認できるようにしています。
LINE 318: 初期化件数の表示。初期化によって登録された在庫の件数を画面に表示し、処理結果を利用者に伝えています。LINE 319: 初期化結果の一覧表示。
format_rows関数で整形した在庫一覧の文字列を画面に表示し、登録内容を確認できるようにしています。LINE 320: listコマンドの判定。指定されたサブコマンドが「list」であるかどうかを判定し、該当する場合は一覧表示処理へ進みます。
LINE 321: 全件検索の実行。検索語を空文字にしてsearch_items関数を呼び出し、登録されている在庫を全件取得しています。LINE 322: DBパスの表示。
操作対象となったDBファイルのパスを画面に表示し、利用者がどのDBを参照しているか確認できるようにしています。LINE 323: 登録件数の表示。取得した在庫データの件数を画面に表示し、現在何件が登録されているかを利用者に伝えています。
LINE 324: 一覧結果の表示。format_rows関数で整形した在庫一覧の文字列を画面に表示し、全件の内容を確認できるようにしています。LINE 325: searchコマンドの判定。
指定されたサブコマンドが「search」であるかどうかを判定し、該当する場合は検索処理へ進みます。LINE 328: 検索語の取得。コマンドライン引数から--queryの値を取り出し、query変数に保存しています。
LINE 329: 検索語未指定の判定。queryがNoneのまま、つまり--queryオプションが省略されているかどうかを判定しています。LINE 330: 入力待ちで検索語を取得。
検索語が省略されていた場合に、ターミナル上でinput関数を使って利用者からその場で検索語を入力してもらいます。LINE 331: 検索処理の実行。run_search_cli関数を呼び出し、検索語に基づいた在庫データとステータスメッセージをまとめて取得しています。
LINE 332: DBパスの表示。操作対象となったDBファイルのパスを画面に表示し、利用者がどのDBを検索したか確認できるようにしています。LINE 333: 検索ステータスの表示。
検索結果のステータスメッセージを「[検索]」の見出し付きで画面に表示し、検索語と件数を利用者に伝えています。LINE 334: 検索結果の一覧表示。format_rows関数で整形した検索結果の在庫一覧を画面に表示し、条件に合致した在庫を確認できるようにしています。
LINE 335: updateコマンドの判定。指定されたサブコマンドが「update」であるかどうかを判定し、該当する場合は在庫数更新処理へ進みます。LINE 336: 更新処理の実行。
run_update_cli関数を呼び出し、指定されたSKUの在庫数を更新した結果とステータスメッセージをまとめて取得しています。LINE 337: DBパスの表示。操作対象となったDBファイルのパスを画面に表示し、利用者がどのDBを更新したか確認できるようにしています。
LINE 338: 更新ステータスの表示。更新結果のステータスメッセージを「[更新]」の見出し付きで画面に表示し、更新前後の在庫数を利用者に伝えています。LINE 339: 更新結果の一覧表示。
format_rows関数で整形した更新後の在庫一覧を画面に表示し、更新内容が反映されていることを確認できるようにしています。LINE 340: tuiコマンドの判定。指定されたサブコマンドが「tui」であるかどうかを判定し、該当する場合はTUIアプリの起動処理へ進みます。
LINE 341: textual未導入の判定。TEXTUAL_AVAILABLEがFalseかどうかを確認し、textualパッケージが導入されていない場合の分岐へ進みます。LINE 342: 未導入時の案内表示。
textualパッケージが未導入のためTUIを起動できない旨のメッセージを画面に表示し、利用者に状況を伝えています。LINE 343: 代替コマンドの案内。TUIの代わりにinit・search・update・listサブコマンドが利用できることを案内するメッセージを画面に表示しています。
LINE 344: 処理の中断。TUIを起動できない状況のため、以降の処理を行わずmain関数の実行をここで終了させています。LINE 345: TUIアプリの起動。
InventoryAppをDBパス付きで生成し、run()メソッドを呼び出してTUI画面を実際に起動しています。LINE 346: 終了メッセージの表示。TUIアプリが終了した後に、どのDBを使って操作していたかとともに終了した旨のメッセージを画面に表示しています。
LINE 349: スクリプト直接実行の判定。このファイルがモジュールとしてインポートされたのではなく、直接実行されたかどうかを判定しています。LINE 350: main関数の呼び出し。
スクリプトとして直接実行された場合にmain関数を呼び出し、コマンドライン引数に応じた処理を開始させています。実行1/4: サンプル在庫を登録してDBを作る。initサブコマンドを実行し、SQLiteのDBファイルへサンプル在庫が投入される様子を確認します。
作成直後の一覧も続けて表示されます。サンプル在庫を登録してDBを作る。initサブコマンドを実行し、SQLiteのDBファイルへサンプル在庫が投入される様子を確認します。
作成直後の一覧も続けて表示されます。カテゴリで在庫を検索する。searchサブコマンドへ検索語を渡し、カテゴリの部分一致で在庫が絞り込まれることを確認します。
件数付きのステータス行も表示されます。指定したSKUの在庫数を更新する。updateサブコマンドでSKUと新しい在庫数を指定し、更新前後の値が並んだメッセージと該当行の表示を確認します。
更新後の在庫を一覧で確認する。在庫数を書き換えたあとにlistサブコマンドを実行し、更新結果がDBへ残っていることを一覧表示で確認します。学習内容のまとめ。
HeaderとFooterで画面の上下に見出しと操作案内を出すCREATE TABLE IF NOT EXISTSで初回だけ表を作る 検索と更新はTUIとCLIで同じ関数を共有するtextualの読み込み失敗をModuleNotFoundErrorで捕捉する 小さく実行確認しながら完成状態まで段階的に組み立てる エンディング。
Python研修はCodeCampでご確認ください。
目次
- PythonのTUIフレームワーク『Textual』とSQLiteとは
- Python・Textualで開発する場合の環境構築
- PythonのTextualとSQLiteで作る在庫管理TUIの要件定義
- TextualとSQLiteで在庫管理TUIを作る際の重要ポイント
- PythonとTextualの処理の流れ
- PythonのTextualとSQLiteで作る在庫管理TUIの完成コード
- PythonのTextualとSQLite利用時に起きやすいエラーと対処法
- Pythonコードを実行して検証
- Python実装で確認するポイント
- PythonとTextualを活用できる場面
- PythonのTextualとSQLiteによる在庫管理TUI開発のまとめ
- 参考にした一次情報
PythonのTUIフレームワーク『Textual』とSQLiteとは
今回使用する主要なライブラリについて、役割と使い分けを順番に確認します。
TUIフレームワーク『Textual』の役割
Textualは、ターミナルの中にボタンや表を並べた画面を組み立てられるPython製のTUIフレームワークです。ブラウザを開かずに、キーボードとマウスで操作できる画面をPythonのクラスとして記述できます。今回の在庫管理TUIでは、検索欄、結果テーブル、更新欄、ステータス表示をひとつの画面へまとめました。
見た目の指定はCSSに近い記法で行えるため、幅や枠線の調整もPythonファイルの中で完結します。
在庫管理TUIの画面づくりでTextualが担っている具体的な仕事を挙げます
- HeaderとFooterで画面の上下に見出しと操作案内を出す
- VerticalとHorizontalで検索欄と更新欄を横並びにする
- DataTableへ4列の在庫表を描画する
- BINDINGSのqキーへ終了操作を割り当てる
- Staticのステータス欄へ検索件数や更新結果を書き込む
在庫を保存するSQLiteとsqlite3の使い方
SQLiteは、1つのファイルにテーブルを保存できる組み込み型のデータベースエンジンです。標準ライブラリのsqlite3モジュールから利用でき、サーバーの起動やユーザー作成といった準備なしで在庫データを永続化できます。今回はitemsテーブルへ、SKU、商品名、カテゴリ、在庫数の4列を用意しました。
TUIを終了してもファイルが残るので、次に起動したときも前回の在庫数をそのまま読み出せます。
在庫データをSQLiteへ保存するうえで押さえたい実装上の判断を並べます
- CREATE TABLE IF NOT EXISTSで初回だけ表を作る
- skuをPRIMARY KEYにして重複登録を防ぐ
- INSERT OR IGNOREで再投入しても件数を増やさない
- --dbで保存先ファイルを差し替える
- withブロックへコミットを任せる
Python・Textualで開発する場合の環境構築
この記事のセットアップ手順と掲載コードは、Windows 11 Pro、PowerShell 5.1、Python 3.13.3で動作確認しています。仮想環境を有効化せず、その中のPythonを直接指定するため、以下のコマンドはPowerShellとコマンドプロンプト(cmd)の両方で使えます。
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install textual
.\.venv\Scripts\python.exe -c "import sqlite3; print('SQLite version:', sqlite3.sqlite_version)"
macOS・Linuxでは仮想環境内のPythonパスが異なります。今回の動作確認環境とは異なるため、以下は環境差分を補う参考手順です。
python3 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install textual
./.venv/bin/python -c "import sqlite3; print('SQLite version:', sqlite3.sqlite_version)"
- SQLiteはPython標準のsqlite3モジュールに同梱されているため、追加のパッケージインストールは不要です。上記の確認コマンドでインストール済みのSQLiteエンジンのバージョンを表示し、動作環境にSQLiteが利用可能であることを検証しています。
- DBファイルは--dbで指定した場所に作られ、初回接続時にCREATE TABLE IF NOT EXISTSでテーブルが自動生成されます。
- TUIを実際に触るときは .\.venv\Scripts\python.exe inventory_tui.py tuiを実行し、qキーで終了します。搭載フォントによっては罫線が崩れて見えることがあります。
- search・updateサブコマンドはTextualのApp.run_test()でヘッドレス起動するため、画面が出ないまま結果だけが標準出力に表示されます。
PythonのTextualとSQLiteで作る在庫管理TUIの要件定義
目的は、PythonのTextualとSQLiteで、SKU・商品名・カテゴリの部分一致検索と在庫数の更新をターミナルから実行し、更新前後の在庫数まで確認できる在庫管理TUIを作ることです。
対象者として、Pythonの基本文法を学び終え、sqlite3でのデータ保存とTextualでのターミナル画面の組み立て方を学びたい人を想定しています。
完成物は、TextualとSQLiteで作り、init・search・update・listの各サブコマンドから在庫の検索結果と更新前後の在庫数を確認できる在庫管理TUIです。
実装へ入る前に、機能・品質・受け入れ条件を分けて確認します。
機能要件
- initサブコマンドでサンプル在庫10件を投入する
- searchサブコマンドでSKU・商品名・カテゴリを部分一致検索する
- --queryを省略した場合はinputで検索語を受け取る
- updateサブコマンドで指定SKUの在庫数を書き換える
- 更新前後の在庫数を並べたメッセージを表示する
- listサブコマンドで登録済みの在庫を全件表示する
- tuiサブコマンドでTextualの在庫管理画面を起動する
- 検索欄と結果テーブルと更新欄をTUI画面へ並べる
- DataTableの行選択でSKU入力欄へ値を転記する
- --dbでSQLiteの保存先ファイルを指定する
非機能要件
- textualの読み込み失敗をModuleNotFoundErrorで捕捉する
- textual未導入でもinitやsearchが動作する構成
- tui起動時にtextual未導入なら代替コマンドを案内する
- SQLの値はプレースホルダとタプルで渡す
- CREATE TABLE IF NOT EXISTSで既存テーブルを保持する
- INSERT OR IGNOREでサンプル投入の重複を避ける
- --resetを付けた場合だけ既存DBファイルを削除する
- row_factoryにsqlite3.Rowを設定して列名で参照する
- withブロックによる明示的なコミット処理
- 東アジア文字幅を数えた桁揃え表示
- SKU未入力や非数値の在庫数をTUI側で弾く判定
- 存在しないSKUの更新はupdatedをFalseで返す
実装方針
今回はTextualとSQLiteの基本動作を追いやすくするため、在庫管理TUI本体を1つのPythonファイルへまとめます。
入力、判定、結果表示の役割を分け、実行結果を確認しながら機能を積み上げます。
在庫管理TUIを安全に組み立てるための実装方針は次のとおりです。
- textualの読み込み失敗をModuleNotFoundErrorで捕捉する
- textual未導入でもinitやsearchが動作する構成
- tui起動時にtextual未導入なら代替コマンドを案内する
- SQLの値はプレースホルダとタプルで渡す
- CREATE TABLE IF NOT EXISTSで既存テーブルを保持する
- INSERT OR IGNOREでサンプル投入の重複を避ける
- --resetを付けた場合だけ既存DBファイルを削除する
- row_factoryにsqlite3.Rowを設定して列名で参照する
- withブロックによる明示的なコミット処理
- 東アジア文字幅を数えた桁揃え表示
- SKU未入力や非数値の在庫数をTUI側で弾く判定
- 存在しないSKUの更新はupdatedをFalseで返す
完成と判断する条件
- initで在庫テーブルを初期化しました: 10件と表示される
- searchで検索語 文房具 の在庫: 4件が出力される
- updateでSTA-1002の在庫数が8から30へ変わる
- listで登録済みの在庫: 10件が表示される
- 未登録SKUの更新で登録がない旨のメッセージが出る
- 5回のコマンド実行がすべて終了コード0で完了する
TextualとSQLiteで在庫管理TUIを作る際の重要ポイント
在庫管理TUIの中身は、SQLiteへの検索と更新という2つの処理に集約されます。検索はSKU・商品名・カテゴリの部分一致で行を集め、更新は対象SKUの在庫数を書き換えて前後の値を返す流れです。TUIの画面操作もCLIのサブコマンドも、最終的にこのsearch_itemsとupdate_stockを呼び出します。
共通化しておいたおかげで、Textualが導入されていない環境でも同じ結果を確かめられました。
在庫管理TUIの中心処理を読むときに注目したい箇所を挙げます
- 検索と更新はTUIとCLIで同じ関数を共有する
- run_search_cliが結果行と件数メッセージを組で返す
- run_update_cliが更新後に同じSKUで再検索する
- format_rowsが東アジア文字幅を数えて桁を揃える
- main関数がサブコマンド名で処理を切り替える
search_itemsが行う部分一致検索の条件
検索はLIKE演算子を使い、SKU、商品名、カテゴリのいずれかに検索語が含まれる行を集める仕組みです。3列をORでつないでいるため、カテゴリからも商品名からも同じ関数で絞り込める点が特徴です。検索語が空文字のときは%だけが残り、結果として全件が返る動きになります。
検索語ごとに返る在庫の範囲を具体例で示します
- STAで文房具のSKUをまとめて拾う
- ボールペンで商品名から3件を絞る
- 電材でカテゴリ単位に2件を取り出す
- 空文字で10件すべてを返す
update_stockが更新前後を返す設計
更新処理では、先に対象SKUの在庫数を取得し、UPDATEを実行したあとで再度在庫数を読み直します。更新前と更新後の値がそろうため、8から30へ変わったという変化をそのまま画面へ出せます。対象が見つからないときはupdatedをFalseにして返し、呼び出し側で登録がない旨の案内へ切り替えました。
update_stockが返す辞書のキーと使いどころを挙げます
- skuは更新対象の識別子
- updatedは対象が存在したかの判定
- beforeは更新前の在庫数
- afterは更新後の在庫数
argparseで操作を振り分ける構成
コマンドラインの入口は、argparseのサブコマンドで分けています。--dbのように共通で使う引数は親パーサーへまとめ、parentsで各サブコマンドへ引き継ぐ形です。searchでは--queryを省略できるようにし、省略時はinputでターミナルからその場で検索語を受け取ります。
用意しているサブコマンドと役割の対応を示します
- initはサンプル在庫の投入
- searchは検索語での絞り込み
- updateはSKU指定の在庫数書き換え
- listは登録済み在庫の全件表示
- tuiはTextual画面の起動
textualが無い環境でも動かす工夫
Textualの読み込みはtryで囲み、ModuleNotFoundErrorのときはフラグをFalseにするだけで処理を続けます。TUIクラスの定義もこのフラグの内側へ置いたので、未導入の環境でファイルを読み込んでもエラーになりません。tuiサブコマンドを実行した場合は、代わりに使えるサブコマンドを案内する動きになります。
textual未導入の環境で起きる動作を整理します
- initやsearchのSQLite経路はそのまま動く
- InventoryAppの定義は読み込まれない状態
- tui実行時に代替コマンドの案内が出る
PythonとTextualの処理の流れ
入力、主要処理、出力の順に役割を分けて実装します。処理の境界を明確にすると、実行結果とエラー箇所を照合しやすくなります。
PythonのTextualとSQLiteで作る在庫管理TUIの完成コード
実行に成功したコード全文を確認できます。実行環境で使ったものと同じコードを掲載しています。
"""TextualとSQLiteで作る在庫管理TUIアプリ。
ターミナル上で在庫を検索し、ターミナル上で在庫数を更新する。
textualパッケージが利用できない実行環境でも、init/search/update/list の
各サブコマンドはSQLiteへの直接アクセスだけで完結するように実装している。
"""
from __future__ import annotations
import argparse
import sqlite3
import unicodedata
from pathlib import Path
# Textual(TUIフレームワーク)は "tui" サブコマンドでのみ必要となるため、
# ここでは読み込みの成否だけを記録し、未導入でも他のサブコマンドが動くようにする
try:
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Button, DataTable, Footer, Header, Input, Static
TEXTUAL_AVAILABLE = True
except ModuleNotFoundError:
TEXTUAL_AVAILABLE = False
# サンプル在庫データの定義
SEED_ITEMS = [
("STA-1001", "油性ボールペン 黒 0.7mm", "文房具", 24),
("STA-1002", "油性ボールペン 赤 0.7mm", "文房具", 8),
("STA-1003", "蛍光マーカー 黄", "文房具", 15),
("STA-1004", "水性ボールペン 青 0.5mm", "文房具", 11),
("PAP-2001", "コピー用紙 A4 500枚", "用紙", 40),
("PAP-2002", "付箋 75mm 5冊パック", "用紙", 12),
("ELE-3001", "単三アルカリ乾電池 20本", "電材", 6),
("ELE-3002", "USB-Cケーブル 1m", "電材", 18),
("OFC-4001", "クリアファイル A4 100枚", "事務用品", 33),
("OFC-4002", "デスクマット 700x500", "事務用品", 4),
]
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS items (
sku TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
stock INTEGER NOT NULL
)
"""
# SQLiteへの接続とテーブル準備
def connect(db_path: str) -> sqlite3.Connection:
path = Path(db_path)
if str(path.parent) not in ("", "."):
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
with conn:
conn.execute(CREATE_SQL)
return conn
def init_db(db_path: str, reset: bool = False) -> list[dict]:
"""DBを用意し、サンプル在庫を投入して全件を返す。"""
path = Path(db_path)
if reset and path.exists():
path.unlink()
conn = connect(db_path)
with conn:
conn.executemany(
"INSERT OR IGNORE INTO items (sku, name, category, stock) VALUES (?, ?, ?, ?)",
SEED_ITEMS,
)
rows = conn.execute(
"SELECT sku, name, category, stock FROM items ORDER BY sku"
).fetchall()
conn.close()
return [dict(row) for row in rows]
# 在庫の検索(SKU・商品名・カテゴリの部分一致)
def search_items(db_path: str, query: str) -> list[dict]:
conn = connect(db_path)
like = f"%{query.strip()}%"
rows = conn.execute(
"SELECT sku, name, category, stock FROM items"
" WHERE sku LIKE ? OR name LIKE ? OR category LIKE ?"
" ORDER BY sku",
(like, like, like),
).fetchall()
conn.close()
return [dict(row) for row in rows]
# 在庫数の更新(更新前後の値を返す)
def update_stock(db_path: str, sku: str, stock: int) -> dict:
conn = connect(db_path)
before = conn.execute("SELECT stock FROM items WHERE sku = ?", (sku,)).fetchone()
if before is None:
conn.close()
return {"sku": sku, "updated": False, "before": None, "after": None}
with conn:
conn.execute("UPDATE items SET stock = ? WHERE sku = ?", (int(stock), sku))
after = conn.execute("SELECT stock FROM items WHERE sku = ?", (sku,)).fetchone()
conn.close()
return {
"sku": sku,
"updated": True,
"before": before["stock"],
"after": after["stock"],
}
# CLIから直接呼び出す検索・更新ヘルパー(textual不要でも動く経路)
def run_search_cli(db_path: str, query: str) -> dict:
"""検索欄に相当する処理をCLIから直接実行し、結果行とステータスを返す。"""
rows = search_items(db_path, query)
label = query if query else "(すべて)"
status = f"検索語 {label} の在庫: {len(rows)}件"
return {"rows": rows, "status": status}
def run_update_cli(db_path: str, sku: str, stock: int) -> dict:
"""在庫数更新欄に相当する処理をCLIから直接実行し、結果行とステータスを返す。"""
result = update_stock(db_path, sku, stock)
if result["updated"]:
message = (
f"{sku} の在庫数を {result['before']} → {result['after']} に更新しました"
)
else:
message = f"{sku} は登録されていません"
rows = search_items(db_path, sku)
return {"rows": rows, "status": message}
# Textualによる在庫管理TUIアプリ(textual導入時のみ定義)
if TEXTUAL_AVAILABLE:
class InventoryApp(App):
"""検索欄・結果テーブル・更新欄を並べた在庫管理TUI。"""
TITLE = "在庫管理TUI"
SUB_TITLE = "Textual + SQLite"
CSS = """
#body { padding: 1 2; }
#search-row { height: 3; }
#search-input { width: 1fr; }
#btn-search { width: 12; }
#result-table { height: 12; border: solid $accent; }
#update-row { height: 3; margin-top: 1; }
#sku-input { width: 20; }
#stock-input { width: 16; }
#btn-update { width: 22; }
#status { padding: 1 0; color: $success; }
"""
BINDINGS = [("q", "quit", "終了")]
def __init__(self, db_path: str) -> None:
super().__init__()
self.db_path = db_path
self.last_rows: list[dict] = []
self.last_status = ""
def compose(self) -> ComposeResult:
yield Header()
with Vertical(id="body"):
with Horizontal(id="search-row"):
yield Input(placeholder="SKU・商品名・カテゴリで検索", id="search-input")
yield Button("検索", id="btn-search", variant="primary")
yield DataTable(id="result-table")
with Horizontal(id="update-row"):
yield Input(placeholder="SKU", id="sku-input")
yield Input(placeholder="在庫数", id="stock-input")
yield Button("在庫数を更新", id="btn-update", variant="success")
yield Static("", id="status")
yield Footer()
def on_mount(self) -> None:
table = self.query_one("#result-table", DataTable)
table.cursor_type = "row"
table.add_columns("SKU", "商品名", "カテゴリ", "在庫数")
self.load_rows("")
self.set_status("SKU・商品名・カテゴリで在庫を検索できます")
# 検索結果でテーブルを描き直す
def load_rows(self, query: str) -> list[dict]:
rows = search_items(self.db_path, query)
table = self.query_one("#result-table", DataTable)
table.clear()
for row in rows:
table.add_row(
row["sku"], row["name"], row["category"], str(row["stock"]), key=row["sku"]
)
self.last_rows = rows
return rows
def set_status(self, message: str) -> None:
self.last_status = message
self.query_one("#status", Static).update(message)
# 「ターミナル上で在庫を検索する」処理:
# 検索欄(#search-input)からユーザーが入力した検索語を取得し、
# SKU・商品名・カテゴリのいずれかに部分一致する在庫だけへ絞り込む。
def run_search(self) -> None:
query = self.query_one("#search-input", Input).value.strip()
rows = self.load_rows(query)
label = query if query else "(すべて)"
self.set_status(f"検索語 {label} の在庫: {len(rows)}件")
# 「ターミナル上で在庫数を更新する」処理
def run_update(self) -> None:
sku = self.query_one("#sku-input", Input).value.strip()
raw_stock = self.query_one("#stock-input", Input).value.strip()
if not sku or not raw_stock.isdigit():
self.set_status("SKUと0以上の在庫数を入力してください")
return
result = update_stock(self.db_path, sku, int(raw_stock))
if result["updated"]:
message = (
f"{sku} の在庫数を {result['before']} → {result['after']} に更新しました"
)
else:
message = f"{sku} は登録されていません"
self.load_rows(self.query_one("#search-input", Input).value.strip())
self.set_status(message)
# ボタン・Enter・行選択のイベント処理
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-search":
self.run_search()
elif event.button.id == "btn-update":
self.run_update()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "search-input":
self.run_search()
elif event.input.id in ("sku-input", "stock-input"):
self.run_update()
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
sku = event.row_key.value
if sku:
self.query_one("#sku-input", Input).value = str(sku)
# 標準出力用の整形(全角文字を考慮した桁揃え)
def _display_width(text: str) -> int:
return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)
def _pad(text: str, width: int) -> str:
return text + " " * max(0, width - _display_width(text))
def format_rows(rows: list[dict]) -> str:
lines = [
f"{_pad('SKU', 10)} | {_pad('商品名', 26)} | {_pad('カテゴリ', 10)} | 在庫数",
"-" * 62,
]
for row in rows:
lines.append(
f"{_pad(row['sku'], 10)} | {_pad(row['name'], 26)} | "
f"{_pad(row['category'], 10)} | {row['stock']:>5}"
)
return "\n".join(lines)
# コマンドライン引数の定義
def build_parser() -> argparse.ArgumentParser:
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--db", default="inventory.db", help="SQLiteのDBファイルパス")
parser = argparse.ArgumentParser(description="TextualとSQLiteの在庫管理TUI")
sub = parser.add_subparsers(dest="command", required=True)
init_parser = sub.add_parser("init", parents=[common], help="DBを初期化してサンプル在庫を投入する")
init_parser.add_argument("--reset", action="store_true", help="既存DBを削除して作り直す")
search_parser = sub.add_parser("search", parents=[common], help="ターミナル上で在庫を検索する")
search_parser.add_argument(
"--query",
default=None,
help="SKU・商品名・カテゴリの検索語(省略時はターミナルからその場で入力を受け取る)",
)
update_parser = sub.add_parser("update", parents=[common], help="ターミナル上で在庫数を更新する")
update_parser.add_argument("--sku", required=True, help="更新対象のSKU")
update_parser.add_argument("--stock", type=int, required=True, help="更新後の在庫数")
sub.add_parser("list", parents=[common], help="登録済みの在庫を一覧表示する")
sub.add_parser("tui", parents=[common], help="在庫管理TUIを起動する")
return parser
# サブコマンドごとの実行
def main() -> None:
args = build_parser().parse_args()
db_path = args.db
if args.command == "init":
rows = init_db(db_path, reset=args.reset)
print(f"DB: {db_path}")
print(f"在庫テーブルを初期化しました: {len(rows)}件")
print(format_rows(rows))
elif args.command == "list":
rows = search_items(db_path, "")
print(f"DB: {db_path}")
print(f"登録済みの在庫: {len(rows)}件")
print(format_rows(rows))
elif args.command == "search":
# 「ターミナル上で在庫を検索する」の入力取得:
# --queryが省略された場合は、ターミナルのinput()でその場から検索語を受け取る。
query = args.query
if query is None:
query = input("検索語(SKU・商品名・カテゴリ)を入力してください: ").strip()
result = run_search_cli(db_path, query)
print(f"DB: {db_path}")
print(f"[検索] {result['status']}")
print(format_rows(result["rows"]))
elif args.command == "update":
result = run_update_cli(db_path, args.sku, args.stock)
print(f"DB: {db_path}")
print(f"[更新] {result['status']}")
print(format_rows(result["rows"]))
elif args.command == "tui":
if not TEXTUAL_AVAILABLE:
print("textualパッケージが未導入のため、TUIを起動できません。")
print("代わりに init / search / update / list サブコマンドを利用してください。")
return
InventoryApp(db_path).run()
print(f"TUIを終了しました(DB: {db_path})")
if __name__ == "__main__":
main()
PythonのTextualとSQLite利用時に起きやすいエラーと対処法
エラーが起きた場合は、入力値、実行環境、出力先の順に確認します。
| 確認箇所 | 確認内容 |
|---|---|
| 入力 | 形式や必須値が想定どおりか確認する |
| 実行環境 | Pythonと必要ライブラリの版を確認する |
| 出力 | 保存先と権限、生成されたファイルを確認する |
Pythonコードを実行して検証
検証結果は「CLIツールを5回のコマンドで実行し、全て正常(終了コード0)に動作した」でした。





Python実装で確認するポイント
入力、処理、出力を分けて確認すると問題を切り分けやすくなります。
ポイントとしては、確認対象と実測範囲を押さえておきましょう。
確認対象:入力と処理と出力を分離
実測範囲:未計測の性能を断定しない
PythonとTextualを活用できる場面
同じ処理の流れを、入力や出力形式を変えた別の業務・学習用途へ応用できます。まず小さな入力で動作を確認してから対象を広げます。
さらに詳しく学べるPython研修の詳細はこちらPythonのTextualとSQLiteによる在庫管理TUI開発のまとめ
実行済みコードと「CLIツールを5回のコマンドで実行し、全て正常(終了コード0)に動作した」を基に結果を整理しました。
さらに詳しく学べるPython研修の詳細はこちら参考にした一次情報
- ^ Textual公式ドキュメント. https://textual.textualize.io/, (参照26-09-09).
- ^ Textual Guide - Testing(run_test / Pilot). https://textual.textualize.io/guide/testing/, (参照26-09-09).
- ^ Textual Widgets - DataTable. https://textual.textualize.io/widgets/data_table/, (参照26-09-09).
- ^ Python標準ライブラリsqlite3 — DB-API 2.0 interface for SQLite databases. https://docs.python.org/ja/3/library/sqlite3.html, (参照26-09-09).
※内容は執筆時点のものです。ライブラリやサイトの仕様は変わる可能性があるため、公式ドキュメントもあわせてご確認ください。






