From fcac127a194cfeb79299bdc9d09d950b0616ca83 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 9 Jun 2026 12:13:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(list):=20list=20TUI=20=E3=82=92=20question?= =?UTF-8?q?ary=20=E7=A7=BB=E8=A1=8C=20+=20running=20=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E9=81=B8=E6=8A=9E=20+=20devbase=20rebuild=20=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20(i30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - devbase list の対話選択を simple-term-menu から questionary へ移行。 ↑長押し時の入力取りこぼし (連結エスケープシーケンス破棄) を構造的に解消。 - 番号 [1]-[9] ショートカットを廃止し、全項目に通し番号ラベル + 名前での インクリメンタル絞り込み (use_search_filter) へ刷新。 - running 中のプロジェクト選択時に「再起動(up)/再ビルド(rebuild)/停止(down)」を 選ぶサブメニューを追加 (非 running は従来どおり直接 up)。 - devbase rebuild コマンド新設 (docker compose build --no-cache 相当)。 project/container/トップレベル + wrapper name 解決 + bash/zsh 補完に登録。 - pyproject: simple-term-menu を questionary>=2.1 へ置換。 - tests: rebuild / running サブメニュー / questionary seam / 補完を追加・更新。 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 21 +++- README.md | 3 +- bin/devbase | 8 +- etc/_devbase | 9 +- etc/devbase-completion.bash | 12 +- issues/i30_list-tui-running-actions.md | 167 +++++++++++++++++++++++++ lib/devbase/cli.py | 27 +++- lib/devbase/commands/container.py | 30 +++++ lib/devbase/commands/project.py | 133 +++++++++++++------- pyproject.toml | 2 +- tests/cli/test_completion.py | 10 +- tests/cli/test_project_list.py | 110 ++++++++++++---- tests/cli/test_rebuild.py | 155 +++++++++++++++++++++++ uv.lock | 46 +++++-- 14 files changed, 630 insertions(+), 103 deletions(-) create mode 100644 issues/i30_list-tui-running-actions.md create mode 100644 tests/cli/test_rebuild.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9905715..153b2a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,21 @@ ## [Unreleased] ### Added -- **`devbase list` の対話選択を TUI 化**しました。`simple-term-menu` 導入により、 - ↑↓ の矢印キーで行移動、先頭 9 件は `1`〜`9` の数字キーで即ジャンプ、`/` で - 名前のインクリメンタル検索ができます。Enter で選択プロジェクトを `up` 起動し、 - Esc で中止します。非 TTY(パイプ/CI/リダイレクト)では従来どおりプレーンな - 一覧表示にフォールバックし、`simple-term-menu` 未導入環境では番号入力方式に - フォールバックします(macOS / Linux 対応)。 +- **`devbase list` の対話選択を TUI 化**しました。`questionary` 導入により、↑↓ の + 矢印キーで行移動、文字入力でプロジェクト名のインクリメンタル絞り込みができます + (全項目に通し番号を表示)。Enter で決定、Ctrl-C で中止します。 + - **選択行が起動中 (running) の場合**は「再起動 (up) / 再ビルド (rebuild --no-cache) / + 停止 (down)」を選ぶサブメニューを表示します。それ以外 (stopped / unknown) は + 従来どおり `up` を起動します。 + - 非 TTY(パイプ/CI/リダイレクト)では従来どおりプレーンな一覧表示にフォールバック + し、`questionary` 未導入環境では番号入力方式にフォールバックします。 + - 入力ライブラリを `simple-term-menu` から `questionary` (prompt_toolkit ベース) へ + 移行し、↑長押し時にスクロールが取りこぼされて遅くなる問題を解消しました。 +- **`devbase rebuild` コマンドを新設**しました。`docker compose build --no-cache` 相当で、 + キャッシュを無効化してプロジェクト (compose) イメージを作り直します。`devbase rebuild + [name]` / `devbase project rebuild [name]` として任意のディレクトリから利用できます + (`devbase list` の running サブメニューからも起動できます)。なお `devbase-base` まで + 作り直す 2 段ビルドは従来どおり `devbase build --no-cache` を使用してください。 - **`devbase project` サブコマンド群を新設**しました (PLAN06)。CWD に依存せずプロジェクト名でコンテナ操作ができます。 - `devbase project up/down/ps/logs/scale [name]` で、任意のディレクトリから `$DEVBASE_ROOT/projects/` を対象に操作できます。名前解決はラッパー (`bin/devbase`) が対象ディレクトリへ `cd` してから実行するため、シェル実装の `build` を含む全操作が名前指定で成立します(呼び出し元シェルの作業ディレクトリは変わりません)。存在しない名前はエラーになり候補が提示されます。 - `devbase project list` で `$DEVBASE_ROOT/projects/` 配下を `NAME` / `PLUGIN` / `STATUS` の一覧表示します。`PLUGIN` 列はシンボリックリンク先から解決するため、PLAN04 の同名衝突 suffix(例 `carmo.takemi`)が付いていても正しいプラグイン名を表示します。**TTY ではデフォルトで対話選択**になり、一覧から番号で選んだプロジェクトを `project up` で起動します。`--no-interactive`(`--plain` / `-P`)で一覧表示のみに切り替えられ、パイプ・リダイレクト・CI などの非 TTY 環境では自動的に一覧表示へフォールバックします(`--interactive` / `-i` は後方互換として引き続き受け付けます)。 diff --git a/README.md b/README.md index b9bb0f8..305392d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ devbaseは、Docker Composeを使った再現性の高い開発環境を提供 - **データ永続化**: 名前付きボリュームでコンテナ再起動後もデータを保持 - **スナップショット管理**: `/home/ubuntu` 共通ボリュームの増分バックアップ・復元・世代管理 - **環境変数の自動収集**: `devbase env init`でAWS/Git/GCP認証情報を対話的に設定 -- **対話的なプロジェクト選択**: `devbase list` で矢印キー・番号・`/` 検索に対応した TUI メニューから起動対象を選べます +- **対話的なプロジェクト選択**: `devbase list` で矢印キー移動・名前での絞り込みに対応した TUI メニューから起動対象を選べます。起動中のプロジェクトは「再起動 (up) / 再ビルド (rebuild) / 停止 (down)」を選択できます +- **キャッシュ無効リビルド**: `devbase rebuild [name]` で `docker compose build --no-cache` 相当のイメージ再ビルドができます ## クイックスタート diff --git a/bin/devbase b/bin/devbase index 22d46eb..a348391 100755 --- a/bin/devbase +++ b/bin/devbase @@ -199,7 +199,7 @@ run_python() { # Resolve abbreviated command to full command name via unique prefix matching resolve_command() { local input="$1" - local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build ps scale list help" + local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build rebuild ps scale list help" local matches=() for cmd in $commands; do [[ "$cmd" == "$input"* ]] && matches+=("$cmd") @@ -331,8 +331,8 @@ _DEVBASE_ARGS=("${@:2}") # そのプロジェクトへ切り替えてから (cd 済みの状態で) コマンドを実行すること。 # こうすれば name 解決トークンを与える必要がなくなり、index/image/service を # 意図どおり渡せる。 -_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale " -_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build " +_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale rebuild " +_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build rebuild " case "$_resolved_cmd" in project|container|ct) # `ct` は container の alias (cli.py: add_parser('container', aliases=['ct']))。 @@ -355,7 +355,7 @@ case "$_resolved_cmd" in # Python-implemented commands --version|-V) run_python "$@" ;; - init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|list) + init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|rebuild|list) run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;; # Shell-implemented commands build) cmd_build "${_DEVBASE_ARGS[@]}" ;; diff --git a/etc/_devbase b/etc/_devbase index 3f50002..ebb590b 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -64,6 +64,7 @@ _devbase() { 'down:Stop containers (shortcut)' 'login:Login to container (shortcut)' 'build:Build container images' + 'rebuild:Rebuild images without cache (shortcut)' 'ps:Show container status (shortcut)' 'scale:Scale containers online (shortcut)' 'list:List projects (shortcut)' @@ -78,6 +79,7 @@ _devbase() { 'logs:Show container logs' 'scale:Scale containers online' 'build:Build container images' + 'rebuild:Rebuild images without cache (docker compose build --no-cache)' 'list:List projects (NAME / PLUGIN / STATUS)' ) @@ -89,6 +91,7 @@ _devbase() { 'logs:Show container logs' 'scale:Scale containers online' 'build:Build container images' + 'rebuild:Rebuild images without cache (docker compose build --no-cache)' ) env_subcommands=( @@ -134,8 +137,8 @@ _devbase() { login) _values 'index' 1 2 ;; - # トップレベルシノニム: up/down/ps/scale は [name] を取るためプロジェクト名を補完。 - up|down) + # トップレベルシノニム: up/down/ps/scale/rebuild は [name] を取るためプロジェクト名を補完。 + up|down|rebuild) _devbase_project_names ;; ps) @@ -158,7 +161,7 @@ _devbase() { ;; project) case "$words[3]" in - up|down) + up|down|rebuild) _devbase_project_names ;; login) diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index b76a2a0..5bb07fc 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -31,10 +31,10 @@ _devbase_completions() { cword=$COMP_CWORD } - local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build ps scale list help" + local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build rebuild ps scale list help" # project / container は同じサブコマンド群 (container は非推奨だが補完は維持)。 - local project_subcommands="up down ps login logs scale build list" - local container_subcommands="up down ps login logs scale build" + local project_subcommands="up down ps login logs scale build rebuild list" + local container_subcommands="up down ps login logs scale build rebuild" local env_subcommands="init sync list set get delete edit project export import" local plugin_subcommands="list install uninstall update info sync repo" local repo_subcommands="add remove list refresh" @@ -49,9 +49,9 @@ _devbase_completions() { login) COMPREPLY=($(compgen -W "1 2" -- "$cur")) ;; - # トップレベルシノニム: up/down/scale は [name] を取るため + # トップレベルシノニム: up/down/scale/rebuild は [name] を取るため # プロジェクト名を補完する (login=index / build=image は対象外)。 - up|down|scale) + up|down|scale|rebuild) COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) ;; # ps は [name] と -a フラグの両方を取る (project ps と同じ挙動)。 @@ -96,7 +96,7 @@ _devbase_completions() { # project subcommand arguments (推奨グループ) if [ "$group" = "project" ]; then case "$prev" in - up|down) + up|down|rebuild) COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) ;; login) diff --git a/issues/i30_list-tui-running-actions.md b/issues/i30_list-tui-running-actions.md new file mode 100644 index 0000000..012580b --- /dev/null +++ b/issues/i30_list-tui-running-actions.md @@ -0,0 +1,167 @@ +# i30 (plan30): devbase list TUI 改善 (questionary 移行) + `devbase rebuild` 追加 + +## 関連リンク +- 先行: [i29 list TUI 化](i29_list-tui-plan.md) / [simple-term-menu 採用](i29_list-tui-simple-term-menu.md) +- 関連 PR: #42 (i29 の TUI 化を main へ統合済み) + +## 概要 +`devbase list` の対話選択 (TUI) を **simple-term-menu から questionary へ移行**しつつ 3 点改善し、 +併せて `docker compose build --no-cache` 相当の新コマンド `devbase rebuild` を追加する。 + +1. **番号選択 (`[1]`〜`[9]`) を廃し、名前絞り込み + 番号ラベルへ刷新** +2. **↑/↓ 長押し時のスクロール遅延を解消**(questionary 移行で構造的に解消) +3. **running 状態の行を選択したときに「再起動 / 再ビルド / 停止」を選べるサブメニューを表示** +4. (3 の前提) **`devbase rebuild` コマンドを新設** + +## 問題・背景 + +### 1. 番号選択のカバレッジが低い → 名前絞り込みへ刷新 +現行の `[1]`〜`[9]` ショートカットは先頭 9 件しかカバーできず、最大 38 件規模のリストでは +カバレッジが低く実質無意味。 + +**採用 (確定): questionary の `use_search_filter=True` による名前絞り込み**。各行に番号を +**目印として表示**しつつ、絞り込みは**プロジェクト名等の部分一致**で行う。文字をタイプして +全 N 件のどれにも到達でき、誤ヒットもない。 + +> ⚠ 補足: リリース版 questionary 2.1.1 には `search_matcher` (カスタム一致関数) が無く +> (main ブランチのみ・未リリース)、絞り込みは「表示テキスト全体への大文字小文字無視の部分一致」 +> 固定。そのため「数字を打って行番号で選ぶ」方式はステータス文字列 (`running (1 containers)`) +> や名前中の数字に誤ヒットするため**採用しない**。番号は視認用ラベルに留める。 + +### 2. ↑長押しのスクロールが遅い (原因特定済み → ライブラリ移行で解消) +simple-term-menu の `_read_next_key` は `os.read(tty, 80)` で一括読みするため、↑長押しの連結 +エスケープシーケンスがキー名照合に失敗して**まるごと無視**され、長押し入力の大半がドロップする +(公式 issue [#99](https://github.com/IngoMeyer441/simple-term-menu/issues/99) / +未マージ修正 PR [#100](https://github.com/IngoMeyer441/simple-term-menu/pull/100) で裏取り済み)。 + +調査の結果、**questionary (prompt_toolkit ベース) へ移行**する方針に決定。prompt_toolkit の +`Vt100Parser` がバイトストリームを 1 バイトずつ食ってキーを離散化するため、「連結シーケンスを +取りこぼす」という simple-term-menu 固有の障害が**構造的に発生しない**。併せて以下も得られる: +- `use_search_filter=True` による組み込みインクリメンタル絞り込み (現行 `/` 検索の代替) +- 活発なメンテナンス (v2.1.x / 2025) ・クロスプラットフォーム対応 +- キャンセル (Ctrl-C) が `.ask()` の戻り値 None で取れる (中止判定が素直) + +代替候補だった pick (検索が組み込みでない) / survey (更新停滞・中止が例外ベース) ではなく +questionary を採用する。 + +### 3. running 行は up/down/rebuild を選びたい +現状は選択行を一律 `project up` していた。running なプロジェクトでは「再起動・再ビルド・停止」を +選べると操作が完結する。 + +### 4. `devbase rebuild` が無い +`docker compose build --no-cache` 相当の単独コマンドが無い (シェルラッパー `bin/devbase` の +`cmd_build` は `--no-cache` 対応だが devbase-base の 2 段ビルドを伴う別物)。3 の「再ビルド」 +選択肢の実体として、Python 側に簡潔な `cmd_rebuild` を新設する。 + +## 修正対象 +- `pyproject.toml` / `uv.lock` — `simple-term-menu` を `questionary>=2.1` へ置換 +- `lib/devbase/commands/project.py` — TUI を questionary へ移行 + 3 点改善 +- `lib/devbase/commands/container.py` — `cmd_rebuild` 追加 + `_dispatch_lifecycle` への登録 +- `lib/devbase/cli.py` — `project rebuild [name]` サブパーサ + トップレベル `rebuild` ショートカット +- `bin/devbase` — `resolve_command` のコマンド一覧 + name 解決対象に `rebuild` 追加 +- `tests/cli/test_project_list.py` — simple-term-menu 前提のテストを questionary seam ベースへ更新 +- `tests/cli/` (container/cli 系) — `cmd_rebuild` / `rebuild` パーサのテスト +- `CHANGELOG.md` / `README.md` — questionary 移行・`devbase rebuild`・操作説明の更新 + +## 確定仕様 + +### A. ライブラリ移行 (questionary) +- `pyproject.toml` の `dependencies` から `simple-term-menu>=1.6` を削除し `questionary>=2.1` を追加。 + `uv.lock` を更新 (prompt_toolkit を間接依存として取り込む)。 +- `project.py` の import を `try: import questionary except ImportError: questionary=None; + _HAVE_QUESTIONARY=False` パターンへ変更 (optional import + fallback 維持の方針は踏襲)。 + +### B. メニュー実装 (`_show_menu`) — 名前絞り込み + 番号ラベル +- `_build_menu_entries(rows)` は各行に**番号ラベル** (`{i+1:>{w}}`、w=桁数) を付けた整列 body + (` 1 NAME PLUGIN STATUS`) の**プレーン文字列**を返す (番号は視認用。色付けは現状どおり無効、 + 将来 questionary style で別途検討)。 +- `_show_menu(rows) -> int | None`: + ```python + choices = [questionary.Choice(title=entry, value=i) for i, entry in enumerate(entries)] + return questionary.select( + "起動するプロジェクトを選択 (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Ctrl-C 中止):", + choices=choices, + use_arrow_keys=True, + use_jk_keys=False, # use_search_filter と併用不可のため False + use_search_filter=True, # 文字入力で名前等を部分一致絞り込み (/ 検索の代替) + use_shortcuts=False, # 単一キーショートカットは使わない + ).ask() # 選択 index / キャンセル時 None + ``` +- テスト容易性のため questionary 呼び出しは `_show_menu` / `_show_action_menu` に閉じ込め、テストは + これらを monkeypatch する (現行も `_show_menu` を monkeypatch する設計を踏襲)。 + +### C. running 行サブメニュー +- 選択 index 確定後、`rows[idx]["status"]` が `"running"` で始まるか判定: + - **running**: `_show_action_menu() -> "up"|"rebuild"|"down"|None` を表示。 + - `再起動 (up)` → `cmd_project(subcommand="up", name=...)` + - `再ビルド (rebuild --no-cache)` → `cmd_project(subcommand="rebuild", name=...)` + - `停止 (down)` → `cmd_project(subcommand="down", name=...)` + - None (Ctrl-C) → 何もせず `return 0` + - **非 running** (stopped / unknown 等): 従来どおり直接 `up`。 +- ディスパッチ用ヘルパ `_start_project_up/down/rebuild(name)` を `cmd_project` 経由で呼ぶ + (down/rebuild は引数なしハンドラだが `_dispatch_lifecycle` が name で chdir してから実行する既存設計に乗る)。 + +### D. `devbase rebuild` (案1 = ご指示どおり) +- `container.py`: + ```python + def cmd_rebuild() -> int: + """Rebuild images without cache (docker compose build --no-cache).""" + # compose.yml 存在チェック → subprocess: docker compose build --no-cache + ``` +- `_dispatch_lifecycle` の handlers に `'rebuild': lambda: cmd_rebuild()` を追加。 +- `cli.py`: `project` に `rebuild [name]` サブパーサ追加 + トップレベル `rebuild` を + `('project',)` subcommand リスト / SHORTCUTS に登録。 +- `bin/devbase`: `resolve_command` の `commands` に `rebuild` 追加 + name 解決対象 + (`up`/`down`/`ps`/`logs`/`scale` 同列) に含める。 +- **注意点 (既知の差分)**: `docker compose build --no-cache` は dev (プロジェクト) イメージのみを + no-cache 再ビルドし、`devbase-base` は作り直さない。base まで作り直すには従来どおり + `devbase build --no-cache` を使う。この差は本プランでは仕様として許容 (ご指示「docker compose + build --no-cache 相当」に準拠)。 +- **TUI からの rebuild は再ビルドのみ** (自動 up はしない / D2 確定)。 + +### E. fallback (維持) +- questionary 未導入時 (`not _HAVE_QUESTIONARY`) は現行 `_fallback_select_and_up` (stdlib `input()` + 番号入力) を維持。非 TTY 時のテーブル表示フォールバックも不変。 + - 補足: running サブメニューは TUI 専用。fallback (番号入力) 経路では従来どおり `up` のみとする + (番号入力でさらに up/rebuild/down を多段で聞くと UX が煩雑になるため)。 + +## タスク分解 + +### Task 1: 依存差し替え (questionary) +- **対象:** `pyproject.toml`, `uv.lock` +- **変更:** `simple-term-menu` 削除・`questionary>=2.1` 追加・lock 更新。 + +### Task 2: `devbase rebuild` コマンド追加 +- **対象:** `lib/devbase/commands/container.py`, `lib/devbase/cli.py`, `bin/devbase` +- **変更:** `cmd_rebuild` 実装 + ディスパッチ登録 + パーサ/ショートカット/wrapper 登録。 + +### Task 3: TUI を questionary へ移行 + 名前絞り込み + 番号ラベル +- **対象:** `lib/devbase/commands/project.py`, `tests/cli/test_project_list.py` +- **変更:** import/`_show_menu`/`_build_menu_entries` を questionary 化・番号ラベルを全項目へ付与・ + 既存テストを questionary seam ベースへ更新。 + +### Task 4: running 行サブメニュー +- **対象:** `lib/devbase/commands/project.py`, `tests/cli/test_project_list.py` +- **変更:** running 判定 + `_show_action_menu` + up/rebuild/down 分岐 + 中止処理 + 分岐テスト追加。 + +### Task 5: ドキュメント +- **対象:** `CHANGELOG.md`, `README.md` +- **変更:** questionary 移行・`devbase rebuild`・list TUI 操作説明 (番号削除・絞り込み・running 操作) の更新。 + +## 影響範囲 +- `devbase list` / `devbase project list` の対話 UI (ライブラリ変更・番号無効化・スクロール改善・ + running 行の操作分岐・`/` 検索 → 文字入力絞り込み)。 +- 依存関係: `simple-term-menu` (Unix 専用) → `questionary` + 間接 `prompt_toolkit`。 +- `devbase project`/トップレベルのサブコマンド集合に `rebuild` が増える (wrapper/cli/補完)。 +- questionary 未導入時の fallback (番号入力) / 非 TTY のテーブル表示は不変。 + +## テスト計画 +- [ ] `_build_menu_entries` が全行に右寄せ番号ラベル付きの整列 body を返す +- [ ] `_show_menu` (questionary) を monkeypatch した選択で対応プロジェクトに対し up が起動する +- [ ] running 行選択 → `_show_action_menu` で up/rebuild/down を選ぶと対応 subcommand で + `cmd_project` が呼ばれる / None (中止) で何も起動しない +- [ ] 非 running 行選択は従来どおり直接 up +- [ ] questionary 未導入時は番号入力 fallback が従来どおり動く / 非 TTY はテーブル表示 +- [ ] `cmd_rebuild` が compose.yml 不在時にエラー終了 / 存在時に `docker compose build --no-cache` を起動 +- [ ] `project rebuild` / トップレベル `rebuild` パーサが subcommand=rebuild を解決 +- [ ] pytest 全通過 (既存 list / fallback / 非 TTY テストにリグレッションなし) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 8dc9182..4b1f624 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -34,6 +34,10 @@ 'login': 'login', 'ps': 'ps', 'scale': 'scale', + # `rebuild` は Python 実装 (cmd_rebuild = docker compose build --no-cache) で + # 完結するため `build` と異なりトップレベルショートカットに含めてよい + # (build は shell 実装に委譲するため除外している。上の NOTE 参照)。 + 'rebuild': 'rebuild', } # Group aliases @@ -45,8 +49,8 @@ # Subcommand map for prefix resolution: {(aliases...): [subcmds]} SUBCMD_MAP = { - ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'list'], - ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'], + ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', 'list'], + ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild'], ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'], ('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'], ('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'], @@ -129,6 +133,8 @@ def _add_container_parser(subparsers): _add_build_subparser(ct_sub) + ct_sub.add_parser('rebuild', help='Rebuild images without cache (docker compose build --no-cache)') + def _add_project_parser(subparsers): """Project group parser (CWD 非依存のプロジェクト操作)。 @@ -175,6 +181,14 @@ def _add_project_parser(subparsers): _add_build_subparser(pj_sub) + # `rebuild` は Python 実装 (docker compose build --no-cache)。up/down 同様に + # 省略可能な `[name]` を取り、name 指定時は _dispatch_lifecycle が chdir してから + # 実行する。wrapper の _PROJECT_NAME_SUBCOMMANDS / _NAME_RESOLVABLE_SHORTCUTS にも + # 追加すること。 + pj_rebuild = pj_sub.add_parser( + 'rebuild', help='Rebuild images without cache (docker compose build --no-cache)') + pj_rebuild.add_argument('name', nargs='?', default=None, help='Project name') + # `list` は lifecycle ではなく一覧表示 (commands/project.py)。name positional は # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 _add_list_subparser(pj_sub) @@ -425,6 +439,12 @@ def _add_shortcuts(subparsers): scale_sc.add_argument('name', nargs='?', default=None, help='Project name') scale_sc.add_argument('new_scale', type=int, help='New number of containers') + # `rebuild` は project rebuild のトップレベルシノニム (Python 実装のため build と + # 異なりショートカット可)。up/down と同じく `[name]` を受け付ける。 + rebuild_sc = subparsers.add_parser( + 'rebuild', help='Rebuild images without cache (docker compose build --no-cache)') + rebuild_sc.add_argument('name', nargs='?', default=None, help='Project name') + # `list` は `project list` のトップレベルシノニム。lifecycle ではなく一覧表示 # のため SHORTCUTS (project lifecycle へ写像) ではなく _dispatch で個別に # cmd_project_list へ振り分ける。 @@ -444,6 +464,7 @@ def _create_parser(): " login project login\n" " ps project ps\n" " scale project scale\n" + " rebuild project rebuild (docker compose build --no-cache)\n" "\n" "Note: `container` is deprecated; use `project` instead.\n" ) @@ -511,7 +532,7 @@ def _expand_argv(): # bin/devbase が build を shell 実装に委譲するため Python 側には top-level # build parser が無い。project build / container build は引き続き利用可能。 commands = ['init', 'status', 'shell-rc', 'project', 'container', 'ct', 'env', 'plugin', 'pl', - 'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'list', 'help'] + 'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'rebuild', 'list', 'help'] repo_subcmds = ['add', 'remove', 'list', 'refresh'] if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'): diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index d38eccf..9ceb27b 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -295,6 +295,7 @@ def _dispatch_lifecycle(args) -> int: 'scale': lambda: cmd_scale(new_scale=getattr(args, 'new_scale', None), project_name=project_name), 'build': lambda: cmd_build(image=getattr(args, 'image', None)), + 'rebuild': lambda: cmd_rebuild(), } handler = handlers.get(subcmd) @@ -601,6 +602,35 @@ def cmd_build(image: str = None) -> int: return result.returncode +# --------------------------------------------------------------------------- +# cmd_rebuild +# --------------------------------------------------------------------------- + +def cmd_rebuild() -> int: + """Rebuild project images without cache (``docker compose build --no-cache``). + + cmd_build がレイヤーキャッシュを使うのに対し、こちらはキャッシュを無効化して + プロジェクト (compose) イメージを作り直す。``devbase rebuild`` / ``devbase project + rebuild [name]`` のエントリ。 + + 注意: シェルラッパー (``bin/devbase`` の ``build --no-cache``) のような + devbase-base の 2 段ビルドは行わず、compose の build 対象サービスのみを + no-cache で再ビルドする。base まで作り直す場合は ``devbase build --no-cache`` + を使う。 + """ + compose_file = Path('compose.yml') + if not compose_file.exists(): + logger.error("compose.yml not found in current directory") + return 1 + + logger.info("Rebuilding images without cache from compose.yml ...") + result = subprocess.run( + ['docker', 'compose', 'build', '--no-cache'], + check=False + ) + return result.returncode + + # --------------------------------------------------------------------------- # 内部関数 # --------------------------------------------------------------------------- diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py index 9a6d3af..c757f10 100644 --- a/lib/devbase/commands/project.py +++ b/lib/devbase/commands/project.py @@ -18,19 +18,22 @@ logger = get_logger(__name__) -# simple_term_menu は Unix 専用の任意依存。未導入/非対応環境では番号入力に -# フォールバックするため、import 失敗を許容する。 +# questionary (prompt_toolkit ベース) は任意依存。未導入環境では番号入力に +# フォールバックするため、import 失敗を許容する。questionary は矢印キー移動 + +# 文字入力での絞り込み (use_search_filter) に対応し、prompt_toolkit が入力を +# 1 イベントずつ分解するため、旧 simple_term_menu のような ↑長押し時の入力 +# 取りこぼし (連結エスケープシーケンスの破棄) が構造的に発生しない。 try: - from simple_term_menu import TerminalMenu - _HAVE_TERMINAL_MENU = True + import questionary + _HAVE_QUESTIONARY = True except ImportError: # pragma: no cover - 未導入環境のフォールバック経路 - TerminalMenu = None - _HAVE_TERMINAL_MENU = False + questionary = None + _HAVE_QUESTIONARY = False -# STATUS 色付けの有効/無効。menu entry に ANSI を埋め込むと simple_term_menu の -# wcswidth() が -1 を返し、表示幅計算とハイライト消去が崩れる。実機検証が完了する -# まではメニューでは色を付けず False を既定とする (機能 > 装飾)。テーブル表示 -# (_print_table) は端末へ直接書くため影響を受けず、色付けは別途検討する。 +# STATUS 色付けの有効/無効。menu entry に生 ANSI を埋め込むと prompt_toolkit の +# 表示幅計算と干渉しうるため、実機検証が完了するまではメニューでは色を付けず +# False を既定とする (機能 > 装飾)。テーブル表示 (_print_table) は端末へ直接書く +# ため影響を受けず、色付けは別途 questionary の style で検討する。 _STATUS_COLOR = False @@ -148,84 +151,124 @@ def _color_status(status: str) -> str: def _build_menu_entries(rows: list[dict], colorize: bool = False) -> list[str]: - """rows を simple_term_menu 用の表示文字列へ変換する。 + """rows を questionary メニュー用の表示文字列へ変換する。 返り値の index は rows の index と 1:1 対応する (entry i ↔ rows[i])。 - 先頭 9 件には simple_term_menu のショートカット記法 ``[n]`` (n=1..9) を付与し、 - 数字キーで即ジャンプできるようにする。simple_term_menu はショートカットが 1 件 - でも定義されると全行に 4 文字幅のショートカットガターを自前描画するため、 - 10 件目以降は body のまま渡し、桁揃えはライブラリ側のガターに委ねる - (手前で字下げすると二重インデントになる)。``colorize`` が True のとき STATUS - に ANSI 色を付ける (検索/桁計算が崩れる端末向けに呼び出し側で False にできる)。 + 各行に**右寄せの番号ラベル** (``1``〜``N``) を付け、全件に通し番号を振る + (旧 ``[1]``〜``[9]`` ショートカットは先頭 9 件しかカバーできず低カバレッジ + だったため廃止)。番号は視認用で、選択は矢印キー / 文字入力での絞り込みで行う。 + ``colorize`` が True のとき STATUS に ANSI 色を付ける (表示幅が崩れる端末向けに + 呼び出し側で False にできる)。 """ name_w = max(len("NAME"), *(len(r["name"]) for r in rows)) plugin_w = max(len("PLUGIN"), *(len(r["plugin"]) for r in rows)) + num_w = len(str(len(rows))) entries: list[str] = [] for i, r in enumerate(rows): status = _color_status(r["status"]) if colorize else r["status"] body = f"{r['name']:<{name_w}} {r['plugin']:<{plugin_w}} {status}" - if i < 9: - entries.append(f"[{i + 1}] {body}") - else: - # ショートカット無し行はライブラリ側のガターが 4 文字ぶん字下げするため - # body をそのまま渡す (手前で字下げすると二重インデントになる)。 - entries.append(body) + entries.append(f"{i + 1:>{num_w}} {body}") return entries -def _start_project_up(name: str) -> int: - """``project up `` を共有ハンドラ cmd_project 経由で起動する。""" +def _start_project_action(name: str, action: str) -> int: + """``project `` を共有ハンドラ cmd_project 経由で起動する。 + + ``action`` は ``"up"`` / ``"down"`` / ``"rebuild"``。共有ハンドラ + (_dispatch_lifecycle) が ``name`` でディレクトリ解決 (chdir) してから各 + サブコマンドを実行する。``scale`` は up のみが参照するが、常に付与しても + 他コマンドは無視するため一律 None を渡す。 + """ import types from devbase.commands.container import cmd_project - return cmd_project(types.SimpleNamespace(subcommand="up", name=name, scale=None)) + return cmd_project(types.SimpleNamespace(subcommand=action, name=name, scale=None)) + + +def _start_project_up(name: str) -> int: + """``project up `` を起動する (後方互換の薄いラッパ)。""" + return _start_project_action(name, "up") def _show_menu(rows: list[dict]) -> int | None: - """TerminalMenu を起動し、選択された rows の index を返す (中止時 None)。 + """questionary の select を起動し、選択された rows の index を返す (中止時 None)。 - テストではこの関数自体を monkeypatch して TerminalMenu の実起動を避ける。 + テストではこの関数自体を monkeypatch して questionary の実起動を避ける。 """ entries = _build_menu_entries(rows, colorize=_STATUS_COLOR) - menu = TerminalMenu( - entries, - title=("起動するプロジェクトを選択 " - "(↑↓ 移動 / 1-9 ジャンプ / / 検索 / Enter 決定 / Esc 中止):"), - cycle_cursor=True, - clear_screen=False, - show_search_hint=True, - ) - return menu.show() + choices = [questionary.Choice(title=entry, value=i) + for i, entry in enumerate(entries)] + return questionary.select( + "起動するプロジェクトを選択 (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Ctrl-C 中止):", + choices=choices, + use_arrow_keys=True, + use_jk_keys=False, # use_search_filter と併用不可のため False + use_search_filter=True, # 文字入力でプロジェクト名等を部分一致絞り込み + use_shortcuts=False, # 単一キーショートカットは使わない + ).ask() # 選択された value (= rows の index) / 中止時 None + + +def _show_action_menu(name: str) -> str | None: + """running 中プロジェクトの操作 (up/rebuild/down) を選ぶサブメニュー。 + + 選択された action 文字列 (``"up"`` / ``"rebuild"`` / ``"down"``) を返す。 + 中止 (Ctrl-C) 時は None。テストではこの関数を monkeypatch する。 + """ + choices = [ + questionary.Choice(title="再起動 (up)", value="up"), + questionary.Choice(title="再ビルド (rebuild --no-cache)", value="rebuild"), + questionary.Choice(title="停止 (down)", value="down"), + ] + return questionary.select( + f"'{name}' は起動中です。操作を選択 (↑↓ 移動 / Enter 決定 / Ctrl-C 中止):", + choices=choices, + use_arrow_keys=True, + use_shortcuts=False, + ).ask() def _tui_select_and_up(rows: list[dict]) -> int: - """TUI メニューで 1 件選択し ``project up `` を起動する。""" + """TUI メニューで 1 件選択して操作を起動する。 + + 選択行が running 中なら ``_show_action_menu`` で up/rebuild/down を選ばせ、 + それ以外 (stopped / unknown 等) は従来どおり直接 ``project up`` を起動する。 + """ idx = _show_menu(rows) if idx is None: logger.info("中止しました。") return 0 - return _start_project_up(rows[idx]["name"]) + + row = rows[idx] + name = row["name"] + if str(row.get("status", "")).startswith("running"): + action = _show_action_menu(name) + if action is None: + logger.info("中止しました。") + return 0 + return _start_project_action(name, action) + + return _start_project_action(name, "up") def _interactive_select_and_up(rows: list[dict]) -> int: """一覧から 1 件選択して ``project up`` を起動する (TTY 専用)。 - simple_term_menu が利用可能なら矢印キー対応の TUI メニューを使う。未導入環境 - では現行の番号入力方式 (_fallback_select_and_up) にフォールバックする。 + questionary が利用可能なら矢印キー + 絞り込み対応の TUI メニューを使う。未導入 + 環境では現行の番号入力方式 (_fallback_select_and_up) にフォールバックする。 """ - if _HAVE_TERMINAL_MENU: + if _HAVE_QUESTIONARY: return _tui_select_and_up(rows) logger.warning( - "simple_term_menu が未導入のため番号入力にフォールバックします " + "questionary が未導入のため番号入力にフォールバックします " "(`uv sync` で導入すると矢印キー選択が使えます)。" ) return _fallback_select_and_up(rows) def _fallback_select_and_up(rows: list[dict]) -> int: - """番号入力で 1 件選択し ``project up `` を起動する (simple_term_menu 未導入時のフォールバック)。 + """番号入力で 1 件選択し ``project up `` を起動する (questionary 未導入時のフォールバック)。 - 外部依存 (simple_term_menu 等) を増やさず stdlib の ``input()`` で実装する。 + 外部依存 (questionary 等) を増やさず stdlib の ``input()`` で実装する。 非対話環境 (stdin が閉じている等で EOFError) ではエラー終了する。空入力は中止。 """ print("起動するプロジェクトを選択してください:") diff --git a/pyproject.toml b/pyproject.toml index 932f107..05c24b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "pyyaml>=6.0", "pyrage>=1.2", "boto3>=1.34", - "simple-term-menu>=1.6", + "questionary>=2.1", ] [dependency-groups] diff --git a/tests/cli/test_completion.py b/tests/cli/test_completion.py index b6c73a9..8a42f2f 100644 --- a/tests/cli/test_completion.py +++ b/tests/cli/test_completion.py @@ -80,7 +80,7 @@ def test_bash_completion_syntax_ok(): def test_bash_project_subcommands(fake_root): out = _bash_complete("devbase project ''", 2, fake_root) - assert set(out) >= {"up", "down", "ps", "login", "logs", "scale", "build", "list"} + assert set(out) >= {"up", "down", "ps", "login", "logs", "scale", "build", "rebuild", "list"} def test_bash_project_name_completion(fake_root): @@ -88,6 +88,14 @@ def test_bash_project_name_completion(fake_root): assert sorted(out) == ["api", "linked", "web"] +def test_bash_rebuild_name_completion(fake_root): + """`devbase rebuild ` / `devbase project rebuild ` がプロジェクト名を補完する。""" + top = _bash_complete("devbase rebuild ''", 2, fake_root) + assert sorted(top) == ["api", "linked", "web"] + proj = _bash_complete("devbase project rebuild ''", 3, fake_root) + assert sorted(proj) == ["api", "linked", "web"] + + def test_bash_top_level_synonym_name_completion(fake_root): """`devbase up ` がプロジェクト名を補完する。""" out = _bash_complete("devbase up ''", 2, fake_root) diff --git a/tests/cli/test_project_list.py b/tests/cli/test_project_list.py index 55e6539..75c4526 100644 --- a/tests/cli/test_project_list.py +++ b/tests/cli/test_project_list.py @@ -290,7 +290,7 @@ def test_cmd_project_list_interactive_selects_and_ups(tmp_path, monkeypatch): # 対話選択は TTY 環境でのみ起動するため isatty を True に固定する。 monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_TERMINAL_MENU", False) + monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) # 番号 "2" を選択 (sorted: alpha-proj=1, beta-proj=2) monkeypatch.setattr("builtins.input", lambda *a, **k: "2") @@ -317,7 +317,7 @@ def test_cmd_project_list_interactive_empty_input_aborts(tmp_path, monkeypatch): monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_TERMINAL_MENU", False) + monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) monkeypatch.setattr("builtins.input", lambda *a, **k: "") called = [] @@ -344,7 +344,7 @@ def raise_eof(*a, **k): monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_TERMINAL_MENU", False) + monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) monkeypatch.setattr("builtins.input", raise_eof) called = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) @@ -370,7 +370,7 @@ def raise_interrupt(*a, **k): monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_TERMINAL_MENU", False) + monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) monkeypatch.setattr("builtins.input", raise_interrupt) called = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) @@ -393,7 +393,7 @@ def test_cmd_project_list_interactive_out_of_range_reprompts(tmp_path, monkeypat monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_TERMINAL_MENU", False) + monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) # "99" (範囲外) → "1" (有効) の順に入力 → 再入力後に up が起動する inputs = iter(["99", "1"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) @@ -419,7 +419,7 @@ def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatc monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_TERMINAL_MENU", False) + monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) # "abc" (数値以外) → "1" (有効) inputs = iter(["abc", "1"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) @@ -575,24 +575,25 @@ def test_get_container_status_uses_per_entry(tmp_path, monkeypatch): # TUI: _build_menu_entries / _color_status # --------------------------------------------------------------------------- -def test_build_menu_entries_shortcuts_and_mapping(): +def test_build_menu_entries_number_label_and_mapping(): from devbase.commands.project import _build_menu_entries rows = [{"name": f"p{i}", "plugin": "-", "status": "stopped"} for i in range(11)] entries = _build_menu_entries(rows) assert len(entries) == 11 - # 先頭 9 件は [1]..[9] ショートカット付き (entry index と rows index は 1:1) - for i in range(9): - assert entries[i].startswith(f"[{i + 1}] ") - assert f"p{i}" in entries[i] - # 10 件目以降はショートカット無し。桁揃え (4 文字ガター) は simple_term_menu の - # ライブラリ側が描画するため、ここでは body をそのまま渡す (手前で字下げすると - # 二重インデントになる)。先頭にショートカット記法も手動字下げも付けない。 - assert not entries[9].startswith(" ") - assert not entries[9].lstrip().startswith("[") - assert entries[9].startswith("p9") + # 全行に右寄せ番号ラベル (1..N) が付く。旧 [1]..[9] ショートカットは廃止。 + # 11 件なので桁数は 2、番号は右寄せ (" 1", " 11" 等)。 + assert entries[0].startswith(" 1 ") # 2 桁右寄せ → " 1" + assert "p0" in entries[0] + assert entries[8].startswith(" 9 ") + assert entries[9].startswith("10 ") + assert "p9" in entries[9] + assert entries[10].startswith("11 ") assert "p10" in entries[10] + # [n] 形式のショートカット記法は残っていない。 + for e in entries: + assert not e.lstrip().startswith("[") def test_build_menu_entries_colorize_wraps_status(): @@ -636,8 +637,8 @@ def test_cmd_project_list_tui_selects_and_ups(tmp_path, monkeypatch): monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - # simple_term_menu 導入済み相当にし、メニューは index=1 (beta-proj) を返すよう差し替え - monkeypatch.setattr(project_mod, "_HAVE_TERMINAL_MENU", True) + # questionary 導入済み相当にし、メニューは index=1 (beta-proj) を返すよう差し替え + monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", True) monkeypatch.setattr(project_mod, "_show_menu", lambda rows: 1) captured = {} @@ -663,7 +664,7 @@ def test_cmd_project_list_tui_abort_returns_zero(tmp_path, monkeypatch): monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_TERMINAL_MENU", True) + monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", True) # ESC 等での中止は _show_menu が None を返す monkeypatch.setattr(project_mod, "_show_menu", lambda rows: None) @@ -677,8 +678,73 @@ def test_cmd_project_list_tui_abort_returns_zero(tmp_path, monkeypatch): assert called == [], "中止時は up を起動しない" +@pytest.mark.parametrize("action", ["up", "rebuild", "down"]) +def test_tui_running_row_shows_action_menu(monkeypatch, action): + """running 行を選ぶとサブメニューで up/rebuild/down を選び、その subcommand で起動する。""" + from devbase.commands import project as project_mod + from devbase.commands import container as container_mod + + rows = [{"name": "carmo", "plugin": "-", "status": "running (2 containers)"}] + monkeypatch.setattr(project_mod, "_show_menu", lambda rows: 0) + seen = {} + monkeypatch.setattr(project_mod, "_show_action_menu", + lambda name: seen.update(name=name) or action) + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update( + subcommand=args.subcommand, name=args.name) or 0) + + rc = project_mod._tui_select_and_up(rows) + assert rc == 0 + assert seen["name"] == "carmo" # action menu に対象名が渡る + assert captured["subcommand"] == action + assert captured["name"] == "carmo" + + +def test_tui_running_action_abort_starts_nothing(monkeypatch): + """running 行のサブメニューを中止 (None) したら何も起動しない。""" + from devbase.commands import project as project_mod + from devbase.commands import container as container_mod + + rows = [{"name": "carmo", "plugin": "-", "status": "running (1 containers)"}] + monkeypatch.setattr(project_mod, "_show_menu", lambda rows: 0) + monkeypatch.setattr(project_mod, "_show_action_menu", lambda name: None) + + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + assert project_mod._tui_select_and_up(rows) == 0 + assert called == [] + + +@pytest.mark.parametrize("status", ["stopped", "unknown"]) +def test_tui_non_running_row_direct_up(monkeypatch, status): + """非 running 行はサブメニューを出さず従来どおり直接 up する。""" + from devbase.commands import project as project_mod + from devbase.commands import container as container_mod + + rows = [{"name": "carmo", "plugin": "-", "status": status}] + monkeypatch.setattr(project_mod, "_show_menu", lambda rows: 0) + + action_menu_calls = [] + monkeypatch.setattr(project_mod, "_show_action_menu", + lambda name: action_menu_calls.append(name) or "down") + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update( + subcommand=args.subcommand, name=args.name) or 0) + + rc = project_mod._tui_select_and_up(rows) + assert rc == 0 + assert action_menu_calls == [], "非 running ではサブメニューを出さない" + assert captured["subcommand"] == "up" + assert captured["name"] == "carmo" + + def test_interactive_falls_back_when_no_terminal_menu(tmp_path, monkeypatch): - """simple_term_menu 未導入時は input() 番号入力にフォールバックして up する。""" + """questionary 未導入時は input() 番号入力にフォールバックして up する。""" from devbase.commands import project as project_mod from devbase.commands import status as status_mod from devbase.commands import container as container_mod @@ -688,7 +754,7 @@ def test_interactive_falls_back_when_no_terminal_menu(tmp_path, monkeypatch): monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) - monkeypatch.setattr(project_mod, "_HAVE_TERMINAL_MENU", False) + monkeypatch.setattr(project_mod, "_HAVE_QUESTIONARY", False) monkeypatch.setattr("builtins.input", lambda *a, **k: "1") captured = {} diff --git a/tests/cli/test_rebuild.py b/tests/cli/test_rebuild.py new file mode 100644 index 0000000..8efcfd1 --- /dev/null +++ b/tests/cli/test_rebuild.py @@ -0,0 +1,155 @@ +"""i30: `devbase rebuild` (docker compose build --no-cache 相当) のテスト。 + +- parser: `project rebuild [name]` / `container rebuild` / top-level `rebuild [name]` +- SHORTCUTS / SUBCMD_MAP への登録 +- `_dispatch_lifecycle` が rebuild を cmd_rebuild へ振り分ける +- cmd_rebuild の振る舞い (compose.yml 不在=1 / 存在時に docker compose build --no-cache) +- wrapper (bin/devbase) が rebuild を Python 経路へ流す (shell build 経路ではない) +""" + +from __future__ import annotations + +import types +from pathlib import Path + +import pytest + +from devbase import cli + + +# --------------------------------------------------------------------------- +# parser / shortcuts +# --------------------------------------------------------------------------- + +def test_project_rebuild_accepts_optional_name(): + parser = cli._create_parser() + with_name = parser.parse_args(['project', 'rebuild', 'carmo']) + assert with_name.command == 'project' + assert with_name.subcommand == 'rebuild' + assert with_name.name == 'carmo' + + without_name = parser.parse_args(['project', 'rebuild']) + assert without_name.subcommand == 'rebuild' + assert without_name.name is None + + +def test_container_rebuild_subcommand(): + parser = cli._create_parser() + ns = parser.parse_args(['container', 'rebuild']) + assert ns.command == 'container' + assert ns.subcommand == 'rebuild' + + +def test_top_level_rebuild_shortcut(): + parser = cli._create_parser() + ns = parser.parse_args(['rebuild', 'carmo']) + assert ns.command == 'rebuild' + assert ns.name == 'carmo' + + +def test_rebuild_in_shortcuts(): + # build と異なり rebuild は Python 実装なのでトップレベルショートカットに含める + assert cli.SHORTCUTS.get('rebuild') == 'rebuild' + + +def test_rebuild_in_subcmd_map(): + assert 'rebuild' in cli.SUBCMD_MAP[('project',)] + assert 'rebuild' in cli.SUBCMD_MAP[('container', 'ct')] + + +def test_expand_argv_resolves_rebuild_prefix(monkeypatch): + """`devbase project re` は rebuild に一意解決される。""" + import sys + monkeypatch.setattr(sys, 'argv', ['devbase', 'project', 're']) + cli._expand_argv() + assert sys.argv == ['devbase', 'project', 'rebuild'] + + +# --------------------------------------------------------------------------- +# dispatch +# --------------------------------------------------------------------------- + +def test_lifecycle_routes_rebuild_to_cmd_rebuild(monkeypatch): + from devbase.commands import container + called = [] + monkeypatch.setattr(container, 'cmd_rebuild', lambda: called.append(1) or 0) + args = types.SimpleNamespace(subcommand='rebuild') + assert container._dispatch_lifecycle(args) == 0 + assert called == [1] + + +def test_lifecycle_rebuild_resolves_name_first(monkeypatch): + """`rebuild ` は handler 前に name 解決 (chdir) を通す。""" + from devbase.commands import container + order = [] + monkeypatch.setattr(container, '_resolve_project_name', + lambda name: order.append(('resolve', name)) or True) + monkeypatch.setattr(container, 'cmd_rebuild', + lambda: order.append('rebuild') or 0) + args = types.SimpleNamespace(subcommand='rebuild', name='carmo') + assert container._dispatch_lifecycle(args) == 0 + assert order == [('resolve', 'carmo'), 'rebuild'] + + +# --------------------------------------------------------------------------- +# cmd_rebuild の振る舞い +# --------------------------------------------------------------------------- + +def test_cmd_rebuild_missing_compose(tmp_path, monkeypatch): + from devbase.commands import container + monkeypatch.chdir(tmp_path) + assert container.cmd_rebuild() == 1 + + +def test_cmd_rebuild_runs_no_cache_build(tmp_path, monkeypatch): + from devbase.commands import container + (tmp_path / 'compose.yml').write_text('services: {}\n') + monkeypatch.chdir(tmp_path) + + captured = {} + + def fake_run(cmd, check=False): + captured['cmd'] = cmd + return types.SimpleNamespace(returncode=0) + + monkeypatch.setattr(container.subprocess, 'run', fake_run) + assert container.cmd_rebuild() == 0 + assert captured['cmd'] == ['docker', 'compose', 'build', '--no-cache'] + + +def test_cmd_rebuild_propagates_returncode(tmp_path, monkeypatch): + from devbase.commands import container + (tmp_path / 'compose.yml').write_text('services: {}\n') + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(container.subprocess, 'run', + lambda cmd, check=False: types.SimpleNamespace(returncode=2)) + assert container.cmd_rebuild() == 2 + + +# --------------------------------------------------------------------------- +# wrapper routing +# --------------------------------------------------------------------------- + +def test_wrapper_routes_rebuild_to_python(): + wrapper = (Path(__file__).resolve().parents[2] / 'bin' / 'devbase').read_text() + lines = wrapper.splitlines() + # rebuild は Python 実装。run_python 委譲ケースの case ラベル行 (直後行が + # run_python "${_resolved_cmd}") に rebuild が含まれること。 + found = False + for i, ln in enumerate(lines): + nxt = lines[i + 1] if i + 1 < len(lines) else '' + if ln.strip().endswith(')') and 'run_python "${_resolved_cmd}"' in nxt \ + and 'rebuild' in ln: + found = True + break + assert found, 'rebuild は wrapper の run_python ケースに含まれる必要がある' + # shell の build) ケースに rebuild が混ざっていないこと + for ln in lines: + if ln.strip().startswith('build)') and 'cmd_build' in ln: + assert 'rebuild' not in ln + + +def test_wrapper_rebuild_in_name_resolvable(): + wrapper = (Path(__file__).resolve().parents[2] / 'bin' / 'devbase').read_text() + assert '_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build rebuild "' in wrapper + assert '_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale rebuild "' in wrapper diff --git a/uv.lock b/uv.lock index a5c7f25..f43434d 100644 --- a/uv.lock +++ b/uv.lock @@ -47,7 +47,7 @@ dependencies = [ { name = "boto3" }, { name = "pyrage" }, { name = "pyyaml" }, - { name = "simple-term-menu" }, + { name = "questionary" }, ] [package.dev-dependencies] @@ -60,7 +60,7 @@ requires-dist = [ { name = "boto3", specifier = ">=1.34" }, { name = "pyrage", specifier = ">=1.2" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "simple-term-menu", specifier = ">=1.6" }, + { name = "questionary", specifier = ">=2.1" }, ] [package.metadata.requires-dev] @@ -114,6 +114,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -230,24 +242,27 @@ wheels = [ ] [[package]] -name = "s3transfer" -version = "0.17.0" +name = "questionary" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore" }, + { name = "prompt-toolkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] [[package]] -name = "simple-term-menu" -version = "1.6.6" +name = "s3transfer" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/80/f0f10b4045628645a841d3d98b584a8699005ee03a211fc7c45f6c6f0e99/simple_term_menu-1.6.6.tar.gz", hash = "sha256:9813d36f5749d62d200a5599b1ec88469c71378312adc084c00c00bfbb383893", size = 35493, upload-time = "2024-12-02T16:31:50.639Z" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/09/21d993e394c1fe5c44cd90453d88ed44932da8dfca006e424c072d77d29b/simple_term_menu-1.6.6-py3-none-any.whl", hash = "sha256:c2a869efa7a9f7e4a9c25858b42ca6974034951c137d5e281f5339b06ed8c9c2", size = 27600, upload-time = "2024-12-02T16:31:48.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, ] [[package]] @@ -330,3 +345,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "wcwidth" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, +]