From 39a29ab8182ed6d8114ce636e019bcb1711a539a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 11 Jun 2026 17:30:17 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix(tui):=20=E5=9B=9E=E7=AD=94=E6=B8=88?= =?UTF-8?q?=E3=81=BF=E3=83=97=E3=83=AD=E3=83=B3=E3=83=97=E3=83=88=E8=A1=8C?= =?UTF-8?q?=E3=82=92=E5=85=A8=E3=83=97=E3=83=AD=E3=83=B3=E3=83=97=E3=83=88?= =?UTF-8?q?=E3=81=A7=E6=B6=88=E5=8E=BB=E3=81=97=E6=AE=8B=E7=95=99=E3=83=BB?= =?UTF-8?q?=E8=A1=8C=E3=81=9A=E3=82=8C=E3=82=92=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit questionary の collapse 行 (質問+回答) が Enter/y-n 回答後も画面に残り、 TUI ループの再描画が 1 回答ごとに下へずれていた。erase_when_done を 全 ask (select/text/confirm/path) で立てる _ask_erased ヘルパを新設。 pty + pyte の実 TTY 統合テストを追加 (monkeypatch では検出不能のため)。 Co-Authored-By: Claude Fable 5 --- lib/devbase/tui/menu.py | 19 +++- pyproject.toml | 1 + tests/cli/tui/test_menu_pty.py | 177 +++++++++++++++++++++++++++++++++ uv.lock | 18 +++- 4 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 tests/cli/tui/test_menu_pty.py diff --git a/lib/devbase/tui/menu.py b/lib/devbase/tui/menu.py index f5331c0..ffba7c3 100644 --- a/lib/devbase/tui/menu.py +++ b/lib/devbase/tui/menu.py @@ -91,6 +91,18 @@ def _cancel(event): return _add_escape_binding(question, _cancel) +def _ask_erased(question): + """``erase_when_done`` を立ててから ``ask()`` する共通ヘルパ (全プロンプト用)。 + + questionary は回答確定時に「質問 + 回答」の collapse 行を画面へ残す。TUI は + ループでメニューを再描画するため、回答のたびにこの行が蓄積して画面全体が + 下へずれていく (実 TTY でのみ再現する残留・行ずれ不具合)。回答後に描画ごと + 消去することで、メニューを常に同じ位置へ再描画する。 + """ + question.application.erase_when_done = True + return question.ask() + + def _ask_with_escape(question): """Esc→``MENU_BACK`` を仕込んでから ``ask()`` する共通ヘルパ (text/confirm/path 用)。 @@ -99,7 +111,7 @@ def _ask_with_escape(question): を適用してから問い合わせる。← は入力カーソル移動と衝突するためバインドしない。 戻り値: 入力値 / ``MENU_BACK`` (Esc) / ``None`` (Ctrl-C)。 """ - return with_escape_back(question, bind_left=False).ask() + return _ask_erased(with_escape_back(question, bind_left=False)) def with_escape_back(question, *, bind_left: bool = True): @@ -120,7 +132,8 @@ def with_escape_back(question, *, bind_left: bool = True): def _back(event): # 戻る操作で残る「質問行 (未回答のまま collapse した行)」は次のメニュー描画と # 重なり 1 行ずれの原因になるため、exit 前に erase_when_done を立てて - # プロンプト描画ごと消去する (Enter での通常回答行は従来どおり残る)。 + # プロンプト描画ごと消去する。通常回答時も ``_ask_erased`` が同フラグを立てる + # ため冗長だが、本関数を ``ask()`` 直呼びと組み合わせても安全なよう残す。 event.app.erase_when_done = True event.app.exit(result=MENU_BACK) @@ -175,7 +188,7 @@ def select(message: str, choices, *, back: bool = False, search: bool = False): question = with_escape_back(question, bind_left=not search) else: question = with_escape_cancel(question) - return question.ask() + return _ask_erased(question) # --------------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 05c24b2..7a03a1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ [dependency-groups] dev = [ + "pyte>=0.8.2", "pytest>=8.0", ] diff --git a/tests/cli/tui/test_menu_pty.py b/tests/cli/tui/test_menu_pty.py new file mode 100644 index 0000000..e37753a --- /dev/null +++ b/tests/cli/tui/test_menu_pty.py @@ -0,0 +1,177 @@ +"""実 TTY (pty) での questionary 統合テスト。 + +monkeypatch 単体テストでは questionary / prompt_toolkit 統合層の描画バグを検出 +できない (PR #61 の実 TTY バグ 3 件が review をすり抜けた教訓) ため、pty 上で +menu.* を実際に起動し、pyte で端末画面を再構成して描画結果を検証する。 + +検証対象: 回答確定後のプロンプト行 (collapse 行) が画面から消去されること。 +TUI はループでメニューを再描画するため、回答済み行が残ると 1 回答ごとに +画面が下へずれていく (プロンプト残留・行ずれ不具合)。 +""" + +from __future__ import annotations + +import os +import pty +import struct +import subprocess +import sys +import threading +import time +from pathlib import Path + +import pytest + +pyte = pytest.importorskip("pyte") + +fcntl = pytest.importorskip("fcntl") +termios = pytest.importorskip("termios") + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_COLS, _ROWS = 100, 30 + +# pty 環境では prompt_toolkit の CPR 問い合わせに端末が応答しないため、 +# この警告行が 1 度だけ出る。描画検証では無視する。 +_CPR_WARNING = "cursor position requests" + + +class _PtySession: + """pty 上で driver スクリプトを実行し、キー送出と画面再構成を行うハーネス。""" + + def __init__(self, driver_source: str): + self.master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, + struct.pack("HHHH", _ROWS, _COLS, 0, 0)) + env = dict(os.environ, TERM="xterm-256color", + PYTHONPATH=str(_REPO_ROOT / "lib")) + self.proc = subprocess.Popen( + [sys.executable, "-c", driver_source], + stdin=slave, stdout=slave, stderr=slave, env=env, + ) + os.close(slave) + self._buf = bytearray() + self._pos = 0 # wait_for の走査開始位置 (同文言の再出現を区別する) + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + + def _read_loop(self): + while True: + try: + data = os.read(self.master, 4096) + except OSError: + break + if not data: + break + self._buf += data + + def wait_for(self, text: str, timeout: float = 15.0): + """出力に text が現れるまで待つ。走査位置を進め、同文言の再出現も待てる。""" + needle = text.encode() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + idx = bytes(self._buf).find(needle, self._pos) + if idx >= 0: + self._pos = idx + len(needle) + return + time.sleep(0.05) + raise AssertionError( + f"timeout: {text!r} が出力に現れない\n--- raw ---\n{bytes(self._buf)!r}") + + def send(self, data: str): + # プロンプト文言の出現直後はキーバインド有効化前の可能性があるため一拍置く。 + time.sleep(0.1) + os.write(self.master, data.encode()) + + def finish(self, timeout: float = 15.0) -> list[str]: + """プロセス終了を待ち、pyte で再構成した最終画面 (空行除去済み) を返す。""" + self.proc.wait(timeout=timeout) + time.sleep(0.2) # 終了直前の出力フラッシュを回収 + os.close(self.master) + self._reader.join(timeout=3) + screen = pyte.Screen(_COLS, _ROWS) + stream = pyte.Stream(screen) + stream.feed(bytes(self._buf).decode("utf-8", errors="replace")) + return [line.rstrip() for line in screen.display if line.strip()] + + +_DRIVER = """ +from devbase.tui import menu + +OPS = [("再起動 (up)", "up"), ("停止 (down)", "down")] + +sel = menu.select("SELECT-PLAIN を選択:", OPS, back=True) +print(f"@SEL1={sel!r}", flush=True) + +sel = menu.select("SELECT-SEARCH を選択:", OPS, back=False, search=True) +print(f"@SEL2={sel!r}", flush=True) + +ok = menu.confirm("CONFIRM 停止しますか?", default=False) +print(f"@OK={ok!r}", flush=True) + +txt = menu.text("TEXT 入力:") +print(f"@TXT={txt!r}", flush=True) + +p = menu.path("PATH 入力:") +print(f"@PATH={p!r}", flush=True) + +back = menu.select("SELECT-BACK を選択:", OPS, back=True) +print("@BACK=" + ("BACK" if back is menu.MENU_BACK else repr(back)), flush=True) + +print("@END", flush=True) +""" + + +@pytest.fixture +def session(): + s = _PtySession(_DRIVER) + yield s + if s.proc.poll() is None: + s.proc.kill() + + +def test_answered_prompts_are_erased(session): + """全プロンプト (select/confirm/text/path) の回答済み行が画面に残留しないこと。 + + 残留すると TUI ループの再描画が 1 回答ごとに下へずれる (実 TTY のみで再現)。 + """ + session.wait_for("SELECT-PLAIN") + session.send("\x1b[B") # ↓ で「停止 (down)」へ + session.send("\r") + session.wait_for("@SEL1='down'") + + session.wait_for("SELECT-SEARCH") + session.send("\r") # 先頭 (再起動 up) を Enter で確定 + session.wait_for("@SEL2='up'") + + session.wait_for("CONFIRM") + session.send("n") # auto_enter で即確定 + session.wait_for("@OK=False") + + session.wait_for("TEXT") + session.send("abc\r") + session.wait_for("@TXT='abc'") + + session.wait_for("PATH") + session.send("xyz\r") + session.wait_for("@PATH='xyz'") + + session.wait_for("SELECT-BACK") + session.send("\x1b") # Esc → MENU_BACK (PR #61 の戻り消去の非回帰) + session.wait_for("@BACK=BACK") + session.wait_for("@END") + + lines = session.finish() + + # 結果マーカーは順序どおり画面に残る (出力自体が壊れていないことの確認) + markers = [ln for ln in lines if ln.startswith("@")] + assert markers == [ + "@SEL1='down'", "@SEL2='up'", "@OK=False", + "@TXT='abc'", "@PATH='xyz'", "@BACK=BACK", "@END", + ] + + # 回答済みプロンプト行 (「? メッセージ 回答」の collapse 行) が残留しないこと + residue = [ + ln for ln in lines + if not ln.startswith("@") and _CPR_WARNING not in ln + ] + assert residue == [], f"プロンプト行が画面に残留: {residue}" diff --git a/uv.lock b/uv.lock index f43434d..3e2a11d 100644 --- a/uv.lock +++ b/uv.lock @@ -52,6 +52,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pyte" }, { name = "pytest" }, ] @@ -64,7 +65,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0" }] +dev = [ + { name = "pyte", specifier = ">=0.8.2" }, + { name = "pytest", specifier = ">=8.0" }, +] [[package]] name = "exceptiongroup" @@ -147,6 +151,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/59/15fd1945b02e6f93eff5a2ff352e67f85f51bf543769484f9bd960868c19/pyrage-1.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:3be314a9746809c2710bfd144a6acf0c54a40f43e306857b9778a9d871ad97b3", size = 767566, upload-time = "2025-06-14T01:28:02.597Z" }, ] +[[package]] +name = "pyte" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/ab/b599762933eba04de7dc5b31ae083112a6c9a9db15b01d3109ad797559d9/pyte-0.8.2.tar.gz", hash = "sha256:5af970e843fa96a97149d64e170c984721f20e52227a2f57f0a54207f08f083f", size = 92301, upload-time = "2023-11-12T09:33:43.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/d0/bb522283b90853afbf506cd5b71c650cf708829914efd0003d615cf426cd/pyte-0.8.2-py3-none-any.whl", hash = "sha256:85db42a35798a5aafa96ac4d8da78b090b2c933248819157fc0e6f78876a0135", size = 31627, upload-time = "2023-11-12T09:33:41.096Z" }, +] + [[package]] name = "pytest" version = "9.0.3" From d9b63408e47fb278bbbf5b5ad9704974ab48890a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 11 Jun 2026 17:40:31 +0000 Subject: [PATCH 2/4] =?UTF-8?q?refactor(tui):=20=E4=B8=AD=E6=AD=A2?= =?UTF-8?q?=E4=BC=9D=E6=90=AC=E3=82=92=20flow=20=E3=83=A2=E3=82=B8?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=83=AB=E3=81=B8=E9=9B=86=E7=B4=84=E3=81=97?= =?UTF-8?q?=E7=95=AA=E5=85=B5=E3=83=A9=E3=83=80=E3=83=BC=E3=83=BB=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E3=83=AB=E3=83=BC=E3=83=97=E3=82=92=E6=8E=92=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tui/flow.py 新設: 番兵→例外変換 (need/need_optional)、境界デコレータ (collect_args)、共通メニューループ (menu_loop)、optional_int / confirm_or_back を一元化 - actions_*: 約 30 回反復していた None/MENU_BACK 番兵チェック 4 行ラダーを flow.need 1 行に、_run_operation の if-elif チェーンを dict ディスパッチに、 4 ファイル重複の run() ループを flow.menu_loop に統合 - menu.py: text/path の重複収集ループを _collect_stripped に統合、 ナビヒント文言を HINT_BACK/HINT_SEARCH 定数化 - 公開契約 (関数名・戻り値番兵・プロンプト文言・プロンプト順序) は不変。 テスト変更なしで 731 passed / ruff クリーン Co-Authored-By: Claude Fable 5 --- lib/devbase/tui/actions_env.py | 358 +++++++++++----------------- lib/devbase/tui/actions_plugin.py | 314 ++++++++++-------------- lib/devbase/tui/actions_project.py | 198 ++++++--------- lib/devbase/tui/actions_snapshot.py | 217 +++++++---------- lib/devbase/tui/flow.py | 149 ++++++++++++ lib/devbase/tui/menu.py | 47 ++-- 6 files changed, 612 insertions(+), 671 deletions(-) create mode 100644 lib/devbase/tui/flow.py diff --git a/lib/devbase/tui/actions_env.py b/lib/devbase/tui/actions_env.py index 4170877..66fa93e 100644 --- a/lib/devbase/tui/actions_env.py +++ b/lib/devbase/tui/actions_env.py @@ -17,16 +17,13 @@ 常に ``$DEVBASE_ROOT/.env`` を開くグローバル操作のため、プロジェクト選択は 行わない (plan 表と実装の乖離。parser / 実装を正とする)。 -破壊的操作 ``delete`` は実行前に ``menu.confirm`` で確認する (plan 3.4)。 +破壊的操作 ``delete`` は実行前に確認する (plan 3.4)。 export/import は引数が多いため TUI では主要引数 (``dest`` / ``source``) のみ 収集し、残りは CLI parser の既定値と同一の属性を明示的に渡す (既定値の乖離を 防ぐ。細かい制御が必要な場合は CLI を使う想定)。 -ナビ規約は actions_project と同一: -- サブメニューで Esc/← → カテゴリメニューへ戻る → (呼び出し元へ ``MENU_BACK``) -- Ctrl-C → ``None`` を伝搬して全体中止 -- 引数収集の中止 (``_ARG_CANCEL``) → サブメニューを再表示 +中止系の伝搬 (Ctrl-C / Esc / ``_ARG_CANCEL``) は ``tui.flow`` のナビ規約に従う。 """ from __future__ import annotations @@ -40,7 +37,7 @@ list_projects, ) from devbase.log import get_logger -from devbase.tui import menu +from devbase.tui import flow, menu from devbase.tui.dispatch import dispatch_group logger = get_logger(__name__) @@ -61,9 +58,8 @@ ("バンドルからインポート (import)", "import"), ] -# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。 -# dispatch の rc (int) や ``None`` (= 全体中止) と区別する (actions_project と同じ)。 -_ARG_CANCEL = object() +# 中止系番兵は flow と同一オブジェクトを再公開する (呼び出し側・テストの契約)。 +_ARG_CANCEL = flow.ARG_CANCEL def _dispatch(devbase_root: Path, subcommand: str, **attrs): @@ -83,10 +79,8 @@ def _select_action(): 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` (Ctrl-C 中止)。 """ - return menu.select( - "環境変数の操作を選択 " - "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", - list(_ENV_OPS), back=True, search=False) + return menu.select(f"環境変数の操作を選択 {menu.HINT_BACK}:", + list(_ENV_OPS), back=True, search=False) def _select_project(devbase_root: Path): @@ -104,10 +98,8 @@ def _select_project(devbase_root: Path): entries = _build_menu_entries(rows, colorize=_STATUS_COLOR) choices = [(entry, i) for i, entry in enumerate(entries)] - idx = menu.select( - "対象プロジェクトを選択 " - "(↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止):", - choices, back=True, search=True) + idx = menu.select(f"対象プロジェクトを選択 {menu.HINT_SEARCH}:", + choices, back=True, search=True) if idx is None: return None # Ctrl-C → 全体中止 (ナビ規約) if idx is menu.MENU_BACK: @@ -202,7 +194,44 @@ def _import_default_attrs() -> dict: } -def _run_list(devbase_root: Path): +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_sync(devbase_root: Path): + # 引数なし。ソースファイルから認証情報を再同期する。 + return _dispatch(devbase_root, "sync") + + +def _op_edit(devbase_root: Path): + # 引数なし。$DEVBASE_ROOT/.env を $EDITOR で開く (グローバル操作。 + # plan 3.3 は CWD スコープとするが実装はグローバルのため chdir しない)。 + return _dispatch(devbase_root, "edit") + + +def _op_init(devbase_root: Path): + # 既存設定がある場合は --reset でやり直し (既存はバックアップされる)。 + reset = flow.need(menu.confirm( + "既存の設定をバックアップしてやり直しますか (--reset)?", default=False)) + return _dispatch(devbase_root, "init", reset=reset) + + +def _op_list(devbase_root: Path): """``env list``: 表示範囲 + 表示オプションを収集して一覧表示する。 ハンドラ (``cmd_env_list``) は CWD (PWD) が projects/ 配下のときだけ @@ -212,86 +241,43 @@ def _run_list(devbase_root: Path): TUI は通常 DEVBASE_ROOT で動くので、切替なしではプロジェクト分が表示 されない)。「グローバルのみ」だけが切替なしで実行できる。 """ - scope = menu.select( - "表示範囲を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + scope, name = _select_scoped_project( + devbase_root, "表示範囲を選択", [("グローバル + プロジェクト", "both"), ("グローバルのみ (--global)", "global"), - ("プロジェクトのみ (--project)", "project")], - back=True, search=False) - if scope is None: - return None # Ctrl-C → 全体中止 - if scope is menu.MENU_BACK: - return _ARG_CANCEL + ("プロジェクトのみ (--project)", "project")]) + reveal = flow.need(menu.confirm( + "機密値を伏せ字にせず表示しますか (--reveal)?", default=False)) + keys_only = flow.need(menu.confirm("キー名のみ表示しますか (--keys)?", default=False)) - name = None - if scope in ("both", "project"): - name = _select_project(devbase_root) - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - - reveal = menu.confirm("機密値を伏せ字にせず表示しますか (--reveal)?", default=False) - if reveal is None: - return None # Ctrl-C → 全体中止 - if reveal is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - keys_only = menu.confirm("キー名のみ表示しますか (--keys)?", default=False) - if keys_only is None: - return None # Ctrl-C → 全体中止 - if keys_only is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - - if scope in ("both", "project"): - return _run_in_project( - devbase_root, name, - lambda: _dispatch(devbase_root, "list", - global_only=False, - project_only=(scope == "project"), - reveal=reveal, keys_only=keys_only)) - return _dispatch(devbase_root, "list", - global_only=True, project_only=False, - reveal=reveal, keys_only=keys_only) - - -def _run_set(devbase_root: Path): + attrs = {"global_only": scope == "global", + "project_only": scope == "project", + "reveal": reveal, "keys_only": keys_only} + 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)。 """ - scope = menu.select( - "設定先を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + _, name = _select_scoped_project( + devbase_root, "設定先を選択", [("グローバル ($DEVBASE_ROOT/.env)", "global"), - ("プロジェクト (projects//.env, --project)", "project")], - back=True, search=False) - if scope is None: - return None # Ctrl-C → 全体中止 - if scope is menu.MENU_BACK: - return _ARG_CANCEL + ("プロジェクト (projects//.env, --project)", "project")]) + assignment = flow.need(_collect_assignment()) - name = None - if scope == "project": - name = _select_project(devbase_root) - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - - assignment = _collect_assignment() - if assignment is None: - return None # Ctrl-C → 全体中止 - if assignment is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - - if scope == "project": - return _run_in_project( - devbase_root, name, - lambda: _dispatch(devbase_root, "set", - assignment=assignment, project=True)) - return _dispatch(devbase_root, "set", assignment=assignment, project=False) - - -def _run_get(devbase_root: Path): + 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 へ @@ -299,131 +285,85 @@ def _run_get(devbase_root: Path): プロジェクト固有キーを取得できない。list/set と同様に取得元を選ばせ、 プロジェクト選択時は chdir + ``PWD`` 切替後に実行する (codex round2 指摘)。 """ - scope = menu.select( - "取得元を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", + _, name = _select_scoped_project( + devbase_root, "取得元を選択", [("グローバル ($DEVBASE_ROOT/.env)", "global"), - ("プロジェクト (グローバルに無ければ projects//.env)", "project")], - back=True, search=False) - if scope is None: - return None # Ctrl-C → 全体中止 - if scope is menu.MENU_BACK: - return _ARG_CANCEL - - name = None - if scope == "project": - name = _select_project(devbase_root) - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - - key = menu.text("取得する変数名", allow_empty=False) - if key is None: - return None # Ctrl-C → 全体中止 - if key is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - - if scope == "project": - return _run_in_project( - devbase_root, name, - lambda: _dispatch(devbase_root, "get", key=key)) - return _dispatch(devbase_root, "get", key=key) - - + ("プロジェクト (グローバルに無ければ 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)。 + name = flow.need(_select_project(devbase_root)) + return _run_in_project(devbase_root, name, + 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 = { + "sync": _op_sync, + "edit": _op_edit, + "init": _op_init, + "list": _op_list, + "set": _op_set, + "get": _op_get, + "delete": _op_delete, + "project": _op_project, + "export": _op_export, + "import": _op_import, +} + + +@flow.collect_args def _run_operation(devbase_root: Path, op: str): """選択された env 操作の引数を収集して ``cmd_env`` へ委譲する。 - 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc で引数収集を中止 = - サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc・確認拒否で引数収集を + 中止 = サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 属性は plan 2.3 の契約表 (cli.py parser と同期確認済み) に従う。 """ - if op == "sync": - # 引数なし。ソースファイルから認証情報を再同期する。 - return _dispatch(devbase_root, "sync") - - if op == "edit": - # 引数なし。$DEVBASE_ROOT/.env を $EDITOR で開く (グローバル操作。 - # plan 3.3 は CWD スコープとするが実装はグローバルのため chdir しない)。 - return _dispatch(devbase_root, "edit") - - if op == "init": - # 既存設定がある場合は --reset でやり直し (既存はバックアップされる)。 - reset = menu.confirm( - "既存の設定をバックアップしてやり直しますか (--reset)?", default=False) - if reset is None: - return None # Ctrl-C → 全体中止 - if reset is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - return _dispatch(devbase_root, "init", reset=reset) - - if op == "list": - return _run_list(devbase_root) - - if op == "set": - return _run_set(devbase_root) - - if op == "get": - return _run_get(devbase_root) - - if op == "delete": - key = menu.text("削除する変数名", allow_empty=False) - if key is None: - return None # Ctrl-C → 全体中止 - if key is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - # 破壊的操作のため実行前に確認する (plan 3.4)。拒否 (False) / Esc は実行せず - # サブメニューへ戻る。MENU_BACK は truthy のため is 判定を not より先に行う。 - ok = menu.confirm(f"変数 '{key}' をグローバル .env から削除しますか?", - default=False) - if ok is None: - return None # Ctrl-C → 全体中止 - if ok is menu.MENU_BACK or not ok: - return _ARG_CANCEL # Esc / 拒否 → 実行しない - return _dispatch(devbase_root, "delete", key=key) - - if op == "project": - # プロジェクト固有変数の対話設定。projects/ 配下で動く CWD スコープ操作の - # ため、対象を選ばせて chdir してから実行する (plan 3.3)。 - name = _select_project(devbase_root) - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - return _run_in_project(devbase_root, name, - lambda: _dispatch(devbase_root, "project")) - - if op == "export": - # 主要引数 dest のみ収集。空入力は parser 既定 (./devbase-env-.dbenv)。 - dest = menu.path("出力先パス (空で既定: ./devbase-env-<タイムスタンプ>.dbenv)", - allow_empty=True) - if dest is None: - return None # Ctrl-C → 全体中止 - if dest is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - return _dispatch(devbase_root, "export", dest=(dest or None), - **_export_default_attrs()) - - if op == "import": - # 主要引数 source のみ収集 (必須 positional)。merge は parser 既定の - # keep-existing (既存キー優先) で安全側。既存 .env はハンドラ側で - # バックアップされる。 - source = menu.path("インポートするバンドルのパス", allow_empty=False) - if source is None: - return None # Ctrl-C → 全体中止 - if source is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - return _dispatch(devbase_root, "import", source=source, - **_import_default_attrs()) - - # 到達しない (メニュー値は _ENV_OPS に限定される)。保守的に no-op。 - logger.error("未知の操作です: %s", op) - return _ARG_CANCEL + handler = _OP_HANDLERS.get(op) + if handler is None: + # 到達しない (メニュー値は _ENV_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + raise flow.BackOut + return handler(devbase_root) def run(devbase_root: Path): """環境変数カテゴリのエントリ。操作選択 → 引数収集 → cmd_env へ委譲。 - 戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同一): + 戻り値プロトコル (``flow.menu_loop``。トップループが ``is`` 同一性で判定する): - **操作を実行した場合**: dispatch の rc (``int``) を返す。「実行したのでトップへ 戻る、rc は呼び出し側が記憶」の意味で、失敗が ``devbase list`` の終了コードへ 伝搬する。 @@ -432,13 +372,5 @@ def run(devbase_root: Path): 引数収集を中止 (``_ARG_CANCEL``) した場合はサブメニューを再表示する。 """ - while True: - op = _select_action() - if op is menu.MENU_BACK: - return menu.MENU_BACK - if op is None: - return None - rc = _run_operation(devbase_root, op) - if rc is _ARG_CANCEL: - continue # 引数収集を中止 → サブメニューへ戻る - return rc # 実行 rc → トップへ復帰 (呼び出し側が記憶) + return flow.menu_loop(_select_action, + lambda op: _run_operation(devbase_root, op)) diff --git a/lib/devbase/tui/actions_plugin.py b/lib/devbase/tui/actions_plugin.py index 901d85f..12456b3 100644 --- a/lib/devbase/tui/actions_plugin.py +++ b/lib/devbase/tui/actions_plugin.py @@ -9,12 +9,9 @@ uninstall/update/info および repo remove/refresh の ``name`` は、registry (``plugins.yml``) から取得した導入済み plugin / 登録済みリポジトリの一覧から 選択させる (自由入力によるタイプミスを防ぐ)。破壊的な uninstall / repo remove は -``menu.confirm`` で実行前確認する (plan 3.4)。 +実行前に確認する (plan 3.4)。 -ナビ規約 (actions_project と同一): -- Esc / ← = 1 つ前のメニューへ戻る (``menu.MENU_BACK``) -- Ctrl-C = 全体中止 (``None`` を伝搬) -- 引数収集の中止 (``_ARG_CANCEL``) = 直前のサブメニューを再表示 +中止系の伝搬 (Ctrl-C / Esc / ``_ARG_CANCEL``) は ``tui.flow`` のナビ規約に従う。 """ from __future__ import annotations @@ -23,7 +20,7 @@ from devbase.errors import DevbaseError from devbase.log import get_logger -from devbase.tui import menu +from devbase.tui import flow, menu from devbase.tui.dispatch import dispatch_group logger = get_logger(__name__) @@ -50,9 +47,8 @@ ("リポジトリ更新 (refresh)", "refresh"), ] -# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。 -# dispatch の rc (int) や ``None`` (= 全体中止) と区別する (actions_project と同じ)。 -_ARG_CANCEL = object() +# 中止系番兵は flow と同一オブジェクトを再公開する (呼び出し側・テストの契約)。 +_ARG_CANCEL = flow.ARG_CANCEL def _dispatch(devbase_root: Path, subcommand: str, **attrs) -> int: @@ -107,9 +103,7 @@ def _select_name(message: str, names: list[str], *, all_label: str | None = None if all_label is not None: choices.append((all_label, "")) choices += [(n, n) for n in names] - sel = menu.select( - f"{message} (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", - choices, back=True, search=False) + sel = menu.select(f"{message} {menu.HINT_BACK}:", choices, back=True, search=False) if sel is None: return None # Ctrl-C → 全体中止 (ナビ規約) if sel is menu.MENU_BACK: @@ -149,9 +143,8 @@ def _select_operation(): 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` (Ctrl-C 中止)。 """ - return menu.select( - "plugin 操作を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", - list(_PLUGIN_OPS), back=True, search=False) + return menu.select(f"plugin 操作を選択 {menu.HINT_BACK}:", + list(_PLUGIN_OPS), back=True, search=False) def _select_repo_operation(): @@ -160,161 +153,130 @@ def _select_repo_operation(): 戻り値: repo_command 文字列 / ``MENU_BACK`` (Esc・← → plugin メニューへ戻る) / ``None`` (Ctrl-C 中止)。 """ - return menu.select( - "リポジトリ操作を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", - list(_REPO_OPS), back=True, search=False) + return menu.select(f"リポジトリ操作を選択 {menu.HINT_BACK}:", + list(_REPO_OPS), back=True, search=False) # --------------------------------------------------------------------------- # 各操作の引数収集 + dispatch (plan 2.3 契約) # --------------------------------------------------------------------------- +def _op_list(devbase_root: Path): + # --available: 導入済み一覧の代わりに未導入の利用可能 plugin を表示する。 + available = flow.need(menu.confirm( + "未導入の利用可能 plugin を表示しますか (--available)?", default=False)) + return _dispatch(devbase_root, "list", available=available) + + +def _op_install(devbase_root: Path): + source = flow.need(menu.text( + "インストールする plugin の source (名前 / URL / パス)", allow_empty=False)) + link = flow.need(menu.confirm( + "symlink としてインストールしますか (--link)?", default=False)) + install_all = flow.need(menu.confirm( + "リポジトリ内の全 plugin をインストールしますか (--all)?", default=False)) + return _dispatch(devbase_root, "install", + source=source, link=link, install_all=install_all) + + +def _op_uninstall(devbase_root: Path): + name = flow.need(_select_installed_plugin( + devbase_root, "アンインストールする plugin を選択")) + flow.confirm_or_back(f"plugin '{name}' をアンインストールしますか?") + return _dispatch(devbase_root, "uninstall", name=name) + + +def _op_update(devbase_root: Path): + # name=None で全 plugin 更新 (CLI の `plugin update` 引数省略と同じ)。 + name = flow.need(_select_installed_plugin( + devbase_root, "更新する plugin を選択", all_label="全 plugin を更新")) + return _dispatch(devbase_root, "update", name=name or None) + + +def _op_info(devbase_root: Path): + name = flow.need(_select_installed_plugin( + devbase_root, "詳細を表示する plugin を選択")) + return _dispatch(devbase_root, "info", name=name) + + +_OP_HANDLERS = { + "list": _op_list, + "install": _op_install, + "uninstall": _op_uninstall, + "update": _op_update, + "info": _op_info, + # sync / migrate は引数なし (plan 2.3: 属性なし)。即実行。 + "sync": lambda root: _dispatch(root, "sync"), + "migrate": lambda root: _dispatch(root, "migrate"), +} + + +@flow.collect_args def _run_operation(devbase_root: Path, op: str): """選択された plugin 操作の引数を収集して ``cmd_plugin`` へ委譲する。 - 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc で引数収集を中止 = - サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 - 破壊的な uninstall は ``menu.confirm`` で確認する (plan 3.4)。 + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc・確認拒否で引数収集を + 中止 = サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 + 破壊的な uninstall は実行前に確認する (plan 3.4)。 """ - if op == "list": - # --available: 導入済み一覧の代わりに未導入の利用可能 plugin を表示する。 - available = menu.confirm( - "未導入の利用可能 plugin を表示しますか (--available)?", default=False) - if available is None: - return None # Ctrl-C → 全体中止 - if available is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - return _dispatch(devbase_root, "list", available=available) - - if op == "install": - source = menu.text( - "インストールする plugin の source (名前 / URL / パス)", - allow_empty=False) - if source is None: - return None # Ctrl-C → 全体中止 - if source is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - link = menu.confirm( - "symlink としてインストールしますか (--link)?", default=False) - if link is None: - return None # Ctrl-C → 全体中止 - if link is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - install_all = menu.confirm( - "リポジトリ内の全 plugin をインストールしますか (--all)?", default=False) - if install_all is None: - return None # Ctrl-C → 全体中止 - if install_all is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - return _dispatch(devbase_root, "install", - source=source, link=link, install_all=install_all) - - if op == "uninstall": - name = _select_installed_plugin( - devbase_root, "アンインストールする plugin を選択") - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - ok = menu.confirm(f"plugin '{name}' をアンインストールしますか?", default=False) - if ok is None: - return None # Ctrl-C → 全体中止 - if ok is menu.MENU_BACK or not ok: - return _ARG_CANCEL # Esc / 拒否 → 実行しない - return _dispatch(devbase_root, "uninstall", name=name) - - if op == "update": - # name=None で全 plugin 更新 (CLI の `plugin update` 引数省略と同じ)。 - name = _select_installed_plugin( - devbase_root, "更新する plugin を選択", - all_label="全 plugin を更新") - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - return _dispatch(devbase_root, "update", name=name or None) - - if op == "info": - name = _select_installed_plugin( - devbase_root, "詳細を表示する plugin を選択") - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - return _dispatch(devbase_root, "info", name=name) - - if op in ("sync", "migrate"): - # 引数なし (plan 2.3: sync/migrate は属性なし)。即実行。 - return _dispatch(devbase_root, op) - - # 到達しない (メニュー値は _PLUGIN_OPS に限定される)。保守的に no-op。 - logger.error("未知の操作です: %s", op) - return _ARG_CANCEL - - + handler = _OP_HANDLERS.get(op) + if handler is None: + # 到達しない (メニュー値は _PLUGIN_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + raise flow.BackOut + return handler(devbase_root) + + +def _op_repo_add(devbase_root: Path): + url = flow.need(menu.text( + "登録するリポジトリの URL (GitHub は owner/repo 短縮形も可)", + allow_empty=False)) + # --name は任意 (空で URL から自動命名)。空文字は None へ変換して渡す。 + name = flow.need(menu.text("カスタム名 (--name 空で自動)", allow_empty=True)) + return _dispatch(devbase_root, "repo", + repo_command="add", url=url, name=name or None) + + +def _op_repo_remove(devbase_root: Path): + name = flow.need(_select_repository(devbase_root, "削除するリポジトリを選択")) + flow.confirm_or_back(f"リポジトリ '{name}' を削除しますか?") + force = flow.need(menu.confirm( + "未 commit / 未 push の変更があっても強制削除しますか (--force)?", + default=False)) + return _dispatch(devbase_root, "repo", + repo_command="remove", name=name, force=force) + + +def _op_repo_refresh(devbase_root: Path): + # name=None で全リポジトリを refresh (CLI の引数省略と同じ)。 + name = flow.need(_select_repository( + devbase_root, "更新するリポジトリを選択", all_label="全リポジトリを更新")) + return _dispatch(devbase_root, "repo", + repo_command="refresh", name=name or None) + + +_REPO_HANDLERS = { + "list": lambda root: _dispatch(root, "repo", repo_command="list"), + "add": _op_repo_add, + "remove": _op_repo_remove, + "refresh": _op_repo_refresh, +} + + +@flow.collect_args def _run_repo_operation(devbase_root: Path, op: str): """選択された plugin repo 操作の引数を収集して ``cmd_plugin`` へ委譲する。 repo 系は ``subcommand='repo'`` + ``repo_command=`` の二段属性で ``cmd_repo`` へ分岐する (plan 2.3 契約)。戻り値プロトコルは ``_run_operation`` - と同じ。破壊的な remove は ``menu.confirm`` で確認する (plan 3.4)。 + と同じ。破壊的な remove は実行前に確認する (plan 3.4)。 """ - if op == "list": - return _dispatch(devbase_root, "repo", repo_command="list") - - if op == "add": - url = menu.text( - "登録するリポジトリの URL (GitHub は owner/repo 短縮形も可)", - allow_empty=False) - if url is None: - return None # Ctrl-C → 全体中止 - if url is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - # --name は任意 (空で URL から自動命名)。空文字は None へ変換して渡す。 - name = menu.text("カスタム名 (--name 空で自動)", allow_empty=True) - if name is None: - return None # Ctrl-C → 全体中止 - if name is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - return _dispatch(devbase_root, "repo", - repo_command="add", url=url, name=name or None) - - if op == "remove": - name = _select_repository(devbase_root, "削除するリポジトリを選択") - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - ok = menu.confirm(f"リポジトリ '{name}' を削除しますか?", default=False) - if ok is None: - return None # Ctrl-C → 全体中止 - if ok is menu.MENU_BACK or not ok: - return _ARG_CANCEL # Esc / 拒否 → 実行しない - force = menu.confirm( - "未 commit / 未 push の変更があっても強制削除しますか (--force)?", - default=False) - if force is None: - return None # Ctrl-C → 全体中止 - if force is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - return _dispatch(devbase_root, "repo", - repo_command="remove", name=name, force=force) - - if op == "refresh": - # name=None で全リポジトリを refresh (CLI の引数省略と同じ)。 - name = _select_repository( - devbase_root, "更新するリポジトリを選択", - all_label="全リポジトリを更新") - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - return _dispatch(devbase_root, "repo", - repo_command="refresh", name=name or None) - - # 到達しない (メニュー値は _REPO_OPS に限定される)。保守的に no-op。 - logger.error("未知のリポジトリ操作です: %s", op) - return _ARG_CANCEL + handler = _REPO_HANDLERS.get(op) + if handler is None: + # 到達しない (メニュー値は _REPO_OPS に限定される)。保守的に no-op。 + logger.error("未知のリポジトリ操作です: %s", op) + raise flow.BackOut + return handler(devbase_root) # --------------------------------------------------------------------------- @@ -324,23 +286,12 @@ def _run_repo_operation(devbase_root: Path, op: str): def _repo_menu(devbase_root: Path): """plugin repo のサブ階層メニューを回す。 - 戻り値プロトコル (``is`` 同一性判定): - - dispatch の rc (``int``): 操作を実行 → 呼び出し元へ (最終的にトップへ復帰)。 - - ``menu.MENU_BACK``: Esc/← で plugin メニューへ戻る。 - - ``None``: Ctrl-C で全体中止。 - + 戻り値 (``flow.menu_loop`` のプロトコル): dispatch の rc (``int``) / + ``menu.MENU_BACK`` (Esc・← で plugin メニューへ戻る) / ``None`` (Ctrl-C 全体中止)。 引数収集を中止 (``_ARG_CANCEL``) した場合はサブ階層メニューを再表示する。 """ - while True: - op = _select_repo_operation() - if op is menu.MENU_BACK: - return menu.MENU_BACK - if op is None: - return None - rc = _run_repo_operation(devbase_root, op) - if rc is _ARG_CANCEL: - continue # 引数収集を中止 → サブ階層メニューへ戻る - return rc # 実行 rc → 呼び出し元へ + return flow.menu_loop(_select_repo_operation, + lambda op: _run_repo_operation(devbase_root, op)) def run(devbase_root: Path): @@ -353,25 +304,14 @@ def run(devbase_root: Path): - ``None``: Ctrl-C による全体中止。 repo はサブ階層メニュー (``_repo_menu``) へ分岐し、Esc/← で plugin メニューへ - 戻れる。引数収集を中止した場合は plugin メニューを再表示する。 + 戻れる (``MENU_BACK`` を ``_ARG_CANCEL`` 相当に読み替えて再表示する)。 操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。 """ - while True: - op = _select_operation() - if op is menu.MENU_BACK: - return menu.MENU_BACK - if op is None: - return None - + def _run(op): if op == "repo": rc = _repo_menu(devbase_root) - if rc is menu.MENU_BACK: - continue # plugin メニューへ戻る - return rc # 実行 rc (int) / None (Ctrl-C) を伝搬 - rc = _run_operation(devbase_root, op) - if rc is _ARG_CANCEL: - continue # 引数収集を中止 → plugin メニューへ戻る - - # 操作完了 → トップメニューへ復帰。rc は呼び出し側 (top loop) が記憶し - # 最終的な devbase の終了コードへ伝搬させる。 - return rc + # repo 階層から Esc で戻ったら plugin メニューを再表示する。 + return _ARG_CANCEL if rc is menu.MENU_BACK else rc + return _run_operation(devbase_root, op) + + return flow.menu_loop(_select_operation, _run) diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index 4fbd550..d4e6ef9 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -8,11 +8,12 @@ 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`` で確認する +収集ヘルパで CLI と同じ属性値を集め、破壊的な down は実行前に確認する (plan 2.3 契約表 / 3.4 破壊的操作確認)。 プロジェクト一覧の表示・選択は ``tui.app`` (トップ画面) が担い、本モジュールは 選択された 1 行の処理 (``handle_row``) と questionary 不在時のフォールバックを提供する。 +中止系の伝搬 (Ctrl-C / Esc) は ``tui.flow`` のナビ規約に従う。 """ from __future__ import annotations @@ -20,7 +21,7 @@ from pathlib import Path from devbase.log import get_logger -from devbase.tui import menu +from devbase.tui import flow, menu from devbase.tui.dispatch import dispatch_lifecycle logger = get_logger(__name__) @@ -39,13 +40,9 @@ ("再ビルド (rebuild --no-cache)", "rebuild"), ] -# 引数収集を Esc で中止したことを示す番兵 (= サブメニューへ戻る)。 -# dispatch の rc (int) や ``None`` (= Ctrl-C 全体中止) と区別する。 -_ARG_CANCEL = object() - -# _optional_int の Ctrl-C 番兵。「空入力 (None = 既定動作)」と衝突するため -# ``None`` を直接返せず、専用番兵で返して呼び出し側で ``None`` (全体中止) へ変換する。 -_ABORT = object() +# 中止系番兵は flow と同一オブジェクトを再公開する (呼び出し側・テストの契約)。 +_ARG_CANCEL = flow.ARG_CANCEL +_ABORT = flow.ABORT def _select_action(name: str): @@ -53,39 +50,17 @@ def _select_action(name: str): 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → 一覧へ戻る) / ``None`` (Ctrl-C 中止)。 """ - return menu.select( - f"'{name}' は起動中です。操作を選択 " - "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", - list(_RUNNING_OPS), back=True, search=False) + return menu.select(f"'{name}' は起動中です。操作を選択 {menu.HINT_BACK}:", + list(_RUNNING_OPS), back=True, search=False) def _optional_int(message: str, *, min_value: int = 0): - """空入力を許す整数収集 (logs --tail 等)。 - - 戻り値: ``int`` / ``None`` (空入力 = 既定動作) / ``_ARG_CANCEL`` (Esc → サブ - メニューへ戻る) / ``_ABORT`` (Ctrl-C → 全体中止。空入力の ``None`` と衝突する - ため専用番兵で返し、呼び出し側で ``None`` へ変換する)。 - 非数値・``min_value`` 未満は再入力を促す。``menu.integer`` は空入力で既定値を返す - 仕様のため、空 = None を表現したい optional な数値はこちらで扱う。``min_value`` の - 既定は 0 で、logs --tail に負数を渡して docker compose をエラーにするのを防ぐ。 + """空入力を許す整数収集 (logs --tail 等)。``flow.optional_int`` の再公開。 + + ``min_value`` の既定は 0 で、logs --tail に負数を渡して docker compose を + エラーにするのを防ぐ。戻り値の番兵契約は ``flow.optional_int`` 参照。 """ - while True: - raw = menu.text(message, allow_empty=True) - if raw is None: - return _ABORT # Ctrl-C → 全体中止 (呼び出し側で None へ変換) - if raw is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - if raw == "": - return None - try: - value = int(raw) - except ValueError: - logger.error("整数で指定してください: %r", raw) - continue - if value < min_value: - logger.error("%d 以上で指定してください。", min_value) - continue - return value + return flow.optional_int(message, min_value=min_value) def _select_build_image(devbase_root: Path): @@ -109,9 +84,8 @@ def _select_build_image(devbase_root: Path): # 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) + sel = menu.select(f"ビルドするイメージを選択 {menu.HINT_BACK}:", + choices, back=True, search=False) if sel is None: return None # Ctrl-C → 全体中止 (ナビ規約) if sel is menu.MENU_BACK: @@ -119,98 +93,84 @@ def _select_build_image(devbase_root: Path): return sel # "" = compose 全体 (呼び出し側で None へ変換) +# --------------------------------------------------------------------------- +# 各操作の引数収集 + dispatch (引数を要する操作のみ。up/rebuild は即実行) +# --------------------------------------------------------------------------- + +def _op_down(devbase_root: Path, name: str): + flow.confirm_or_back(f"'{name}' のコンテナを停止しますか?") + return dispatch_lifecycle("down", name) + + +def _op_login(devbase_root: Path, name: str): + # menu.text は空入力 (既定値を消して確定) で "" を返し、wrapper で --index= + # と展開されてコマンドが失敗する。menu.integer なら空入力は default=1 を返し、 + # min_value=1 で正の整数を保証する。cmd_login の index は文字列契約なので str 化。 + index = flow.need(menu.integer("ログインするコンテナ番号", default=1, min_value=1)) + return dispatch_lifecycle("login", name, index=str(index)) + + +def _op_ps(devbase_root: Path, name: str): + all_c = flow.need(menu.confirm( + "停止中も含め全コンテナを表示しますか (--all)?", default=False)) + return dispatch_lifecycle("ps", name, all=all_c) + + +def _op_logs(devbase_root: Path, name: str): + follow = flow.need(menu.confirm("ログを追従表示しますか (--follow)?", default=False)) + tail = flow.need_optional(_optional_int("末尾何行を表示しますか (空で全件)")) + return dispatch_lifecycle("logs", name, follow=follow, tail=tail) + + +def _op_scale(devbase_root: Path, name: str): + new_scale = flow.need(menu.integer(f"'{name}' の新しいコンテナ数", min_value=1)) + return dispatch_lifecycle("scale", name, new_scale=new_scale) + + +def _op_build(devbase_root: Path, name: str): + image = flow.need(_select_build_image(devbase_root)) + return dispatch_lifecycle("build", name, image=image or None) + + +_OP_HANDLERS = { + "down": _op_down, + "login": _op_login, + "ps": _op_ps, + "logs": _op_logs, + "scale": _op_scale, + "build": _op_build, +} + + +@flow.collect_args def _run_operation(devbase_root: Path, name: str, op: str): """選択された操作の引数を収集して ``dispatch_lifecycle`` で起動する。 - 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc で引数収集を中止 = - サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 - 引数を要さない up/rebuild は即実行。down は破壊的のため ``menu.confirm`` で確認する。 + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc・確認拒否で引数収集を + 中止 = サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 """ if op in ("up", "rebuild"): - # up は scale 属性を参照する (常に None。他コマンドは無視する)。 + # 引数なしで即実行。up は scale 属性を参照する (常に None。他コマンドは無視)。 return dispatch_lifecycle(op, name, scale=None) - if op == "down": - ok = menu.confirm(f"'{name}' のコンテナを停止しますか?", default=False) - if ok is None: - return None # Ctrl-C → 全体中止 - if ok is menu.MENU_BACK or not ok: - return _ARG_CANCEL # Esc / 拒否 → 実行しない - return dispatch_lifecycle("down", name) - - if op == "login": - # 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 None # Ctrl-C → 全体中止 - if index is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - return dispatch_lifecycle("login", name, index=str(index)) - - if op == "ps": - all_c = menu.confirm("停止中も含め全コンテナを表示しますか (--all)?", default=False) - if all_c is None: - return None # Ctrl-C → 全体中止 - if all_c is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - return dispatch_lifecycle("ps", name, all=all_c) - - if op == "logs": - follow = menu.confirm("ログを追従表示しますか (--follow)?", default=False) - if follow is None: - return None # Ctrl-C → 全体中止 - if follow is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - tail = _optional_int("末尾何行を表示しますか (空で全件)") - if tail is _ABORT: - return None # Ctrl-C → 全体中止 - if tail is _ARG_CANCEL: - return _ARG_CANCEL # Esc → サブメニューへ戻る - 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 None # Ctrl-C → 全体中止 - if new_scale is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューへ戻る - return dispatch_lifecycle("scale", name, new_scale=new_scale) - - if op == "build": - image = _select_build_image(devbase_root) - if image is None: - return None # Ctrl-C → 全体中止 - if image is _ARG_CANCEL: - return _ARG_CANCEL - return dispatch_lifecycle("build", name, image=image or None) - - # 到達しない (メニュー値は _RUNNING_OPS に限定される)。保守的に no-op。 - logger.error("未知の操作です: %s", op) - return _ARG_CANCEL + handler = _OP_HANDLERS.get(op) + if handler is None: + # 到達しない (メニュー値は _RUNNING_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + raise flow.BackOut + return handler(devbase_root, name) def _operation_menu(devbase_root: Path, name: str): """running 行の操作サブメニューを回す。 - 戻り値プロトコル (run と同じ ``is`` 同一性判定): - - dispatch の rc (``int``): 操作を実行 → 呼び出し元へ (最終的にトップへ復帰)。 - - ``menu.MENU_BACK``: Esc/← で一覧へ戻る。 - - ``None``: Ctrl-C で全体中止。 - + 戻り値 (``flow.menu_loop`` のプロトコル): 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 → 呼び出し元へ + return flow.menu_loop( + lambda: _select_action(name), + lambda op: _run_operation(devbase_root, name, op)) def handle_row(devbase_root: Path, row: dict): diff --git a/lib/devbase/tui/actions_snapshot.py b/lib/devbase/tui/actions_snapshot.py index 59332ea..53ea3a5 100644 --- a/lib/devbase/tui/actions_snapshot.py +++ b/lib/devbase/tui/actions_snapshot.py @@ -11,12 +11,13 @@ - delete: ``name`` - rotate: ``keep`` (3) -破壊的な restore / delete は実行前に ``menu.confirm`` で確認する (plan 3.4)。 -restore は ``cmd_snapshot`` 側にも TTY 時の input() 確認が残るが、TUI の規約として +破壊的な restore / delete は実行前に確認する (plan 3.4)。restore は +``cmd_snapshot`` 側にも TTY 時の input() 確認が残るが、TUI の規約として メニュー段階でも確認する (多重確認になっても安全側に倒す)。 restore / copy / delete の対象 ``name`` は ``SnapshotManager.list()`` の既存一覧 から選択させる (タイプミス防止)。一覧の取得に失敗した場合のみ自由入力へ縮退する。 +中止系の伝搬 (Ctrl-C / Esc / ``_ARG_CANCEL``) は ``tui.flow`` のナビ規約に従う。 """ from __future__ import annotations @@ -26,7 +27,7 @@ from devbase.commands.snapshot import cmd_snapshot from devbase.log import get_logger from devbase.snapshot.manager import SnapshotManager -from devbase.tui import menu +from devbase.tui import flow, menu from devbase.tui.dispatch import dispatch_group logger = get_logger(__name__) @@ -43,14 +44,9 @@ ("ローテーション (rotate)", "rotate"), ] -# 引数収集を Esc で中止したことを示す番兵 (= サブメニューへ戻る)。 -# dispatch の rc (int) や ``None`` (= Ctrl-C 全体中止) と区別する (actions_project と同じ規約)。 -_ARG_CANCEL = object() - -# _optional_point の Ctrl-C 番兵。「空入力 (None = 全差分適用)」と衝突するため -# ``None`` を直接返せず、専用番兵で返して呼び出し側で ``None`` (全体中止) へ変換する -# (actions_project._ABORT と同じ理由)。 -_ABORT = object() +# 中止系番兵は flow と同一オブジェクトを再公開する (呼び出し側・テストの契約)。 +_ARG_CANCEL = flow.ARG_CANCEL +_ABORT = flow.ABORT def _select_operation(): @@ -58,10 +54,8 @@ def _select_operation(): 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` (Ctrl-C 中止)。 """ - return menu.select( - "スナップショット操作を選択 " - "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", - list(_SNAPSHOT_OPS), back=True, search=False) + return menu.select(f"スナップショット操作を選択 {menu.HINT_BACK}:", + list(_SNAPSHOT_OPS), back=True, search=False) def _select_snapshot_name(devbase_root: Path, message: str): @@ -99,9 +93,8 @@ def _select_snapshot_name(devbase_root: Path, message: str): s.get("name")) for s in snapshots ] - sel = menu.select( - f"{message} (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止):", - choices, back=True, search=True) + sel = menu.select(f"{message} {menu.HINT_SEARCH}:", choices, + back=True, search=True) if sel is None: return None # Ctrl-C → 全体中止 (ナビ規約) if sel is menu.MENU_BACK: @@ -112,125 +105,89 @@ def _select_snapshot_name(devbase_root: Path, message: str): def _optional_point(message: str): """restore の ``--point`` を収集する (空入力 = 全差分適用 = None)。 - 戻り値: ``int`` / ``None`` (空入力) / ``_ARG_CANCEL`` (Esc → 操作メニューへ - 戻る) / ``_ABORT`` (Ctrl-C → 全体中止。空入力の ``None`` と衝突するため専用 - 番兵で返し、呼び出し側で ``None`` へ変換する)。 - ``menu.integer`` は空入力で既定値を返す仕様のため、空 = None を表現したい - optional な数値はこちらで扱う (actions_project._optional_int と同じ理由)。 + ``flow.optional_int`` の再公開 (戻り値の番兵契約はそちらを参照)。 ``SnapshotManager.restore`` は point に正の整数のみ受理するため 1 以上を要求する。 """ - while True: - raw = menu.text(message, allow_empty=True) - if raw is None: - return _ABORT # Ctrl-C → 全体中止 (呼び出し側で None へ変換) - if raw is menu.MENU_BACK: - return _ARG_CANCEL # Esc → 操作メニューへ戻る - if raw == "": - return None - try: - value = int(raw) - except ValueError: - logger.error("整数で指定してください: %r", raw) - continue - if value < 1: - logger.error("1 以上で指定してください。") - continue - return value + return flow.optional_int(message, min_value=1) + + +# --------------------------------------------------------------------------- +# 各操作の引数収集 + dispatch (plan 2.3 契約) +# --------------------------------------------------------------------------- + +def _op_create(devbase_root: Path): + name = flow.need(menu.text("スナップショット名 (空でタイムスタンプ自動命名)", + allow_empty=True)) + full = flow.need(menu.confirm("フルバックアップを強制しますか (--full)?", + default=False)) + # 空入力は CLI の --name 省略と同じ None (自動命名) に正規化する。 + return dispatch_group(cmd_snapshot, devbase_root, "create", + name=name or None, full=full) + + +def _op_restore(devbase_root: Path): + name = flow.need(_select_snapshot_name( + devbase_root, "復元するスナップショットを選択")) + point = flow.need_optional(_optional_point( + "適用する差分番号 incr-N の上限 (--point / 空で全差分適用)")) + point_msg = f" (incr-{point:03d} まで)" if point is not None else "" + flow.confirm_or_back( + f"'{name}'{point_msg} から復元しますか? 現在のボリュームデータは上書きされます。") + return dispatch_group(cmd_snapshot, devbase_root, "restore", + name=name, point=point) + + +def _op_copy(devbase_root: Path): + name = flow.need(_select_snapshot_name( + devbase_root, "複製元のスナップショットを選択")) + new_name = flow.need(menu.text("複製先のスナップショット名", allow_empty=False)) + return dispatch_group(cmd_snapshot, devbase_root, "copy", + name=name, new_name=new_name) + + +def _op_delete(devbase_root: Path): + name = flow.need(_select_snapshot_name( + devbase_root, "削除するスナップショットを選択")) + flow.confirm_or_back(f"スナップショット '{name}' を削除しますか?") + return dispatch_group(cmd_snapshot, devbase_root, "delete", name=name) +def _op_rotate(devbase_root: Path): + # keep=0 は manager 実装上 no-op (空スライス) のため 1 以上を要求する。 + keep = flow.need(menu.integer("保持する世代数 (--keep)", default=3, min_value=1)) + return dispatch_group(cmd_snapshot, devbase_root, "rotate", keep=keep) + + +_OP_HANDLERS = { + "list": lambda root: dispatch_group(cmd_snapshot, root, "list"), + "create": _op_create, + "restore": _op_restore, + "copy": _op_copy, + "delete": _op_delete, + "rotate": _op_rotate, +} + + +@flow.collect_args def _run_operation(devbase_root: Path, op: str): """選択された操作の引数を収集して ``dispatch_group`` で ``cmd_snapshot`` へ委譲する。 - 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc で引数収集を中止 = - サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 - 破壊的な restore / delete は ``menu.confirm`` で確認し、拒否時は実行しない (plan 3.4)。 + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc・確認拒否で引数収集を + 中止 = サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 + 破壊的な restore / delete は実行前に確認し、拒否時は実行しない (plan 3.4)。 """ - if op == "list": - return dispatch_group(cmd_snapshot, devbase_root, "list") - - if op == "create": - name = menu.text("スナップショット名 (空でタイムスタンプ自動命名)", - allow_empty=True) - if name is None: - return None # Ctrl-C → 全体中止 - if name is menu.MENU_BACK: - return _ARG_CANCEL # Esc → 操作メニューへ戻る - full = menu.confirm("フルバックアップを強制しますか (--full)?", default=False) - if full is None: - return None # Ctrl-C → 全体中止 - if full is menu.MENU_BACK: - return _ARG_CANCEL # Esc → 操作メニューへ戻る - # 空入力は CLI の --name 省略と同じ None (自動命名) に正規化する。 - return dispatch_group(cmd_snapshot, devbase_root, "create", - name=name or None, full=full) - - if op == "restore": - name = _select_snapshot_name(devbase_root, "復元するスナップショットを選択") - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - point = _optional_point("適用する差分番号 incr-N の上限 (--point / 空で全差分適用)") - if point is _ABORT: - return None # Ctrl-C → 全体中止 - if point is _ARG_CANCEL: - return _ARG_CANCEL # Esc → 操作メニューへ戻る - point_msg = f" (incr-{point:03d} まで)" if point is not None else "" - ok = menu.confirm( - f"'{name}'{point_msg} から復元しますか? 現在のボリュームデータは上書きされます。", - default=False) - if ok is None: - return None # Ctrl-C → 全体中止 - if ok is menu.MENU_BACK or not ok: - return _ARG_CANCEL # Esc / 拒否 → 実行しない - return dispatch_group(cmd_snapshot, devbase_root, "restore", - name=name, point=point) - - if op == "copy": - name = _select_snapshot_name(devbase_root, "複製元のスナップショットを選択") - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - new_name = menu.text("複製先のスナップショット名", allow_empty=False) - if new_name is None: - return None # Ctrl-C → 全体中止 - if new_name is menu.MENU_BACK: - return _ARG_CANCEL # Esc → 操作メニューへ戻る - return dispatch_group(cmd_snapshot, devbase_root, "copy", - name=name, new_name=new_name) - - if op == "delete": - name = _select_snapshot_name(devbase_root, "削除するスナップショットを選択") - if name is None: - return None # Ctrl-C → 全体中止 - if name is _ARG_CANCEL: - return _ARG_CANCEL - ok = menu.confirm(f"スナップショット '{name}' を削除しますか?", default=False) - if ok is None: - return None # Ctrl-C → 全体中止 - if ok is menu.MENU_BACK or not ok: - return _ARG_CANCEL # Esc / 拒否 → 実行しない - return dispatch_group(cmd_snapshot, devbase_root, "delete", name=name) - - if op == "rotate": - # keep=0 は manager 実装上 no-op (空スライス) のため 1 以上を要求する。 - keep = menu.integer("保持する世代数 (--keep)", default=3, min_value=1) - if keep is None: - return None # Ctrl-C → 全体中止 - if keep is menu.MENU_BACK: - return _ARG_CANCEL # Esc → 操作メニューへ戻る - return dispatch_group(cmd_snapshot, devbase_root, "rotate", keep=keep) - - # 到達しない (メニュー値は _SNAPSHOT_OPS に限定される)。保守的に no-op。 - logger.error("未知の操作です: %s", op) - return _ARG_CANCEL + handler = _OP_HANDLERS.get(op) + if handler is None: + # 到達しない (メニュー値は _SNAPSHOT_OPS に限定される)。保守的に no-op。 + logger.error("未知の操作です: %s", op) + raise flow.BackOut + return handler(devbase_root) def run(devbase_root: Path): """スナップショット操作カテゴリ。操作選択 → 引数収集 → 実行。 - 戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同じ): + 戻り値プロトコル (``flow.menu_loop``。トップループが ``is`` 同一性で判定する): - **操作を実行した場合**: ``dispatch_group`` の rc (``int``) を返す。 「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。 - ``menu.MENU_BACK``: 操作メニューで Esc/← (操作なしでトップへ)。 @@ -239,13 +196,5 @@ def run(devbase_root: Path): 引数収集を中止 (``_ARG_CANCEL``) した場合は操作メニューを再表示する。 操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。 """ - while True: - op = _select_operation() - if op is menu.MENU_BACK: - return menu.MENU_BACK - if op is None: - return None - rc = _run_operation(devbase_root, op) - if rc is _ARG_CANCEL: - continue # 引数収集を中止 → 操作メニューへ戻る - return rc # 実行 rc → 呼び出し元 (トップ) へ + return flow.menu_loop(_select_operation, + lambda op: _run_operation(devbase_root, op)) diff --git a/lib/devbase/tui/flow.py b/lib/devbase/tui/flow.py new file mode 100644 index 0000000..555a043 --- /dev/null +++ b/lib/devbase/tui/flow.py @@ -0,0 +1,149 @@ +"""TUI ナビゲーションのフロー制御 (番兵 → 例外変換と共通ループ)。 + +actions_* の各操作フローは共通のナビ規約を持つ: + +- Ctrl-C (``None``) は全体中止としてトップループまで伝搬する +- Esc / ← (``menu.MENU_BACK`` / ``ARG_CANCEL``) は 1 つ前のメニューへ戻る +- 破壊的操作の確認で拒否されたら実行せずサブメニューへ戻る + +これを戻り値の番兵チェックで実装すると、全プロンプトの直後に同じ分岐が並ぶ +(PLAN31_2 時点で約 30 回の反復)。本モジュールは番兵を例外へ変換する ``need`` と、 +操作関数の境界で例外を番兵へ戻すデコレータ ``collect_args`` を提供し、 +収集フローを「値を取り出すだけの直線コード」に保つ。 + +例外は ``collect_args`` を付けた操作関数の内側でのみ使い、モジュール間の +戻り値プロトコル (rc / ``ARG_CANCEL`` / ``MENU_BACK`` / ``None``) は従来どおり +番兵で受け渡す (テスト・呼び出し側の契約を変えない)。 +""" + +from __future__ import annotations + +import functools + +from devbase.log import get_logger +from devbase.tui import menu + +logger = get_logger(__name__) + +# 引数収集を Esc / 確認拒否で中止したことを示す番兵 (= サブメニューを再表示)。 +# dispatch の rc (int) や ``None`` (= Ctrl-C 全体中止) と区別する。 +ARG_CANCEL = object() + +# 「空入力 = 既定動作 (None)」を許すプロンプトの Ctrl-C 番兵。``None`` が空入力と +# 衝突するため専用番兵で返し、呼び出し側 (``need_optional``) で全体中止へ変換する。 +ABORT = object() + + +class CancelAll(Exception): + """Ctrl-C による全体中止。``collect_args`` 境界で ``None`` へ変換される。""" + + +class BackOut(Exception): + """Esc / 確認拒否による中止。``collect_args`` 境界で ``ARG_CANCEL`` へ変換される。""" + + +def need(value): + """プロンプト戻り値の番兵を例外へ変換し、実値のみを返す。 + + ``menu.*`` ヘルパおよび actions_* の選択ヘルパは「実値 / ``None`` (Ctrl-C) / + ``MENU_BACK`` または ``ARG_CANCEL`` (Esc 系)」を返す。``collect_args`` 配下では + 本関数を通すことで、中止系の分岐を呼び出し元の境界へ集約できる。 + """ + if value is None: + raise CancelAll + if value is menu.MENU_BACK or value is ARG_CANCEL: + raise BackOut + return value + + +def need_optional(value): + """``optional_int`` の番兵 (``ABORT`` / ``ARG_CANCEL``) を例外へ変換する。 + + 実値 ``int`` と「空入力 = 既定動作」の ``None`` はそのまま返す。 + """ + if value is ABORT: + raise CancelAll + if value is ARG_CANCEL: + raise BackOut + return value + + +def collect_args(fn): + """操作関数の境界で ``CancelAll``/``BackOut`` を番兵へ戻すデコレータ。 + + 付与した関数は「dispatch の rc (``int``) / ``ARG_CANCEL`` (Esc・確認拒否 = + サブメニュー再表示) / ``None`` (Ctrl-C = 全体中止)」を返す従来プロトコルを保つ。 + """ + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except CancelAll: + return None + except BackOut: + return ARG_CANCEL + return wrapper + + +def confirm_or_back(message: str, *, default: bool = False) -> None: + """破壊的操作の実行前確認 (plan 3.4)。拒否 / Esc なら ``BackOut`` で戻る。 + + ``menu.confirm`` の ``MENU_BACK`` は truthy のため、素の bool 判定では + 「Esc = 承認」と誤読する。番兵判定を ``need`` に集約した本ヘルパを使うこと。 + """ + if not need(menu.confirm(message, default=default)): + raise BackOut + + +def optional_int(message: str, *, min_value: int = 0): + """空入力を許す整数収集 (logs --tail / restore --point 等)。 + + ``menu.integer`` は空入力で既定値を返す仕様のため、「空 = 既定動作 (None)」を + 表現したい optional な数値はこちらで扱う。非数値・``min_value`` 未満は再入力を促す。 + + 戻り値: ``int`` / ``None`` (空入力 = 既定動作) / ``ARG_CANCEL`` (Esc → サブ + メニューへ戻る) / ``ABORT`` (Ctrl-C → 全体中止。``need_optional`` で変換する)。 + """ + while True: + raw = menu.text(message, allow_empty=True) + if raw is None: + return ABORT + if raw is menu.MENU_BACK: + return ARG_CANCEL + if raw == "": + return None + try: + value = int(raw) + except ValueError: + logger.error("整数で指定してください: %r", raw) + continue + if value < min_value: + logger.error("%d 以上で指定してください。", min_value) + continue + return value + + +def menu_loop(select_op, run_op): + """「操作選択 → 実行」のサブメニューループ (actions_* の ``run`` 共通骨格)。 + + Parameters + ---------- + select_op: 操作値 / ``MENU_BACK`` / ``None`` を返す選択関数。 + run_op: 選択された操作値を受け取り rc / ``ARG_CANCEL`` / ``None`` を返す実行関数。 + + Returns + ------- + rc (``int``, 操作を実行) / ``menu.MENU_BACK`` (Esc・← で 1 つ上へ) / ``None`` + (Ctrl-C 全体中止)。``run_op`` が ``ARG_CANCEL`` を返したら同じメニューを再表示する。 + 判定は必ず ``is`` 同一性で行う (rc=0 を番兵と誤マッチさせない)。 + """ + while True: + op = select_op() + if op is menu.MENU_BACK: + return menu.MENU_BACK + if op is None: + return None + rc = run_op(op) + if rc is ARG_CANCEL: + continue + return rc diff --git a/lib/devbase/tui/menu.py b/lib/devbase/tui/menu.py index ffba7c3..c08699a 100644 --- a/lib/devbase/tui/menu.py +++ b/lib/devbase/tui/menu.py @@ -42,6 +42,11 @@ # ``None`` (= Ctrl-C による全体中止) と区別するための番兵。 MENU_BACK = object() +# 選択メニューのプロンプト文言に添えるキー操作ヒント (各 actions_* で共通)。 +# search 有効メニューは ← が入力カーソルと衝突するため Esc のみを案内する。 +HINT_BACK = "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止)" +HINT_SEARCH = "(↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止)" + # --------------------------------------------------------------------------- # キーバインド (Esc / ←) @@ -195,6 +200,24 @@ def select(message: str, choices, *, back: bool = False, search: bool = False): # 引数収集ヘルパ (PR2 以降の各カテゴリ操作が CLI 相当の属性値を集めるのに使う) # --------------------------------------------------------------------------- +def _collect_stripped(make_question, *, allow_empty: bool, empty_error: str): + """text/path 共通の収集ループ。strip した入力を返す。 + + ``allow_empty=False`` のとき空文字は受け付けず再入力を促す + (自己再帰を避け while で回す)。戻り値: 入力文字列 / ``MENU_BACK`` (Esc → + 1 つ前のメニューへ戻る) / ``None`` (Ctrl-C → 全体中止)。 + """ + while True: + ans = _ask_with_escape(make_question()) + if ans is None or ans is MENU_BACK: + return ans # None=Ctrl-C 全体中止 / MENU_BACK=Esc 戻る + ans = ans.strip() + if not ans and not allow_empty: + logger.error(empty_error) + continue + return ans + + def text(message: str, *, default: str | None = None, allow_empty: bool = True): """自由入力 (1 行) を収集する。 @@ -205,15 +228,9 @@ def text(message: str, *, default: str | None = None, EOF / Ctrl-C のどちらも ``None`` = 中止)。 """ if HAVE_QUESTIONARY: - while True: # 空 (allow_empty=False) は再入力。自己再帰を避け while で回す。 - ans = _ask_with_escape(questionary.text(message, default=default or "")) - if ans is None or ans is MENU_BACK: - return ans # None=Ctrl-C 全体中止 / MENU_BACK=Esc 戻る - ans = ans.strip() - if not ans and not allow_empty: - logger.error("値を入力してください。") - continue - return ans + return _collect_stripped( + lambda: questionary.text(message, default=default or ""), + allow_empty=allow_empty, empty_error="値を入力してください。") return _input_text(message, default=default, allow_empty=allow_empty) @@ -267,15 +284,9 @@ def path(message: str, *, default: str | None = None, ``MENU_BACK`` (Esc → 1 つ前のメニューへ戻る) / ``None`` (Ctrl-C → 全体中止)。 """ if HAVE_QUESTIONARY: - while True: # 空 (allow_empty=False) は再入力。自己再帰を避け while で回す。 - ans = _ask_with_escape(questionary.path(message, default=default or "")) - if ans is None or ans is MENU_BACK: - return ans # None=Ctrl-C 全体中止 / MENU_BACK=Esc 戻る - ans = ans.strip() - if not ans and not allow_empty: - logger.error("パスを入力してください。") - continue - return ans + return _collect_stripped( + lambda: questionary.path(message, default=default or ""), + allow_empty=allow_empty, empty_error="パスを入力してください。") return _input_text(message, default=default, allow_empty=allow_empty) From b1b9ad66dae4b2ca01d25692409532a9ff528d15 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 11 Jun 2026 23:51:46 +0000 Subject: [PATCH 3/4] =?UTF-8?q?refactor(tui):=20=E7=AC=AC2=E5=BC=BE=20?= =?UTF-8?q?=E2=80=94=20=E9=81=B8=E6=8A=9E=E3=83=98=E3=83=AB=E3=83=91?= =?UTF-8?q?=E3=81=AE=E6=AE=8B=E3=83=A9=E3=83=80=E3=83=BC=E8=A7=A3=E6=B6=88?= =?UTF-8?q?=E3=81=A8=E3=82=AB=E3=83=86=E3=82=B4=E3=83=AA=E5=AE=9A=E7=BE=A9?= =?UTF-8?q?=E3=81=AE=20SSoT=20=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flow.back_as_cancel 新設: 選択ヘルパに残っていた None/MENU_BACK→ARG_CANCEL 変換ラダー 4 箇所を 1 行化 (env/_select_project, project/_select_build_image, snapshot/_select_snapshot_name×2, plugin/_select_name) - app.py: TOP_CATEGORIES / _LABELS / _route の if-elif で三重持ちだった カテゴリ定義を _CATEGORIES 単一 SSoT から導出。routing は module.run の 遅延解決 dict に (monkeypatch 互換) - actions_plugin: registry 名取得 2 関数を _registry_names(lister) に統合、 空一覧の案内+中止を _select_name へ吸収し wrapper 2 関数を薄い委譲に - actions_env / actions_project: 引数なし操作 (sync/edit/up/rebuild) を dict 直書き lambda に統一 (plugin/snapshot と同形) 公開契約・プロンプト文言・順序は不変。731 passed / ruff クリーン Co-Authored-By: Claude Fable 5 --- lib/devbase/tui/actions_env.py | 26 ++++-------- lib/devbase/tui/actions_plugin.py | 62 ++++++++++++----------------- lib/devbase/tui/actions_project.py | 18 ++++----- lib/devbase/tui/actions_snapshot.py | 16 ++------ lib/devbase/tui/app.py | 31 +++++++-------- lib/devbase/tui/flow.py | 10 +++++ 6 files changed, 68 insertions(+), 95 deletions(-) diff --git a/lib/devbase/tui/actions_env.py b/lib/devbase/tui/actions_env.py index 66fa93e..2208cc8 100644 --- a/lib/devbase/tui/actions_env.py +++ b/lib/devbase/tui/actions_env.py @@ -100,11 +100,9 @@ def _select_project(devbase_root: Path): choices = [(entry, i) for i, entry in enumerate(entries)] idx = menu.select(f"対象プロジェクトを選択 {menu.HINT_SEARCH}:", choices, back=True, search=True) - if idx is None: - return None # Ctrl-C → 全体中止 (ナビ規約) - if idx is menu.MENU_BACK: - return _ARG_CANCEL # Esc → サブメニューを再表示 - return rows[idx]["name"] + if isinstance(idx, int): + return rows[idx]["name"] + return flow.back_as_cancel(idx) # None=Ctrl-C / MENU_BACK=Esc → 再表示 def _run_in_project(devbase_root: Path, project_name: str, fn): @@ -213,17 +211,6 @@ def _select_scoped_project(devbase_root: Path, message: str, choices): # 各操作の引数収集 + dispatch (plan 2.3 契約) # --------------------------------------------------------------------------- -def _op_sync(devbase_root: Path): - # 引数なし。ソースファイルから認証情報を再同期する。 - return _dispatch(devbase_root, "sync") - - -def _op_edit(devbase_root: Path): - # 引数なし。$DEVBASE_ROOT/.env を $EDITOR で開く (グローバル操作。 - # plan 3.3 は CWD スコープとするが実装はグローバルのため chdir しない)。 - return _dispatch(devbase_root, "edit") - - def _op_init(devbase_root: Path): # 既存設定がある場合は --reset でやり直し (既存はバックアップされる)。 reset = flow.need(menu.confirm( @@ -331,8 +318,11 @@ def _op_import(devbase_root: Path): _OP_HANDLERS = { - "sync": _op_sync, - "edit": _op_edit, + # sync は引数なしで即実行 (ソースファイルから認証情報を再同期する)。 + # edit も引数なし。$DEVBASE_ROOT/.env を $EDITOR で開くグローバル操作のため + # chdir しない (plan 3.3 は CWD スコープとするが実装を正とする)。 + "sync": lambda root: _dispatch(root, "sync"), + "edit": lambda root: _dispatch(root, "edit"), "init": _op_init, "list": _op_list, "set": _op_set, diff --git a/lib/devbase/tui/actions_plugin.py b/lib/devbase/tui/actions_plugin.py index 12456b3..01e4edf 100644 --- a/lib/devbase/tui/actions_plugin.py +++ b/lib/devbase/tui/actions_plugin.py @@ -66,30 +66,25 @@ def _dispatch(devbase_root: Path, subcommand: str, **attrs) -> int: # 名前選択 (registry から一覧を取得して選ばせる) # --------------------------------------------------------------------------- -def _installed_plugin_names(devbase_root: Path) -> list[str]: - """導入済み plugin 名の一覧を registry (plugins.yml) から取得する。""" - from devbase.plugin.registry import PluginRegistry - - try: - return [p.name for p in PluginRegistry(Path(devbase_root)).list_installed()] - except DevbaseError as e: - logger.error("%s", e) - return [] - +def _registry_names(devbase_root: Path, lister: str) -> list[str]: + """registry (plugins.yml) から名前一覧を取得する。 -def _repository_names(devbase_root: Path) -> list[str]: - """登録済みリポジトリ名の一覧を registry (plugins.yml) から取得する。""" + ``lister`` は ``PluginRegistry`` の一覧メソッド名 (``list_installed`` / + ``list_repositories``)。取得に失敗したら案内して空リストを返す。 + """ from devbase.plugin.registry import PluginRegistry try: - return [r.name for r in PluginRegistry(Path(devbase_root)).list_repositories()] + registry = PluginRegistry(Path(devbase_root)) + return [item.name for item in getattr(registry, lister)()] except DevbaseError as e: logger.error("%s", e) return [] -def _select_name(message: str, names: list[str], *, all_label: str | None = None): - """名前一覧から 1 件選ばせる共通ヘルパ。 +def _select_name(message: str, names: list[str], *, + all_label: str | None = None, empty_hint: str = "対象がありません。"): + """名前一覧から 1 件選ばせる共通ヘルパ。対象が無ければ案内して ``_ARG_CANCEL``。 ``all_label`` 指定時は「全対象」(value="") を先頭に置く。選択メニューの ``None`` (Ctrl-C → 全体中止) と衝突させないため空文字を番兵にし、``None`` への変換は @@ -97,40 +92,33 @@ def _select_name(message: str, names: list[str], *, all_label: str | None = None 戻り値: 名前 (``str``) / ``""`` (all_label 選択 = 全対象。呼び出し側で ``None`` へ変換) / ``None`` (Ctrl-C → 全体中止を呼び出し元へ伝搬) / ``_ARG_CANCEL`` - (Esc・← → サブメニューへ戻る)。 + (Esc・← → サブメニューへ戻る、または対象が 1 件もない)。 """ - choices: list[tuple[str, str]] = [] - if all_label is not None: - choices.append((all_label, "")) + if not names: + logger.info("%s", empty_hint) + return _ARG_CANCEL + choices = ([(all_label, "")] if all_label is not None else []) choices += [(n, n) for n in names] - sel = menu.select(f"{message} {menu.HINT_BACK}:", choices, back=True, search=False) - if sel is None: - return None # Ctrl-C → 全体中止 (ナビ規約) - if sel is menu.MENU_BACK: - return _ARG_CANCEL # Esc/← → サブメニューを再表示 - return sel # "" = 全対象 (呼び出し側で None へ変換) + return flow.back_as_cancel(menu.select( + f"{message} {menu.HINT_BACK}:", choices, back=True, search=False)) def _select_installed_plugin(devbase_root: Path, message: str, *, all_label: str | None = None): """導入済み plugin から 1 件選ばせる。対象が無ければ案内して ``_ARG_CANCEL``。""" - names = _installed_plugin_names(devbase_root) - if not names: - logger.info("導入済みの plugin がありません。" - "`plugin install` で導入してください。") - return _ARG_CANCEL - return _select_name(message, names, all_label=all_label) + return _select_name( + message, _registry_names(devbase_root, "list_installed"), + all_label=all_label, + empty_hint="導入済みの plugin がありません。`plugin install` で導入してください。") def _select_repository(devbase_root: Path, message: str, *, all_label: str | None = None): """登録済みリポジトリから 1 件選ばせる。対象が無ければ案内して ``_ARG_CANCEL``。""" - names = _repository_names(devbase_root) - if not names: - logger.info("登録済みのリポジトリがありません。" - "`plugin repo add` で登録してください。") - return _ARG_CANCEL - return _select_name(message, names, all_label=all_label) + return _select_name( + message, _registry_names(devbase_root, "list_repositories"), + all_label=all_label, + empty_hint="登録済みのリポジトリがありません。`plugin repo add` で登録してください。") # --------------------------------------------------------------------------- diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index d4e6ef9..60ede96 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -84,13 +84,9 @@ def _select_build_image(devbase_root: Path): # value="" を「compose.yml 全体」に割り当て、選択メニューの None (Ctrl-C = # 全体中止) と衝突させない。呼び出し側で空文字 → None へ変換する。 choices = [("compose.yml 全体をビルド", "")] + [(img, img) for img in images] - sel = menu.select(f"ビルドするイメージを選択 {menu.HINT_BACK}:", - choices, back=True, search=False) - if sel is None: - return None # Ctrl-C → 全体中止 (ナビ規約) - if sel is menu.MENU_BACK: - return _ARG_CANCEL # Esc/← → サブメニューを再表示 - return sel # "" = compose 全体 (呼び出し側で None へ変換) + return flow.back_as_cancel(menu.select( + f"ビルドするイメージを選択 {menu.HINT_BACK}:", + choices, back=True, search=False)) # --------------------------------------------------------------------------- @@ -133,6 +129,10 @@ def _op_build(devbase_root: Path, name: str): _OP_HANDLERS = { + # up/rebuild は引数なしで即実行。up は scale 属性を参照する (常に None。 + # 他コマンドは無視する)。 + "up": lambda root, name: dispatch_lifecycle("up", name, scale=None), + "rebuild": lambda root, name: dispatch_lifecycle("rebuild", name, scale=None), "down": _op_down, "login": _op_login, "ps": _op_ps, @@ -149,10 +149,6 @@ def _run_operation(devbase_root: Path, name: str, op: str): 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc・確認拒否で引数収集を 中止 = サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。 """ - if op in ("up", "rebuild"): - # 引数なしで即実行。up は scale 属性を参照する (常に None。他コマンドは無視)。 - return dispatch_lifecycle(op, name, scale=None) - handler = _OP_HANDLERS.get(op) if handler is None: # 到達しない (メニュー値は _RUNNING_OPS に限定される)。保守的に no-op。 diff --git a/lib/devbase/tui/actions_snapshot.py b/lib/devbase/tui/actions_snapshot.py index 53ea3a5..f76b730 100644 --- a/lib/devbase/tui/actions_snapshot.py +++ b/lib/devbase/tui/actions_snapshot.py @@ -74,12 +74,7 @@ def _select_snapshot_name(devbase_root: Path, message: str): if snapshots is None: # 一覧が取れない環境では名前を直接入力させる。 - name = menu.text(message, allow_empty=False) - if name is None: - return None # Ctrl-C → 全体中止 (ナビ規約) - if name is menu.MENU_BACK: - return _ARG_CANCEL # Esc → 操作メニューを再表示 - return name + return flow.back_as_cancel(menu.text(message, allow_empty=False)) if not snapshots: logger.info("スナップショットがありません。先に作成 (create) してください。") @@ -93,13 +88,8 @@ def _select_snapshot_name(devbase_root: Path, message: str): s.get("name")) for s in snapshots ] - sel = menu.select(f"{message} {menu.HINT_SEARCH}:", choices, - back=True, search=True) - if sel is None: - return None # Ctrl-C → 全体中止 (ナビ規約) - if sel is menu.MENU_BACK: - return _ARG_CANCEL # Esc → 操作メニューを再表示 - return sel + return flow.back_as_cancel(menu.select( + f"{message} {menu.HINT_SEARCH}:", choices, back=True, search=True)) def _optional_point(message: str): diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py index 0fdda44..37be6e7 100644 --- a/lib/devbase/tui/app.py +++ b/lib/devbase/tui/app.py @@ -35,16 +35,20 @@ logger = get_logger(__name__) -# プロジェクト一覧の末尾に並べるカテゴリ (表示順)。``(key, label)`` で保持し、 +# プロジェクト一覧の末尾に並べるカテゴリの SSoT (表示順 / ラベル / 実装モジュール)。 # 一覧メニューには ``label (key)`` 形式で表示する (key 入力での絞り込みも効く)。 -TOP_CATEGORIES: list[tuple[str, str]] = [ - ("env", "環境変数"), - ("plugin", "プラグイン"), - ("snapshot", "スナップショット"), - ("status", "ステータス"), +# モジュール参照を保持し ``run`` の解決を呼び出し時まで遅らせる +# (テストが ``actions_*.run`` を monkeypatch できるようにするため)。 +_CATEGORIES: list[tuple[str, str, object]] = [ + ("env", "環境変数", actions_env), + ("plugin", "プラグイン", actions_plugin), + ("snapshot", "スナップショット", actions_snapshot), + ("status", "ステータス", actions_status), ] +TOP_CATEGORIES: list[tuple[str, str]] = [(k, label) for k, label, _ in _CATEGORIES] _LABELS = dict(TOP_CATEGORIES) +_CATEGORY_MODULES = {k: mod for k, _, mod in _CATEGORIES} def _route(category: str, devbase_root: Path): @@ -55,16 +59,11 @@ def _route(category: str, devbase_root: Path): - 操作なしで一覧へ戻るときは ``menu.MENU_BACK`` - Ctrl-C 全体中止のときは ``None`` """ - if category == "env": - return actions_env.run(devbase_root) - if category == "plugin": - return actions_plugin.run(devbase_root) - if category == "snapshot": - return actions_snapshot.run(devbase_root) - if category == "status": - return actions_status.run(devbase_root) - logger.error("未知のカテゴリです: %s", _LABELS.get(category, category)) - return menu.MENU_BACK + module = _CATEGORY_MODULES.get(category) + if module is None: + logger.error("未知のカテゴリです: %s", _LABELS.get(category, category)) + return menu.MENU_BACK + return module.run(devbase_root) def _select_top(rows: list[dict]): diff --git a/lib/devbase/tui/flow.py b/lib/devbase/tui/flow.py index 555a043..b28f3bd 100644 --- a/lib/devbase/tui/flow.py +++ b/lib/devbase/tui/flow.py @@ -56,6 +56,16 @@ def need(value): return value +def back_as_cancel(value): + """``MENU_BACK`` を ``ARG_CANCEL`` へ読み替える (選択ヘルパの番兵契約用)。 + + actions_* の選択ヘルパは「Esc = 呼び出し元メニューの再表示」を ``ARG_CANCEL`` + で表現する契約を持つ (メニューループ自身の ``MENU_BACK`` = 1 つ上の階層へ、 + と区別するため)。実値と ``None`` (Ctrl-C) はそのまま通す。 + """ + return ARG_CANCEL if value is menu.MENU_BACK else value + + def need_optional(value): """``optional_int`` の番兵 (``ABORT`` / ``ARG_CANCEL``) を例外へ変換する。 From d98b67e4e53ad03cfe7ef2c6d4907ae382e7214e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 12 Jun 2026 00:19:02 +0000 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=E7=AC=AC3=E5=BC=BE=20=E2=80=94?= =?UTF-8?q?=20100=E8=A1=8C=E8=B6=85=E9=96=A2=E6=95=B0=E3=81=AE=E5=85=A8?= =?UTF-8?q?=E8=A7=A3=E6=B6=88=E3=81=A8=E9=87=8D=E8=A4=87=E3=83=AD=E3=82=B8?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=81=AE=20SSoT=20=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 可読性・Pythonic 化・関数行数適正化・メンテナンス性向上を目的とした 横断リファクタリング (動作変更なし、731 tests green / ruff clean)。 - 関数分割: migrate / _install_from_repo / install_plugin / add_repository / refresh_repository / _add_env_parser / generate_scaled_compose / _ensure_images / cmd_up / sync_projects → 100 行超の関数 8 件をゼロに - SSoT 化: RegistryInfo.available_plugins() 新設 (AvailablePlugin 変換 4 箇所を一元化)、env パース共通化 (_parse_env_assignment)、 @ref 拒否エラー・Available plugins 表示・git 実行・[name] positional 定義の共通ヘルパ化、レコード更新を dataclasses.replace に統一 - Pythonic 化: 自前 _deep_copy → copy.deepcopy、線形探索 → next()、 内包表記 / setdefault / startswith タプル化、_dispatch の if ラダー → 宣言的テーブル + importlib - 死蔵コード除去: 到達不能 except、未使用 import / 変数 Co-Authored-By: Claude Fable 5 --- lib/devbase/cli.py | 96 ++++---- lib/devbase/commands/container.py | 237 +++++++++--------- lib/devbase/commands/init.py | 1 - lib/devbase/plugin/installer.py | 377 ++++++++++++++++------------- lib/devbase/plugin/migrator.py | 164 +++++++------ lib/devbase/plugin/models.py | 19 +- lib/devbase/plugin/repo_manager.py | 282 ++++++++++----------- lib/devbase/plugin/syncer.py | 117 +++++---- lib/devbase/volume/compose.py | 256 +++++++++----------- lib/devbase/volume/manager.py | 1 - 10 files changed, 798 insertions(+), 752 deletions(-) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 7886750..e370673 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -4,6 +4,7 @@ import argparse import os import sys +from importlib import import_module from pathlib import Path from devbase.errors import DevbaseError @@ -86,6 +87,14 @@ def _require_devbase_root() -> Path: return Path(root) +def _add_name_arg(parser): + """省略可能な `[name]` positional (プロジェクト名) を登録する。 + + `project [name]` とトップレベルショートカットで同一定義を共有する。 + """ + parser.add_argument('name', nargs='?', default=None, help='Project name') + + def _add_login_subparser(sub): """`login` サブコマンドを登録する (project / container 共通)。 @@ -156,27 +165,24 @@ def _add_project_parser(subparsers): pj_parser = subparsers.add_parser('project', help='Manage projects (CWD-independent)') pj_sub = pj_parser.add_subparsers(dest='subcommand') - pj_up = pj_sub.add_parser('up', help='Start containers') - pj_up.add_argument('name', nargs='?', default=None, help='Project name') - - pj_down = pj_sub.add_parser('down', help='Stop and remove containers') - pj_down.add_argument('name', nargs='?', default=None, help='Project name') + _add_name_arg(pj_sub.add_parser('up', help='Start containers')) + _add_name_arg(pj_sub.add_parser('down', help='Stop and remove containers')) _add_login_subparser(pj_sub) pj_ps = pj_sub.add_parser('ps', help='Show container status') - pj_ps.add_argument('name', nargs='?', default=None, help='Project name') + _add_name_arg(pj_ps) pj_ps.add_argument('--all', '-a', action='store_true', help='Show all containers') pj_logs = pj_sub.add_parser('logs', help='Show container logs') - pj_logs.add_argument('name', nargs='?', default=None, help='Project name') + _add_name_arg(pj_logs) pj_logs.add_argument('--follow', '-f', action='store_true', help='Follow log output') pj_logs.add_argument('--tail', type=int, default=None, help='Number of lines') # NOTE: `[name]` optional + `new_scale` 必須 int の順。値が 1 個なら new_scale に、 # 2 個なら (name, new_scale) に割り当てられ曖昧にならない (tests/cli 参照)。 pj_scale = pj_sub.add_parser('scale', help='Scale containers online') - pj_scale.add_argument('name', nargs='?', default=None, help='Project name') + _add_name_arg(pj_scale) pj_scale.add_argument('new_scale', type=int, help='New number of containers') _add_build_subparser(pj_sub) @@ -185,9 +191,8 @@ def _add_project_parser(subparsers): # 省略可能な `[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') + _add_name_arg(pj_sub.add_parser( + 'rebuild', help='Rebuild images without cache (docker compose build --no-cache)')) # `list` は lifecycle ではなく一覧表示 (commands/project.py)。name positional は # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 @@ -243,6 +248,12 @@ def _add_env_parser(subparsers): env_sub.add_parser('edit', help='Open .env in editor') env_sub.add_parser('project', help='Setup project-specific variables') + _add_env_export_parser(env_sub) + _add_env_import_parser(env_sub) + + +def _add_env_export_parser(env_sub): + """`env export` サブコマンドを登録する。""" env_export = env_sub.add_parser( 'export', help='Export .env files as an encrypted bundle (age)', @@ -278,6 +289,9 @@ def _add_env_parser(subparsers): '(per-object SSE is always applied regardless of this flag). ' 'Has no effect for non-s3:// destinations.') + +def _add_env_import_parser(env_sub): + """`env import` サブコマンドを登録する。""" env_import = env_sub.add_parser( 'import', help='Import .env files from a bundle (age-encrypted or plaintext tar.gz)', @@ -421,29 +435,24 @@ def _add_shortcuts(subparsers): 注記参照): bin/devbase が build を shell 実装 (cmd_build) に委譲するため、 Python 側でトップレベル build を広告すると実経路と乖離する。 """ - login_sc = subparsers.add_parser('login', help='Login to container') - login_sc.add_argument('index', nargs='?', default='1', help='Container index') + _add_login_subparser(subparsers) ps_sc = subparsers.add_parser('ps', help='Show container status') - ps_sc.add_argument('name', nargs='?', default=None, help='Project name') + _add_name_arg(ps_sc) ps_sc.add_argument('--all', '-a', action='store_true', help='Show all containers') - up_sc = subparsers.add_parser('up', help='Start containers') - up_sc.add_argument('name', nargs='?', default=None, help='Project name') - - down_sc = subparsers.add_parser('down', help='Stop and remove containers') - down_sc.add_argument('name', nargs='?', default=None, help='Project name') + _add_name_arg(subparsers.add_parser('up', help='Start containers')) + _add_name_arg(subparsers.add_parser('down', help='Stop and remove containers')) # `[name]` optional + `new_scale` 必須 int の順 (project scale と同じ規則)。 scale_sc = subparsers.add_parser('scale', help='Scale containers online') - scale_sc.add_argument('name', nargs='?', default=None, help='Project name') + _add_name_arg(scale_sc) 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') + _add_name_arg(subparsers.add_parser( + 'rebuild', help='Rebuild images without cache (docker compose build --no-cache)')) # `list` は `project list` のトップレベルシノニム。lifecycle ではなく一覧表示 # のため SHORTCUTS (project lifecycle へ写像) ではなく _dispatch で個別に @@ -569,6 +578,17 @@ def main(): return 1 +# DEVBASE_ROOT 必須コマンドの定義: cmd -> (module, function, args を渡すか)。 +# 起動コストを抑えるため import は dispatch 時に遅延させる (従来の関数内 import と同等)。 +_ROOT_COMMANDS = { + 'init': ('devbase.commands.init', 'cmd_init', False), + 'status': ('devbase.commands.status', 'cmd_status', False), + 'env': ('devbase.commands.env', 'cmd_env', True), + 'plugin': ('devbase.commands.plugin', 'cmd_plugin', True), + 'snapshot': ('devbase.commands.snapshot', 'cmd_snapshot', True), +} + + def _dispatch(cmd, args): """Dispatch command to handler.""" # Resolve group aliases @@ -604,30 +624,14 @@ def _dispatch(cmd, args): return cmd_container(args) # --- Commands requiring DEVBASE_ROOT --- + spec = _ROOT_COMMANDS.get(cmd) + if spec is None: + logger.error("Unknown command: '%s'", cmd) + return 1 + module_name, func_name, takes_args = spec + func = getattr(import_module(module_name), func_name) devbase_root = _require_devbase_root() - - if cmd == 'init': - from devbase.commands.init import cmd_init - return cmd_init(devbase_root) - - if cmd == 'status': - from devbase.commands.status import cmd_status - return cmd_status(devbase_root) - - if cmd == 'env': - from devbase.commands.env import cmd_env - return cmd_env(devbase_root, args) - - if cmd == 'plugin': - from devbase.commands.plugin import cmd_plugin - return cmd_plugin(devbase_root, args) - - if cmd == 'snapshot': - from devbase.commands.snapshot import cmd_snapshot - return cmd_snapshot(devbase_root, args) - - logger.error("Unknown command: '%s'", cmd) - return 1 + return func(devbase_root, args) if takes_args else func(devbase_root) if __name__ == '__main__': diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 9ceb27b..ac39922 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -118,33 +118,43 @@ def _report_unknown_project(name: str, projects_dir: Path) -> None: logger.error("利用可能なプロジェクト: %s", listing) +def _parse_env_assignment(raw_line: str) -> Optional[tuple[str, str]]: + """env ファイルの 1 行を ``(key, raw_value)`` にパースする。 + + パース規則 (wrapper の env_var_keys とも揃える): 行頭空白除去 → 先頭 ``#`` は + コメント → ``export`` 接頭辞除去 → ``=`` の左辺をキーとして採用。 + コメント / 空行 / 代入でない行 / キーが空の行は None。 + """ + line = raw_line.strip() + if not line or line.startswith('#'): + return None + if line.startswith('export '): + line = line[len('export '):].lstrip() + if '=' not in line: + return None + key, value = line.split('=', 1) + key = key.strip() + return (key, value) if key else None + + def _env_var_keys(env_file: Path) -> set: """env ファイルが定義する変数キー名の集合を返す (値は読まない)。 project 切替時に「呼び出し元プロジェクト固有の env キー」を unset するために - 使う。パース前提は :func:`_load_project_env` と同一 (wrapper の env_var_keys - とも揃える): 行頭空白除去 → 先頭 ``#`` はコメント → ``export`` 接頭辞除去 → - ``=`` の左辺をキーとして採用。 + 使う。パース前提は :func:`_load_project_env` と同一 (:func:`_parse_env_assignment` + を共有)。 """ - keys: set = set() if not env_file.is_file(): - return keys + return set() try: lines = env_file.read_text().splitlines() except OSError: - return keys - for raw in lines: - line = raw.strip() - if not line or line.startswith('#'): - continue - if line.startswith('export '): - line = line[len('export '):].lstrip() - if '=' not in line: - continue - key = line.split('=', 1)[0].strip() - if key: - keys.add(key) - return keys + return set() + return { + assignment[0] + for assignment in map(_parse_env_assignment, lines) + if assignment + } def _load_project_env(env_file: Path) -> None: @@ -184,18 +194,10 @@ def _load_project_env(env_file: Path) -> None: except OSError as e: logger.warning("env ファイルを読み込めませんでした (%s): %s", env_file, e) return - for raw in lines: - line = raw.strip() - if not line or line.startswith('#'): - continue - if line.startswith('export '): - line = line[len('export '):].lstrip() - if '=' not in line: - continue - key, value = line.split('=', 1) - key = key.strip() - if not key: + for assignment in map(_parse_env_assignment, lines): + if not assignment: continue + key, value = assignment value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): value = value[1:-1] @@ -328,6 +330,29 @@ def cmd_container(args) -> int: # cmd_up (deploy.py の cmd_deploy を移植) # --------------------------------------------------------------------------- +def _auto_snapshot() -> None: + """デプロイ前の自動スナップショット (差分世代数ベース世代管理)。 + + 失敗してもデプロイは続行する (warning のみ)。DEVBASE_ROOT 未設定なら no-op。 + """ + devbase_root = os.environ.get('DEVBASE_ROOT') + if not devbase_root: + return + try: + from devbase.snapshot.manager import SnapshotManager + mgr = SnapshotManager(Path(devbase_root)) + if mgr.should_start_new_generation(): + logger.info("[0/6] 新しいスナップショット世代を作成中...") + mgr.create() + else: + latest = mgr.list()[-1]['name'] + logger.info("[0/6] スナップショットを差分更新中: %s", latest) + mgr.create(name=latest, full=False) + mgr.rotate() + except Exception as e: + logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e) + + def cmd_up(project_name: str = None, scale: int = None) -> int: """Deploy containers with specified scale""" if project_name is None: @@ -360,21 +385,7 @@ def cmd_up(project_name: str = None, scale: int = None) -> int: return 1 # Pre-step: Auto snapshot(差分世代数ベース世代管理) - devbase_root_env = os.environ.get('DEVBASE_ROOT') - if devbase_root_env: - try: - from devbase.snapshot.manager import SnapshotManager - mgr = SnapshotManager(Path(devbase_root_env)) - if mgr.should_start_new_generation(): - logger.info("[0/6] 新しいスナップショット世代を作成中...") - mgr.create() - else: - latest = mgr.list()[-1]['name'] - logger.info("[0/6] スナップショットを差分更新中: %s", latest) - mgr.create(name=latest, full=False) - mgr.rotate() - except Exception as e: - logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e) + _auto_snapshot() try: logger.info("[1/6] Ensuring volumes exist...") @@ -766,55 +777,10 @@ def _ensure_images() -> bool: ) if inspect.returncode != 0: - if has_build: - logger.info("Container image '%s' not found", image_name) - logger.info("Running 'devbase container build' to create it...") - return _run_build() - logger.info("Container image '%s' not found, pulling...", image_name) - ok = _run_pull(image_name) - if ok: - _mark_pulled(image_name) - return ok - - max_age = _image_max_age_days() - - # Image-only services: use local touch-file mtime, since image - # 'Created' reflects upstream build time, not local pull time. + return _fetch_missing_image(image_name, has_build) if not has_build: - pull_age = _pull_age_days(image_name) - if pull_age is None: - # Pre-existing image with no marker (e.g., upgrade from a - # devbase version without touch-file tracking). Bootstrap a - # marker now so future runs can apply the threshold. We do - # not auto-pull here to avoid surprising network calls on - # the first `up` after upgrade. - logger.info( - "First time tracking image '%s'; recording marker (no pull this run)", - image_name - ) - _mark_pulled(image_name) - return True - if pull_age < max_age: - return True - logger.info( - "Image '%s' last pulled %d days ago (>= %d days threshold), re-pulling...", - image_name, pull_age, max_age - ) - ok = _run_pull(image_name) - if ok: - _mark_pulled(image_name) - return ok - - age_days = _get_image_age_days(inspect.stdout) - if age_days is None or age_days < max_age: - return True - - logger.info( - "Container image '%s' is %d days old (>= %d days threshold)", - image_name, age_days, max_age - ) - logger.info("Rebuilding with --no-cache...") - return _run_build(no_cache=True) + return _repull_if_stale(image_name) + return _rebuild_if_stale(image_name, inspect.stdout) except Exception as e: logger.warning("Error checking image: %s", e) @@ -822,6 +788,66 @@ def _ensure_images() -> bool: return _run_build() +def _fetch_missing_image(image_name: str, has_build: bool) -> bool: + """存在しないイメージを取得する: build 定義があればビルド、なければ pull。""" + if has_build: + logger.info("Container image '%s' not found", image_name) + logger.info("Running 'devbase container build' to create it...") + return _run_build() + logger.info("Container image '%s' not found, pulling...", image_name) + return _pull_and_mark(image_name) + + +def _repull_if_stale(image_name: str) -> bool: + """image-only サービスの鮮度チェック: pull マーカーが閾値超過なら再 pull。 + + Image-only services: use local touch-file mtime, since image 'Created' + reflects upstream build time, not local pull time. + """ + pull_age = _pull_age_days(image_name) + if pull_age is None: + # Pre-existing image with no marker (e.g., upgrade from a devbase + # version without touch-file tracking). Bootstrap a marker now so + # future runs can apply the threshold. We do not auto-pull here to + # avoid surprising network calls on the first `up` after upgrade. + logger.info( + "First time tracking image '%s'; recording marker (no pull this run)", + image_name + ) + _mark_pulled(image_name) + return True + max_age = _image_max_age_days() + if pull_age < max_age: + return True + logger.info( + "Image '%s' last pulled %d days ago (>= %d days threshold), re-pulling...", + image_name, pull_age, max_age + ) + return _pull_and_mark(image_name) + + +def _rebuild_if_stale(image_name: str, inspect_json: str) -> bool: + """build 定義のあるサービスの鮮度チェック: イメージ作成日が閾値超過なら no-cache 再ビルド。""" + max_age = _image_max_age_days() + age_days = _get_image_age_days(inspect_json) + if age_days is None or age_days < max_age: + return True + logger.info( + "Container image '%s' is %d days old (>= %d days threshold)", + image_name, age_days, max_age + ) + logger.info("Rebuilding with --no-cache...") + return _run_build(no_cache=True) + + +def _pull_and_mark(image_name: str) -> bool: + """docker pull を実行し、成功時は pull マーカーを更新する。""" + ok = _run_pull(image_name) + if ok: + _mark_pulled(image_name) + return ok + + def _get_image_age_days(inspect_json: str) -> Optional[int]: """Return age of the inspected image in days, or None on failure.""" try: @@ -936,25 +962,18 @@ def _update_scale_in_env(new_scale: int) -> bool: logger.error("env file not found: %s", env_file) return False + def _is_scale_line(line: str) -> bool: + return line.strip().startswith('CONTAINER_SCALE=') + try: - with open(env_file, 'r') as f: - lines = f.readlines() - - updated = False - new_lines = [] - for line in lines: - if line.strip().startswith('CONTAINER_SCALE='): - new_lines.append(f'CONTAINER_SCALE={new_scale}\n') - updated = True - else: - new_lines.append(line) - - if not updated: + lines = env_file.read_text().splitlines(keepends=True) + new_lines = [ + f'CONTAINER_SCALE={new_scale}\n' if _is_scale_line(line) else line + for line in lines + ] + if not any(map(_is_scale_line, lines)): new_lines.append(f'\n# Added by devbase scale command\nCONTAINER_SCALE={new_scale}\n') - - with open(env_file, 'w') as f: - f.writelines(new_lines) - + env_file.write_text(''.join(new_lines)) return True except Exception as e: diff --git a/lib/devbase/commands/init.py b/lib/devbase/commands/init.py index b1af5d7..629ebad 100644 --- a/lib/devbase/commands/init.py +++ b/lib/devbase/commands/init.py @@ -170,7 +170,6 @@ def _migrate_rc_devbase_block(rc_file: Path, devbase_root: Path) -> bool: # Remove old hardcoded PATH lines that don't use ${DEVBASE_ROOT} variable # Keep lines using ${DEVBASE_ROOT} or matching current devbase_root - escaped_root = re.escape(devbase_root_str) lines = content.split('\n') filtered = [] for line in lines: diff --git a/lib/devbase/plugin/installer.py b/lib/devbase/plugin/installer.py index f03fb73..22cbe13 100644 --- a/lib/devbase/plugin/installer.py +++ b/lib/devbase/plugin/installer.py @@ -1,6 +1,5 @@ """Plugin installer - handles install/uninstall operations""" -import os import shutil import subprocess import yaml @@ -30,13 +29,14 @@ def parse_registry_yml(path: Path) -> Optional[RegistryInfo]: data = yaml.safe_load(f) or {} except yaml.YAMLError as e: raise PluginError(f"Failed to parse {yml_path}: {e}") - plugins = [] - for p_data in data.get('plugins', []): - plugins.append(RegistryEntry( + plugins = [ + RegistryEntry( name=p_data.get('name', ''), path=p_data.get('path', ''), description=p_data.get('description', ''), - )) + ) + for p_data in data.get('plugins', []) + ] return RegistryInfo( name=data.get('name', ''), description=data.get('description', ''), @@ -73,9 +73,9 @@ def git_clone( def resolve_repo_url(repo: str) -> str: """Resolve a repo string to a git URL""" - if repo.startswith('http://') or repo.startswith('https://') or repo.startswith('git@'): + if repo.startswith(('http://', 'https://', 'git@')): return repo - if repo.startswith('/') or repo.startswith('.'): + if repo.startswith(('/', '.')): return repo # local path # GitHub shorthand: user/repo return f"https://github.com/{repo}.git" @@ -107,69 +107,62 @@ def _auto_migrate(registry: PluginRegistry) -> None: ) -def install_plugin( - registry: PluginRegistry, - source_str: str, - link: bool = False, - install_all: bool = False, -) -> None: - """Install a plugin from a source string. +def _pinned_ref_error(ref: str, subject: str, hint: str) -> PluginError: + """@ref 指定を拒否する共通エラーを組み立てる。 - Raises PluginError on failure. + permanent clone は default branch を追跡するため pinned ref は非サポート。 + `subject` は対象 (plugin / repository) の説明、`hint` は復旧コマンド例。 """ - _auto_migrate(registry) + return PluginError( + f"Cannot use @{ref} with {subject}.\n" + "Permanent clones track the default branch and do not support pinned refs.\n" + f"{hint}" + ) - source = PluginSource.parse(source_str, link=link) - plugins_dir = registry.get_plugins_dir() - if not source.repo and source.plugin_name: - # Reject @ref on name-only installs too — the permanent clone tracks - # the default branch and does not support pinned refs. Without this - # guard, `devbase plugin install myplugin@v1` would silently drop the - # ref in _install_from_repo() and install the default branch instead. - # This matches the validation for unregistered/registered repos below. - if source.ref: - raise PluginError( - f"Cannot use @{source.ref} with plugin '{source.plugin_name}'.\n" - "Permanent clones track the default branch and do not support pinned refs.\n" - f"Install without @ref:\n" - f" devbase plugin install {source.plugin_name}" - ) - result = registry.find_plugin_in_repos(source.plugin_name) - if result: - repo, avail_plugin = result - repo_source = PluginSource( - repo=repo.url, plugin_name=source.plugin_name, - ref=None, linked=False, - ) - _install_from_repo( - registry, repo_source, install_all=False, - ) - return +def _install_by_name(registry: PluginRegistry, source: PluginSource) -> None: + """name-only インストール: 登録済みリポジトリ群から plugin を検索して導入する。""" + # Reject @ref on name-only installs too — without this guard, + # `devbase plugin install myplugin@v1` would silently drop the ref in + # _install_from_repo() and install the default branch instead. + # This matches the validation for unregistered/registered repos. + if source.ref: + raise _pinned_ref_error( + source.ref, f"plugin '{source.plugin_name}'", + f"Install without @ref:\n" + f" devbase plugin install {source.plugin_name}", + ) + result = registry.find_plugin_in_repos(source.plugin_name) + if not result: raise PluginError( f"Plugin '{source.plugin_name}' not found in registered repositories.\n" "Use 'devbase plugin repo add ' to register a repository first.\n" "Use 'devbase plugin repo list' to see registered repositories and available plugins." ) + repo, _avail_plugin = result + repo_source = PluginSource( + repo=repo.url, plugin_name=source.plugin_name, + ref=None, linked=False, + ) + _install_from_repo(registry, repo_source, install_all=False) - repo_url = resolve_repo_url(source.repo) - if link and (Path(source.repo).is_dir()): - plugins_dir.mkdir(exist_ok=True) - _install_from_local(registry, source, plugins_dir) - return +def _ensure_repo_registered( + registry: PluginRegistry, repo_url: str, source: PluginSource, +) -> None: + """インストール対象リポジトリの登録を保証し、@ref 指定を拒否する。 - # Auto-register the repository if not already registered, so that - # `devbase plugin install user/repo:plugin-name` keeps working without - # a prior `repo add`. + Auto-register the repository if not already registered, so that + `devbase plugin install user/repo:plugin-name` keeps working without + a prior `repo add`. + """ if not registry.get_repository_by_url(repo_url): if source.ref: - raise PluginError( - f"Cannot use @{source.ref} with unregistered repository '{repo_url}'.\n" - "Permanent clones track the default branch and do not support pinned refs.\n" + raise _pinned_ref_error( + source.ref, f"unregistered repository '{repo_url}'", "Register the repository first, then install without @ref:\n" f" devbase plugin repo add {repo_url}\n" - f" devbase plugin install {repo_url}:{source.plugin_name}" + f" devbase plugin install {repo_url}:{source.plugin_name}", ) from .repo_manager import add_repository try: @@ -179,18 +172,45 @@ def install_plugin( f"Repository '{repo_url}' is not registered and auto-registration failed: {e}\n" "Use 'devbase plugin repo add ' to register manually." ) + return - # Reject @ref on already-registered repos — the permanent clone tracks - # the default branch and does not support pinned refs. This matches the - # validation for *unregistered* repos (line 126-133 above). + # Reject @ref on already-registered repos too (same rationale as above). if source.ref: - raise PluginError( - f"Cannot use @{source.ref} with registered repository '{repo_url}'.\n" - "Permanent clones track the default branch and do not support pinned refs.\n" + raise _pinned_ref_error( + source.ref, f"registered repository '{repo_url}'", f"Install without @ref:\n" - f" devbase plugin install {repo_url}:{source.plugin_name}" + f" devbase plugin install {repo_url}:{source.plugin_name}", ) + +def install_plugin( + registry: PluginRegistry, + source_str: str, + link: bool = False, + install_all: bool = False, +) -> None: + """Install a plugin from a source string. + + Raises PluginError on failure. + """ + _auto_migrate(registry) + + source = PluginSource.parse(source_str, link=link) + + if not source.repo and source.plugin_name: + _install_by_name(registry, source) + return + + repo_url = resolve_repo_url(source.repo) + + if link and Path(source.repo).is_dir(): + plugins_dir = registry.get_plugins_dir() + plugins_dir.mkdir(exist_ok=True) + _install_from_local(registry, source, plugins_dir) + return + + _ensure_repo_registered(registry, repo_url, source) + _install_from_repo( registry, PluginSource( repo=repo_url, plugin_name=source.plugin_name, ref=None, linked=False, @@ -208,23 +228,20 @@ def _install_from_local( Raises PluginError on failure. """ - local_path = Path(source.repo) + if not source.plugin_name: + raise PluginError("Plugin name is required for local install (use /path:plugin-name)") - if source.plugin_name: - plugin_path = local_path / source.plugin_name + local_path = Path(source.repo) + plugin_path = local_path / source.plugin_name + if not plugin_path.is_dir(): + reg_info = parse_registry_yml(local_path) + entry = reg_info.find_plugin(source.plugin_name) if reg_info else None + if entry: + plugin_path = local_path / entry.path.rstrip('/') if not plugin_path.is_dir(): - reg_info = parse_registry_yml(local_path) - if reg_info: - for entry in reg_info.plugins: - if entry.name == source.plugin_name: - plugin_path = local_path / entry.path.rstrip('/') - break - if not plugin_path.is_dir(): - raise PluginError(f"Plugin '{source.plugin_name}' not found in {local_path}") - - _link_plugin(registry, source.plugin_name, plugin_path, source.repo, plugins_dir) - else: - raise PluginError("Plugin name is required for local install (use /path:plugin-name)") + raise PluginError(f"Plugin '{source.plugin_name}' not found in {local_path}") + + _link_plugin(registry, source.plugin_name, plugin_path, source.repo, plugins_dir) def _link_plugin( @@ -261,6 +278,105 @@ def _link_plugin( sync_projects(registry) +def _migrate_repo_to_persistent_clone(registry: PluginRegistry, repo_reg): + """local_path を持たない legacy リポジトリ登録を repos/ の永続 clone へ移行する。 + + Legacy repository registered before persistent-clone support. + Auto-migrate by creating a persistent clone in repos/. + Returns the updated RegisteredRepository. + """ + logger.info( + "Migrating repository '%s' to persistent clone...", repo_reg.name, + ) + from .repo_manager import _url_to_repos_dirname + dir_name = _url_to_repos_dirname(repo_reg.url) + repos_dir = registry.get_repos_dir() + repos_dir.mkdir(exist_ok=True) + clone_dir = repos_dir / dir_name + + readd_hint = ( + "Remove and re-add the repository:\n" + f" devbase plugin repo remove {repo_reg.name}\n" + f" devbase plugin repo add {repo_reg.url}" + ) + + if not clone_dir.is_dir(): + try: + git_clone(repo_reg.url, clone_dir, shallow=False) + except PluginError as e: + raise PluginError( + f"Failed to create persistent clone for '{repo_reg.name}': {e}\n" + + readd_hint + ) + + # Validate the clone by parsing registry.yml BEFORE saving to + # plugins.yml. This prevents a broken clone from polluting the + # persisted state. If parsing fails, the old repository entry + # (without local_path) is kept so the user can retry. + reg_info = parse_registry_yml(clone_dir) + if not reg_info: + raise PluginError( + f"No registry.yml found in cloned repository '{repo_reg.name}'.\n" + + readd_hint + ) + + from dataclasses import replace + local_path = f"repos/{dir_name}" + # Build an up-to-date plugin list from the freshly cloned + # registry.yml instead of carrying over stale metadata. + updated_repo = replace( + repo_reg, local_path=local_path, plugins=reg_info.available_plugins(), + ) + registry.add_repository(updated_repo) + logger.info("Repository '%s' migrated to %s", updated_repo.name, local_path) + return updated_repo + + +def _install_all_from_repo( + registry: PluginRegistry, reg_info, clone_dir: Path, source_repo: str, + repo_local_path: str, +) -> None: + """registry.yml の全 plugin を導入する (--all)。失敗分はまとめて報告する。""" + errors = [] + for entry in reg_info.plugins: + try: + _register_repo_plugin( + registry, entry.name, + clone_dir / entry.path.rstrip('/'), + source_repo, repo_local_path, + ) + except PluginError as e: + errors.append(str(e)) + sync_projects(registry) + if errors: + raise PluginError( + "Some plugins failed to install:\n" + "\n".join(errors) + ) + + +def _install_named_from_repo( + registry: PluginRegistry, reg_info, clone_dir: Path, source_repo: str, + repo_local_path: str, plugin_name: str, +) -> None: + """registry.yml から指定名の plugin を 1 つ導入する。""" + target_entry = reg_info.find_plugin(plugin_name) + if not target_entry: + available = "\n".join( + f" - {e.name}: {e.description}" for e in reg_info.plugins + ) + raise PluginError( + f"Plugin '{plugin_name}' not found in repository\n" + f"Available plugins:\n{available}" + ) + + _register_repo_plugin( + registry, target_entry.name, + clone_dir / target_entry.path.rstrip('/'), + source_repo, repo_local_path, + ) + sync_projects(registry) + + def _install_from_repo( registry: PluginRegistry, source: PluginSource, @@ -278,63 +394,7 @@ def _install_from_repo( ) if not repo_reg.local_path: - # Legacy repository registered before persistent-clone support. - # Auto-migrate by creating a persistent clone in repos/. - logger.info( - "Migrating repository '%s' to persistent clone...", repo_reg.name, - ) - from .repo_manager import _url_to_repos_dirname - dir_name = _url_to_repos_dirname(repo_reg.url) - repos_dir = registry.get_repos_dir() - repos_dir.mkdir(exist_ok=True) - clone_dir = repos_dir / dir_name - - if not clone_dir.is_dir(): - try: - git_clone(repo_reg.url, clone_dir, shallow=False) - except PluginError as e: - raise PluginError( - f"Failed to create persistent clone for '{repo_reg.name}': {e}\n" - "Remove and re-add the repository:\n" - f" devbase plugin repo remove {repo_reg.name}\n" - f" devbase plugin repo add {repo_reg.url}" - ) - - # Validate the clone by parsing registry.yml BEFORE saving to - # plugins.yml. This prevents a broken clone from polluting the - # persisted state. If parsing fails, the old repository entry - # (without local_path) is kept so the user can retry. - reg_info = parse_registry_yml(clone_dir) - if not reg_info: - raise PluginError( - f"No registry.yml found in cloned repository '{repo_reg.name}'.\n" - "Remove and re-add the repository:\n" - f" devbase plugin repo remove {repo_reg.name}\n" - f" devbase plugin repo add {repo_reg.url}" - ) - - from .models import RegisteredRepository, AvailablePlugin - local_path = f"repos/{dir_name}" - # Build an up-to-date plugin list from the freshly cloned - # registry.yml instead of carrying over stale metadata. - migrated_plugins = [ - AvailablePlugin( - name=e.name, - description=e.description, - path=e.path, - ) - for e in reg_info.plugins - ] - updated_repo = RegisteredRepository( - name=repo_reg.name, - url=repo_reg.url, - added_at=repo_reg.added_at, - local_path=local_path, - plugins=migrated_plugins, - ) - registry.add_repository(updated_repo) - repo_reg = updated_repo - logger.info("Repository '%s' migrated to %s", repo_reg.name, local_path) + repo_reg = _migrate_repo_to_persistent_clone(registry, repo_reg) clone_dir = registry.devbase_root / repo_reg.local_path if not clone_dir.is_dir(): @@ -348,50 +408,16 @@ def _install_from_repo( raise PluginError("No registry.yml found in repository") if install_all: - errors = [] - for entry in reg_info.plugins: - try: - _register_repo_plugin( - registry, entry.name, - clone_dir / entry.path.rstrip('/'), - source.repo, repo_reg.local_path, - ) - except PluginError as e: - errors.append(str(e)) - sync_projects(registry) - if errors: - raise PluginError( - "Some plugins failed to install:\n" + "\n".join(errors) - ) + _install_all_from_repo( + registry, reg_info, clone_dir, source.repo, repo_reg.local_path, + ) return - if source.plugin_name: - target_entry = None - for entry in reg_info.plugins: - if entry.name == source.plugin_name: - target_entry = entry - break - - if not target_entry: - available = "\n".join( - f" - {e.name}: {e.description}" for e in reg_info.plugins - ) - raise PluginError( - f"Plugin '{source.plugin_name}' not found in repository\n" - f"Available plugins:\n{available}" - ) - - _register_repo_plugin( - registry, target_entry.name, - clone_dir / target_entry.path.rstrip('/'), - source.repo, repo_reg.local_path, - ) - sync_projects(registry) - else: + if not source.plugin_name: + # plugin 名未指定はインストールせず、候補一覧の表示のみ行いエラー終了する。 logger.info("Available plugins in %s:", source.repo) for entry in reg_info.plugins: - installed = registry.get(entry.name) - status = " (installed)" if installed else "" + status = " (installed)" if registry.get(entry.name) else "" logger.info(" %s: %s%s", entry.name, entry.description, status) logger.info( "Use 'devbase plugin install %s:PLUGIN_NAME' to install", @@ -399,6 +425,11 @@ def _install_from_repo( ) raise PluginError("No plugin name specified") + _install_named_from_repo( + registry, reg_info, clone_dir, source.repo, repo_reg.local_path, + source.plugin_name, + ) + def _register_repo_plugin( registry: PluginRegistry, diff --git a/lib/devbase/plugin/migrator.py b/lib/devbase/plugin/migrator.py index 5e91e4a..a99ffce 100644 --- a/lib/devbase/plugin/migrator.py +++ b/lib/devbase/plugin/migrator.py @@ -3,14 +3,14 @@ import re import shutil import stat -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import TYPE_CHECKING, Optional from devbase.errors import PluginError from devbase.log import get_logger -from .models import AvailablePlugin, InstalledPlugin, RegisteredRepository +from .models import InstalledPlugin, RegisteredRepository from .registry import PluginRegistry if TYPE_CHECKING: @@ -237,14 +237,8 @@ def _build_persisted_repo( every repo update is accumulated and flushed in a single plugins.yml save (see migrate()), so this no longer writes per repo. """ - plugins = [ - AvailablePlugin(name=e.name, description=e.description, path=e.path) - for e in reg_info.plugins - ] if reg_info else list(repo.plugins) - return RegisteredRepository( - name=repo.name, url=repo.url, added_at=repo.added_at, - local_path=f"repos/{dir_name}", plugins=plugins, - ) + plugins = reg_info.available_plugins() if reg_info else list(repo.plugins) + return replace(repo, local_path=f"repos/{dir_name}", plugins=plugins) def _ensure_repo_cloned( @@ -385,42 +379,25 @@ def _cleanup_plugins_dir(registry: PluginRegistry) -> bool: return True -def migrate(registry: PluginRegistry, *, run_sync: bool = True) -> MigrationResult: - """Migrate legacy plugins/ copy installs to repos/ clones. - - For each legacy plugin: ensure its source repo is cloned to repos/, rewrite - InstalledPlugin.path to the repos/ location, then delete the old copy when - byte-identical or preserve it as .bak when it diverged. Finally - re-sync project symlinks and empty plugins/ to .gitkeep when safe. +def _plan_migration( + registry: PluginRegistry, + legacy: list[InstalledPlugin], + result: MigrationResult, +) -> tuple[ + list[InstalledPlugin], list[RegisteredRepository], list[tuple[str, Path, Path]], +]: + """Phase 1 (no destructive fs ops): 各 legacy plugin の移行内容を決定する. + + Validate the repo/clone/entry and decide each copy's fate, collecting the + cloned-repo rows in `pending_repos`, the repos/ path rewrites in `pending`, + and the retire actions in `retire` (plugin_name, old_dir, repo_dir). + Cloning stages the repo row in `pending_repos` rather than saving per + clone, so the save count stays O(1) regardless of how many repos are + cloned. Failures are recorded in `result` (skipped + errors) per plugin. """ from .installer import parse_registry_yml - from .syncer import load_plugin_info, sync_projects - - result = MigrationResult() - legacy = [p for p in registry.list_installed() if _is_legacy_plugin(p)] - if not legacy: - return result + from .syncer import load_plugin_info - # Two-phase migration so a registry-save failure can never leave a copy - # deleted while plugins.yml still points at the stale plugins/ path: - # - # Phase 1 (no destructive fs ops): validate the repo/clone/entry and - # decide each copy's fate (delete vs preserve as .bak), collecting the - # cloned-repo rows in `pending_repos`, the repos/ path rewrites in - # `pending`, and the retire actions in `retire`. Cloning stages the - # repo row in `pending_repos` rather than saving per clone, so the save - # count stays O(1) regardless of how many repos are cloned. - # Persist: write every repo row + path rewrite in a single plugins.yml - # save (save_migration), flushing the staged clones BEFORE any cleanup. - # Phase 2 (destructive): only after plugins.yml is durably at repos/ do we - # delete/rename the old copies. - # - # Ordering rationale: if the save raises, no copy has been touched yet, so - # the registry stays legacy and the next run retries cleanly with the copies - # intact (recoverable). Conversely the validated repos/ clone is known good - # before we commit, so committing the rewrite first cannot strand a plugin - # on a missing tree; a stray copy left by a phase-2 hiccup is merely surfaced - # by _cleanup_plugins_dir, never silent data loss. pending: list[InstalledPlugin] = [] pending_repos: list[RegisteredRepository] = [] # cloned-repo rows to persist retire: list[tuple[str, Path, Path]] = [] # (plugin_name, old_dir, repo_dir) @@ -444,12 +421,10 @@ def migrate(registry: PluginRegistry, *, run_sync: bool = True) -> MigrationResu try: repo = repos_by_url.get(plugin.source) if plugin.source else None if not repo: - result.skipped.append(plugin.name) - result.errors.append( - f"{plugin.name}: source repository not registered " + raise PluginError( + "source repository not registered " f"({plugin.source or 'no source URL'})" ) - continue clone_dir, repo, reg_info = _ensure_repo_cloned( registry, repo, pending_repos, @@ -474,53 +449,39 @@ def migrate(registry: PluginRegistry, *, run_sync: bool = True) -> MigrationResu if repo.url not in reg_info_by_url: reg_info_by_url[repo.url] = parse_registry_yml(clone_dir) reg_info = reg_info_by_url[repo.url] - entry = None - if reg_info: - entry = next( - (e for e in reg_info.plugins if e.name == plugin.name), None, - ) + + entry = reg_info.find_plugin(plugin.name) if reg_info else None if not entry: - result.skipped.append(plugin.name) - result.errors.append( - f"{plugin.name}: not found in registry.yml of '{repo.name}'" - ) - continue + raise PluginError(f"not found in registry.yml of '{repo.name}'") repo_plugin_dir = clone_dir / entry.path.rstrip('/') if not repo_plugin_dir.is_dir(): - result.skipped.append(plugin.name) - result.errors.append( - f"{plugin.name}: plugin dir missing in clone: {repo_plugin_dir}" - ) - continue + raise PluginError(f"plugin dir missing in clone: {repo_plugin_dir}") rel_path = str(repo_plugin_dir.relative_to(registry.devbase_root)) info = load_plugin_info(repo_plugin_dir) version = info.version if info else plugin.version - old_dir = registry.devbase_root / plugin.path - pending.append(InstalledPlugin( - name=plugin.name, - version=version, - source=plugin.source, - installed_at=plugin.installed_at, - path=rel_path, - linked=False, + pending.append(replace( + plugin, version=version, path=rel_path, linked=False, + )) + retire.append(( + plugin.name, registry.devbase_root / plugin.path, repo_plugin_dir, )) - retire.append((plugin.name, old_dir, repo_plugin_dir)) except Exception as e: result.skipped.append(plugin.name) result.errors.append(f"{plugin.name}: {e}") - # Persist every staged cloned-repo row AND validated path rewrite in a - # single save BEFORE retiring any copy. This both (a) keeps the registry - # durably pointing at the repos/ clones before destructive cleanup — the - # two-phase atomicity invariant — and (b) collapses what used to be one save - # per cloned repo plus the path-rewrite save into a single O(1) write. A - # failure here aborts with the copies untouched (recoverable). - registry.save_migration(pending_repos, pending) + return pending, pending_repos, retire + + +def _retire_legacy_copies( + retire: list[tuple[str, Path, Path]], result: MigrationResult, +) -> None: + """Phase 2 (destructive): 旧 plugins/ コピーを削除または .bak として保全する。 - # Now that plugins.yml durably points at repos/, retire the old copies. + Only call this AFTER plugins.yml durably points at repos/ (save_migration). + """ for name, old_dir, repo_plugin_dir in retire: try: if old_dir.is_dir() and not old_dir.is_symlink(): @@ -545,6 +506,51 @@ def migrate(registry: PluginRegistry, *, run_sync: bool = True) -> MigrationResu result.migrated.append(name) result.errors.append(f"{name}: copy not retired: {e}") + +def migrate(registry: PluginRegistry, *, run_sync: bool = True) -> MigrationResult: + """Migrate legacy plugins/ copy installs to repos/ clones. + + For each legacy plugin: ensure its source repo is cloned to repos/, rewrite + InstalledPlugin.path to the repos/ location, then delete the old copy when + byte-identical or preserve it as .bak when it diverged. Finally + re-sync project symlinks and empty plugins/ to .gitkeep when safe. + + Two-phase migration so a registry-save failure can never leave a copy + deleted while plugins.yml still points at the stale plugins/ path: + + Phase 1 (_plan_migration, no destructive fs ops): decide each copy's + fate (delete vs preserve as .bak) and stage every row to persist. + Persist: write every repo row + path rewrite in a single plugins.yml + save (save_migration), flushing the staged clones BEFORE any cleanup. + Phase 2 (_retire_legacy_copies, destructive): only after plugins.yml is + durably at repos/ do we delete/rename the old copies. + + Ordering rationale: if the save raises, no copy has been touched yet, so + the registry stays legacy and the next run retries cleanly with the copies + intact (recoverable). Conversely the validated repos/ clone is known good + before we commit, so committing the rewrite first cannot strand a plugin + on a missing tree; a stray copy left by a phase-2 hiccup is merely surfaced + by _cleanup_plugins_dir, never silent data loss. + """ + from .syncer import sync_projects + + result = MigrationResult() + legacy = [p for p in registry.list_installed() if _is_legacy_plugin(p)] + if not legacy: + return result + + pending, pending_repos, retire = _plan_migration(registry, legacy, result) + + # Persist every staged cloned-repo row AND validated path rewrite in a + # single save BEFORE retiring any copy. This both (a) keeps the registry + # durably pointing at the repos/ clones before destructive cleanup — the + # two-phase atomicity invariant — and (b) collapses what used to be one save + # per cloned repo plus the path-rewrite save into a single O(1) write. A + # failure here aborts with the copies untouched (recoverable). + registry.save_migration(pending_repos, pending) + + _retire_legacy_copies(retire, result) + if run_sync: sync_projects(registry) diff --git a/lib/devbase/plugin/models.py b/lib/devbase/plugin/models.py index 24b87e9..eeb0447 100644 --- a/lib/devbase/plugin/models.py +++ b/lib/devbase/plugin/models.py @@ -96,6 +96,20 @@ class RegistryInfo: official: bool = False plugins: list[RegistryEntry] = field(default_factory=list) + def available_plugins(self) -> list['AvailablePlugin']: + """registry.yml のエントリを plugins.yml 用の AvailablePlugin 一覧へ変換する。 + + installer / repo_manager / migrator が persist 時に共通で使う変換の SSoT。 + """ + return [ + AvailablePlugin(name=e.name, description=e.description, path=e.path) + for e in self.plugins + ] + + def find_plugin(self, plugin_name: str) -> Optional[RegistryEntry]: + """Find a registry entry by plugin name.""" + return next((e for e in self.plugins if e.name == plugin_name), None) + @dataclass class InstalledPlugin: @@ -188,7 +202,4 @@ def from_dict(cls, data: dict) -> 'RegisteredRepository': def find_plugin(self, plugin_name: str) -> Optional[AvailablePlugin]: """Find a plugin by name in this repository""" - for p in self.plugins: - if p.name == plugin_name: - return p - return None + return next((p for p in self.plugins if p.name == plugin_name), None) diff --git a/lib/devbase/plugin/repo_manager.py b/lib/devbase/plugin/repo_manager.py index a0b1fbb..63eed41 100644 --- a/lib/devbase/plugin/repo_manager.py +++ b/lib/devbase/plugin/repo_manager.py @@ -1,9 +1,12 @@ """Repository management - handles repo add/remove/list/refresh operations""" +import shutil import subprocess import yaml +from dataclasses import replace from pathlib import Path from typing import Optional +from urllib.parse import urlparse from devbase.errors import PluginError, RepositoryError from devbase.log import get_logger @@ -45,7 +48,6 @@ def _derive_repo_name(url: str) -> str: name = name[:-4] if ':' in name and '@' in name: return name.rsplit(':', 1)[-1] - from urllib.parse import urlparse path = urlparse(name).path.strip('/') segments = path.split('/') if len(segments) >= 2: @@ -65,9 +67,7 @@ def _extract_host(url: str) -> str: # SSH form: git@host:owner/repo.git after_at = stripped.split('@', 1)[1] return after_at.split(':', 1)[0] - from urllib.parse import urlparse - parsed = urlparse(stripped) - return parsed.hostname or "unknown" + return urlparse(stripped).hostname or "unknown" def _url_to_repos_dirname(url: str) -> str: @@ -88,6 +88,14 @@ def _url_to_repos_dirname(url: str) -> str: return f"{host}--{owner_repo.replace('/', '--')}" +def _run_git(repo_dir: Path, *args: str) -> subprocess.CompletedProcess: + """repo_dir で git コマンドを実行し CompletedProcess を返す (check なし)。""" + return subprocess.run( + ['git', *args], + capture_output=True, text=True, cwd=str(repo_dir), + ) + + def _is_repo_dirty(repo_dir: Path) -> tuple[bool, str]: """Check if a git repository has uncommitted or unpushed changes. @@ -95,40 +103,57 @@ def _is_repo_dirty(repo_dir: Path) -> tuple[bool, str]: """ issues = [] - try: - result = subprocess.run( - ['git', 'status', '--porcelain'], - capture_output=True, text=True, cwd=str(repo_dir), + status = _run_git(repo_dir, 'status', '--porcelain') + if status.returncode == 0 and status.stdout.strip(): + issues.append("uncommitted changes") + + # Check if upstream tracking branch exists + upstream_check = _run_git(repo_dir, 'rev-parse', '--abbrev-ref', '@{u}') + if upstream_check.returncode == 0: + # Upstream exists — check for unpushed commits + unpushed = _run_git(repo_dir, 'log', '--oneline', '@{u}..HEAD') + if unpushed.returncode == 0 and unpushed.stdout.strip(): + issues.append("unpushed commits") + else: + # No upstream tracking branch — local commits may be lost + # if deleted, so treat as dirty to be safe + issues.append("no upstream tracking branch (local-only commits may exist)") + + return (True, ", ".join(issues)) if issues else (False, "") + + +def _missing_upstream_error(repo_dir: Path) -> PluginError: + """upstream 不在時の git pull エラーを、原因別の対処付きメッセージで組み立てる。""" + # Detect current branch name + branch_result = _run_git(repo_dir, 'branch', '--show-current') + current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "" + + # Detect the first available remote (usually "origin") + remote_result = _run_git(repo_dir, 'remote') + remote_name = "" + if remote_result.returncode == 0 and remote_result.stdout.strip(): + remotes = remote_result.stdout.strip().splitlines() + # Prefer "origin" when multiple remotes exist + remote_name = "origin" if "origin" in remotes else remotes[0] + + if not current_branch: + return PluginError( + f"git pull failed in {repo_dir}: HEAD is detached.\n" + "This can happen if the branch was changed manually in repos/.\n" + f"Check out a branch first, then retry:\n" + f" git -C {repo_dir} checkout main" ) - if result.returncode == 0 and result.stdout.strip(): - issues.append("uncommitted changes") - except subprocess.CalledProcessError: - pass - - try: - # Check if upstream tracking branch exists - upstream_check = subprocess.run( - ['git', 'rev-parse', '--abbrev-ref', '@{u}'], - capture_output=True, text=True, cwd=str(repo_dir), + if not remote_name: + return PluginError( + f"git pull failed in {repo_dir}: no remote configured.\n" + f"Current branch '{current_branch}' has no remote to pull from." ) - if upstream_check.returncode == 0: - # Upstream exists — check for unpushed commits - result = subprocess.run( - ['git', 'log', '--oneline', '@{u}..HEAD'], - capture_output=True, text=True, cwd=str(repo_dir), - ) - if result.returncode == 0 and result.stdout.strip(): - issues.append("unpushed commits") - else: - # No upstream tracking branch — local commits may be lost - # if deleted, so treat as dirty to be safe - issues.append("no upstream tracking branch (local-only commits may exist)") - except subprocess.CalledProcessError: - pass - - if issues: - return True, ", ".join(issues) - return False, "" + return PluginError( + f"git pull failed in {repo_dir}: no upstream tracking branch.\n" + f"Current branch '{current_branch}' has no remote to pull from.\n" + "This can happen if the branch was changed manually in repos/.\n" + f"Fix with: git -C {repo_dir} branch --set-upstream-to={remote_name}/{current_branch}" + ) def _git_pull(repo_dir: Path) -> None: @@ -139,47 +164,9 @@ def _git_pull(repo_dir: Path) -> None: """ # Pre-check: verify an upstream tracking branch is set. # Without it, `git pull` will fail with a confusing message. - upstream = subprocess.run( - ['git', 'rev-parse', '--abbrev-ref', '@{u}'], - capture_output=True, text=True, cwd=str(repo_dir), - ) + upstream = _run_git(repo_dir, 'rev-parse', '--abbrev-ref', '@{u}') if upstream.returncode != 0: - # Detect current branch name - branch_result = subprocess.run( - ['git', 'branch', '--show-current'], - capture_output=True, text=True, cwd=str(repo_dir), - ) - current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "" - - # Detect the first available remote (usually "origin") - remote_result = subprocess.run( - ['git', 'remote'], - capture_output=True, text=True, cwd=str(repo_dir), - ) - remote_name = "" - if remote_result.returncode == 0 and remote_result.stdout.strip(): - remotes = remote_result.stdout.strip().splitlines() - # Prefer "origin" when multiple remotes exist - remote_name = "origin" if "origin" in remotes else remotes[0] - - if not current_branch: - raise PluginError( - f"git pull failed in {repo_dir}: HEAD is detached.\n" - "This can happen if the branch was changed manually in repos/.\n" - f"Check out a branch first, then retry:\n" - f" git -C {repo_dir} checkout main" - ) - if not remote_name: - raise PluginError( - f"git pull failed in {repo_dir}: no remote configured.\n" - f"Current branch '{current_branch}' has no remote to pull from." - ) - raise PluginError( - f"git pull failed in {repo_dir}: no upstream tracking branch.\n" - f"Current branch '{current_branch}' has no remote to pull from.\n" - "This can happen if the branch was changed manually in repos/.\n" - f"Fix with: git -C {repo_dir} branch --set-upstream-to={remote_name}/{current_branch}" - ) + raise _missing_upstream_error(repo_dir) try: subprocess.run( @@ -192,17 +179,24 @@ def _git_pull(repo_dir: Path) -> None: ) -def add_repository( - registry: PluginRegistry, - url: str, - name: Optional[str] = None, +def _log_available_plugins( + registry: PluginRegistry, plugins: list[AvailablePlugin], ) -> None: - """Register a repository: clone to repos/ -> read registry.yml -> save to plugins.yml. + """Available plugins 一覧をインストール済みマーク付きで出力する。""" + if not plugins: + return + logger.info("Available plugins:") + for p in plugins: + status = " (installed)" if registry.get(p.name) else "" + logger.info(" - %s: %s%s", p.name, p.description, status) - Raises RepositoryError on failure. - """ - repo_url = resolve_repo_url(url) +def _reject_duplicate_registration(registry: PluginRegistry, repo_url: str) -> None: + """同一 URL / URL variant (SSH vs HTTPS) の二重登録を拒否する。 + + _url_to_repos_dirname normalizes both forms to the same dirname, + so we compare against existing repos to prevent redundant clones. + """ existing = registry.get_repository_by_url(repo_url) if existing: raise RepositoryError( @@ -210,9 +204,6 @@ def add_repository( "Use 'devbase plugin repo refresh' to update the plugin list." ) - # Detect duplicate registration via SSH/HTTPS URL variants. - # _url_to_repos_dirname normalizes both forms to the same dirname, - # so we compare against existing repos to prevent redundant clones. new_dirname = _url_to_repos_dirname(repo_url) for repo in registry.list_repositories(): if _url_to_repos_dirname(repo.url) == new_dirname and repo.url != repo_url: @@ -228,6 +219,41 @@ def add_repository( "Use 'devbase plugin repo refresh' to update the existing entry." ) + +def _resolve_new_repo_name( + registry: PluginRegistry, repo_url: str, name: Optional[str], reg_info, +) -> str: + """新規登録リポジトリの名前を決定する (--name > registry.yml > derived)。 + + 候補名が既存と衝突する場合は derived 名へフォールバックし、それでも + 衝突するなら RepositoryError。 + """ + derived_name = _derive_repo_name(repo_url) + candidate_name = name or reg_info.name or derived_name + + if registry.get_repository(candidate_name) and candidate_name != derived_name: + candidate_name = derived_name + + if registry.get_repository(candidate_name): + raise RepositoryError( + f"Repository name '{candidate_name}' already exists.\n" + "Use --name to specify a different name." + ) + return candidate_name + + +def add_repository( + registry: PluginRegistry, + url: str, + name: Optional[str] = None, +) -> None: + """Register a repository: clone to repos/ -> read registry.yml -> save to plugins.yml. + + Raises RepositoryError on failure. + """ + repo_url = resolve_repo_url(url) + _reject_duplicate_registration(registry, repo_url) + repos_dir = registry.get_repos_dir() repos_dir.mkdir(exist_ok=True) @@ -248,55 +274,26 @@ def add_repository( if not reg_info: raise RepositoryError(f"No registry.yml found in {repo_url}") - derived_name = _derive_repo_name(repo_url) - candidate_name = name or reg_info.name or derived_name - - if registry.get_repository(candidate_name) and candidate_name != derived_name: - candidate_name = derived_name - - repo_name = candidate_name - - if registry.get_repository(repo_name): - raise RepositoryError( - f"Repository name '{repo_name}' already exists.\n" - "Use --name to specify a different name." - ) + repo_name = _resolve_new_repo_name(registry, repo_url, name, reg_info) except Exception: # Clean up the cloned directory so a retry won't fail with # "Directory already exists". This also handles partial clones # (e.g. disk full, network interruption mid-clone). - import shutil as _shutil if clone_dir.is_dir(): - _shutil.rmtree(clone_dir) + shutil.rmtree(clone_dir) raise - plugins = [ - AvailablePlugin( - name=e.name, - description=e.description, - path=e.path, - ) - for e in reg_info.plugins - ] - - local_path = f"repos/{dir_name}" - repo = RegisteredRepository( name=repo_name, url=repo_url, added_at=registry.now_iso(), - local_path=local_path, - plugins=plugins, + local_path=f"repos/{dir_name}", + plugins=reg_info.available_plugins(), ) registry.add_repository(repo) logger.info("Repository registered: %s (%s)", repo_name, repo_url) - if plugins: - logger.info("Available plugins:") - for p in plugins: - installed = registry.get(p.name) - status = " (installed)" if installed else "" - logger.info(" - %s: %s%s", p.name, p.description, status) + _log_available_plugins(registry, repo.plugins) def remove_repository( @@ -308,14 +305,12 @@ def remove_repository( Raises RepositoryError if not found or if repos/ is dirty (without --force). """ - import shutil from .installer import uninstall_plugin repo = registry.get_repository(name) if not repo: raise RepositoryError(f"Repository '{name}' not found.") - repos_dir = registry.get_repos_dir() repo_clone_dir = registry.devbase_root / repo.local_path if repo.local_path else None if repo_clone_dir and repo_clone_dir.is_dir() and not force: @@ -417,35 +412,19 @@ def refresh_repository( if not reg_info: raise RepositoryError(f"No registry.yml found in {repo.url}") - old_plugin_names = {p.name for p in repo.plugins} + plugins = reg_info.available_plugins() - plugins = [ - AvailablePlugin( - name=e.name, - description=e.description, - path=e.path, - ) - for e in reg_info.plugins - ] + # registry.yml から消えたインストール済み plugin を警告する + old_plugin_names = {p.name for p in repo.plugins} new_plugin_names = {p.name for p in plugins} - installed_names = {p.name for p in installed} - removed_installed = (old_plugin_names - new_plugin_names) & installed_names - if removed_installed: - for pname in sorted(removed_installed): - logger.warning( - "Installed plugin '%s' no longer exists in registry.yml of '%s'", - pname, name, - ) + for pname in sorted((old_plugin_names - new_plugin_names) & installed_names): + logger.warning( + "Installed plugin '%s' no longer exists in registry.yml of '%s'", + pname, name, + ) - updated_repo = RegisteredRepository( - name=repo.name, - url=repo.url, - added_at=repo.added_at, - local_path=repo.local_path, - plugins=plugins, - ) - registry.add_repository(updated_repo) + registry.add_repository(replace(repo, plugins=plugins)) # After pull, update installed plugin metadata (version, path) and # re-sync project symlinks so that registry.yml changes (e.g. renamed @@ -472,12 +451,7 @@ def refresh_repository( sync_projects(registry) logger.info("Repository refreshed: %s", repo.name) - if plugins: - logger.info("Available plugins:") - for p in plugins: - installed_p = registry.get(p.name) - status = " (installed)" if installed_p else "" - logger.info(" - %s: %s%s", p.name, p.description, status) + _log_available_plugins(registry, plugins) def add_official_repository(registry: PluginRegistry) -> bool: diff --git a/lib/devbase/plugin/syncer.py b/lib/devbase/plugin/syncer.py index 771b4ed..78eb26d 100644 --- a/lib/devbase/plugin/syncer.py +++ b/lib/devbase/plugin/syncer.py @@ -1,6 +1,6 @@ """Project symlink synchronization for plugins""" -import os +import yaml from pathlib import Path from typing import Optional @@ -14,7 +14,6 @@ def load_plugin_info(plugin_dir: Path) -> Optional[PluginInfo]: """Load plugin.yml from a plugin directory""" - import yaml yml_path = plugin_dir / 'plugin.yml' if not yml_path.exists(): return None @@ -58,6 +57,59 @@ def _extract_owner(plugin: InstalledPlugin) -> str: return plugin.name +def _collect_project_candidates( + registry: PluginRegistry, + installed: list[InstalledPlugin], + verbose: bool, +) -> dict[str, list[tuple[InstalledPlugin, int]]]: + """全 plugin のプロジェクトを project 名 -> [(plugin, priority)] へ集約する。""" + candidates: dict[str, list[tuple[InstalledPlugin, int]]] = {} + for plugin in installed: + plugin_dir = registry.devbase_root / plugin.path + if not plugin_dir.is_dir(): + if verbose: + logger.warning("Plugin directory missing: %s", plugin.path) + continue + + info = load_plugin_info(plugin_dir) + priority = info.priority if info else 0 + + for proj_name in discover_projects(plugin_dir): + candidates.setdefault(proj_name, []).append((plugin, priority)) + return candidates + + +def _link_loser_projects( + projects_dir: Path, + proj_name: str, + losers: list[tuple[InstalledPlugin, int]], + real_projects: set, + verbose: bool, +) -> int: + """衝突に敗れた plugin のプロジェクトを . サフィックスで symlink する。 + + Returns the number of symlinks created. + """ + created = 0 + for loser_plugin, _ in losers: + owner = _extract_owner(loser_plugin) + suffix_name = f"{proj_name}.{owner}" + + if suffix_name in real_projects: + if verbose: + logger.info(" Skip: %s (real directory exists)", suffix_name) + continue + + suffix_link = projects_dir / suffix_name + if suffix_link.exists() or suffix_link.is_symlink(): + if verbose: + logger.warning(" Skip: %s (symlink already exists)", suffix_name) + continue + suffix_link.symlink_to(_make_relative_target(loser_plugin, proj_name)) + created += 1 + return created + + def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: """Synchronize project symlinks from all installed plugins. @@ -74,10 +126,10 @@ def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: projects_dir = registry.get_projects_dir() projects_dir.mkdir(exist_ok=True) - real_projects = set() - for entry in projects_dir.iterdir(): - if not entry.is_symlink() and entry.is_dir(): - real_projects.add(entry.name) + real_projects = { + entry.name for entry in projects_dir.iterdir() + if not entry.is_symlink() and entry.is_dir() + } for entry in projects_dir.iterdir(): if entry.is_symlink(): @@ -89,24 +141,7 @@ def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: logger.info("No plugins installed") return 0 - project_candidates: dict[str, list[tuple[InstalledPlugin, int, Path]]] = {} - - for plugin in installed: - plugin_dir = registry.devbase_root / plugin.path - if not plugin_dir.is_dir(): - if verbose: - logger.warning("Plugin directory missing: %s", plugin.path) - continue - - info = load_plugin_info(plugin_dir) - priority = info.priority if info else 0 - - for proj_name in discover_projects(plugin_dir): - if proj_name not in project_candidates: - project_candidates[proj_name] = [] - project_candidates[proj_name].append( - (plugin, priority, plugin_dir) - ) + project_candidates = _collect_project_candidates(registry, installed, verbose) created = 0 for proj_name, candidates in sorted(project_candidates.items()): @@ -115,44 +150,28 @@ def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: logger.info(" Skip: %s (real directory exists)", proj_name) continue + # 優先度降順 → plugin 名昇順。先頭が bare 名を獲得する winner。 candidates.sort(key=lambda c: (-c[1], c[0].name)) - winner_plugin, winner_priority, winner_dir = candidates[0] + (winner_plugin, winner_priority), losers = candidates[0], candidates[1:] - if len(candidates) > 1 and verbose: + if losers and verbose: logger.warning( "Project '%s' exists in multiple plugins — using '%s' (priority: %d)", proj_name, winner_plugin.name, winner_priority, ) - for loser_plugin, _, _ in candidates[1:]: - owner = _extract_owner(loser_plugin) + for loser_plugin, _ in losers: logger.info( " Also available as: projects/%s.%s", - proj_name, owner, + proj_name, _extract_owner(loser_plugin), ) - target = _make_relative_target(winner_plugin, proj_name) link_path = projects_dir / proj_name - link_path.symlink_to(target) + link_path.symlink_to(_make_relative_target(winner_plugin, proj_name)) created += 1 - if len(candidates) > 1: - for loser_plugin, _, loser_dir in candidates[1:]: - owner = _extract_owner(loser_plugin) - suffix_name = f"{proj_name}.{owner}" - - if suffix_name in real_projects: - if verbose: - logger.info(" Skip: %s (real directory exists)", suffix_name) - continue - - suffix_target = _make_relative_target(loser_plugin, proj_name) - suffix_link = projects_dir / suffix_name - if suffix_link.exists() or suffix_link.is_symlink(): - if verbose: - logger.warning(" Skip: %s (symlink already exists)", suffix_name) - continue - suffix_link.symlink_to(suffix_target) - created += 1 + created += _link_loser_projects( + projects_dir, proj_name, losers, real_projects, verbose, + ) if verbose: logger.info("Synced %d project(s) from %d plugin(s)", created, len(installed)) diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 0c00fc0..599797c 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -1,30 +1,24 @@ """Docker Compose file generation for scaled deployments""" +import copy import os import yaml from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, Optional from devbase.errors import DockerError from .manager import get_work_volume_for_index, get_ai_volume_for_index +# 旧 /home/ubuntu マウントは非推奨のため scale 生成時に除去する +_DEPRECATED_TARGET = '/home/ubuntu' + def get_dev_service_name() -> str: """Get development service name from environment variable or default to 'dev'""" return os.environ.get('DEV_SERVICE_NAME', 'dev') -def _deep_copy(obj: Any) -> Any: - """Deep copy an object (handles dicts, lists, and primitives)""" - if isinstance(obj, dict): - return {k: _deep_copy(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [_deep_copy(item) for item in obj] - else: - return obj - - def _rewrite_depends_on( service_config: Dict[str, Any], dev_service_name: str, @@ -44,17 +38,25 @@ def _rewrite_depends_on( instance_names = [f"{dev_service_name}-{i}" for i in range(1, scale + 1)] if isinstance(deps, list): - new_deps = [] - for d in deps: - if d == dev_service_name: - new_deps.extend(instance_names) - else: - new_deps.append(d) - service_config['depends_on'] = new_deps + service_config['depends_on'] = [ + name + for dep in deps + for name in (instance_names if dep == dev_service_name else [dep]) + ] elif isinstance(deps, dict) and dev_service_name in deps: condition = deps.pop(dev_service_name) for name in instance_names: - deps[name] = _deep_copy(condition) + deps[name] = copy.deepcopy(condition) + + +def _volume_target(vol: Any) -> Optional[str]: + """Return the mount target of a volume entry (string / dict form), or None.""" + if isinstance(vol, str): + parts = vol.split(':') + return parts[1] if len(parts) >= 2 else None + if isinstance(vol, dict): + return vol.get('target') + return None def _replace_volumes_for_instance( @@ -66,80 +68,53 @@ def _replace_volumes_for_instance( /persistent/ai is mapped to ai_volume. /work is mapped to work_volume. """ + replacements = {'/persistent/ai': ai_volume, '/work': work_volume} + replaced_targets = set() new_volumes = [] - has_ai_mount = False - has_work_mount = False for vol in volumes: + target = _volume_target(vol) + if target == _DEPRECATED_TARGET: + continue + source = replacements.get(target) + if source is None: + new_volumes.append(vol) + continue + replaced_targets.add(target) if isinstance(vol, str): # String format: "source:target" or "source:target:options" parts = vol.split(':') - if len(parts) >= 2: - target = parts[1] - if target == '/home/ubuntu': - continue - elif target == '/persistent/ai': - new_vol = f"{ai_volume}:/persistent/ai" - if len(parts) >= 3: - new_vol += f":{parts[2]}" - new_volumes.append(new_vol) - has_ai_mount = True - elif target == '/work': - new_vol = f"{work_volume}:/work" - if len(parts) >= 3: - new_vol += f":{parts[2]}" - new_volumes.append(new_vol) - has_work_mount = True - else: - new_volumes.append(vol) - else: - new_volumes.append(vol) - elif isinstance(vol, dict): - # Dict format: {type, source, target} - target = vol.get('target') - if target == '/home/ubuntu': - continue - elif target == '/persistent/ai': - vol['source'] = ai_volume - vol['type'] = 'volume' - has_ai_mount = True - new_volumes.append(vol) - elif target == '/work': - vol['source'] = work_volume - vol['type'] = 'volume' - has_work_mount = True - new_volumes.append(vol) - else: - new_volumes.append(vol) + options = f":{parts[2]}" if len(parts) >= 3 else "" + new_volumes.append(f"{source}:{target}{options}") else: + # Dict format: {type, source, target} + vol['source'] = source + vol['type'] = 'volume' new_volumes.append(vol) # Add missing mounts - if not has_ai_mount: - new_volumes.append(f"{ai_volume}:/persistent/ai") - if not has_work_mount: - new_volumes.append(f"{work_volume}:/work") - + new_volumes.extend( + f"{source}:{target}" + for target, source in replacements.items() + if target not in replaced_targets + ) return new_volumes def _build_volumes_section(config: dict, scale: int) -> dict: """Build the volumes section for a scaled compose file.""" - volumes: Dict[str, Any] = {} - # Copy original volumes (mysql, valkey, etc.) from config - if 'volumes' in config: - for vol_name, vol_config in config['volumes'].items(): - volumes[vol_name] = _deep_copy(vol_config) if vol_config else {} + volumes: Dict[str, Any] = { + vol_name: copy.deepcopy(vol_config) if vol_config else {} + for vol_name, vol_config in config.get('volumes', {}).items() + } # Add shared home volume (devbase_home_ubuntu) once for all instances - home_volume = get_ai_volume_for_index(1) - volumes[home_volume] = {'external': True} + volumes[get_ai_volume_for_index(1)] = {'external': True} # Add work volumes for each dev instance (external) for i in range(1, scale + 1): - work_volume = get_work_volume_for_index(i) - volumes[work_volume] = {'external': True} + volumes[get_work_volume_for_index(i)] = {'external': True} return volumes @@ -151,6 +126,71 @@ def _build_networks_section(config: dict) -> dict: return {'net': {'driver': 'bridge'}} +def _load_compose_config(compose_file: Path) -> dict: + """Read and parse the base compose file. + + docker compose config を使わず直接読むのは、環境変数 (secrets を含み得る) の + 展開を避けるため。 + """ + if not compose_file.exists(): + raise FileNotFoundError(f"Compose file not found: {compose_file}") + try: + with open(compose_file, 'r') as f: + return yaml.safe_load(f) + except yaml.YAMLError as e: + raise DockerError(f"Failed to parse compose file: {e}") + + +def _build_dev_instance( + dev_service: dict, dev_service_name: str, index: int, +) -> dict: + """Build the service definition for one scaled dev instance (dev-).""" + service = copy.deepcopy(dev_service) + service['container_name'] = f"${{COMPOSE_PROJECT_NAME}}-{dev_service_name}-{index}" + + # Insert tini as PID 1 so orphaned children are reaped (no zombies). + # setdefault keeps an explicit `init: false` if the project set one. + service.setdefault('init', True) + + # Remove environment section (use env_file instead to avoid exposing secrets) + service.pop('environment', None) + + # Update volume mounts for /persistent/ai and /work + ai_volume = get_ai_volume_for_index(index) + work_volume = get_work_volume_for_index(index) + service['volumes'] = _replace_volumes_for_instance( + service.get('volumes', []), ai_volume, work_volume, + ) + return service + + +def _build_scaled_services( + services: dict, dev_service: dict, dev_service_name: str, scale: int, +) -> dict: + """Build the services section: non-dev services + dev-1..dev-N instances.""" + scaled_services = {} + + # Copy non-dev services (mysql, valkey, etc.) — rewriting any + # `depends_on: ` reference to the scaled instances (dev-1..N) so + # service_healthy chains keep working after dev is renamed. + for service_name, service_config in services.items(): + if service_name == dev_service_name: + continue + copied = copy.deepcopy(service_config) + _rewrite_depends_on(copied, dev_service_name, scale) + # Insert tini as PID 1 so orphaned children are reaped (no zombies). + # setdefault keeps an explicit `init: false` if the project set one. + copied.setdefault('init', True) + scaled_services[service_name] = copied + + # Generate a service for each instance + for i in range(1, scale + 1): + scaled_services[f'{dev_service_name}-{i}'] = _build_dev_instance( + dev_service, dev_service_name, i, + ) + return scaled_services + + def generate_scaled_compose( scale: int, project_name: str, @@ -171,21 +211,10 @@ def generate_scaled_compose( """ compose_file = compose_file or Path("compose.yml") override_file = Path(".docker-compose.scale.yml") - - # Get development service name from parameter, environment variable, or default if dev_service_name is None: dev_service_name = get_dev_service_name() - if not compose_file.exists(): - raise FileNotFoundError(f"Compose file not found: {compose_file}") - - # Read base compose configuration directly (not using docker compose config - # to avoid expanding environment variables which may contain secrets) - try: - with open(compose_file, 'r') as f: - config = yaml.safe_load(f) - except yaml.YAMLError as e: - raise DockerError(f"Failed to parse compose file: {e}") + config = _load_compose_config(compose_file) # Extract dev service (configurable via DEV_SERVICE_NAME) services = config.get('services', {}) @@ -193,59 +222,14 @@ def generate_scaled_compose( if not dev_service: raise DockerError(f"No '{dev_service_name}' service found in compose file") - # Build scaled compose - scaled_config = {'services': {}} - - # Copy non-dev services (mysql, valkey, etc.) — rewriting any - # `depends_on: ` reference to the scaled instances (dev-1..N) so - # service_healthy chains keep working after dev is renamed. - for service_name, service_config in services.items(): - if service_name != dev_service_name: - copied = _deep_copy(service_config) - _rewrite_depends_on(copied, dev_service_name, scale) - # Insert tini as PID 1 so orphaned children are reaped (no zombies). - # setdefault keeps an explicit `init: false` if the project set one. - copied.setdefault('init', True) - scaled_config['services'][service_name] = copied - - # Generate a service for each instance - for i in range(1, scale + 1): - ai_volume = get_ai_volume_for_index(i) - work_volume = get_work_volume_for_index(i) - - # Clone dev service - service = _deep_copy(dev_service) - - # Update container name - service['container_name'] = f"${{COMPOSE_PROJECT_NAME}}-{dev_service_name}-{i}" - - # Insert tini as PID 1 so orphaned children are reaped (no zombies). - # setdefault keeps an explicit `init: false` if the project set one. - service.setdefault('init', True) - - # Remove environment section (use env_file instead to avoid exposing secrets) - if 'environment' in service: - del service['environment'] - - # Update volume mounts for /persistent/ai and /work - if 'volumes' in service: - service['volumes'] = _replace_volumes_for_instance( - service['volumes'], ai_volume, work_volume, - ) - else: - # No volumes section, create one - service['volumes'] = [ - f"{ai_volume}:/persistent/ai", - f"{work_volume}:/work" - ] - - scaled_config['services'][f'{dev_service_name}-{i}'] = service - - # Add volumes and networks sections - scaled_config['volumes'] = _build_volumes_section(config, scale) - scaled_config['networks'] = _build_networks_section(config) + scaled_config = { + 'services': _build_scaled_services( + services, dev_service, dev_service_name, scale, + ), + 'volumes': _build_volumes_section(config, scale), + 'networks': _build_networks_section(config), + } - # Write scaled compose file try: with open(override_file, 'w') as f: yaml.dump( diff --git a/lib/devbase/volume/manager.py b/lib/devbase/volume/manager.py index dfe7951..b124687 100644 --- a/lib/devbase/volume/manager.py +++ b/lib/devbase/volume/manager.py @@ -1,7 +1,6 @@ """Volume management functions for devbase""" import subprocess -from typing import Optional from devbase.errors import DockerError from devbase.log import get_logger