diff --git a/lib/devbase/tui/actions_env.py b/lib/devbase/tui/actions_env.py index d4eeba7..5741100 100644 --- a/lib/devbase/tui/actions_env.py +++ b/lib/devbase/tui/actions_env.py @@ -1,27 +1,24 @@ -"""env カテゴリの TUI 操作フロー (PLAN31_2 PR3)。 +"""env カテゴリの TUI 操作フロー (PLAN31_2 PR3 → メニュー再構成)。 -``devbase env`` の全サブコマンド (init/list/set/get/delete/edit/sync/project/ -export/import) をトップ階層メニューから実行できるようにする。引数収集は -``tui.menu`` のヘルパで CLI parser (cli.py ``_add_env_parser``) と同じ属性値を -集め、``tui.dispatch.dispatch_group`` 経由で既存ハンドラ ``cmd_env`` へ委譲する -(plan 2.3 契約表 / ロジック二重実装なし)。 +TUI では参照・対話系の操作のみに絞り、メニュー階層を浅くする: + +- 変数一覧はスコープ選択の中間プロンプトを挟まず、グローバル一覧のみを + 即実行する。プロジェクト単位の一覧は TUI から除外する (CLI で実行)。 +- キー単位の get/set/delete と export/import も TUI から除外する (CLI で実行。 + 値の変更は ``edit`` ($EDITOR) と ``project`` (対話設定) で代替できる)。 + +引数収集は ``tui.menu`` のヘルパで CLI parser (cli.py ``_add_env_parser``) と +同じ属性値を集め、``tui.dispatch.dispatch_group`` 経由で既存ハンドラ +``cmd_env`` へ委譲する (ロジック二重実装なし)。 project スコープ依存の扱い (plan 3.3): -- ``set --project`` / ``project`` / ``list`` (プロジェクトを含む表示範囲) / - ``get`` (プロジェクト取得) は CWD (環境変数 ``PWD``) のプロジェクト - ディレクトリで動くため、先にプロジェクト選択メニューで対象を選ばせて - chdir + ``PWD`` 差し替えしてからハンドラを呼び、実行後は必ず元へ復帰する +- ``project`` (対話設定) は CWD (環境変数 ``PWD``) のプロジェクトディレクトリで + 動くため、先にプロジェクト選択メニューで対象を選ばせて chdir + ``PWD`` + 差し替えしてからハンドラを呼び、実行後は必ず元へ復帰する (``_run_in_project``)。``cmd_env_*`` は ``os.environ.get('PWD', os.getcwd())`` で現在地を判定するため、``os.chdir`` だけでなく ``PWD`` も併せて切り替える。 -- ``edit`` は plan 3.3 で CWD スコープとされているが、実装 (``cmd_env_edit``) は - 常に ``$DEVBASE_ROOT/.env`` を開くグローバル操作のため、プロジェクト選択は - 行わない (plan 表と実装の乖離。parser / 実装を正とする)。 - -破壊的操作 ``delete`` は実行前に確認する (plan 3.4)。 - -export/import は引数が多いため TUI では主要引数 (``dest`` / ``source``) のみ -収集し、残りは CLI parser の既定値と同一の属性を明示的に渡す (既定値の乖離を -防ぐ。細かい制御が必要な場合は CLI を使う想定)。 +- ``edit`` は常に ``$DEVBASE_ROOT/.env`` を開くグローバル操作のため、 + プロジェクト選択は行わない。 中止系の伝搬 (Ctrl-C / Esc / ``_ARG_CANCEL``) は ``tui.flow`` のナビ規約に従う。 """ @@ -42,20 +39,16 @@ logger = get_logger(__name__) -# env カテゴリで選べる操作 (表示順 = ハイライト既定順)。参照系の list を先頭に -# 置き、Enter 連打で安全な一覧表示へ到達できるようにする。各 value は cmd_env の -# サブコマンド名。 +# env カテゴリで選べる操作 (表示順 = ハイライト既定順)。参照系のグローバル一覧を +# 先頭に置き、Enter 連打で安全な一覧表示へ到達できるようにする (中間プロンプト +# なしで即実行)。プロジェクト単位の一覧と get/set/delete/export/import は +# TUI から除外 (CLI で実行)。 _ENV_OPS: list[tuple[str, str]] = [ - ("変数一覧 (list)", "list"), - ("値の取得 (get)", "get"), - ("変数の設定 (set)", "set"), - ("変数の削除 (delete)", "delete"), + ("変数一覧 (グローバル)", "list-global"), ("エディタで編集 (edit)", "edit"), ("認証情報の再同期 (sync)", "sync"), ("プロジェクト変数の対話設定 (project)", "project"), ("初期セットアップ (init)", "init"), - ("暗号化バンドルへエクスポート (export)", "export"), - ("バンドルからインポート (import)", "import"), ] # 中止系番兵は flow と同一オブジェクトを再公開する (呼び出し側・テストの契約)。 @@ -136,153 +129,10 @@ def _run_in_project(devbase_root: Path, project_name: str, fn): os.environ["PWD"] = old_pwd -def _collect_assignment(): - """``env set`` の KEY=VALUE を収集する。 - - 形式エラー (``=`` 無し / キー名空) は ``cmd_env_set`` でも弾かれるが、TUI では - 実行前に再入力を促す。戻り値: 入力文字列 / ``MENU_BACK`` (Esc → サブメニューへ - 戻る) / ``None`` (Ctrl-C → 全体中止)。 - """ - while True: - raw = menu.text("設定する変数 (KEY=VALUE 形式)", allow_empty=False) - if raw is None or raw is menu.MENU_BACK: - return raw # None=Ctrl-C 全体中止 / MENU_BACK=Esc 戻る - if "=" not in raw or not raw.partition("=")[0].strip(): - logger.error("形式: KEY=VALUE (キー名は必須)") - continue - return raw - - -def _export_default_attrs() -> dict: - """``env export`` の CLI parser 既定値 (cli.py:246-279) と同一の属性セット。 - - TUI で収集しない引数も Namespace に明示的に載せ、CLI 実行と完全に同じ属性で - ハンドラを呼ぶ (getattr 既定値とのズレを防ぐ)。list は呼び出しごとに新規生成。 - """ - return { - "include_projects": None, - "exclude_projects": [], - "no_global": False, - "no_metadata": False, - "recipients": [], - "passphrase_env": None, - "passphrase_stdin": False, - "force_unencrypted": False, - "unsafe_allow_unencrypted_bucket": False, - } - - -def _import_default_attrs() -> dict: - """``env import`` の CLI parser 既定値 (cli.py:281-328) と同一の属性セット。""" - return { - "merge": "keep-existing", - "replace_keys": "", - "replace": False, - "dry_run": False, - "identities": [], - "passphrase_env": None, - "passphrase_stdin": False, - "include_projects": None, - "exclude_projects": [], - "no_global": False, - "no_metadata": False, - "merge_metadata": False, - "backup_dir": None, - "keep_last": 10, - } - - -def _select_scoped_project(devbase_root: Path, message: str, choices): - """スコープ選択 + プロジェクトスコープなら対象プロジェクトも選ぶ共通フロー。 - - list/set/get が共有する「グローバル or プロジェクトを選び、プロジェクトを - 含むスコープなら対象名も選ぶ」の前半 2 プロンプト。``(scope, name)`` を返す - (グローバルのみのとき ``name`` は ``None``)。中止系は flow 例外で伝搬する。 - """ - scope = flow.need(menu.select(f"{message} {menu.HINT_BACK}:", - choices, back=True, search=False)) - name = None - if scope != "global": - name = flow.need(_select_project(devbase_root)) - return scope, name - - # --------------------------------------------------------------------------- # 各操作の引数収集 + dispatch (plan 2.3 契約) # --------------------------------------------------------------------------- -def _op_list(devbase_root: Path): - """``env list``: 表示範囲を収集して一覧表示する。 - - ハンドラ (``cmd_env_list``) は CWD (PWD) が projects/ 配下のときだけ - プロジェクト .env を表示するため、プロジェクトを含む表示範囲 - (「グローバル + プロジェクト」「プロジェクトのみ」) は対象プロジェクトを - 選ばせて chdir + ``PWD`` 切替後に実行する (plan 3.3 / codex round3 指摘。 - TUI は通常 DEVBASE_ROOT で動くので、切替なしではプロジェクト分が表示 - されない)。「グローバルのみ」だけが切替なしで実行できる。 - """ - scope, name = _select_scoped_project( - devbase_root, "表示範囲を選択", - [("グローバル + プロジェクト", "both"), - ("グローバルのみ (--global)", "global"), - ("プロジェクトのみ (--project)", "project")]) - - # --reveal / --keys は CLI 既定 (False = 機密値は伏せ字・通常表示) で実行する - # (非破壊操作の確認プロンプト廃止)。必要な場合は CLI を使う想定。 - attrs = {"global_only": scope == "global", - "project_only": scope == "project", - "reveal": False, "keys_only": False} - if name is None: - return _dispatch(devbase_root, "list", **attrs) - return _run_in_project(devbase_root, name, - lambda: _dispatch(devbase_root, "list", **attrs)) - - -def _op_set(devbase_root: Path): - """``env set``: 設定先 (グローバル / プロジェクト) と KEY=VALUE を収集して設定する。 - - プロジェクト設定 (--project) は対象を選ばせて chdir してから実行する (plan 3.3)。 - """ - _, name = _select_scoped_project( - devbase_root, "設定先を選択", - [("グローバル ($DEVBASE_ROOT/.env)", "global"), - ("プロジェクト (projects//.env, --project)", "project")]) - assignment = flow.need(_collect_assignment()) - - if name is None: - return _dispatch(devbase_root, "set", assignment=assignment, project=False) - return _run_in_project( - devbase_root, name, - lambda: _dispatch(devbase_root, "set", assignment=assignment, project=True)) - - -def _op_get(devbase_root: Path): - """``env get``: 取得元 (グローバル / プロジェクト) と変数名を収集して値を表示する。 - - ``cmd_env_get`` はグローバル .env に無いキーを CWD (PWD) のプロジェクト .env へ - フォールバックして探すが、TUI は常に DEVBASE_ROOT で動くため、そのままでは - プロジェクト固有キーを取得できない。list/set と同様に取得元を選ばせ、 - プロジェクト選択時は chdir + ``PWD`` 切替後に実行する (codex round2 指摘)。 - """ - _, name = _select_scoped_project( - devbase_root, "取得元を選択", - [("グローバル ($DEVBASE_ROOT/.env)", "global"), - ("プロジェクト (グローバルに無ければ projects//.env)", "project")]) - key = flow.need(menu.text("取得する変数名", allow_empty=False)) - - if name is None: - return _dispatch(devbase_root, "get", key=key) - return _run_in_project(devbase_root, name, - lambda: _dispatch(devbase_root, "get", key=key)) - - -def _op_delete(devbase_root: Path): - key = flow.need(menu.text("削除する変数名", allow_empty=False)) - # 破壊的操作のため実行前に確認する (plan 3.4)。拒否 / Esc は実行せず戻る。 - flow.confirm_or_back(f"変数 '{key}' をグローバル .env から削除しますか?") - return _dispatch(devbase_root, "delete", key=key) - - def _op_project(devbase_root: Path): # プロジェクト固有変数の対話設定。projects/ 配下で動く CWD スコープ操作の # ため、対象を選ばせて chdir してから実行する (plan 3.3)。 @@ -291,40 +141,21 @@ def _op_project(devbase_root: Path): lambda: _dispatch(devbase_root, "project")) -def _op_export(devbase_root: Path): - # 主要引数 dest のみ収集。空入力は parser 既定 (./devbase-env-.dbenv)。 - dest = flow.need(menu.path( - "出力先パス (空で既定: ./devbase-env-<タイムスタンプ>.dbenv)", - allow_empty=True)) - return _dispatch(devbase_root, "export", dest=(dest or None), - **_export_default_attrs()) - - -def _op_import(devbase_root: Path): - # 主要引数 source のみ収集 (必須 positional)。merge は parser 既定の - # keep-existing (既存キー優先) で安全側。既存 .env はハンドラ側で - # バックアップされる。 - source = flow.need(menu.path("インポートするバンドルのパス", allow_empty=False)) - return _dispatch(devbase_root, "import", source=source, - **_import_default_attrs()) - - _OP_HANDLERS = { + # グローバル一覧は引数収集なしで即実行 (chdir 不要)。--reveal/--keys は + # CLI 既定の False (伏せ字・通常表示)。 # sync は引数なしで即実行 (ソースファイルから認証情報を再同期する)。 # edit も引数なし。$DEVBASE_ROOT/.env を $EDITOR で開くグローバル操作のため # chdir しない (plan 3.3 は CWD スコープとするが実装を正とする)。 # init は --reset なし (CLI 既定) で即実行。セットアップ済みなら # cmd_env_init が案内を出して安全に終了し、やり直しは CLI --reset を使う。 + "list-global": lambda root: _dispatch(root, "list", global_only=True, + project_only=False, + reveal=False, keys_only=False), "sync": lambda root: _dispatch(root, "sync"), "edit": lambda root: _dispatch(root, "edit"), "init": lambda root: _dispatch(root, "init", reset=False), - "list": _op_list, - "set": _op_set, - "get": _op_get, - "delete": _op_delete, "project": _op_project, - "export": _op_export, - "import": _op_import, } diff --git a/lib/devbase/tui/menu.py b/lib/devbase/tui/menu.py index c08699a..e4f5b2a 100644 --- a/lib/devbase/tui/menu.py +++ b/lib/devbase/tui/menu.py @@ -96,6 +96,27 @@ def _cancel(event): return _add_escape_binding(question, _cancel) +def _guard_after_done(question): + """回答確定後 (``Application.exit`` 済み) のキー処理を無効化する。 + + prompt_toolkit は 1 回の読み取りで複数キーを同一バッチとして処理するため、 + 確定キーの直後に入力が溜まっていると (例: Ctrl-C 連打 / Enter 直後の Ctrl-C)、 + 1 つ目のキーで exit して戻り値が確定した後も残りのキーが同じバッチ内で + 処理され、questionary 組み込みの Ctrl-C ハンドラ等が再度 exit を呼んで + 「Return value already set. Application.exit() failed.」のクラッシュになる + (実 TTY でのみ再現)。アプリ単位の key_bindings (questionary 組み込み + 本 + モジュールが後付けする Esc/← を含む) を ``~is_done`` でガードし、確定後の + キーは無視する。 + """ + from prompt_toolkit.filters import is_done + from prompt_toolkit.key_binding import ConditionalKeyBindings + + kb = question.application.key_bindings + if kb is not None: + question.application.key_bindings = ConditionalKeyBindings(kb, ~is_done) + return question + + def _ask_erased(question): """``erase_when_done`` を立ててから ``ask()`` する共通ヘルパ (全プロンプト用)。 @@ -103,9 +124,12 @@ def _ask_erased(question): ループでメニューを再描画するため、回答のたびにこの行が蓄積して画面全体が 下へずれていく (実 TTY でのみ再現する残留・行ずれ不具合)。回答後に描画ごと 消去することで、メニューを常に同じ位置へ再描画する。 + + 併せて ``_guard_after_done`` で確定後のキー処理を無効化する (全プロンプトが + 本ヘルパを通るため、ここが単一の適用点)。 """ question.application.erase_when_done = True - return question.ask() + return _guard_after_done(question).ask() def _ask_with_escape(question): diff --git a/tests/cli/tui/test_actions_env.py b/tests/cli/tui/test_actions_env.py index 83f328d..f36038e 100644 --- a/tests/cli/tui/test_actions_env.py +++ b/tests/cli/tui/test_actions_env.py @@ -1,9 +1,11 @@ -"""PLAN31_2 PR3: tui.actions_env (env カテゴリ操作) のテスト。 +"""tui.actions_env (env カテゴリ操作) のテスト (PLAN31_2 PR3 → メニュー再構成)。 test_actions_project.py のパターンを踏襲し、`menu.*` を monkeypatch して選択値を -注入、`cmd_env` を mock して **plan 2.3 の契約どおりの属性を持つ Namespace** で -呼ばれることを各サブコマンドで検証する。project スコープ操作の chdir →復帰、 -破壊的操作 (delete) の confirm、Esc/←/Ctrl-C の遷移も検証する。 +注入、`cmd_env` を mock して契約どおりの属性を持つ Namespace で呼ばれることを +検証する。TUI は参照・対話系 (グローバル一覧 / edit / sync / project / init) のみ +提供し、プロジェクト単位の一覧と get/set/delete/export/import は CLI 専用 +(メニューに出さない)。project スコープ操作の chdir → 復帰、Esc/←/Ctrl-C の +遷移も検証する。 """ from __future__ import annotations @@ -92,21 +94,21 @@ def test_run_ctrl_c_aborts(monkeypatch, tmp_path): def test_run_arg_cancel_reshows_submenu(monkeypatch, tmp_path): """引数収集を中止 (_ARG_CANCEL) するとサブメニューを再表示し、再選択で実行する。""" select_calls = [] - # 1 回目: delete を選ぶ (→ 引数収集中止) / 2 回目: sync を選ぶ (→ 実行) + # 1 回目: project を選ぶ (→ 引数収集中止) / 2 回目: sync を選ぶ (→ 実行) monkeypatch.setattr(actions_env, "_select_action", lambda: (select_calls.append(1), - "delete" if len(select_calls) == 1 else "sync")[1]) + "project" if len(select_calls) == 1 else "sync")[1]) run_calls = [] def fake_run_op(root, op): run_calls.append(op) - return actions_env._ARG_CANCEL if op == "delete" else 0 + return actions_env._ARG_CANCEL if op == "project" else 0 monkeypatch.setattr(actions_env, "_run_operation", fake_run_op) assert actions_env.run(tmp_path) == 0 - assert run_calls == ["delete", "sync"] + assert run_calls == ["project", "sync"] assert len(select_calls) == 2, "引数中止でサブメニューが再表示される" @@ -114,7 +116,7 @@ def test_run_propagates_ctrl_c_from_operation(monkeypatch, tmp_path): """引数収集中の Ctrl-C (None) はサブメニューを再表示せず全体中止を伝搬する。""" select_calls = [] monkeypatch.setattr(actions_env, "_select_action", - lambda: select_calls.append(1) or "list") + lambda: select_calls.append(1) or "project") monkeypatch.setattr(actions_env, "_run_operation", lambda root, op: None) assert actions_env.run(tmp_path) is None @@ -122,7 +124,7 @@ def test_run_propagates_ctrl_c_from_operation(monkeypatch, tmp_path): # --------------------------------------------------------------------------- -# _select_action: menu.select への委譲 (全 10 サブコマンドの提示) +# _select_action: menu.select への委譲 (参照・対話系のみの提示) # --------------------------------------------------------------------------- def test_select_action_lists_all_ops(monkeypatch): @@ -131,17 +133,18 @@ def test_select_action_lists_all_ops(monkeypatch): def fake_select(message, choices, *, back, search): captured.update(back=back, search=search, values=[c[1] for c in choices]) - return "list" + return "list-global" monkeypatch.setattr(menu, "select", fake_select) - assert actions_env._select_action() == "list" + assert actions_env._select_action() == "list-global" assert captured["back"] is True assert captured["search"] is False - # 参照系の list を先頭にしつつ env の全 10 サブコマンドを提示する (PR3)。 - assert sorted(captured["values"]) == sorted([ - "init", "list", "set", "get", "delete", - "edit", "sync", "project", "export", "import"]) - assert captured["values"][0] == "list", "Enter 連打で安全な一覧表示に到達できる" + # 参照系のグローバル一覧を先頭に、参照・対話系のみを提示する (メニュー再構成)。 + # プロジェクト単位の一覧と get/set/delete/export/import は CLI 専用で + # メニューに出さない。 + assert captured["values"] == [ + "list-global", "edit", "sync", "project", "init"] + assert captured["values"][0] == "list-global", "Enter 連打で安全な一覧表示に到達できる" # --------------------------------------------------------------------------- @@ -180,313 +183,25 @@ def test_run_operation_init_runs_without_confirm(monkeypatch, tmp_path): # --------------------------------------------------------------------------- -# _run_operation: list (表示範囲のみ収集。reveal/keys は CLI 既定の False) +# _run_operation: list-global (中間プロンプトなしの即実行) # --------------------------------------------------------------------------- -def test_run_operation_list_global_scope_no_chdir(monkeypatch, tmp_path): - """list の「グローバルのみ」は --global へ写像し、プロジェクト選択も chdir もしない。""" +def test_run_operation_list_global_no_prompts_no_chdir(monkeypatch, tmp_path): + """「変数一覧 (グローバル)」は中間プロンプトなしで --global 相当を即実行する。""" captured = _capture_dispatch(monkeypatch) - monkeypatch.setattr(menu, "select", lambda *a, **k: "global") + monkeypatch.setattr(menu, "select", + lambda *a, **k: pytest.fail("グローバル一覧で選択を求めない")) monkeypatch.setattr(actions_env, "_select_project", - lambda root: pytest.fail("global でプロジェクト選択してはいけない")) - monkeypatch.setattr(menu, "confirm", - lambda *a, **k: pytest.fail("list で確認を求めない")) + lambda root: pytest.fail("グローバル一覧でプロジェクト選択しない")) before = os.getcwd() - assert actions_env._run_operation(tmp_path, "list") == 0 + assert actions_env._run_operation(tmp_path, "list-global") == 0 assert captured["attrs"] == { "subcommand": "list", "global_only": True, "project_only": False, "reveal": False, "keys_only": False, } - assert captured["cwd"] == before, "global スコープは chdir しない" - - -def test_run_operation_list_both_scope_chdirs_and_restores(monkeypatch, tmp_path): - """list の「グローバル + プロジェクト」も対象を選ばせて chdir + PWD 切替後に実行する。 - - cmd_env_list は PWD が projects/ 配下のときだけプロジェクト .env を表示する - ため、DEVBASE_ROOT のまま global_only=False で呼んでもグローバルしか表示 - されない (codex round3 指摘の回帰テスト)。 - """ - captured = _capture_dispatch(monkeypatch) - target = tmp_path / "projects" / "carmo" - target.mkdir(parents=True) - monkeypatch.setattr(menu, "select", lambda *a, **k: "both") - monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") - monkeypatch.setenv("PWD", str(tmp_path)) - - before = os.getcwd() - assert actions_env._run_operation(tmp_path, "list") == 0 - assert captured["attrs"] == { - "subcommand": "list", - "global_only": False, "project_only": False, - "reveal": False, "keys_only": False, - } - # ハンドラ実行中は projects/carmo に居る (グローバル + プロジェクト両方が出る) - assert captured["cwd"] == str(target) - assert captured["pwd"] == str(target) - # 実行後は元の CWD / PWD へ復帰する (try/finally) - assert os.getcwd() == before - assert os.environ["PWD"] == str(tmp_path) - - -def test_run_operation_list_project_chdirs_and_restores(monkeypatch, tmp_path): - """list の「プロジェクトのみ」は対象を選ばせて chdir + PWD 切替後に実行し、復帰する。 - - cmd_env_list は PWD が projects/ 配下のときだけプロジェクト .env を表示する - ため、切替なしでは何も表示されない (codex round1 指摘の回帰テスト)。 - """ - captured = _capture_dispatch(monkeypatch) - target = tmp_path / "projects" / "carmo" - target.mkdir(parents=True) - monkeypatch.setattr(menu, "select", lambda *a, **k: "project") - monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") - monkeypatch.setenv("PWD", str(tmp_path)) - - before = os.getcwd() - assert actions_env._run_operation(tmp_path, "list") == 0 - assert captured["attrs"] == { - "subcommand": "list", - "global_only": False, "project_only": True, - "reveal": False, "keys_only": False, - } - # ハンドラ実行中は projects/carmo に居る (CWD と PWD の両方を切り替える) - assert captured["cwd"] == str(target) - assert captured["pwd"] == str(target) - # 実行後は元の CWD / PWD へ復帰する (try/finally) - assert os.getcwd() == before - assert os.environ["PWD"] == str(tmp_path) - - -def test_run_operation_list_project_select_cancel(monkeypatch, tmp_path): - """list のプロジェクト選択を中止したら実行しない。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - monkeypatch.setattr(menu, "select", lambda *a, **k: "project") - monkeypatch.setattr(actions_env, "_select_project", - lambda root: actions_env._ARG_CANCEL) - assert actions_env._run_operation(tmp_path, "list") is actions_env._ARG_CANCEL - assert called == [] - - -@pytest.mark.parametrize("scope_ret", ["BACK", None]) -def test_run_operation_list_scope_cancel(monkeypatch, tmp_path, scope_ret): - """表示範囲選択で Esc/← は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - ret = menu.MENU_BACK if scope_ret == "BACK" else None - monkeypatch.setattr(menu, "select", lambda *a, **k: ret) - expected = actions_env._ARG_CANCEL if scope_ret == "BACK" else None - assert actions_env._run_operation(tmp_path, "list") is expected - assert called == [] - - -# --------------------------------------------------------------------------- -# _run_operation: get / delete -# --------------------------------------------------------------------------- - -def test_run_operation_get_global_collects_key(monkeypatch, tmp_path): - """グローバル取得は chdir せず key のみ渡して委譲する。""" - captured = _capture_dispatch(monkeypatch) - monkeypatch.setattr(menu, "select", lambda *a, **k: "global") - monkeypatch.setattr(menu, "text", lambda *a, **k: "MY_KEY") - - before = os.getcwd() - assert actions_env._run_operation(tmp_path, "get") == 0 - assert captured["attrs"] == {"subcommand": "get", "key": "MY_KEY"} - assert captured["cwd"] == before, "グローバル取得は chdir しない" - - -def test_run_operation_get_project_chdirs_and_restores(monkeypatch, tmp_path): - """get のプロジェクト取得は対象へ chdir + PWD 切替後に実行し、復帰する。 - - cmd_env_get はグローバル .env に無いキーを CWD (PWD) のプロジェクト .env へ - フォールバックして探すため、切替なしではプロジェクト固有キーを取得できない - (codex round2 指摘の回帰テスト)。 - """ - captured = _capture_dispatch(monkeypatch) - target = tmp_path / "projects" / "carmo" - target.mkdir(parents=True) - monkeypatch.setattr(menu, "select", lambda *a, **k: "project") - monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") - monkeypatch.setattr(menu, "text", lambda *a, **k: "DB_HOST") - monkeypatch.setenv("PWD", str(tmp_path)) - - before = os.getcwd() - assert actions_env._run_operation(tmp_path, "get") == 0 - assert captured["attrs"] == {"subcommand": "get", "key": "DB_HOST"} - # ハンドラ実行中は projects/carmo に居る (CWD と PWD の両方を切り替える) - assert captured["cwd"] == str(target) - assert captured["pwd"] == str(target) - # 実行後は元の CWD / PWD へ復帰する (try/finally) - assert os.getcwd() == before - assert os.environ["PWD"] == str(tmp_path) - - -def test_run_operation_get_project_select_cancel(monkeypatch, tmp_path): - """get のプロジェクト選択を中止したらキー入力にも進まない。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - monkeypatch.setattr(menu, "select", lambda *a, **k: "project") - monkeypatch.setattr(actions_env, "_select_project", - lambda root: actions_env._ARG_CANCEL) - monkeypatch.setattr(menu, "text", - lambda *a, **k: pytest.fail("選択中止後に入力を求めない")) - assert actions_env._run_operation(tmp_path, "get") is actions_env._ARG_CANCEL - assert called == [] - - -@pytest.mark.parametrize("scope_ret", ["BACK", None]) -def test_run_operation_get_scope_cancel(monkeypatch, tmp_path, scope_ret): - """取得元選択で Esc/← は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - ret = menu.MENU_BACK if scope_ret == "BACK" else None - monkeypatch.setattr(menu, "select", lambda *a, **k: ret) - expected = actions_env._ARG_CANCEL if scope_ret == "BACK" else None - assert actions_env._run_operation(tmp_path, "get") is expected - assert called == [] - - -@pytest.mark.parametrize("text_ret", ["BACK", None]) -def test_run_operation_get_key_cancel(monkeypatch, tmp_path, text_ret): - """キー入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - monkeypatch.setattr(menu, "select", lambda *a, **k: "global") - ret = menu.MENU_BACK if text_ret == "BACK" else None - monkeypatch.setattr(menu, "text", lambda *a, **k: ret) - expected = actions_env._ARG_CANCEL if text_ret == "BACK" else None - assert actions_env._run_operation(tmp_path, "get") is expected - assert called == [] - - -def test_run_operation_delete_confirmed(monkeypatch, tmp_path): - """delete は confirm=True で削除を実行する (plan 3.4 破壊的操作)。""" - captured = _capture_dispatch(monkeypatch) - monkeypatch.setattr(menu, "text", lambda *a, **k: "OLD_KEY") - monkeypatch.setattr(menu, "confirm", lambda *a, **k: True) - assert actions_env._run_operation(tmp_path, "delete") == 0 - assert captured["attrs"] == {"subcommand": "delete", "key": "OLD_KEY"} - - -@pytest.mark.parametrize("confirm_ret", [False, "BACK", None]) -def test_run_operation_delete_cancelled_does_not_dispatch(monkeypatch, tmp_path, - confirm_ret): - """delete の confirm を拒否 (False) / Esc / Ctrl-C したら削除しない。 - - 拒否と Esc はサブメニュー再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。 - """ - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - monkeypatch.setattr(menu, "text", lambda *a, **k: "OLD_KEY") - ret = menu.MENU_BACK if confirm_ret == "BACK" else confirm_ret - monkeypatch.setattr(menu, "confirm", lambda *a, **k: ret) - expected = None if confirm_ret is None else actions_env._ARG_CANCEL - assert actions_env._run_operation(tmp_path, "delete") is expected - assert called == [], "確認を拒否/中止したら delete しない" - - -@pytest.mark.parametrize("text_ret", ["BACK", None]) -def test_run_operation_delete_key_cancel(monkeypatch, tmp_path, text_ret): - """delete のキー入力を中止したら confirm にも進まない。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - ret = menu.MENU_BACK if text_ret == "BACK" else None - monkeypatch.setattr(menu, "text", lambda *a, **k: ret) - monkeypatch.setattr(menu, "confirm", - lambda *a, **k: pytest.fail("キー未入力で confirm しない")) - expected = actions_env._ARG_CANCEL if text_ret == "BACK" else None - assert actions_env._run_operation(tmp_path, "delete") is expected - assert called == [] - - -# --------------------------------------------------------------------------- -# _run_operation: set (グローバル / プロジェクト + chdir) -# --------------------------------------------------------------------------- - -def test_run_operation_set_global(monkeypatch, tmp_path): - """グローバル設定は chdir せず project=False で委譲する (plan 2.3)。""" - captured = _capture_dispatch(monkeypatch) - monkeypatch.setattr(menu, "select", lambda *a, **k: "global") - monkeypatch.setattr(menu, "text", lambda *a, **k: "API_KEY=secret") - - before = os.getcwd() - assert actions_env._run_operation(tmp_path, "set") == 0 - assert captured["attrs"] == {"subcommand": "set", - "assignment": "API_KEY=secret", "project": False} - assert captured["cwd"] == before, "グローバル設定は chdir しない" - - -def test_run_operation_set_project_chdirs_and_restores(monkeypatch, tmp_path): - """プロジェクト設定は対象へ chdir + PWD 切替後に project=True で委譲し、復帰する。""" - captured = _capture_dispatch(monkeypatch) - target = tmp_path / "projects" / "carmo" - target.mkdir(parents=True) - monkeypatch.setattr(menu, "select", lambda *a, **k: "project") - monkeypatch.setattr(actions_env, "_select_project", lambda root: "carmo") - monkeypatch.setattr(menu, "text", lambda *a, **k: "DB_HOST=localhost") - monkeypatch.setenv("PWD", str(tmp_path)) - - before = os.getcwd() - assert actions_env._run_operation(tmp_path, "set") == 0 - assert captured["attrs"] == {"subcommand": "set", - "assignment": "DB_HOST=localhost", "project": True} - # ハンドラ実行中は projects/carmo に居る (CWD と PWD の両方を切り替える) - assert captured["cwd"] == str(target) - assert captured["pwd"] == str(target) - # 実行後は元の CWD / PWD へ復帰する (try/finally) - assert os.getcwd() == before - assert os.environ["PWD"] == str(tmp_path) - - -def test_run_operation_set_project_select_cancel(monkeypatch, tmp_path): - """プロジェクト選択を中止したら assignment 入力にも進まない。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - monkeypatch.setattr(menu, "select", lambda *a, **k: "project") - monkeypatch.setattr(actions_env, "_select_project", - lambda root: actions_env._ARG_CANCEL) - monkeypatch.setattr(menu, "text", - lambda *a, **k: pytest.fail("選択中止後に入力を求めない")) - assert actions_env._run_operation(tmp_path, "set") is actions_env._ARG_CANCEL - assert called == [] - - -@pytest.mark.parametrize("scope_ret", ["BACK", None]) -def test_run_operation_set_scope_cancel(monkeypatch, tmp_path, scope_ret): - """設定先選択で Esc/← は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - ret = menu.MENU_BACK if scope_ret == "BACK" else None - monkeypatch.setattr(menu, "select", lambda *a, **k: ret) - expected = actions_env._ARG_CANCEL if scope_ret == "BACK" else None - assert actions_env._run_operation(tmp_path, "set") is expected - assert called == [] - - -@pytest.mark.parametrize("text_ret", ["BACK", None]) -def test_run_operation_set_assignment_cancel(monkeypatch, tmp_path, text_ret): - """assignment 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - monkeypatch.setattr(menu, "select", lambda *a, **k: "global") - ret = menu.MENU_BACK if text_ret == "BACK" else None - monkeypatch.setattr(menu, "text", lambda *a, **k: ret) - expected = actions_env._ARG_CANCEL if text_ret == "BACK" else None - assert actions_env._run_operation(tmp_path, "set") is expected - assert called == [] + assert captured["cwd"] == before, "グローバル一覧は chdir しない" # --------------------------------------------------------------------------- @@ -573,115 +288,9 @@ def test_run_in_project_missing_dir_cancels(monkeypatch, tmp_path): # --------------------------------------------------------------------------- -# _run_operation: export / import (parser 既定値との同期) +# _select_project # --------------------------------------------------------------------------- -def test_run_operation_export_default_dest(monkeypatch, tmp_path): - """export は空入力で dest=None、残り属性は CLI parser 既定値と一致する。""" - captured = _capture_dispatch(monkeypatch) - monkeypatch.setattr(menu, "path", lambda *a, **k: "") - assert actions_env._run_operation(tmp_path, "export") == 0 - assert captured["attrs"] == { - "subcommand": "export", "dest": None, - "include_projects": None, "exclude_projects": [], - "no_global": False, "no_metadata": False, "recipients": [], - "passphrase_env": None, "passphrase_stdin": False, - "force_unencrypted": False, "unsafe_allow_unencrypted_bucket": False, - } - - -def test_run_operation_export_explicit_dest(monkeypatch, tmp_path): - captured = _capture_dispatch(monkeypatch) - monkeypatch.setattr(menu, "path", lambda *a, **k: "/tmp/bundle.dbenv") - assert actions_env._run_operation(tmp_path, "export") == 0 - assert captured["attrs"]["dest"] == "/tmp/bundle.dbenv" - - -@pytest.mark.parametrize("path_ret", ["BACK", None]) -def test_run_operation_export_cancel(monkeypatch, tmp_path, path_ret): - """dest 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - ret = menu.MENU_BACK if path_ret == "BACK" else None - monkeypatch.setattr(menu, "path", lambda *a, **k: ret) - expected = actions_env._ARG_CANCEL if path_ret == "BACK" else None - assert actions_env._run_operation(tmp_path, "export") is expected - assert called == [] - - -def test_run_operation_import_collects_source(monkeypatch, tmp_path): - """import は source を収集し、残り属性は CLI parser 既定値と一致する。""" - captured = _capture_dispatch(monkeypatch) - monkeypatch.setattr(menu, "path", lambda *a, **k: "/tmp/bundle.dbenv") - assert actions_env._run_operation(tmp_path, "import") == 0 - assert captured["attrs"] == { - "subcommand": "import", "source": "/tmp/bundle.dbenv", - "merge": "keep-existing", "replace_keys": "", "replace": False, - "dry_run": False, "identities": [], - "passphrase_env": None, "passphrase_stdin": False, - "include_projects": None, "exclude_projects": [], - "no_global": False, "no_metadata": False, "merge_metadata": False, - "backup_dir": None, "keep_last": 10, - } - - -@pytest.mark.parametrize("path_ret", ["BACK", None]) -def test_run_operation_import_cancel(monkeypatch, tmp_path, path_ret): - """source 入力で Esc は再表示 (_ARG_CANCEL)、Ctrl-C は全体中止 (None)。""" - from devbase.commands import env as env_mod - called = [] - monkeypatch.setattr(env_mod, "cmd_env", lambda root, args: called.append(1) or 0) - ret = menu.MENU_BACK if path_ret == "BACK" else None - monkeypatch.setattr(menu, "path", lambda *a, **k: ret) - expected = actions_env._ARG_CANCEL if path_ret == "BACK" else None - assert actions_env._run_operation(tmp_path, "import") is expected - assert called == [] - - -def test_export_defaults_match_cli_parser(tmp_path): - """TUI が補う export 既定値が cli.py parser の parse 結果と一致する (plan 6 同期)。""" - from devbase import cli - parsed = vars(cli._create_parser().parse_args(["env", "export"])) - tui_attrs = {"dest": None, **actions_env._export_default_attrs()} - for key, value in tui_attrs.items(): - assert parsed[key] == value, f"export 属性 {key} が parser 既定値と乖離" - - -def test_import_defaults_match_cli_parser(tmp_path): - """TUI が補う import 既定値が cli.py parser の parse 結果と一致する (plan 6 同期)。""" - from devbase import cli - parsed = vars(cli._create_parser().parse_args(["env", "import", "b.dbenv"])) - tui_attrs = {"source": "b.dbenv", **actions_env._import_default_attrs()} - for key, value in tui_attrs.items(): - assert parsed[key] == value, f"import 属性 {key} が parser 既定値と乖離" - - -# --------------------------------------------------------------------------- -# _collect_assignment / _select_project -# --------------------------------------------------------------------------- - -def test_collect_assignment_valid(monkeypatch): - monkeypatch.setattr(menu, "text", lambda *a, **k: "K=V") - assert actions_env._collect_assignment() == "K=V" - - -def test_collect_assignment_reprompts_invalid(monkeypatch): - """`=` 無し / キー名空は再入力を促す (cmd_env_set 到達前に弾く)。""" - vals = iter(["NOEQUAL", "=value", "K=V"]) - monkeypatch.setattr(menu, "text", lambda *a, **k: next(vals)) - assert actions_env._collect_assignment() == "K=V" - - -def test_collect_assignment_cancel(monkeypatch): - """Ctrl-C (None) は None、Esc (MENU_BACK) は MENU_BACK をそのまま返す。""" - monkeypatch.setattr(menu, "text", lambda *a, **k: None) - assert actions_env._collect_assignment() is None - - monkeypatch.setattr(menu, "text", lambda *a, **k: menu.MENU_BACK) - assert actions_env._collect_assignment() is menu.MENU_BACK - - def test_select_project_returns_name(monkeypatch, tmp_path): """一覧 (actions_project と同じ取得方法) から選んだ行の name を返す。""" from devbase.commands import status as status_mod diff --git a/tests/cli/tui/test_menu.py b/tests/cli/tui/test_menu.py index b3918d1..c31749e 100644 --- a/tests/cli/tui/test_menu.py +++ b/tests/cli/tui/test_menu.py @@ -112,6 +112,25 @@ def test_back_handler_sets_erase_when_done(): assert captured == {"result": menu.MENU_BACK} +def test_guard_after_done_wraps_app_key_bindings(): + """_guard_after_done が app の key_bindings を ~is_done 条件でラップすること。 + + 回答確定 (Application.exit) 後に同一バッチへ溜まったキー (Ctrl-C 連打 / + Enter 直後の Ctrl-C 等) が questionary 組み込みハンドラへ届くと exit が + 二重に呼ばれ「Return value already set」でクラッシュする (実 TTY のみで + 再現)。ガード適用で確定後のキーが無視されることの構造検証。 + """ + questionary = pytest.importorskip("questionary") + from prompt_toolkit.key_binding import ConditionalKeyBindings + + q = questionary.select("t", choices=[questionary.Choice(title="a", value="a")]) + inner = q.application.key_bindings + assert menu._guard_after_done(q) is q + wrapped = q.application.key_bindings + assert isinstance(wrapped, ConditionalKeyBindings) + assert wrapped.key_bindings is inner, "既存バインドを内包したままガードする" + + # --------------------------------------------------------------------------- # select: バインドの仕込みと戻り値 # --------------------------------------------------------------------------- diff --git a/tests/cli/tui/test_menu_pty.py b/tests/cli/tui/test_menu_pty.py index e37753a..ccb666a 100644 --- a/tests/cli/tui/test_menu_pty.py +++ b/tests/cli/tui/test_menu_pty.py @@ -175,3 +175,44 @@ def test_answered_prompts_are_erased(session): if not ln.startswith("@") and _CPR_WARNING not in ln ] assert residue == [], f"プロンプト行が画面に残留: {residue}" + + +_RAPID_KEYS_DRIVER = """ +from devbase.tui import menu + +OPS = [("再起動 (up)", "up"), ("停止 (down)", "down")] + +sel = menu.select("SELECT-RAPID を選択:", OPS, back=True) +print("@SEL=" + ("BACK" if sel is menu.MENU_BACK else repr(sel)), flush=True) +print("@END", flush=True) +""" + + +@pytest.fixture +def rapid_session(): + s = _PtySession(_RAPID_KEYS_DRIVER) + yield s + if s.proc.poll() is None: + s.proc.kill() + + +def test_buffered_key_after_answer_does_not_crash(rapid_session): + """Esc 確定と同時に届いた Ctrl-C で exit が二重に呼ばれないこと。 + + Esc バインドは矢印キーのシーケンスと区別するため eager=False (確定待ち) + なので、Esc + Ctrl-C を 1 回の write で送ると prompt_toolkit は 1 回の + キー処理の中で「Esc ハンドラ (exit 確定) → 残りバッファ再処理で Ctrl-C + ハンドラ」を連続実行する。``_guard_after_done`` が無いと questionary + 組み込みの Ctrl-C ハンドラが確定後に再度 ``Application.exit()`` を呼び、 + 「Return value already set. Application.exit() failed.」のクラッシュ画面が + 出て入力待ちで固まる (実 TTY でのみ再現。Ctrl-C 連打でも同様)。 + """ + rapid_session.wait_for("SELECT-RAPID") + rapid_session.send("\x1b\x03") # Esc 確定 + 直後の Ctrl-C を同一 write で送出 + rapid_session.wait_for("@SEL=BACK") + rapid_session.wait_for("@END") + + rapid_session.finish() + raw = bytes(rapid_session._buf).decode("utf-8", errors="replace") + assert "Application.exit() failed" not in raw + assert "Unhandled exception" not in raw