Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 26 additions & 195 deletions lib/devbase/tui/actions_env.py
Original file line number Diff line number Diff line change
@@ -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`` のナビ規約に従う。
"""
Expand All @@ -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 と同一オブジェクトを再公開する (呼び出し側・テストの契約)。
Expand Down Expand Up @@ -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/<name>/.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/<name>/.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)。
Expand All @@ -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-<TS>.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,
}


Expand Down
26 changes: 25 additions & 1 deletion lib/devbase/tui/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,40 @@ 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()`` する共通ヘルパ (全プロンプト用)。

questionary は回答確定時に「質問 + 回答」の collapse 行を画面へ残す。TUI は
ループでメニューを再描画するため、回答のたびにこの行が蓄積して画面全体が
下へずれていく (実 TTY でのみ再現する残留・行ずれ不具合)。回答後に描画ごと
消去することで、メニューを常に同じ位置へ再描画する。

併せて ``_guard_after_done`` で確定後のキー処理を無効化する (全プロンプトが
本ヘルパを通るため、ここが単一の適用点)。
"""
question.application.erase_when_done = True
return question.ask()
return _guard_after_done(question).ask()


def _ask_with_escape(question):
Expand Down
Loading
Loading