From 918249ecaeadcc3763e42738142278429a75f7ec Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 30 May 2026 07:40:44 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20PLAN06-3=20project=20list=20?= =?UTF-8?q?=E4=B8=80=E8=A6=A7=E8=A1=A8=E7=A4=BA=20+=20--interactive=20?= =?UTF-8?q?=E9=81=B8=E6=8A=9E=E8=B5=B7=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `devbase project list` / トップレベルシノニム `devbase list` を新設し、 $DEVBASE_ROOT/projects/ 配下を NAME / PLUGIN / STATUS で一覧表示する。 - lib/devbase/commands/project.py (新規): - _resolve_plugin_name: symlink 先から plugin 名を解決。PLAN04 の同名衝突 suffix (carmo.takemi) はリンク名のみに付きリンク先 dir は素の の ままなので、リンク先を辿ることで suffix 有無に関わらず正しく解決する。 - list_projects: projects/ 配下 (symlink/実dir/broken symlink) を列挙。 status は status._container_status_for を共有 (取得不能は unknown)。 - cmd_project_list: 整列テーブル表示 / --interactive で番号入力選択 → project up 起動 (新規依存を足さず stdlib input、非TTY は EOFError graceful)。 - lib/devbase/commands/status.py: - per-entry の _container_status_for を抽出し project list と共有 (cmd_status の挙動は不変)。 - lib/devbase/cli.py: - project list サブコマンド (--interactive/-i) + トップレベル list シノニム - SUBCMD_MAP / _expand_argv / dispatch に list を同期 (DEVBASE_ROOT 必須) - bin/devbase: - resolve_command 候補 + dispatch case に list を追加 (name 解決対象外) - tests: test_project_list.py 新規 (25件) + wrapper dispatch に list 経路 4件 pytest: 395 passed (baseline 366 + 29) Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/devbase | 4 +- lib/devbase/cli.py | 34 ++- lib/devbase/commands/project.py | 155 +++++++++++ lib/devbase/commands/status.py | 136 +++++----- tests/cli/test_project_list.py | 396 +++++++++++++++++++++++++++++ tests/cli/test_wrapper_dispatch.py | 20 ++ 6 files changed, 674 insertions(+), 71 deletions(-) create mode 100644 lib/devbase/commands/project.py create mode 100644 tests/cli/test_project_list.py diff --git a/bin/devbase b/bin/devbase index a3723fd..49e5df3 100755 --- a/bin/devbase +++ b/bin/devbase @@ -168,7 +168,7 @@ run_python() { # Resolve abbreviated command to full command name via unique prefix matching resolve_command() { local input="$1" - local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build ps scale help" + local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build ps scale list help" local matches=() for cmd in $commands; do [[ "$cmd" == "$input"* ]] && matches+=("$cmd") @@ -279,7 +279,7 @@ case "$_resolved_cmd" in # Python-implemented commands --version|-V) run_python "$@" ;; - init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale) + init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|list) run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;; # Shell-implemented commands build) cmd_build "${_DEVBASE_ARGS[@]}" ;; diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 24f499a..e3704dd 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -45,7 +45,7 @@ # Subcommand map for prefix resolution: {(aliases...): [subcmds]} SUBCMD_MAP = { - ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'], + ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'list'], ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'], ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'], ('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'], @@ -167,6 +167,20 @@ def _add_project_parser(subparsers): _add_build_subparser(pj_sub) + # `list` は lifecycle ではなく一覧表示 (commands/project.py)。name positional は + # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 + _add_list_subparser(pj_sub) + + +def _add_list_subparser(sub): + """`list` サブコマンドを登録する (project list / top-level list 共通)。 + + NAME / PLUGIN / STATUS の一覧表示。`--interactive` で選択 → `project up` 起動。 + """ + p = sub.add_parser('list', help='List projects (NAME / PLUGIN / STATUS)') + p.add_argument('--interactive', '-i', action='store_true', + help='Select a project interactively and start it') + def _add_env_parser(subparsers): """Env group parser""" @@ -396,6 +410,11 @@ def _add_shortcuts(subparsers): scale_sc.add_argument('name', nargs='?', default=None, help='Project name') scale_sc.add_argument('new_scale', type=int, help='New number of containers') + # `list` は `project list` のトップレベルシノニム。lifecycle ではなく一覧表示 + # のため SHORTCUTS (project lifecycle へ写像) ではなく _dispatch で個別に + # cmd_project_list へ振り分ける。 + _add_list_subparser(subparsers) + def _create_parser(): """Create command line parser""" @@ -477,7 +496,7 @@ def _expand_argv(): # bin/devbase が build を shell 実装に委譲するため Python 側には top-level # build parser が無い。project build / container build は引き続き利用可能。 commands = ['init', 'status', 'shell-rc', 'project', 'container', 'ct', 'env', 'plugin', 'pl', - 'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'help'] + 'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'list', 'help'] repo_subcmds = ['add', 'remove', 'list', 'refresh'] if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'): @@ -533,9 +552,20 @@ def _dispatch(cmd, args): # --- Project group (推奨) --- if cmd == 'project': + # `project list` は lifecycle ではなく一覧表示 (DEVBASE_ROOT 必須)。 + if getattr(args, 'subcommand', None) == 'list': + devbase_root = _require_devbase_root() + from devbase.commands.project import cmd_project_list + return cmd_project_list(devbase_root, args) from devbase.commands.container import cmd_project return cmd_project(args) + # --- Top-level `list` synonym for `project list` --- + if cmd == 'list': + devbase_root = _require_devbase_root() + from devbase.commands.project import cmd_project_list + return cmd_project_list(devbase_root, args) + # --- Container group (非推奨: project へ委譲 + warning) --- if cmd == 'container': from devbase.commands.container import cmd_container diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py new file mode 100644 index 0000000..b48e722 --- /dev/null +++ b/lib/devbase/commands/project.py @@ -0,0 +1,155 @@ +"""Project listing commands (`devbase project list` / `devbase list`). + +PLAN06 Task 3。`$DEVBASE_ROOT/projects/` 配下を NAME / PLUGIN / STATUS で一覧表示し、 +``--interactive`` で選択 → `project up` 起動を行う。 + +ライフサイクル操作 (up/down/ps/login/logs/scale/build) は引き続き +``commands/container.py`` の共有ハンドラが担当し、本モジュールは listing と +interactive 起動のみを担う。 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from devbase.log import get_logger + +logger = get_logger(__name__) + + +def _resolve_plugin_name(entry: Path) -> str | None: + """projects/ 配下の entry が属する plugin 名を解決する。 + + entry が symlink の場合、その **リンク先** (``..//projects/``) + から plugin 名を解決する。PLAN04 の同名衝突 suffix (例 ``carmo.takemi--carmo``) + は **リンク名のみ** に付与され、リンク先 dir 名は素の ```` のままであるため、 + リンク名でなくリンク先を辿ることで suffix の有無に関わらず正しく解決できる。 + + plugin 名はリンク先パスの ``projects`` セグメント直前の要素: + - repos ベース: ``../repos/--//projects/`` → ```` + - --link ベース: ``../plugins//projects/`` → ```` + + symlink でない実ディレクトリ (plugin に属さない) や解決不能な場合は ``None``。 + リンク先実体が存在しない (broken symlink) 場合もリンクテキストから解決する。 + """ + if not entry.is_symlink(): + return None + try: + target = os.readlink(entry) + except OSError: + return None + + parts = Path(target).parts + # `projects` の最後の出現位置 (proj 名の直前) を採用する。 + for i in range(len(parts) - 1, 0, -1): + if parts[i] == "projects": + return parts[i - 1] + return None + + +def list_projects(projects_dir: Path) -> list[dict]: + """projects/ 配下のプロジェクトを NAME / PLUGIN / STATUS で列挙する。 + + 各要素は ``{"name", "plugin", "status"}``。 + + - ``name``: projects/ 内のエントリ名 (衝突 suffix 付きもそのまま) + - ``plugin``: ``_resolve_plugin_name`` の結果。実ディレクトリ / 解決不能は ``"-"`` + - ``status``: ``status._container_status_for`` の状態文字列。 + compose.yml 無し / docker 不在等で取得できない場合は ``"unknown"`` + + symlink (broken 含む) と実ディレクトリの両方を対象とする。 + """ + # status ロジックは commands/status.py と共有する (PLAN06 リファクタで per-entry + # 関数 _container_status_for を分離済み)。import は循環回避のため関数内で行う。 + from devbase.commands import status as status_mod + + results: list[dict] = [] + if not projects_dir.exists(): + return results + + for entry in sorted(projects_dir.iterdir()): + # broken symlink は is_dir() が False になるため symlink 自体も拾う。 + if not (entry.is_symlink() or entry.is_dir()): + continue + + plugin = _resolve_plugin_name(entry) + + status = "unknown" + # is_dir() は symlink 先まで辿る。broken symlink は False → unknown のまま。 + if entry.is_dir(): + st = status_mod._container_status_for(entry) + if st is not None: + status = st["status"] + + results.append({ + "name": entry.name, + "plugin": plugin or "-", + "status": status, + }) + + return results + + +def _print_table(rows: list[dict]) -> None: + """NAME / PLUGIN / STATUS の整列テーブルを標準出力に表示する。""" + name_w = max(len("NAME"), *(len(r["name"]) for r in rows)) + plugin_w = max(len("PLUGIN"), *(len(r["plugin"]) for r in rows)) + print(f"{'NAME':<{name_w}} {'PLUGIN':<{plugin_w}} STATUS") + for r in rows: + print(f"{r['name']:<{name_w}} {r['plugin']:<{plugin_w}} {r['status']}") + + +def _interactive_select_and_up(rows: list[dict]) -> int: + """一覧から番号入力で 1 件選択し ``project up `` を起動する。 + + 外部依存 (simple_term_menu 等) を増やさず stdlib の ``input()`` で実装する。 + 非対話環境 (stdin が閉じている等で EOFError) ではエラー終了する。空入力は中止。 + """ + print("起動するプロジェクトを選択してください:") + for i, r in enumerate(rows, 1): + print(f" [{i}] {r['name']} ({r['plugin']}, {r['status']})") + + try: + raw = input("番号 (空で中止): ").strip() + except EOFError: + logger.error("対話入力ができません (非 TTY 環境)。" + "`devbase project up ` で直接指定してください。") + return 1 + + if not raw: + logger.info("中止しました。") + return 0 + + try: + idx = int(raw) + except ValueError: + logger.error("番号で指定してください: %r", raw) + return 1 + + if not (1 <= idx <= len(rows)): + logger.error("範囲外の番号です: %d (1〜%d)", idx, len(rows)) + return 1 + + name = rows[idx - 1]["name"] + # name 解決 (chdir) + up は共有ハンドラ cmd_project に委譲する。 + import types + + from devbase.commands.container import cmd_project + return cmd_project(types.SimpleNamespace(subcommand="up", name=name, scale=None)) + + +def cmd_project_list(devbase_root: Path, args) -> int: + """`devbase project list [--interactive]` / `devbase list [--interactive]`。""" + projects_dir = Path(devbase_root) / "projects" + rows = list_projects(projects_dir) + + if not rows: + logger.info("プロジェクトがありません (%s)。", projects_dir) + return 0 + + if getattr(args, "interactive", False): + return _interactive_select_and_up(rows) + + _print_table(rows) + return 0 diff --git a/lib/devbase/commands/status.py b/lib/devbase/commands/status.py index 1416483..3f3081c 100644 --- a/lib/devbase/commands/status.py +++ b/lib/devbase/commands/status.py @@ -16,82 +16,84 @@ logger = get_logger(__name__) -def _get_container_status(projects_dir: Path) -> list[dict]: - """projects/ 配下の各プロジェクトのコンテナ状態を取得する""" - results = [] - if not projects_dir.exists(): - return results - - for entry in sorted(projects_dir.iterdir()): - if not entry.is_dir(): - continue - compose_file = entry / "compose.yml" - if not compose_file.exists(): - continue +def _container_status_for(entry: Path) -> dict | None: + """単一プロジェクトディレクトリのコンテナ状態を取得する。 + + `projects/` (実ディレクトリ or plugin への symlink) を受け取り、 + ``{"name", "status", "count"}`` を返す。対象外 (compose.yml が無い) や docker + コマンドが利用できない / タイムアウト / 異常終了の場合は ``None`` を返す。 + + PLAN06 で ``project list`` (commands/project.py) が同じ per-entry ロジックを + 再利用するため、``_get_container_status`` のループ本体から分離した。挙動は + 分離前と同一 (None を返す条件 = 旧実装で ``continue`` していた条件)。 + """ + compose_file = entry / "compose.yml" + if not compose_file.exists(): + return None - try: - proc = subprocess.run( - ["docker", "compose", "ps", "--format", "json"], - cwd=str(entry), - capture_output=True, - text=True, - timeout=10, - ) - if proc.returncode != 0: + try: + proc = subprocess.run( + ["docker", "compose", "ps", "--format", "json"], + cwd=str(entry), + capture_output=True, + text=True, + timeout=10, + ) + if proc.returncode != 0: + return None + + output = proc.stdout.strip() + if not output: + return {"name": entry.name, "status": "stopped", "count": 0} + + # docker compose ps --format json は1行1JSONまたはJSON配列 + containers = [] + for line in output.splitlines(): + line = line.strip() + if not line: continue - - output = proc.stdout.strip() - if not output: - results.append({ - "name": entry.name, - "status": "stopped", - "count": 0, - }) + try: + parsed = json.loads(line) + if isinstance(parsed, list): + containers.extend(parsed) + else: + containers.append(parsed) + except json.JSONDecodeError: continue - # docker compose ps --format json は1行1JSONまたはJSON配列 - containers = [] - for line in output.splitlines(): - line = line.strip() - if not line: - continue - try: - parsed = json.loads(line) - if isinstance(parsed, list): - containers.extend(parsed) - else: - containers.append(parsed) - except json.JSONDecodeError: - continue - - if not containers: - results.append({ - "name": entry.name, - "status": "stopped", - "count": 0, - }) - continue + if not containers: + return {"name": entry.name, "status": "stopped", "count": 0} - running = sum( - 1 for c in containers - if c.get("State", "").lower() == "running" - ) - total = len(containers) + running = sum( + 1 for c in containers + if c.get("State", "").lower() == "running" + ) + total = len(containers) - if running > 0: - status = f"running ({total} containers)" - else: - status = "stopped" + if running > 0: + status = f"running ({total} containers)" + else: + status = "stopped" - results.append({ - "name": entry.name, - "status": status, - "count": total, - }) + return {"name": entry.name, "status": status, "count": total} - except (subprocess.TimeoutExpired, OSError): - # dockerコマンドが利用できない、またはタイムアウト + except (subprocess.TimeoutExpired, OSError): + # dockerコマンドが利用できない、またはタイムアウト + return None + + +def _get_container_status(projects_dir: Path) -> list[dict]: + """projects/ 配下の各プロジェクトのコンテナ状態を取得する""" + results = [] + if not projects_dir.exists(): + return results + + for entry in sorted(projects_dir.iterdir()): + if not entry.is_dir(): continue + status = _container_status_for(entry) + if status is not None: + results.append(status) return results diff --git a/tests/cli/test_project_list.py b/tests/cli/test_project_list.py new file mode 100644 index 0000000..406fbba --- /dev/null +++ b/tests/cli/test_project_list.py @@ -0,0 +1,396 @@ +"""PLAN06 Task 3: `project list` 一覧表示 + `--interactive` 選択起動のテスト + +検証対象: +- `lib/devbase/commands/project.py` + - `_resolve_plugin_name`: symlink 先から plugin 名を解決する (衝突 suffix 耐性) + - `list_projects`: projects/ 配下を NAME/PLUGIN/STATUS で列挙する + - `cmd_project_list`: table 表示 / `--interactive` での選択起動 +- `lib/devbase/commands/status.py` + - `_container_status_for`: per-entry status 抽出後の回帰 +- `lib/devbase/cli.py` + - `project list` parser / dispatch ルーティング / トップレベル `list` シノニム / prefix 解決 +""" + +from __future__ import annotations + +import os +import types +from pathlib import Path + +import pytest + +from devbase import cli + + +# --------------------------------------------------------------------------- +# 補助: projects/ 配下に plugin project への symlink を作る +# --------------------------------------------------------------------------- + +def _make_plugin_project(devbase_root: Path, plugin_path: str, proj: str) -> Path: + """repos/ or plugins/ 配下に plugin の projects/ 実体を作って返す。""" + target_dir = devbase_root / plugin_path / "projects" / proj + target_dir.mkdir(parents=True, exist_ok=True) + return target_dir + + +def _link_project(devbase_root: Path, link_name: str, plugin_path: str, proj: str) -> Path: + """projects/ -> ..//projects/ の相対 symlink を作る。 + + syncer.sync_projects と同じ相対ターゲット形式 (衝突時は link_name に suffix が + 付くが、ターゲット dir 名は素の proj のまま) を再現する。 + """ + projects_dir = devbase_root / "projects" + projects_dir.mkdir(exist_ok=True) + target = Path("..") / plugin_path / "projects" / proj + link = projects_dir / link_name + link.symlink_to(target) + return link + + +# --------------------------------------------------------------------------- +# _resolve_plugin_name +# --------------------------------------------------------------------------- + +def test_resolve_plugin_name_repos_based(tmp_path): + from devbase.commands.project import _resolve_plugin_name + + _make_plugin_project(tmp_path, "repos/owner--repo/myplugin", "carmo") + link = _link_project(tmp_path, "carmo", "repos/owner--repo/myplugin", "carmo") + + assert _resolve_plugin_name(link) == "myplugin" + + +def test_resolve_plugin_name_linked(tmp_path): + from devbase.commands.project import _resolve_plugin_name + + _make_plugin_project(tmp_path, "plugins/foo", "carmo") + link = _link_project(tmp_path, "carmo", "plugins/foo", "carmo") + + assert _resolve_plugin_name(link) == "foo" + + +def test_resolve_plugin_name_collision_suffix_uses_target_not_linkname(tmp_path): + """衝突 suffix (carmo.takemi) はリンク名のみに付き、ターゲット dir は素の carmo。 + + PLUGIN 解決は link 名でなく symlink 先から行うため suffix で壊れてはならない。 + """ + from devbase.commands.project import _resolve_plugin_name + + _make_plugin_project(tmp_path, "repos/takemi--carmo/carmo-plugin", "carmo") + link = _link_project(tmp_path, "carmo.takemi--carmo", + "repos/takemi--carmo/carmo-plugin", "carmo") + + assert _resolve_plugin_name(link) == "carmo-plugin" + + +def test_resolve_plugin_name_real_dir_returns_none(tmp_path): + """symlink でない実ディレクトリは plugin に属さないため None。""" + from devbase.commands.project import _resolve_plugin_name + + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + real = projects_dir / "standalone" + real.mkdir() + + assert _resolve_plugin_name(real) is None + + +def test_resolve_plugin_name_broken_symlink(tmp_path): + """ターゲットが存在しない symlink でも link テキストから plugin を解決できる。""" + from devbase.commands.project import _resolve_plugin_name + + link = _link_project(tmp_path, "ghost", "repos/o--r/ghostplugin", "ghost") + # ターゲット実体は作らない (broken) + assert not link.exists() + assert _resolve_plugin_name(link) == "ghostplugin" + + +# --------------------------------------------------------------------------- +# list_projects +# --------------------------------------------------------------------------- + +def test_list_projects_enumerates_name_plugin_status(tmp_path, monkeypatch): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + _make_plugin_project(tmp_path, "plugins/beta", "beta-proj") + _link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj") + + # status は docker に依存させず固定値を返す + def fake_status(entry: Path): + return {"name": entry.name, "status": "running (2 containers)", "count": 2} + + monkeypatch.setattr(status_mod, "_container_status_for", fake_status) + + rows = project_mod.list_projects(tmp_path / "projects") + by_name = {r["name"]: r for r in rows} + + assert by_name["alpha-proj"]["plugin"] == "alpha" + assert by_name["alpha-proj"]["status"] == "running (2 containers)" + assert by_name["beta-proj"]["plugin"] == "beta" + + +def test_list_projects_unknown_status_when_none(tmp_path, monkeypatch): + """_container_status_for が None (compose.yml 無し/docker 不在) なら 'unknown'。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + rows = project_mod.list_projects(tmp_path / "projects") + assert rows[0]["status"] == "unknown" + + +def test_list_projects_real_dir_plugin_dash(tmp_path, monkeypatch): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + (projects_dir / "standalone").mkdir() + + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + rows = project_mod.list_projects(projects_dir) + assert rows[0]["name"] == "standalone" + assert rows[0]["plugin"] == "-" + + +def test_list_projects_empty_when_no_projects_dir(tmp_path): + from devbase.commands import project as project_mod + assert project_mod.list_projects(tmp_path / "projects") == [] + + +# --------------------------------------------------------------------------- +# cmd_project_list: table 出力 +# --------------------------------------------------------------------------- + +def test_cmd_project_list_prints_table(tmp_path, monkeypatch, capsys): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry: {"name": entry.name, "status": "stopped", "count": 0}) + + args = types.SimpleNamespace(interactive=False) + rc = project_mod.cmd_project_list(tmp_path, args) + out = capsys.readouterr().out + + assert rc == 0 + assert "NAME" in out and "PLUGIN" in out and "STATUS" in out + assert "alpha-proj" in out + assert "alpha" in out + assert "stopped" in out + + +def test_cmd_project_list_empty(tmp_path, capsys): + from devbase.commands import project as project_mod + args = types.SimpleNamespace(interactive=False) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 0 + + +# --------------------------------------------------------------------------- +# cmd_project_list: --interactive +# --------------------------------------------------------------------------- + +def test_cmd_project_list_interactive_selects_and_ups(tmp_path, monkeypatch): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + _make_plugin_project(tmp_path, "plugins/beta", "beta-proj") + _link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + # 番号 "2" を選択 (sorted: alpha-proj=1, beta-proj=2) + monkeypatch.setattr("builtins.input", lambda *a, **k: "2") + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update( + subcommand=args.subcommand, name=args.name) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + + assert rc == 0 + assert captured["subcommand"] == "up" + assert captured["name"] == "beta-proj" + + +def test_cmd_project_list_interactive_empty_input_aborts(tmp_path, monkeypatch): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr("builtins.input", lambda *a, **k: "") + + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 0 + assert called == [], "空入力では up を起動しない" + + +def test_cmd_project_list_interactive_non_tty_eof(tmp_path, monkeypatch): + """非対話環境 (input が EOFError) では up を起動せずエラー終了する。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + def raise_eof(*a, **k): + raise EOFError + + monkeypatch.setattr("builtins.input", raise_eof) + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 1 + assert called == [] + + +def test_cmd_project_list_interactive_out_of_range(tmp_path, monkeypatch): + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr("builtins.input", lambda *a, **k: "99") + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 1 + assert called == [] + + +# --------------------------------------------------------------------------- +# parser: project list / --interactive +# --------------------------------------------------------------------------- + +def test_parser_project_list(): + parser = cli._create_parser() + args = parser.parse_args(["project", "list"]) + assert args.command == "project" + assert args.subcommand == "list" + assert args.interactive is False + + +def test_parser_project_list_interactive_flag(): + parser = cli._create_parser() + for flag in ("--interactive", "-i"): + args = parser.parse_args(["project", "list", flag]) + assert args.interactive is True + + +def test_parser_top_level_list_synonym(): + parser = cli._create_parser() + args = parser.parse_args(["list", "-i"]) + assert args.command == "list" + assert args.interactive is True + + +# --------------------------------------------------------------------------- +# prefix 解決: project list / 単独 list +# --------------------------------------------------------------------------- + +def test_expand_argv_project_list_prefix(monkeypatch): + """`devbase project li` は `list` に解決される。""" + import sys + monkeypatch.setattr(sys, "argv", ["devbase", "project", "li"]) + cli._expand_argv() + assert sys.argv == ["devbase", "project", "list"] + + +def test_expand_argv_top_level_list_prefix(monkeypatch): + """`devbase li` は一意に `list` へ解決される (login とは li/lo で分離)。""" + import sys + monkeypatch.setattr(sys, "argv", ["devbase", "li"]) + cli._expand_argv() + assert sys.argv[1] == "list" + + +# --------------------------------------------------------------------------- +# dispatch ルーティング +# --------------------------------------------------------------------------- + +def test_dispatch_project_list_routes_to_cmd_project_list(monkeypatch): + from devbase.commands import project as project_mod + monkeypatch.setenv("DEVBASE_ROOT", "/tmp/devbase-root-test") + calls = [] + monkeypatch.setattr(project_mod, "cmd_project_list", + lambda root, args: calls.append(str(root)) or 0) + args = types.SimpleNamespace(command="project", subcommand="list", interactive=False) + assert cli._dispatch("project", args) == 0 + assert calls == ["/tmp/devbase-root-test"] + + +def test_dispatch_project_up_still_routes_to_lifecycle(monkeypatch): + """project list 追加後も up 等は従来通り cmd_project (lifecycle) へ。""" + from devbase.commands import container as container_mod + calls = [] + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: calls.append(args.subcommand) or 0) + args = types.SimpleNamespace(command="project", subcommand="up", name=None, scale=None) + assert cli._dispatch("project", args) == 0 + assert calls == ["up"] + + +def test_dispatch_top_level_list_routes_to_cmd_project_list(monkeypatch): + from devbase.commands import project as project_mod + monkeypatch.setenv("DEVBASE_ROOT", "/tmp/devbase-root-test") + calls = [] + monkeypatch.setattr(project_mod, "cmd_project_list", + lambda root, args: calls.append("list") or 0) + args = types.SimpleNamespace(command="list", interactive=False) + assert cli._dispatch("list", args) == 0 + assert calls == ["list"] + + +# --------------------------------------------------------------------------- +# status.py リファクタ回帰: _container_status_for / _get_container_status +# --------------------------------------------------------------------------- + +def test_container_status_for_none_without_compose(tmp_path): + from devbase.commands.status import _container_status_for + entry = tmp_path / "proj" + entry.mkdir() + assert _container_status_for(entry) is None + + +def test_get_container_status_uses_per_entry(tmp_path, monkeypatch): + from devbase.commands import status as status_mod + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + (projects_dir / "a").mkdir() + (projects_dir / "b").mkdir() + + monkeypatch.setattr(status_mod, "_container_status_for", + lambda entry: {"name": entry.name, "status": "stopped", "count": 0}) + results = status_mod._get_container_status(projects_dir) + names = sorted(r["name"] for r in results) + assert names == ["a", "b"] diff --git a/tests/cli/test_wrapper_dispatch.py b/tests/cli/test_wrapper_dispatch.py index de1e187..500ddde 100644 --- a/tests/cli/test_wrapper_dispatch.py +++ b/tests/cli/test_wrapper_dispatch.py @@ -90,6 +90,26 @@ def test_unknown_command_still_errors(self): assert "unknown command" in result.stderr.lower() assert result.returncode != 0 + def test_top_level_list_reaches_python(self): + """PLAN06 Task 3: `devbase list` シノニムが Python へルーティングされる。""" + result = _run_wrapper("list") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:list" in result.stdout, result.stdout + + def test_top_level_list_interactive_flag_passthrough(self): + result = _run_wrapper("list", "--interactive") + assert "PYTHON:list --interactive" in result.stdout, result.stdout + + def test_project_list_reaches_python(self): + result = _run_wrapper("project", "list") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:project list" in result.stdout, result.stdout + + def test_list_prefix_resolves(self): + # `li` は list に一意解決される (login は lo)。 + result = _run_wrapper("li") + assert "PYTHON:list" in result.stdout, result.stdout + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"])) From 915bc1b0b9c2fc9122497a228f6cb0e1f87db7c5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 30 May 2026 14:05:30 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(project):=20PR#36=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E5=AF=BE=E5=BF=9C=20=E2=80=94=20l=E2=86=92lo?= =?UTF-8?q?gin=20=E4=BA=92=E6=8F=9B=20/=20status=20=E4=B8=A6=E5=88=97?= =?UTF-8?q?=E5=8C=96=20/=20=E5=A0=85=E7=89=A2=E6=80=A7=20/=20=E5=86=8D?= =?UTF-8?q?=E5=85=A5=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [major/互換性] `list` 追加で ambiguous になった `devbase l` を `login` に維持 (bin/devbase resolve_command + cli.py TOP_PREFIX_PREFERENCES)。`li` は list のまま。 - [major/性能] list_projects の `docker compose ps` を ThreadPoolExecutor で並列化 (cwd= で完結し global chdir せずスレッド安全)。 - [minor/堅牢性] _resolve_plugin_name が `/projects/proj` 等で `/`・`..` を plugin 名に返さず None に。 - [minor/UX] _interactive_select_and_up を誤入力 (数値以外/範囲外) で再入力ループに。 - 回帰テスト 6 件追加。pytest 402 passed。 Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/devbase | 18 +++++- lib/devbase/cli.py | 10 ++- lib/devbase/commands/project.py | 97 ++++++++++++++++++------------ tests/cli/test_project_list.py | 85 ++++++++++++++++++++++++-- tests/cli/test_wrapper_dispatch.py | 11 ++++ 5 files changed, 174 insertions(+), 47 deletions(-) diff --git a/bin/devbase b/bin/devbase index 49e5df3..aabad9d 100755 --- a/bin/devbase +++ b/bin/devbase @@ -175,9 +175,23 @@ resolve_command() { done if [ ${#matches[@]} -eq 1 ]; then echo "${matches[0]}" - else - echo "$input" # no match or ambiguous -> return as-is + return + fi + # ambiguous の場合の後方互換 preference。`list` 追加で `l` が login/list の + # 両方にマッチするようになったため、既存の `devbase l` → `login` を維持する。 + # cli.py の TOP_PREFIX_PREFERENCES と同期させること。 + if [ ${#matches[@]} -gt 1 ]; then + local preferred="" + case "$input" in + l) preferred="login" ;; + esac + if [ -n "$preferred" ]; then + for m in "${matches[@]}"; do + [ "$m" = "$preferred" ] && { echo "$preferred"; return; } + done + fi fi + echo "$input" # no match or ambiguous -> return as-is } # =================================================================== diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index e3704dd..d22773e 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -64,6 +64,14 @@ }, } +# トップレベルコマンドの ambiguous prefix 後方互換 preference。 +# `list` (PLAN06 Task 3) 追加で `l` が `login` / `list` の両方にマッチして +# ambiguous になったため、既存ショートカット (`devbase l` → `login`) を維持する。 +# bin/devbase の resolve_command 内 preference と同期させること。 +TOP_PREFIX_PREFERENCES = { + 'l': 'login', +} + def _require_devbase_root() -> Path: """Get DEVBASE_ROOT from environment, exiting if not set.""" @@ -500,7 +508,7 @@ def _expand_argv(): repo_subcmds = ['add', 'remove', 'list', 'refresh'] if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'): - sys.argv[1] = _resolve_prefix(sys.argv[1], commands) + sys.argv[1] = _resolve_prefix(sys.argv[1], commands, TOP_PREFIX_PREFERENCES) if len(sys.argv) >= 3 and not sys.argv[2].startswith('-'): cmd = sys.argv[1] diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py index b48e722..a80d0b8 100644 --- a/lib/devbase/commands/project.py +++ b/lib/devbase/commands/project.py @@ -42,9 +42,14 @@ def _resolve_plugin_name(entry: Path) -> str | None: parts = Path(target).parts # `projects` の最後の出現位置 (proj 名の直前) を採用する。 + # ただし直前要素が plugin 名として無効なパス区切り (`/` ルートや `..` 相対) の + # 場合は解決失敗扱い (None)。例: `/projects/proj` → parts[0] が `/` になる。 for i in range(len(parts) - 1, 0, -1): if parts[i] == "projects": - return parts[i - 1] + candidate = parts[i - 1] + if candidate in (os.sep, "/", "..", "."): + return None + return candidate return None @@ -62,33 +67,44 @@ def list_projects(projects_dir: Path) -> list[dict]: """ # status ロジックは commands/status.py と共有する (PLAN06 リファクタで per-entry # 関数 _container_status_for を分離済み)。import は循環回避のため関数内で行う。 + from concurrent.futures import ThreadPoolExecutor + from devbase.commands import status as status_mod - results: list[dict] = [] if not projects_dir.exists(): - return results + return [] - for entry in sorted(projects_dir.iterdir()): + entries = [ # broken symlink は is_dir() が False になるため symlink 自体も拾う。 - if not (entry.is_symlink() or entry.is_dir()): - continue - - plugin = _resolve_plugin_name(entry) + entry for entry in sorted(projects_dir.iterdir()) + if entry.is_symlink() or entry.is_dir() + ] - status = "unknown" + def _status_for(entry: Path) -> str: # is_dir() は symlink 先まで辿る。broken symlink は False → unknown のまま。 - if entry.is_dir(): - st = status_mod._container_status_for(entry) - if st is not None: - status = st["status"] - - results.append({ + # _container_status_for は cwd= 引数で完結し global chdir を行わないため + # スレッド安全。各 `docker compose ps` は I/O バウンドで 10s timeout を + # 持つため、プロジェクト数が増えても並列化で総待ち時間を抑える。 + if not entry.is_dir(): + return "unknown" + st = status_mod._container_status_for(entry) + return st["status"] if st is not None else "unknown" + + # entries が空だと max_workers=0 で ValueError になるため早期 return。 + if not entries: + return [] + + with ThreadPoolExecutor(max_workers=min(8, len(entries))) as ex: + statuses = list(ex.map(_status_for, entries)) + + return [ + { "name": entry.name, - "plugin": plugin or "-", + "plugin": _resolve_plugin_name(entry) or "-", "status": status, - }) - - return results + } + for entry, status in zip(entries, statuses) + ] def _print_table(rows: list[dict]) -> None: @@ -110,26 +126,31 @@ def _interactive_select_and_up(rows: list[dict]) -> int: for i, r in enumerate(rows, 1): print(f" [{i}] {r['name']} ({r['plugin']}, {r['status']})") - try: - raw = input("番号 (空で中止): ").strip() - except EOFError: - logger.error("対話入力ができません (非 TTY 環境)。" - "`devbase project up ` で直接指定してください。") - return 1 - - if not raw: - logger.info("中止しました。") - return 0 + # 一覧取得が重い場合があるため、誤入力 (数値以外 / 範囲外) では即終了せず + # 再入力を促す。空入力は中止、非 TTY (EOFError) はエラー終了。 + while True: + try: + raw = input("番号 (空で中止): ").strip() + except EOFError: + logger.error("対話入力ができません (非 TTY 環境)。" + "`devbase project up ` で直接指定してください。") + return 1 + + if not raw: + logger.info("中止しました。") + return 0 + + try: + idx = int(raw) + except ValueError: + logger.error("番号で指定してください: %r", raw) + continue - try: - idx = int(raw) - except ValueError: - logger.error("番号で指定してください: %r", raw) - return 1 - - if not (1 <= idx <= len(rows)): - logger.error("範囲外の番号です: %d (1〜%d)", idx, len(rows)) - return 1 + if not (1 <= idx <= len(rows)): + logger.error("範囲外の番号です: %d (1〜%d)", idx, len(rows)) + continue + + break name = rows[idx - 1]["name"] # name 解決 (chdir) + up は共有ハンドラ cmd_project に委譲する。 diff --git a/tests/cli/test_project_list.py b/tests/cli/test_project_list.py index 406fbba..92388f9 100644 --- a/tests/cli/test_project_list.py +++ b/tests/cli/test_project_list.py @@ -95,6 +95,31 @@ def test_resolve_plugin_name_real_dir_returns_none(tmp_path): assert _resolve_plugin_name(real) is None +def test_resolve_plugin_name_absolute_root_target_returns_none(tmp_path): + """symlink 先が `/projects/proj` のような絶対パスだと parts[0] が '/' になる。 + + plugin 名として無効な root 区切りを返さず None にする (堅牢性指摘 #36)。 + """ + from devbase.commands.project import _resolve_plugin_name + + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + link = projects_dir / "rooted" + link.symlink_to("/projects/proj") # 先頭 '/' で parts[0] == '/' + assert _resolve_plugin_name(link) is None + + +def test_resolve_plugin_name_relative_dotdot_target_returns_none(tmp_path): + """`../projects/proj` だと直前要素が '..' になり plugin 名として無効 → None。""" + from devbase.commands.project import _resolve_plugin_name + + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + link = projects_dir / "dotdot" + link.symlink_to(Path("..") / "projects" / "proj") + assert _resolve_plugin_name(link) is None + + def test_resolve_plugin_name_broken_symlink(tmp_path): """ターゲットが存在しない symlink でも link テキストから plugin を解決できる。""" from devbase.commands.project import _resolve_plugin_name @@ -270,7 +295,8 @@ def raise_eof(*a, **k): assert called == [] -def test_cmd_project_list_interactive_out_of_range(tmp_path, monkeypatch): +def test_cmd_project_list_interactive_out_of_range_reprompts(tmp_path, monkeypatch): + """範囲外の番号では即終了せず再入力を促す。有効入力で最終的に up する。""" from devbase.commands import project as project_mod from devbase.commands import status as status_mod from devbase.commands import container as container_mod @@ -278,14 +304,41 @@ def test_cmd_project_list_interactive_out_of_range(tmp_path, monkeypatch): _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) - monkeypatch.setattr("builtins.input", lambda *a, **k: "99") - called = [] - monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + # "99" (範囲外) → "1" (有効) の順に入力 → 再入力後に up が起動する + inputs = iter(["99", "1"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update(name=args.name) or 0) args = types.SimpleNamespace(interactive=True) rc = project_mod.cmd_project_list(tmp_path, args) - assert rc == 1 - assert called == [] + assert rc == 0 + assert captured["name"] == "alpha-proj" + + +def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatch): + """数値以外の入力では即終了せず再入力を促す。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + # "abc" (数値以外) → "1" (有効) + inputs = iter(["abc", "1"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update(name=args.name) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 0 + assert captured["name"] == "alpha-proj" # --------------------------------------------------------------------------- @@ -334,6 +387,26 @@ def test_expand_argv_top_level_list_prefix(monkeypatch): assert sys.argv[1] == "list" +def test_expand_argv_top_level_l_resolves_to_login(monkeypatch): + """後方互換: `list` 追加で ambiguous になった `devbase l` を `login` に維持する。 + + `l` は `login` / `list` の両方にマッチするが TOP_PREFIX_PREFERENCES で + 既存挙動 (`l` → `login`) を保つ (互換性指摘 #36)。 + """ + import sys + monkeypatch.setattr(sys, "argv", ["devbase", "l"]) + cli._expand_argv() + assert sys.argv[1] == "login" + + +def test_expand_argv_top_level_lo_resolves_to_login(monkeypatch): + """`devbase lo` は一意に `login` へ解決される (回帰確認)。""" + import sys + monkeypatch.setattr(sys, "argv", ["devbase", "lo"]) + cli._expand_argv() + assert sys.argv[1] == "login" + + # --------------------------------------------------------------------------- # dispatch ルーティング # --------------------------------------------------------------------------- diff --git a/tests/cli/test_wrapper_dispatch.py b/tests/cli/test_wrapper_dispatch.py index 500ddde..b9d4dbc 100644 --- a/tests/cli/test_wrapper_dispatch.py +++ b/tests/cli/test_wrapper_dispatch.py @@ -110,6 +110,17 @@ def test_list_prefix_resolves(self): result = _run_wrapper("li") assert "PYTHON:list" in result.stdout, result.stdout + def test_l_prefix_resolves_to_login(self): + # 後方互換: `list` 追加で ambiguous になった `devbase l` を login に維持する + # (互換性指摘 #36)。preference 無しだと unknown command 'l' になる。 + result = _run_wrapper("l") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:login" in result.stdout, result.stdout + + def test_lo_prefix_resolves_to_login(self): + result = _run_wrapper("lo") + assert "PYTHON:login" in result.stdout, result.stdout + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"])) From ef3ec7d322ce6c891c94fa2d489a3054157316d2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 30 May 2026 14:13:53 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(project):=20PR#36=20round2=20=E2=80=94?= =?UTF-8?q?=20KeyboardInterrupt=20=E4=B8=AD=E6=AD=A2=20/=20prefix=20prefer?= =?UTF-8?q?ence=20=E5=90=8C=E6=9C=9F=E3=83=86=E3=82=B9=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _interactive_select_and_up: Ctrl+C (KeyboardInterrupt) を捕捉し traceback を出さず中止 (rc=0) として扱う (minor / 堅牢性) - TOP_PREFIX_PREFERENCES の bin/devbase と cli.py の乖離防止のため、 両者の preference 対応表が一致することを検証する同期テストを追加 (major / 正確性) - 回帰テスト 2 件追加 (keyboard_interrupt_aborts / synced_with_cli) Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/project.py | 5 +++++ tests/cli/test_project_list.py | 23 +++++++++++++++++++++ tests/cli/test_wrapper_dispatch.py | 32 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py index a80d0b8..b0a4368 100644 --- a/lib/devbase/commands/project.py +++ b/lib/devbase/commands/project.py @@ -135,6 +135,11 @@ def _interactive_select_and_up(rows: list[dict]) -> int: logger.error("対話入力ができません (非 TTY 環境)。" "`devbase project up ` で直接指定してください。") return 1 + except KeyboardInterrupt: + # Ctrl+C は traceback を出さず中止として扱う。 + print() + logger.info("中止しました。") + return 0 if not raw: logger.info("中止しました。") diff --git a/tests/cli/test_project_list.py b/tests/cli/test_project_list.py index 92388f9..f0e264c 100644 --- a/tests/cli/test_project_list.py +++ b/tests/cli/test_project_list.py @@ -295,6 +295,29 @@ def raise_eof(*a, **k): assert called == [] +def test_cmd_project_list_interactive_keyboard_interrupt_aborts(tmp_path, monkeypatch): + """Ctrl+C (KeyboardInterrupt) は traceback を出さず中止 (rc=0) として扱う。""" + from devbase.commands import project as project_mod + from devbase.commands import status as status_mod + from devbase.commands import container as container_mod + + _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") + _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + + def raise_interrupt(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr("builtins.input", raise_interrupt) + called = [] + monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) + + args = types.SimpleNamespace(interactive=True) + rc = project_mod.cmd_project_list(tmp_path, args) + assert rc == 0 + assert called == [] + + def test_cmd_project_list_interactive_out_of_range_reprompts(tmp_path, monkeypatch): """範囲外の番号では即終了せず再入力を促す。有効入力で最終的に up する。""" from devbase.commands import project as project_mod diff --git a/tests/cli/test_wrapper_dispatch.py b/tests/cli/test_wrapper_dispatch.py index b9d4dbc..9973930 100644 --- a/tests/cli/test_wrapper_dispatch.py +++ b/tests/cli/test_wrapper_dispatch.py @@ -10,6 +10,7 @@ """ import os +import re import subprocess import sys from pathlib import Path @@ -20,6 +21,21 @@ WRAPPER = REPO_ROOT / "bin" / "devbase" +def _parse_wrapper_top_prefix_preferences() -> dict[str, str]: + """bin/devbase の resolve_command 内 ambiguous preference を抽出する。 + + `case "$input" in` ... `) preferred="" ;;` 形式の対を + 辞書に変換する。cli.py の TOP_PREFIX_PREFERENCES と同期検証するため。 + """ + text = WRAPPER.read_text() + # resolve_command の case ブロックを切り出す。 + block = text.split('case "$input" in', 1)[1].split("esac", 1)[0] + prefs: dict[str, str] = {} + for inp, cmd in re.findall(r'(\w+)\)\s*preferred="(\w+)"', block): + prefs[inp] = cmd + return prefs + + def _run_wrapper(*args): """run_python を no-op に差し替えて wrapper の dispatch だけを実行する。 @@ -67,6 +83,22 @@ def test_project_in_dispatch_case(self): assert any("project|" in line or "|project|" in line for line in text.splitlines()) + def test_top_prefix_preferences_synced_with_cli(self): + """wrapper と cli.py の top-level ambiguous preference が一致すること。 + + `l` → `login` の後方互換 preference は bin/devbase の resolve_command と + cli.py の TOP_PREFIX_PREFERENCES の 2 箇所に独立して定義されている。 + 片方だけ更新して乖離すると個別テストは通るのに挙動が割れるため、 + 両者の対応表が完全一致することをここで検証する (正確性指摘 #36)。 + """ + from devbase.cli import TOP_PREFIX_PREFERENCES + + wrapper_prefs = _parse_wrapper_top_prefix_preferences() + assert wrapper_prefs, "wrapper の preference 抽出に失敗" + assert wrapper_prefs == TOP_PREFIX_PREFERENCES, ( + f"wrapper={wrapper_prefs} vs cli.py={TOP_PREFIX_PREFERENCES} が乖離" + ) + class TestWrapperDispatch: def test_project_reaches_python(self): From 5b666e0b436d3cb5bf1e6516895a84ca72a271fd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 30 May 2026 14:23:43 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs(project):=20PR#36=20deferred=20nit=20?= =?UTF-8?q?=E2=80=94=20=5Fload=5Fproject=5Fenv=20=E3=81=A8=20shell=20sourc?= =?UTF-8?q?e=20=E3=81=AE=E4=BB=95=E6=A7=98=E4=B9=96=E9=9B=A2=E3=82=92?= =?UTF-8?q?=E6=98=8E=E6=96=87=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gemini round2 review-body の将来課題推奨 (Python パーサと shell source の env 解釈が乖離し得る) に対し、仕様統一はリスクが大きいため制約のドキュメント化で 対応する。 - _load_project_env docstring に shell ``source`` との具体的な乖離ケース (変数展開 / コマンド置換 / 行中クォート / インラインコメント) を note 追記。 いずれも wrapper を経ない直接起動のフォールバック時のみ影響する旨を明記。 - 乖離挙動を pin する回帰テスト test_load_project_env_diverges_from_shell_source を追加。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/container.py | 18 +++++++++++++++++ tests/cli/test_project_name_resolution.py | 24 +++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index cdf9382..25c886f 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -129,6 +129,24 @@ def _load_project_env(env_file: Path) -> None: ため、ここでは ``export`` 接頭辞付き / 無しの単純な ``KEY=VALUE`` 行のみを 解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。shell の変数展開やコマンド置換は意図的にサポートしない (安全側に倒す)。 + + .. note:: shell ``source`` との仕様乖離について + + 本パーサは完全な POSIX shell パーサではなく、shell ``source ./env`` + (wrapper 経路) とは以下のケースで挙動が乖離する。env は単純な + ``KEY=VALUE`` 定義に限定する運用前提のため、これらは意図的な制約として + 受容し、ファイル側で利用しない方針とする (仕様統一ではなく制約の明示):: + + FOO=$BAR # shell: 展開 → 本実装: リテラル文字列 "$BAR" + FOO=$(cmd) # shell: コマンド置換 → 本実装: リテラル "$(cmd)" + FOO=a"b"c # shell: クォート除去で "abc" → 本実装: 行頭/行末以外の + # クォートは除去せず "a\"b\"c" + FOO=bar # x # shell: インラインコメント無効 (値は "bar # x") → + # 本実装も値は "bar # x" (行頭 # のみコメント扱い) + + いずれも wrapper を経ない直接起動 (例: + ``python -m devbase.cli project up ``) のフォールバック時のみ影響し、 + 通常運用の wrapper 経路では shell が env を解釈するため差異は生じない。 """ if not env_file.is_file(): return diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index 61b5eb8..23fa066 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -160,6 +160,30 @@ def test_resolve_missing_env_file_is_noop(fake_root): assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" +def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch): + """shell ``source`` との仕様乖離を固定する回帰テスト (docstring の note 対応)。 + + 本パーサは変数展開・コマンド置換・行中クォート除去・インラインコメントを + 解釈せず、値を安全側にリテラルとして扱う。この意図的な制約を pin する。 + """ + for k in ("LIT_VAR", "LIT_CMD", "INNER_Q", "INLINE_C"): + monkeypatch.delenv(k, raising=False) + env_path = tmp_path / "env" + env_path.write_text( + "LIT_VAR=$HOME\n" # 変数展開しない (リテラル "$HOME") + "LIT_CMD=$(echo x)\n" # コマンド置換しない (リテラル "$(echo x)") + 'INNER_Q=a"b"c\n' # 行中クォートは除去しない + "INLINE_C=bar # note\n" # 行頭以外の # はコメント扱いしない + ) + + container._load_project_env(env_path) + + assert os.environ["LIT_VAR"] == "$HOME" + assert os.environ["LIT_CMD"] == "$(echo x)" + assert os.environ["INNER_Q"] == 'a"b"c' + assert os.environ["INLINE_C"] == "bar # note" + + # =========================================================================== # wrapper: cd + argv strip + 存在性ベースの曖昧性回避 # ===========================================================================