From e3155e8d8ccd6776be6dcd10f7d4b4dbe58a7e11 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 10 Jun 2026 03:53:53 +0000 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20PLAN31=5F2-project-ops=20Draft=20P?= =?UTF-8?q?R=20=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c3a620cd2745198e5b6b02140e21a5d5d193e105 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 10 Jun 2026 05:33:26 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(tui):=20PLAN31=5F2=20project=20?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E3=82=92=20login/ps/logs/scale/build=20?= =?UTF-8?q?=E3=81=B8=E6=8B=A1=E5=BC=B5=20(PR2=20#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit running 行の操作サブメニューを up/down/login/ps/logs/scale/build/rebuild の全操作へ 拡張し、各操作の引数を tui.menu の収集ヘルパで CLI と同じ属性 (plan 2.3 契約) として 集める。stopped/unknown は従来どおり直接 up (PR1 非回帰)。 - login: index (既定 "1") を text で収集 - ps: --all を confirm で収集 - logs: --follow を confirm、--tail を optional int (空=全件) で収集 - scale: new_scale を integer (min=1) で収集 - build: containers//Dockerfile を列挙して選択 (compose.yml 全体= image None) - down: 破壊的操作のため confirm で確認 (plan 3.4) - 引数収集を Esc/Ctrl-C で中止したら操作サブメニューへ戻る (_ARG_CANCEL) login/ps/logs/scale は running コンテナ対象のため running 行限定とした。 全 pytest 542 passed / 1 skipped (PR2 で +22)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/tui/actions_project.py | 176 ++++++++++++++++--- tests/cli/tui/test_actions_project.py | 235 +++++++++++++++++++++++++- 2 files changed, 385 insertions(+), 26 deletions(-) diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index 3af0615..69e4842 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -4,8 +4,12 @@ ``_show_action_menu`` / ``_fallback_select_and_up`` をこのモジュールへ移送し、 メニュー部品は ``tui.menu`` に、ハンドラ委譲は ``tui.dispatch`` に一般化した。 -PR1 で扱うのは既存の **一覧選択 → (running なら up/rebuild/down サブメニュー) → -それ以外は直接 up** までで、login/ps/logs/scale/build の追加は PR2 で行う。 +PR1 で **一覧選択 → (running なら操作サブメニュー) → それ以外は直接 up** を移送し、 +PR2 で running 操作サブメニューを **up/down/login/ps/logs/scale/build/rebuild の全操作** +へ拡張した。login/ps/logs/scale は running 中コンテナを対象とするため running 行限定、 +stopped/unknown は従来どおり直接 up (PR1 非回帰)。引数を要する操作は ``tui.menu`` の +収集ヘルパで CLI と同じ属性値を集め、破壊的な down は ``menu.confirm`` で確認する +(plan 2.3 契約表 / 3.4 破壊的操作確認)。 一覧表示・整形 (``list_projects`` / ``_build_menu_entries``) は ``commands/project`` の純粋ロジックを再利用する (TUI からも CLI(table) からも共有)。 @@ -41,36 +45,171 @@ def _select_project(rows: list[dict]): choices, back=True, search=True) +# running 行で選べる操作 (表示順 = ハイライト既定順)。up を先頭に置き、PR1 同様 +# Enter 連打で再起動へ到達できるようにする。各 value は cmd_project のサブコマンド名。 +_RUNNING_OPS: list[tuple[str, str]] = [ + ("再起動 (up)", "up"), + ("停止 (down)", "down"), + ("ログイン (login)", "login"), + ("コンテナ状態 (ps)", "ps"), + ("ログ表示 (logs)", "logs"), + ("スケール変更 (scale)", "scale"), + ("イメージビルド (build)", "build"), + ("再ビルド (rebuild --no-cache)", "rebuild"), +] + +# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。 +# dispatch の rc (int) や ``None`` (= 全体中止) と区別する。 +_ARG_CANCEL = object() + + def _select_action(name: str): - """running 中プロジェクトの操作 (up/rebuild/down) を選ぶサブメニュー。 + """running 中プロジェクトの操作を選ぶサブメニュー。 - 戻り値: action 文字列 / ``MENU_BACK`` (Esc・← → 一覧へ戻る) / ``None`` (Ctrl-C 中止)。 + 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → 一覧へ戻る) / ``None`` (Ctrl-C 中止)。 """ - choices = [ - ("再起動 (up)", "up"), - ("再ビルド (rebuild --no-cache)", "rebuild"), - ("停止 (down)", "down"), - ] return menu.select( f"'{name}' は起動中です。操作を選択 " "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + list(_RUNNING_OPS), back=True, search=False) + + +def _optional_int(message: str): + """空入力を許す整数収集 (logs --tail 等)。 + + 戻り値: ``int`` / ``None`` (空入力 = 既定動作) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止)。 + 非数値は再入力を促す。``menu.integer`` は空入力で既定値を返す仕様のため、空 = None を + 表現したい optional な数値はこちらで扱う。 + """ + while True: + raw = menu.text(message, allow_empty=True) + if raw is None: + return _ARG_CANCEL + if raw == "": + return None + try: + return int(raw) + except ValueError: + logger.error("整数で指定してください: %r", raw) + + +def _select_build_image(devbase_root: Path): + """build 対象イメージを選ぶ。``containers//Dockerfile`` を列挙する。 + + 戻り値: イメージ名 (``str``) / ``None`` (compose.yml 全体ビルド) / ``_ARG_CANCEL`` + (Esc・Ctrl-C 中止)。``containers/`` が無い / 空なら compose.yml 全体ビルド (None) に + フォールバックする。 + """ + containers_dir = Path(devbase_root) / "containers" + images = sorted( + d.name for d in containers_dir.iterdir() + if d.is_dir() and (d / "Dockerfile").exists() + ) if containers_dir.is_dir() else [] + + if not images: + # 個別イメージが無ければ compose.yml 全体ビルド (image=None) のみ。 + return None + + # value="" を「compose.yml 全体」に割り当て、選択メニューの None (Ctrl-C) と衝突 + # させない。呼び出し側で空文字 → None へ変換する。 + choices = [("compose.yml 全体をビルド", "")] + [(img, img) for img in images] + sel = menu.select( + "ビルドするイメージを選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", choices, back=True, search=False) + if sel is menu.MENU_BACK or sel is None: + return _ARG_CANCEL + return sel or None # "" → None (compose 全体) + + +def _run_operation(devbase_root: Path, name: str, op: str): + """選択された操作の引数を収集して ``dispatch_lifecycle`` で起動する。 + + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 = サブメニューへ戻る)。 + 引数を要さない up/rebuild は即実行。down は破壊的のため ``menu.confirm`` で確認する。 + """ + if op in ("up", "rebuild"): + # up は scale 属性を参照する (常に None。他コマンドは無視する)。 + return dispatch_lifecycle(op, name, scale=None) + + if op == "down": + ok = menu.confirm(f"'{name}' のコンテナを停止しますか?", default=False) + if not ok: # False (拒否) / None (中止) → 実行しない + return _ARG_CANCEL + return dispatch_lifecycle("down", name) + + if op == "login": + index = menu.text("ログインするコンテナ番号", default="1") + if index is None: + return _ARG_CANCEL + return dispatch_lifecycle("login", name, index=index) + + if op == "ps": + all_c = menu.confirm("停止中も含め全コンテナを表示しますか (--all)?", default=False) + if all_c is None: + return _ARG_CANCEL + return dispatch_lifecycle("ps", name, all=all_c) + + if op == "logs": + follow = menu.confirm("ログを追従表示しますか (--follow)?", default=False) + if follow is None: + return _ARG_CANCEL + tail = _optional_int("末尾何行を表示しますか (空で全件)") + if tail is _ARG_CANCEL: + return _ARG_CANCEL + return dispatch_lifecycle("logs", name, follow=follow, tail=tail) + + if op == "scale": + new_scale = menu.integer(f"'{name}' の新しいコンテナ数", min_value=1) + if new_scale is None: + return _ARG_CANCEL + return dispatch_lifecycle("scale", name, new_scale=new_scale) + + if op == "build": + image = _select_build_image(devbase_root) + if image is _ARG_CANCEL: + return _ARG_CANCEL + return dispatch_lifecycle("build", name, image=image) + + # 到達しない (メニュー値は _RUNNING_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + return _ARG_CANCEL + + +def _operation_menu(devbase_root: Path, name: str): + """running 行の操作サブメニューを回す。 + + 戻り値プロトコル (run と同じ ``is`` 同一性判定): + - dispatch の rc (``int``): 操作を実行 → 呼び出し元へ (最終的にトップへ復帰)。 + - ``menu.MENU_BACK``: Esc/← で一覧へ戻る。 + - ``None``: Ctrl-C で全体中止。 + + 引数収集を中止 (``_ARG_CANCEL``) した場合はサブメニューを再表示する。 + """ + while True: + op = _select_action(name) + if op is menu.MENU_BACK: + return menu.MENU_BACK + if op is None: + return None + rc = _run_operation(devbase_root, name, op) + if rc is _ARG_CANCEL: + continue # 引数収集を中止 → サブメニューへ戻る + return rc # 実行 rc → 呼び出し元へ def run(devbase_root: Path): - """プロジェクト操作カテゴリ。一覧選択 → up/rebuild/down を起動する。 + """プロジェクト操作カテゴリ。一覧選択 → (running は操作サブメニュー / 他は up)。 戻り値プロトコル (トップループが ``is`` 同一性で判定する): - **操作を実行した場合**: ``dispatch_lifecycle`` の rc (``int``) を返す。 「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。これにより - ``project up/down/rebuild`` の失敗が ``devbase list`` の終了コードへ伝搬する。 + project 操作の失敗が ``devbase list`` の終了コードへ伝搬する。 - ``menu.MENU_BACK``: 一覧で Esc/← (操作なしでトップへ) / プロジェクト無し。 - ``None``: 一覧・サブメニューで Ctrl-C による全体中止。 - 選択行が running 中なら ``_select_action`` で up/rebuild/down を選ばせ、それ以外 - (stopped / unknown 等) は従来どおり直接 ``project up`` を起動する。サブメニューで - Esc/← を押すと (``MENU_BACK``) 一覧へ戻る。操作完了後はトップメニューへ復帰する - (plan 3.5 状態遷移: Exec → Top)。 + 選択行が running 中なら ``_operation_menu`` で全操作を選ばせ、それ以外 + (stopped / unknown) は従来どおり直接 ``project up`` を起動する (PR1 非回帰)。 + 操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。 """ projects_dir = Path(devbase_root) / "projects" while True: @@ -88,12 +227,11 @@ def run(devbase_root: Path): row = rows[idx] name = row["name"] if str(row.get("status", "")).startswith("running"): - action = _select_action(name) - if action is menu.MENU_BACK: + rc = _operation_menu(devbase_root, name) + if rc is menu.MENU_BACK: continue # 一覧へ戻る - if action is None: + if rc is None: return None # Ctrl-C → 全体中止 - rc = dispatch_lifecycle(action, name, scale=None) else: rc = dispatch_lifecycle("up", name, scale=None) diff --git a/tests/cli/tui/test_actions_project.py b/tests/cli/tui/test_actions_project.py index 15e40f0..6899a4b 100644 --- a/tests/cli/tui/test_actions_project.py +++ b/tests/cli/tui/test_actions_project.py @@ -28,9 +28,9 @@ def _link_project(root, link_name, plugin_path, proj): # run(): 一覧選択 → up/rebuild/down # --------------------------------------------------------------------------- -@pytest.mark.parametrize("action", ["up", "rebuild", "down"]) +@pytest.mark.parametrize("action", ["up", "rebuild"]) def test_run_running_row_shows_action_menu(monkeypatch, tmp_path, action): - """running 行を選ぶとサブメニューで up/rebuild/down を選び、その subcommand で起動する。""" + """running 行を選ぶとサブメニューで操作を選び、引数不要の up/rebuild は即起動する。""" from devbase.commands import container as container_mod from devbase.commands import status as status_mod @@ -68,7 +68,7 @@ def test_run_propagates_nonzero_dispatch_rc(monkeypatch, tmp_path): lambda entry, counts=None: {"name": entry.name, "status": "running (1 containers)", "count": 1}) monkeypatch.setattr(actions_project, "_select_project", lambda rows: 0) - monkeypatch.setattr(actions_project, "_select_action", lambda name: "down") + monkeypatch.setattr(actions_project, "_select_action", lambda name: "up") monkeypatch.setattr(container_mod, "cmd_project", lambda args: 1) result = actions_project.run(tmp_path) @@ -218,19 +218,240 @@ def fake_select(message, choices, *, back, search): assert captured == {"back": True, "search": True, "n": 1} -def test_select_action_lists_three_ops(monkeypatch): +def test_select_action_lists_all_ops(monkeypatch): captured = {} def fake_select(message, choices, *, back, search): captured.update(back=back, search=search, values=[c[1] for c in choices]) - return "rebuild" + return "logs" monkeypatch.setattr(menu, "select", fake_select) - assert actions_project._select_action("carmo") == "rebuild" + assert actions_project._select_action("carmo") == "logs" assert captured["back"] is True assert captured["search"] is False - assert captured["values"] == ["up", "rebuild", "down"] + # up を先頭にしつつ全8操作を提示する (PR2)。 + assert captured["values"] == [ + "up", "down", "login", "ps", "logs", "scale", "build", "rebuild"] + assert captured["values"][0] == "up", "Enter 連打で up に到達できる" + + +# --------------------------------------------------------------------------- +# _run_operation: 各操作の引数収集 + dispatch 契約 (plan 2.3) +# --------------------------------------------------------------------------- + +def _capture_dispatch(monkeypatch): + """cmd_project の呼び出し引数を全属性キャプチャするヘルパ。""" + from devbase.commands import container as container_mod + captured = {} + + def _spy(args): + captured["subcommand"] = args.subcommand + captured["name"] = args.name + for k in ("scale", "index", "all", "follow", "tail", "new_scale", "image"): + if hasattr(args, k): + captured[k] = getattr(args, k) + return 0 + + monkeypatch.setattr(container_mod, "cmd_project", _spy) + return captured + + +def test_run_operation_up_passes_scale_none(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + assert actions_project._run_operation(tmp_path, "carmo", "up") == 0 + assert captured["subcommand"] == "up" and captured["scale"] is None + + +def test_run_operation_rebuild(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + assert actions_project._run_operation(tmp_path, "carmo", "rebuild") == 0 + assert captured["subcommand"] == "rebuild" and captured["name"] == "carmo" + + +def test_run_operation_down_confirmed(monkeypatch, tmp_path): + """down は confirm=True で停止を実行する (plan 3.4)。""" + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_project._run_operation(tmp_path, "carmo", "down") == 0 + assert captured["subcommand"] == "down" + + +@pytest.mark.parametrize("confirm_ret", [False, None]) +def test_run_operation_down_cancelled_does_not_dispatch(monkeypatch, tmp_path, confirm_ret): + """down の confirm を拒否 (False) / 中止 (None) したら停止しない (_ARG_CANCEL)。""" + from devbase.commands import container as container_mod + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: confirm_ret) + assert actions_project._run_operation(tmp_path, "carmo", "down") is actions_project._ARG_CANCEL + assert called == [], "確認を拒否/中止したら down しない" + + +def test_run_operation_login_collects_index(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "text", lambda *a, **k: "3") + assert actions_project._run_operation(tmp_path, "carmo", "login") == 0 + assert captured["subcommand"] == "login" and captured["index"] == "3" + + +def test_run_operation_login_cancel(monkeypatch, tmp_path): + from devbase.commands import container as container_mod + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + monkeypatch.setattr(menu, "text", lambda *a, **k: None) # Esc/Ctrl-C + assert actions_project._run_operation(tmp_path, "carmo", "login") is actions_project._ARG_CANCEL + assert called == [] + + +def test_run_operation_ps_all_flag(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) + assert actions_project._run_operation(tmp_path, "carmo", "ps") == 0 + assert captured["subcommand"] == "ps" and captured["all"] is True + + +def test_run_operation_logs_follow_and_tail(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) # follow + monkeypatch.setattr(actions_project, "_optional_int", lambda msg: 50) # tail=50 + assert actions_project._run_operation(tmp_path, "carmo", "logs") == 0 + assert captured["subcommand"] == "logs" + assert captured["follow"] is True and captured["tail"] == 50 + + +def test_run_operation_logs_tail_empty_is_none(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "confirm", lambda *a, **k: False) + monkeypatch.setattr(actions_project, "_optional_int", lambda msg: None) # 空 = 全件 + assert actions_project._run_operation(tmp_path, "carmo", "logs") == 0 + assert captured["follow"] is False and captured["tail"] is None + + +def test_run_operation_scale_collects_int(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(menu, "integer", lambda *a, **k: 4) + assert actions_project._run_operation(tmp_path, "carmo", "scale") == 0 + assert captured["subcommand"] == "scale" and captured["new_scale"] == 4 + + +def test_run_operation_scale_cancel(monkeypatch, tmp_path): + from devbase.commands import container as container_mod + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + monkeypatch.setattr(menu, "integer", lambda *a, **k: None) + assert actions_project._run_operation(tmp_path, "carmo", "scale") is actions_project._ARG_CANCEL + assert called == [] + + +def test_run_operation_build_selects_image(monkeypatch, tmp_path): + captured = _capture_dispatch(monkeypatch) + monkeypatch.setattr(actions_project, "_select_build_image", lambda root: "web") + assert actions_project._run_operation(tmp_path, "carmo", "build") == 0 + assert captured["subcommand"] == "build" and captured["image"] == "web" + + +def test_run_operation_build_cancel(monkeypatch, tmp_path): + from devbase.commands import container as container_mod + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + monkeypatch.setattr(actions_project, "_select_build_image", + lambda root: actions_project._ARG_CANCEL) + assert actions_project._run_operation(tmp_path, "carmo", "build") is actions_project._ARG_CANCEL + assert called == [] + + +# --------------------------------------------------------------------------- +# _optional_int / _select_build_image +# --------------------------------------------------------------------------- + +def test_optional_int_value(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: "20") + assert actions_project._optional_int("tail") == 20 + + +def test_optional_int_empty_is_none(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: "") + assert actions_project._optional_int("tail") is None + + +def test_optional_int_cancel(monkeypatch): + monkeypatch.setattr(menu, "text", lambda *a, **k: None) + assert actions_project._optional_int("tail") is actions_project._ARG_CANCEL + + +def test_optional_int_reprompts_non_numeric(monkeypatch): + vals = iter(["abc", "7"]) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) + assert actions_project._optional_int("tail") == 7 + + +def test_select_build_image_lists_containers(monkeypatch, tmp_path): + """containers//Dockerfile を列挙し、選択値をそのまま返す。""" + for img in ("web", "db"): + d = tmp_path / "containers" / img + d.mkdir(parents=True) + (d / "Dockerfile").write_text("FROM scratch\n") + # Dockerfile 無しのディレクトリは除外される + (tmp_path / "containers" / "nodockerfile").mkdir() + + captured = {} + + def fake_select(message, choices, *, back, search): + captured["values"] = [c[1] for c in choices] + return "db" + + monkeypatch.setattr(menu, "select", fake_select) + assert actions_project._select_build_image(tmp_path) == "db" + # 先頭は compose 全体 (value="")、続いて sorted な img 名 + assert captured["values"] == ["", "db", "web"] + + +def test_select_build_image_compose_all_is_none(monkeypatch, tmp_path): + """『compose.yml 全体』(value='') を選ぶと None を返す。""" + d = tmp_path / "containers" / "web" + d.mkdir(parents=True) + (d / "Dockerfile").write_text("FROM scratch\n") + monkeypatch.setattr(menu, "select", lambda *a, **k: "") + assert actions_project._select_build_image(tmp_path) is None + + +def test_select_build_image_no_containers_returns_none(tmp_path): + """containers/ が無ければ選択メニューを出さず compose 全体 (None)。""" + assert actions_project._select_build_image(tmp_path) is None + + +def test_select_build_image_cancel(monkeypatch, tmp_path): + d = tmp_path / "containers" / "web" + d.mkdir(parents=True) + (d / "Dockerfile").write_text("FROM scratch\n") + monkeypatch.setattr(menu, "select", lambda *a, **k: menu.MENU_BACK) + assert actions_project._select_build_image(tmp_path) is actions_project._ARG_CANCEL + + +# --------------------------------------------------------------------------- +# _operation_menu: 引数収集中止 → サブメニュー再表示 +# --------------------------------------------------------------------------- + +def test_operation_menu_arg_cancel_reshows_submenu(monkeypatch, tmp_path): + """引数収集を中止 (_ARG_CANCEL) するとサブメニューを再表示し、再選択で実行する。""" + select_calls = [] + # 1 回目: scale を選ぶ (→ 引数収集中止) / 2 回目: up を選ぶ (→ 実行) + monkeypatch.setattr(actions_project, "_select_action", + lambda name: (select_calls.append(1), + "scale" if len(select_calls) == 1 else "up")[1]) + + run_calls = [] + + def fake_run_op(root, name, op): + run_calls.append(op) + return actions_project._ARG_CANCEL if op == "scale" else 0 + + monkeypatch.setattr(actions_project, "_run_operation", fake_run_op) + + assert actions_project._operation_menu(tmp_path, "carmo") == 0 + assert run_calls == ["scale", "up"] + assert len(select_calls) == 2, "引数中止でサブメニューが再表示される" # --------------------------------------------------------------------------- From 88ee5306741a4bb15fca64e7b5ed0783f570fa46 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 10 Jun 2026 05:41:05 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(tui):=20project=20login=20=E3=81=AE?= =?UTF-8?q?=E7=A9=BA=20index=20=E3=83=90=E3=82=B0=E4=BF=AE=E6=AD=A3=20+=20?= =?UTF-8?q?logs=20--tail=20=E8=B2=A0=E6=95=B0=E3=83=90=E3=83=AA=E3=83=87?= =?UTF-8?q?=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=20(PR2=20#57=20round1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - login: menu.text(空入力で "" → --index= 失敗) を menu.integer(default=1, min_value=1) に変更し、str(index) で文字列契約を満たして dispatch。 - _optional_int: min_value=0 検証を追加し、logs --tail への負数入力を弾いて 再入力を促す (docker compose エラー防止)。 - テスト: login を menu.integer モックに追従、負数再入力テストを追加。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/tui/actions_project.py | 21 +++++++++++++++------ tests/cli/tui/test_actions_project.py | 12 ++++++++++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index 69e4842..8ebc555 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -74,12 +74,13 @@ def _select_action(name: str): list(_RUNNING_OPS), back=True, search=False) -def _optional_int(message: str): +def _optional_int(message: str, *, min_value: int = 0): """空入力を許す整数収集 (logs --tail 等)。 戻り値: ``int`` / ``None`` (空入力 = 既定動作) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止)。 - 非数値は再入力を促す。``menu.integer`` は空入力で既定値を返す仕様のため、空 = None を - 表現したい optional な数値はこちらで扱う。 + 非数値・``min_value`` 未満は再入力を促す。``menu.integer`` は空入力で既定値を返す + 仕様のため、空 = None を表現したい optional な数値はこちらで扱う。``min_value`` の + 既定は 0 で、logs --tail に負数を渡して docker compose をエラーにするのを防ぐ。 """ while True: raw = menu.text(message, allow_empty=True) @@ -88,9 +89,14 @@ def _optional_int(message: str): if raw == "": return None try: - return int(raw) + value = int(raw) except ValueError: logger.error("整数で指定してください: %r", raw) + continue + if value < min_value: + logger.error("%d 以上で指定してください。", min_value) + continue + return value def _select_build_image(devbase_root: Path): @@ -138,10 +144,13 @@ def _run_operation(devbase_root: Path, name: str, op: str): return dispatch_lifecycle("down", name) if op == "login": - index = menu.text("ログインするコンテナ番号", default="1") + # menu.text は空入力 (既定値を消して確定) で "" を返し、wrapper で --index= + # と展開されてコマンドが失敗する。menu.integer なら空入力は default=1 を返し、 + # min_value=1 で正の整数を保証する。cmd_login の index は文字列契約なので str 化。 + index = menu.integer("ログインするコンテナ番号", default=1, min_value=1) if index is None: return _ARG_CANCEL - return dispatch_lifecycle("login", name, index=index) + return dispatch_lifecycle("login", name, index=str(index)) if op == "ps": all_c = menu.confirm("停止中も含め全コンテナを表示しますか (--all)?", default=False) diff --git a/tests/cli/tui/test_actions_project.py b/tests/cli/tui/test_actions_project.py index 6899a4b..58868ab 100644 --- a/tests/cli/tui/test_actions_project.py +++ b/tests/cli/tui/test_actions_project.py @@ -290,7 +290,8 @@ def test_run_operation_down_cancelled_does_not_dispatch(monkeypatch, tmp_path, c def test_run_operation_login_collects_index(monkeypatch, tmp_path): captured = _capture_dispatch(monkeypatch) - monkeypatch.setattr(menu, "text", lambda *a, **k: "3") + # menu.integer で正の整数を保証し、index は文字列契約のため str 化して渡す。 + monkeypatch.setattr(menu, "integer", lambda *a, **k: 3) assert actions_project._run_operation(tmp_path, "carmo", "login") == 0 assert captured["subcommand"] == "login" and captured["index"] == "3" @@ -299,7 +300,7 @@ def test_run_operation_login_cancel(monkeypatch, tmp_path): from devbase.commands import container as container_mod called = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) - monkeypatch.setattr(menu, "text", lambda *a, **k: None) # Esc/Ctrl-C + monkeypatch.setattr(menu, "integer", lambda *a, **k: None) # Esc/Ctrl-C assert actions_project._run_operation(tmp_path, "carmo", "login") is actions_project._ARG_CANCEL assert called == [] @@ -386,6 +387,13 @@ def test_optional_int_reprompts_non_numeric(monkeypatch): assert actions_project._optional_int("tail") == 7 +def test_optional_int_reprompts_negative(monkeypatch): + """負数 (min_value=0 未満) は弾いて再入力を促す (logs --tail への負数防止)。""" + vals = iter(["-5", "10"]) + monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) + assert actions_project._optional_int("tail") == 10 + + def test_select_build_image_lists_containers(monkeypatch, tmp_path): """containers//Dockerfile を列挙し、選択値をそのまま返す。""" for img in ("web", "db"):