From 28dffcb823550249c91941c5e5f1a7e345f10ec6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 28 May 2026 23:00:57 +0000 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20feature/PLAN06-project-group=20Dra?= =?UTF-8?q?ft=20PR=20=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) From 469c892a9d2412a39e0c936d30eb65aed768728b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 29 May 2026 21:58:07 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20PLAN06-1=20project=20=E3=82=B5?= =?UTF-8?q?=E3=83=96=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=20group=20+=20?= =?UTF-8?q?=E5=85=B1=E6=9C=89=E3=83=8F=E3=83=B3=E3=83=89=E3=83=A9=20+=20co?= =?UTF-8?q?ntainer=20=E9=9D=9E=E6=8E=A8=E5=A5=A8=E5=A7=94=E8=AD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli.py: _add_project_parser を追加し up/down/ps/login/logs/scale/build に 省略可能な [name] positional を付与 - cli.py: SUBCMD_MAP / _expand_argv / _dispatch に project を追加(prefix 解決対応) - cli.py: ショートカット (up 等) は非推奨の container ではなく共有 cmd_project へ委譲 - container.py: cmd_container 本体を _dispatch_lifecycle に抽出し cmd_project (推奨) / cmd_container (非推奨 warning + 委譲) で共有 - name は up/scale の project_name へ畳み込み(ディレクトリ解決 / cd は PR2 で実装) - tests/cli/test_project_dispatch.py: parser / scale positional 非曖昧性 / prefix 解決 / dispatch ルーティング / 非推奨 warning を検証 runtime 挙動は従来と同等(リネーム + 委譲のみ、cd なし)。wrapper (bin/devbase) の project ルーティング + name 解決は PR2 (Task 2) に分離。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/cli.py | 76 +++++++++++-- lib/devbase/commands/container.py | 35 +++++- tests/cli/test_project_dispatch.py | 173 +++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 tests/cli/test_project_dispatch.py diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index f185419..d4f1a38 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -17,6 +17,8 @@ logger = get_logger("devbase.cli") # Shortcuts: top-level command -> (group, subcommand) +# 委譲先は共有の cmd_project (PLAN06 で container は非推奨化)。group 要素は歴史的経緯で +# 残しているが dispatch では subcommand のみ参照する。 SHORTCUTS = { 'up': ('container', 'up'), 'down': ('container', 'down'), @@ -35,6 +37,7 @@ # Subcommand map for prefix resolution: {(aliases...): [subcmds]} SUBCMD_MAP = { + ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'], ('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'], @@ -89,6 +92,47 @@ def _add_container_parser(subparsers): ct_build.add_argument('image', nargs='?', default=None, help='Image name') +def _add_project_parser(subparsers): + """Project group parser (CWD 非依存のプロジェクト操作)。 + + `container` と同じ subcommand 群に、省略可能な `[name]` positional を加える。 + name によるディレクトリ解決 / COMPOSE_PROJECT_NAME 上書きは PLAN06 Task 2 (PR2) + で wrapper の cd + Python フォールバックとして実装する。PR1 では parser 構造と + name のパースまでを用意する。 + """ + 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') + + pj_login = pj_sub.add_parser('login', help='Login to container') + pj_login.add_argument('name', nargs='?', default=None, help='Project name') + pj_login.add_argument('index', nargs='?', default='1', help='Container index') + + pj_ps = pj_sub.add_parser('ps', help='Show container status') + pj_ps.add_argument('name', nargs='?', default=None, help='Project name') + 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') + 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') + pj_scale.add_argument('new_scale', type=int, help='New number of containers') + + pj_build = pj_sub.add_parser('build', help='Build container images') + pj_build.add_argument('name', nargs='?', default=None, help='Project name') + pj_build.add_argument('image', nargs='?', default=None, help='Image name') + + def _add_env_parser(subparsers): """Env group parser""" env_parser = subparsers.add_parser('env', help='Manage environment variables') @@ -310,12 +354,14 @@ def _create_parser(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Shortcuts:\n" - " up container up\n" - " down container down\n" - " login container login\n" - " build container build\n" - " ps container ps\n" - " scale container scale\n" + " up project up\n" + " down project down\n" + " login project login\n" + " build project build\n" + " ps project ps\n" + " scale project scale\n" + "\n" + "Note: `container` is deprecated; use `project` instead.\n" ) ) @@ -342,6 +388,7 @@ def _create_parser(): help='Print shell RC file path (e.g. source "$(devbase shell-rc)")' ) + _add_project_parser(subparsers) _add_container_parser(subparsers) _add_env_parser(subparsers) _add_plugin_parser(subparsers) @@ -371,7 +418,7 @@ def _resolve_prefix(input_cmd, candidates, preferences=None): def _expand_argv(): """Expand abbreviated command/subcommand names in sys.argv in-place.""" - commands = ['init', 'status', 'shell-rc', 'container', 'ct', 'env', 'plugin', 'pl', + commands = ['init', 'status', 'shell-rc', 'project', 'container', 'ct', 'env', 'plugin', 'pl', 'snapshot', 'ss', 'up', 'down', 'login', 'build', 'ps', 'scale', 'help'] repo_subcmds = ['add', 'remove', 'list', 'refresh'] @@ -418,13 +465,20 @@ def _dispatch(cmd, args): # Resolve group aliases cmd = GROUP_ALIASES.get(cmd, cmd) - # --- Shortcuts (top-level -> container subcommand) --- + # --- Shortcuts (top-level -> project subcommand) --- + # ショートカットは非推奨ではないため、warning を出す cmd_container ではなく + # 共有の cmd_project へ委譲する。 if cmd in SHORTCUTS: args.subcommand = SHORTCUTS[cmd][1] - from devbase.commands.container import cmd_container - return cmd_container(args) + from devbase.commands.container import cmd_project + return cmd_project(args) + + # --- Project group (推奨) --- + if cmd == 'project': + from devbase.commands.container import cmd_project + return cmd_project(args) - # --- Container group --- + # --- Container group (非推奨: project へ委譲 + warning) --- if cmd == 'container': from devbase.commands.container import cmd_container return cmd_container(args) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 528a143..997b4c1 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -81,12 +81,21 @@ def _run_pre_up_hook() -> bool: # ディスパッチャ # --------------------------------------------------------------------------- -def cmd_container(args) -> int: - """サブコマンドディスパッチャ""" +def _dispatch_lifecycle(args) -> int: + """`project` / `container` 共有のサブコマンドディスパッチャ。 + + `project [name]` の `name` を解決して project_name へ畳み込む。 + `container` 経路には `name` 属性が無いため従来通り None になる。 + + NOTE (PLAN06): name によるディレクトリ解決 / COMPOSE_PROJECT_NAME 上書きの + 本体は Task 2 (PR2) で wrapper の cd + Python フォールバックとして実装する。 + PR1 では project_name 引数を取れる up / scale にのみ name を伝播する。 + """ subcmd = getattr(args, 'subcommand', None) + project_name = getattr(args, 'name', None) or getattr(args, 'project_name', None) handlers = { - 'up': lambda: cmd_up(project_name=getattr(args, 'project_name', None), + 'up': lambda: cmd_up(project_name=project_name, scale=getattr(args, 'scale', None)), 'down': lambda: cmd_down(), 'login': lambda: cmd_login(index=getattr(args, 'index', '1')), @@ -94,7 +103,7 @@ def cmd_container(args) -> int: 'logs': lambda: cmd_logs(follow=getattr(args, 'follow', False), tail=getattr(args, 'tail', None)), 'scale': lambda: cmd_scale(new_scale=getattr(args, 'new_scale', None), - project_name=getattr(args, 'project_name', None)), + project_name=project_name), 'build': lambda: cmd_build(image=getattr(args, 'image', None)), } @@ -106,6 +115,24 @@ def cmd_container(args) -> int: return 1 +def cmd_project(args) -> int: + """`devbase project [name]` ディスパッチャ (推奨エントリ)。""" + return _dispatch_lifecycle(args) + + +def cmd_container(args) -> int: + """`devbase container ` ディスパッチャ。 + + 非推奨: `devbase project` に移行してください (移行期間後に削除予定)。 + 挙動は `cmd_project` と同一で、警告のみ追加する。 + """ + logger.warning( + "`devbase container` は非推奨です。`devbase project` を使用してください " + "(将来のリリースで削除されます)。" + ) + return _dispatch_lifecycle(args) + + # --------------------------------------------------------------------------- # cmd_up (deploy.py の cmd_deploy を移植) # --------------------------------------------------------------------------- diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py new file mode 100644 index 0000000..c8ec916 --- /dev/null +++ b/tests/cli/test_project_dispatch.py @@ -0,0 +1,173 @@ +"""PLAN06 Task 1: `project` サブコマンド group / 共有ハンドラ / `container` 非推奨委譲のテスト + +PR1 の範囲は Python レベルのリネーム + 委譲のみ(wrapper の cd / name 解決は PR2)。 +ここでは parser の構造・prefix 解決・dispatch ルーティング・非推奨 warning を検証する。 +""" + +from __future__ import annotations + +import logging +import sys +import types + +import pytest + +from devbase import cli + + +# --------------------------------------------------------------------------- +# parser: project サブコマンド群と [name] positional +# --------------------------------------------------------------------------- + +LIFECYCLE_SUBCMDS = ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'] + + +@pytest.mark.parametrize('sub', LIFECYCLE_SUBCMDS) +def test_create_parser_accepts_project_subcommands(sub): + parser = cli._create_parser() + argv = ['project', sub] + if sub == 'scale': + argv.append('1') # scale は new_scale (必須) を要求する + args = parser.parse_args(argv) + assert args.command == 'project' + assert args.subcommand == sub + + +def test_project_up_accepts_optional_name(): + parser = cli._create_parser() + with_name = parser.parse_args(['project', 'up', 'carmo']) + assert with_name.subcommand == 'up' + assert with_name.name == 'carmo' + + without_name = parser.parse_args(['project', 'up']) + assert without_name.name is None + + +def test_project_scale_positional_is_unambiguous(): + """`[name]` optional + `new_scale` 必須 int の組合せが曖昧にならない。""" + parser = cli._create_parser() + + only_scale = parser.parse_args(['project', 'scale', '3']) + assert only_scale.name is None + assert only_scale.new_scale == 3 + + name_and_scale = parser.parse_args(['project', 'scale', 'carmo', '3']) + assert name_and_scale.name == 'carmo' + assert name_and_scale.new_scale == 3 + + +# --------------------------------------------------------------------------- +# prefix 解決: project を 3 箇所同期した結果の検証 +# --------------------------------------------------------------------------- + +def test_expand_argv_resolves_project_command_prefix(monkeypatch): + """`devbase pr ...` は一意なので `project` に解決される。""" + monkeypatch.setattr(sys, 'argv', ['devbase', 'pr', 'up']) + cli._expand_argv() + assert sys.argv[1] == 'project' + + +def test_expand_argv_resolves_project_subcommand_prefix(monkeypatch): + """`devbase project u` は `up` に解決される (SUBCMD_MAP に project を追加した結果)。""" + monkeypatch.setattr(sys, 'argv', ['devbase', 'project', 'u']) + cli._expand_argv() + assert sys.argv == ['devbase', 'project', 'up'] + + +# --------------------------------------------------------------------------- +# container.py: 共有 lifecycle dispatcher と非推奨委譲 +# --------------------------------------------------------------------------- + +def _args(**kwargs): + return types.SimpleNamespace(**kwargs) + + +def test_cmd_project_delegates_to_lifecycle(monkeypatch): + from devbase.commands import container + captured = {} + + def fake_lifecycle(args): + captured['args'] = args + return 0 + + monkeypatch.setattr(container, '_dispatch_lifecycle', fake_lifecycle) + args = _args(subcommand='ps') + assert container.cmd_project(args) == 0 + assert captured['args'] is args + + +def test_cmd_container_warns_and_delegates(monkeypatch, caplog): + from devbase.commands import container + monkeypatch.setattr(container, '_dispatch_lifecycle', lambda args: 0) + args = _args(subcommand='ps') + with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): + assert container.cmd_container(args) == 0 + assert any('非推奨' in r.message for r in caplog.records), \ + '`container` は非推奨 warning を出さなければならない' + + +def test_cmd_project_does_not_warn(monkeypatch, caplog): + from devbase.commands import container + monkeypatch.setattr(container, '_dispatch_lifecycle', lambda args: 0) + args = _args(subcommand='ps') + with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): + container.cmd_project(args) + assert not any('非推奨' in r.message for r in caplog.records), \ + '`project` は非推奨 warning を出してはならない' + + +def test_lifecycle_passes_name_to_cmd_up(monkeypatch): + """`project up ` の name は project_name として up に伝播する。""" + from devbase.commands import container + captured = {} + monkeypatch.setattr(container, 'cmd_up', + lambda project_name=None, scale=None: + captured.update(project_name=project_name) or 0) + args = _args(subcommand='up', name='carmo', scale=None) + assert container._dispatch_lifecycle(args) == 0 + assert captured['project_name'] == 'carmo' + + +def test_lifecycle_container_path_has_no_name(monkeypatch): + """container 経路には name 属性が無く、従来通り project_name=None になる。""" + from devbase.commands import container + captured = {} + monkeypatch.setattr(container, 'cmd_up', + lambda project_name=None, scale=None: + captured.update(project_name=project_name) or 0) + args = _args(subcommand='up', scale=None) # name 属性なし + assert container._dispatch_lifecycle(args) == 0 + assert captured['project_name'] is None + + +# --------------------------------------------------------------------------- +# cli._dispatch: ルーティング +# --------------------------------------------------------------------------- + +def test_dispatch_project_routes_to_cmd_project(monkeypatch): + from devbase.commands import container + calls = [] + monkeypatch.setattr(container, 'cmd_project', lambda args: calls.append('project') or 0) + args = _args(command='project', subcommand='ps') + assert cli._dispatch('project', args) == 0 + assert calls == ['project'] + + +def test_dispatch_container_routes_to_cmd_container(monkeypatch): + from devbase.commands import container + calls = [] + monkeypatch.setattr(container, 'cmd_container', lambda args: calls.append('container') or 0) + args = _args(command='container', subcommand='ps') + assert cli._dispatch('container', args) == 0 + assert calls == ['container'] + + +def test_dispatch_shortcut_routes_to_cmd_project_not_container(monkeypatch): + """トップレベルショートカット (up 等) は非推奨の container ではなく project へ。""" + from devbase.commands import container + calls = [] + monkeypatch.setattr(container, 'cmd_project', lambda args: calls.append('project') or 0) + monkeypatch.setattr(container, 'cmd_container', lambda args: calls.append('container') or 0) + args = _args(command='up') + assert cli._dispatch('up', args) == 0 + assert calls == ['project'] From 979568728c7c9389f14a0e9f1a530a5ccac736ab Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 29 May 2026 22:25:15 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix(cli):=20project=20login/build=20?= =?UTF-8?q?=E3=81=AE=E5=BC=95=E6=95=B0=E6=9B=96=E6=98=A7=E3=81=95=E8=A7=A3?= =?UTF-8?q?=E6=B6=88=20+=20name=20=E6=9C=AA=E5=AF=BE=E5=BF=9C=E3=81=AE?= =?UTF-8?q?=E6=98=8E=E7=A4=BA=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #34 round1 レビュー対応 (codex / gemini)。 - project login / build から `[name]` positional を削除し、単一 positional を index / image として扱う (旧 container login / build と一致)。 `project login 2` が name='2' で index=1 にログインしてしまう曖昧さ、 `project build web` が name='web', image=None で image 指定が無視される問題を解消。 - 共有ディスパッチャ _dispatch_lifecycle で、PR1 未対応の name 指定時に 「カレントディレクトリの compose に作用する」旨を warning 出力。 name を黙って無視して意図しない compose に作用するのを防ぐ (up/scale は既に name を受け取る経路のため対象外)。 - name 解決自体は設計どおり PR2 スコープ (docs/plans/PLAN06_project_name.md)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/cli.py | 13 +++++++++++-- lib/devbase/commands/container.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index d4f1a38..ba0c890 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -99,6 +99,10 @@ def _add_project_parser(subparsers): name によるディレクトリ解決 / COMPOSE_PROJECT_NAME 上書きは PLAN06 Task 2 (PR2) で wrapper の cd + Python フォールバックとして実装する。PR1 では parser 構造と name のパースまでを用意する。 + + 例外: `login` / `build` は単一 positional が旧 `container` と同義 (index / image) + であり、`[name]` を足すと `project login 2` / `project build web` が誤解釈される + ため name を受け付けない (各 add_parser のコメント参照)。 """ pj_parser = subparsers.add_parser('project', help='Manage projects (CWD-independent)') pj_sub = pj_parser.add_subparsers(dest='subcommand') @@ -109,8 +113,11 @@ def _add_project_parser(subparsers): pj_down = pj_sub.add_parser('down', help='Stop and remove containers') pj_down.add_argument('name', nargs='?', default=None, help='Project name') + # login / build は単一 positional の意味を `container` と一致させるため + # `[name]` を受け付けない。`project login 2` を name='2' と誤解釈して index=1 に + # ログインしてしまう曖昧さ (旧 `container login ` との非互換) を防ぐ。 + # name 解決は PR2 で導入する際にあらためて曖昧さのない形 (例: --name) で扱う。 pj_login = pj_sub.add_parser('login', help='Login to container') - pj_login.add_argument('name', nargs='?', default=None, help='Project name') pj_login.add_argument('index', nargs='?', default='1', help='Container index') pj_ps = pj_sub.add_parser('ps', help='Show container status') @@ -128,8 +135,10 @@ def _add_project_parser(subparsers): pj_scale.add_argument('name', nargs='?', default=None, help='Project name') pj_scale.add_argument('new_scale', type=int, help='New number of containers') + # build も単一 positional は `image` として扱う (container build と一致)。 + # `[name]` を許すと `project build web` が name='web', image=None となり + # image 指定ビルドが compose build に化けるため受け付けない (上記 login 参照)。 pj_build = pj_sub.add_parser('build', help='Build container images') - pj_build.add_argument('name', nargs='?', default=None, help='Project name') pj_build.add_argument('image', nargs='?', default=None, help='Image name') diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 997b4c1..22d5c72 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -94,6 +94,17 @@ def _dispatch_lifecycle(args) -> int: subcmd = getattr(args, 'subcommand', None) project_name = getattr(args, 'name', None) or getattr(args, 'project_name', None) + # PR1 では name の解決は未実装で、CWD の compose.yml に対して動作する。 + # name を黙って無視すると意図しない compose に作用しうるため、明示的に警告する + # (name → ディレクトリ解決 / COMPOSE_PROJECT_NAME 上書きは PR2 で実装)。 + if project_name and subcmd not in ('up', 'scale'): + logger.warning( + "project name '%s' の解決は未実装です。" + "カレントディレクトリの compose に対して実行します " + "(name 指定は将来のリリースで対応予定)。", + project_name, + ) + handlers = { 'up': lambda: cmd_up(project_name=project_name, scale=getattr(args, 'scale', None)), From f8ebb760223b1386b7e93864853fcdc493926d8e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 29 May 2026 22:47:02 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix(cli):=20PLAN06=20round2=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E5=AF=BE=E5=BF=9C=20(wrapper=20proj?= =?UTF-8?q?ect=20=E5=88=B0=E9=81=94=E4=B8=8D=E8=83=BD=20+=20up/scale=20?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=E3=81=BB=E3=81=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bin/devbase: resolve_command 候補と dispatch case に `project` を追加。 これまで `devbase project ...` が `*)` 節で unknown command になっていた問題を解消。 - _dispatch_lifecycle: name 指定時の未実装警告を up/scale でも出すよう変更。 PR1 では全サブコマンドが CWD の compose に作用し name によるディレクトリ解決を しないため、up/scale だけ無警告だと「指定 project に作用した」と誤解を与えるため。 - SHORTCUTS: 死んだ group 要素 (`('container', sub)`) を除去し subcommand 文字列に簡略化。 - _add_project_parser: login/build が name を取らない設計意図と PR2 での --name 方針を docstring に明記。 - tests: wrapper の project ルーティング (test_wrapper_dispatch.py) と up/scale name 警告のテストを追加。 Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/devbase | 4 +- lib/devbase/cli.py | 27 +++++---- lib/devbase/commands/container.py | 20 ++++--- tests/cli/test_wrapper_dispatch.py | 95 ++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 tests/cli/test_wrapper_dispatch.py diff --git a/bin/devbase b/bin/devbase index a4b6222..bb964f7 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 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 help" local matches=() for cmd in $commands; do [[ "$cmd" == "$input"* ]] && matches+=("$cmd") @@ -191,7 +191,7 @@ case "$_resolved_cmd" in # Python-implemented commands --version|-V) run_python "$@" ;; - init|status|shell-rc|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) run_python "${_resolved_cmd}" "${@:2}" ;; # Shell-implemented commands build) shift; cmd_build "$@" ;; diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index ba0c890..5837d4c 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -16,16 +16,15 @@ logger = get_logger("devbase.cli") -# Shortcuts: top-level command -> (group, subcommand) -# 委譲先は共有の cmd_project (PLAN06 で container は非推奨化)。group 要素は歴史的経緯で -# 残しているが dispatch では subcommand のみ参照する。 +# Shortcuts: top-level command -> project subcommand +# 委譲先は共有の cmd_project (PLAN06 で container は非推奨化)。 SHORTCUTS = { - 'up': ('container', 'up'), - 'down': ('container', 'down'), - 'login': ('container', 'login'), - 'build': ('container', 'build'), - 'ps': ('container', 'ps'), - 'scale': ('container', 'scale'), + 'up': 'up', + 'down': 'down', + 'login': 'login', + 'build': 'build', + 'ps': 'ps', + 'scale': 'scale', } # Group aliases @@ -114,9 +113,11 @@ def _add_project_parser(subparsers): pj_down.add_argument('name', nargs='?', default=None, help='Project name') # login / build は単一 positional の意味を `container` と一致させるため - # `[name]` を受け付けない。`project login 2` を name='2' と誤解釈して index=1 に - # ログインしてしまう曖昧さ (旧 `container login ` との非互換) を防ぐ。 - # name 解決は PR2 で導入する際にあらためて曖昧さのない形 (例: --name) で扱う。 + # `[name]` を受け付けない (他サブコマンドの `[name]` positional とは意図的に + # 不整合)。`project login 2` を name='2' と誤解釈して index=1 にログインして + # しまう曖昧さ (旧 `container login ` との非互換) を防ぐため。 + # PR2 で project name 解決を導入する際は、login / build にも曖昧さのない + # `--name` オプションを追加して他サブコマンドと整合させる方針。 pj_login = pj_sub.add_parser('login', help='Login to container') pj_login.add_argument('index', nargs='?', default='1', help='Container index') @@ -478,7 +479,7 @@ def _dispatch(cmd, args): # ショートカットは非推奨ではないため、warning を出す cmd_container ではなく # 共有の cmd_project へ委譲する。 if cmd in SHORTCUTS: - args.subcommand = SHORTCUTS[cmd][1] + args.subcommand = SHORTCUTS[cmd] from devbase.commands.container import cmd_project return cmd_project(args) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 22d5c72..e1a735c 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -87,19 +87,23 @@ def _dispatch_lifecycle(args) -> int: `project [name]` の `name` を解決して project_name へ畳み込む。 `container` 経路には `name` 属性が無いため従来通り None になる。 - NOTE (PLAN06): name によるディレクトリ解決 / COMPOSE_PROJECT_NAME 上書きの - 本体は Task 2 (PR2) で wrapper の cd + Python フォールバックとして実装する。 - PR1 では project_name 引数を取れる up / scale にのみ name を伝播する。 + NOTE (PLAN06): name によるディレクトリ解決の本体は Task 2 (PR2) で wrapper の + cd + Python フォールバックとして実装する。PR1 では project_name 引数を取れる + up / scale にのみ name を伝播するが、その name も compose のプロジェクトラベル + (COMPOSE_PROJECT_NAME 相当) として使われるだけで、操作対象はあくまで CWD の + compose.yml である点に注意 (ディレクトリ解決は未実装)。 """ subcmd = getattr(args, 'subcommand', None) project_name = getattr(args, 'name', None) or getattr(args, 'project_name', None) - # PR1 では name の解決は未実装で、CWD の compose.yml に対して動作する。 - # name を黙って無視すると意図しない compose に作用しうるため、明示的に警告する - # (name → ディレクトリ解決 / COMPOSE_PROJECT_NAME 上書きは PR2 で実装)。 - if project_name and subcmd not in ('up', 'scale'): + # PR1 では name によるディレクトリ解決は未実装で、どのサブコマンドも CWD の + # compose.yml に対して動作する。name を指定されたまま黙って CWD に作用すると + # 「指定したプロジェクトに対して操作できた」と誤解させるため、明示的に警告する + # (name → ディレクトリ解決は PR2 で実装)。up / scale は name をプロジェクト + # ラベルには反映するが、対象ディレクトリは依然 CWD であるため同様に警告する。 + if project_name: logger.warning( - "project name '%s' の解決は未実装です。" + "project name '%s' によるディレクトリ解決は未実装です。" "カレントディレクトリの compose に対して実行します " "(name 指定は将来のリリースで対応予定)。", project_name, diff --git a/tests/cli/test_wrapper_dispatch.py b/tests/cli/test_wrapper_dispatch.py new file mode 100644 index 0000000..de1e187 --- /dev/null +++ b/tests/cli/test_wrapper_dispatch.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""bin/devbase wrapper の command dispatch のテスト。 + +`project` サブコマンドが wrapper の resolve_command 候補と case dispatch に +含まれており、`devbase project ...` が Python 実装へルーティングされることを +検証する (含まれていないと `*)` 節で `unknown command` で終了してしまう)。 + +実際の `uv run` を起動すると環境依存になるため、run_python / ensure_uv を +差し替えた薄いハーネス経由で wrapper の dispatch ロジックだけを実行する。 +""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / "bin" / "devbase" + + +def _run_wrapper(*args): + """run_python を no-op に差し替えて wrapper の dispatch だけを実行する。 + + wrapper を関数定義のみ読み込む形にできないため、`run_python` / + `ensure_uv` を export -f で先に定義し、wrapper 末尾の dispatch を + 別プロセスで評価する。wrapper は自身の run_python を再定義するので、 + `sed` で wrapper の run_python / ensure_uv 定義を取り除いてから評価する。 + """ + harness = ( + 'run_python() { echo "PYTHON:$*"; exit 0; }\n' + 'ensure_uv() { :; }\n' + # wrapper から関数再定義を除いた本体を読み込む + 'eval "$(sed -e \'/^run_python()/,/^}/d\' ' + '-e \'/^ensure_uv()/,/^}/d\' "$WRAPPER_PATH")"\n' + ) + env = { + **os.environ, + "DEVBASE_ROOT": str(REPO_ROOT), + "WRAPPER_PATH": str(WRAPPER), + } + return subprocess.run( + ["bash", "-c", harness, "devbase", *args], + capture_output=True, + text=True, + env=env, + ) + + +class TestWrapperStaticContent: + """静的に project が両所に登録されていることを確認 (回帰防止)。""" + + def test_project_in_resolve_command_list(self): + text = WRAPPER.read_text() + # resolve_command の候補リスト + assert " project " in text.split('local commands="', 1)[1].split('"', 1)[0] + " " + + def test_project_in_dispatch_case(self): + text = WRAPPER.read_text() + # Python-implemented commands の case ラベルに project が含まれる + case_labels = [ + line for line in text.splitlines() + if "run_python " in line and "_resolved_cmd" in line + ] + # 直前行 (case パターン) に project があること + assert any("project|" in line or "|project|" in line + for line in text.splitlines()) + + +class TestWrapperDispatch: + def test_project_reaches_python(self): + result = _run_wrapper("project", "--help") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:project --help" in result.stdout, result.stdout + + def test_project_subcommand_reaches_python(self): + result = _run_wrapper("project", "up") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:project up" in result.stdout, result.stdout + + def test_project_prefix_resolves_to_project(self): + # `proj` は project に一意に解決される。 + result = _run_wrapper("proj", "up") + assert "unknown command" not in result.stderr.lower(), result.stderr + assert "PYTHON:project up" in result.stdout, result.stdout + + def test_unknown_command_still_errors(self): + result = _run_wrapper("bogus") + assert "unknown command" in result.stderr.lower() + assert result.returncode != 0 + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) From 5c56fbf761ee0b8b933153049db296782b9ce2c2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 29 May 2026 22:49:08 +0000 Subject: [PATCH 5/7] =?UTF-8?q?test(cli):=20=5Fdispatch=5Flifecycle=20?= =?UTF-8?q?=E3=81=AE=20up/scale=20name=20=E8=AD=A6=E5=91=8A=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit round2 のレビュー対応 (container.py:100) で up/scale も name 指定時に未実装 警告を出すようにしたため、その回帰防止テストを追加する。前 commit の編集が 既存テストの記法不一致で取り込めていなかったため本 commit で補完する。 Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/cli/test_project_dispatch.py | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index c8ec916..2224093 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -140,6 +140,45 @@ def test_lifecycle_container_path_has_no_name(monkeypatch): assert captured['project_name'] is None +# --------------------------------------------------------------------------- +# _dispatch_lifecycle: name 未実装 warning +# (PR1 では up/scale も含め全サブコマンドが CWD の compose に作用するため、 +# name 指定時はサブコマンドに関わらず警告する) +# --------------------------------------------------------------------------- + +def test_lifecycle_warns_for_up_with_name(monkeypatch, caplog): + """`project up ` は name 指定時に未実装 warning を出す。""" + from devbase.commands import container + monkeypatch.setattr(container, 'cmd_up', lambda project_name=None, scale=None: 0) + args = _args(subcommand='up', name='carmo', scale=None) + with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): + assert container._dispatch_lifecycle(args) == 0 + assert any('未実装' in r.message for r in caplog.records), \ + 'up でも name 指定時は警告しなければならない' + + +def test_lifecycle_warns_for_scale_with_name(monkeypatch, caplog): + """`project scale N` も name 指定時に未実装 warning を出す。""" + from devbase.commands import container + monkeypatch.setattr(container, 'cmd_scale', + lambda new_scale=None, project_name=None: 0) + args = _args(subcommand='scale', name='carmo', new_scale=3) + with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): + assert container._dispatch_lifecycle(args) == 0 + assert any('未実装' in r.message for r in caplog.records), \ + 'scale でも name 指定時は警告しなければならない' + + +def test_lifecycle_no_warning_without_name(monkeypatch, caplog): + """name 未指定なら警告を出さない。""" + from devbase.commands import container + monkeypatch.setattr(container, 'cmd_up', lambda project_name=None, scale=None: 0) + args = _args(subcommand='up', scale=None) # name 属性なし + with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): + assert container._dispatch_lifecycle(args) == 0 + assert not any('未実装' in r.message for r in caplog.records) + + # --------------------------------------------------------------------------- # cli._dispatch: ルーティング # --------------------------------------------------------------------------- From de82d06a5fe3a186a8eceab627276c0e14e277fe Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 29 May 2026 23:45:43 +0000 Subject: [PATCH 6/7] =?UTF-8?q?fix(cli):=20top-level=20build=20=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=BC=E3=83=88=E3=82=AB=E3=83=83=E3=83=88=E3=82=92?= =?UTF-8?q?=20wrapper=20=E3=81=AE=E5=AE=9F=E7=B5=8C=E8=B7=AF=20(shell)=20?= =?UTF-8?q?=E3=81=AB=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bin/devbase は top-level `build` を shell の cmd_build (devbase-base 依存検出 + 2 段ビルド + --no-cache 対応) に委譲しているが、Python 側は `build` を SHORTCUTS / _add_shortcuts / help epilog で `project build` ショートカットとして広告していた。 Python の project build は単純な compose build であり、wrapper 経由の `devbase build` は実際には shell の cmd_build を通る (共有ハンドラ経路にならない) ため、広告と実経路が 乖離していた (codex major)。 Python の project build へ委譲する案 (codex 案1) は shell の base-image ビルド オーケストレーションを失う回帰になるため採らず、codex 案2 を採用: Python 側の SHORTCUTS / _add_shortcuts / help epilog / _expand_argv から top-level `build` を 除外し、実経路 (shell cmd_build) と広告を一致させた。project build / container build サブコマンド自体は維持。整合性回帰テスト 5 件を追加。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/cli.py | 17 ++++-- tests/cli/test_build_shortcut_consistency.py | 64 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 tests/cli/test_build_shortcut_consistency.py diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 5837d4c..a3a6cc9 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -18,11 +18,15 @@ # Shortcuts: top-level command -> project subcommand # 委譲先は共有の cmd_project (PLAN06 で container は非推奨化)。 +# NOTE: `build` はここに含めない。配布入口 bin/devbase が `build` を shell の +# cmd_build (devbase-base 依存検出 + 2 段ビルド + --no-cache 対応) に委譲しており、 +# Python の project build (単純な compose build) とは実装が異なるため。Python 側で +# `build` を project build ショートカットとして広告すると wrapper の実経路と乖離する。 +# project build / container build サブコマンド自体は引き続き利用可能。 SHORTCUTS = { 'up': 'up', 'down': 'down', 'login': 'login', - 'build': 'build', 'ps': 'ps', 'scale': 'scale', } @@ -343,8 +347,9 @@ def _add_shortcuts(subparsers): login_sc = subparsers.add_parser('login', help='Login to container') login_sc.add_argument('index', nargs='?', default='1', help='Container index') - build_sc = subparsers.add_parser('build', help='Build container images') - build_sc.add_argument('image', nargs='?', default=None, help='Image name') + # NOTE: `build` はショートカットに含めない (SHORTCUTS の注記参照)。 + # bin/devbase が build を shell 実装 (cmd_build) に委譲するため、Python 側で + # トップレベル build を広告すると実経路と乖離する。 ps_sc = subparsers.add_parser('ps', help='Show container status') ps_sc.add_argument('--all', '-a', action='store_true', help='Show all containers') @@ -367,7 +372,6 @@ def _create_parser(): " up project up\n" " down project down\n" " login project login\n" - " build project build\n" " ps project ps\n" " scale project scale\n" "\n" @@ -428,8 +432,11 @@ def _resolve_prefix(input_cmd, candidates, preferences=None): def _expand_argv(): """Expand abbreviated command/subcommand names in sys.argv in-place.""" + # `build` はトップレベルショートカットから除外 (SHORTCUTS の注記参照)。 + # 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', 'build', 'ps', 'scale', 'help'] + 'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'help'] repo_subcmds = ['add', 'remove', 'list', 'refresh'] if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'): diff --git a/tests/cli/test_build_shortcut_consistency.py b/tests/cli/test_build_shortcut_consistency.py new file mode 100644 index 0000000..2796d5d --- /dev/null +++ b/tests/cli/test_build_shortcut_consistency.py @@ -0,0 +1,64 @@ +"""bin/devbase の build dispatch と Python 側 build ショートカットの整合性テスト。 + +配布入口 bin/devbase は top-level `build` を shell 実装 (cmd_build: devbase-base +依存検出 + 2 段ビルド + --no-cache 対応) に委譲する。Python の project build は +単純な `compose build` であり実装が異なるため、Python 側が top-level `build` を +`project build` ショートカットとして広告すると、wrapper の実経路と乖離してしまう。 + +このテストは「Python は top-level build ショートカットを持たない / 広告しない」 +ことを固定し、wrapper の build) ケースが shell 経路を保つことを検証する。 +project build / container build サブコマンド自体は引き続き利用可能。 +""" + +from pathlib import Path + +from devbase import cli + + +def test_build_not_in_shortcuts(): + # top-level build は SHORTCUTS から除外されている (wrapper が shell へ委譲するため) + assert "build" not in cli.SHORTCUTS + # 他のショートカットは維持されている + for sc in ("up", "down", "login", "ps", "scale"): + assert sc in cli.SHORTCUTS + + +def test_top_level_build_has_no_python_parser(): + # top-level `build` には Python parser が無く、parse_args はエラー終了する + # (wrapper が build を shell の cmd_build に委譲し Python に渡さないため) + parser = cli._create_parser() + import pytest + + with pytest.raises(SystemExit): + parser.parse_args(["build"]) + + +def test_help_epilog_does_not_advertise_build_shortcut(): + parser = cli._create_parser() + epilog = parser.epilog or "" + # "build project build" のショートカット広告が無いこと + assert "project build" not in epilog + # 残りのショートカット広告は維持 + assert "project up" in epilog + assert "project scale" in epilog + + +def test_project_build_subcommand_still_available(): + # project build / container build サブコマンド自体は削除していない + parser = cli._create_parser() + ns = parser.parse_args(["project", "build", "myimage"]) + assert ns.command == "project" + assert ns.subcommand == "build" + assert ns.image == "myimage" + + +def test_wrapper_routes_build_to_shell_not_python(): + # bin/devbase の dispatch で build は shell の cmd_build に委譲され、 + # Python 用 run_python の case には含まれないことを確認する。 + wrapper = (Path(__file__).resolve().parents[2] / "bin" / "devbase").read_text() + # build は専用の shell ケースへ + assert "build) shift; cmd_build" in wrapper or "build) shift; cmd_build" in wrapper + # run_python に委譲する case 行に build が紛れ込んでいないこと + for line in wrapper.splitlines(): + if "run_python" in line and "${_resolved_cmd}" in line: + assert "build" not in line From 412fa2452f7e07a438a444bd29c07ab1c8bc68d4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 30 May 2026 00:57:30 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix(cli):=20PLAN06=20deferred=203=20?= =?UTF-8?q?=E4=BB=B6=E5=AF=BE=E5=BF=9C=20(parser=20=E5=85=B1=E9=80=9A?= =?UTF-8?q?=E5=8C=96=20/=20shortcut=20name=20=E5=8F=97=E7=90=86=20/=20?= =?UTF-8?q?=E7=99=BB=E9=8C=B2=E9=A0=86=E5=BA=8F=E6=95=B4=E7=90=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ユーザ指示により cross-review で deferred としていた 3 件を本 PR で対応する。 1. [minor] _add_container_parser / _add_project_parser の重複 project / container で完全一致する login / build サブコマンド定義を _add_login_subparser / _add_build_subparser に括り出し共有化。分岐する up/down/ps/logs/scale の [name] 構成は呼び出し側に残し可読性を維持。 2. [minor] トップレベルショートタットの [name] 受理と伝播 up/down/ps/scale を project サブコマンドと同様に省略可能な [name] positional を受理するよう _add_shortcuts を更新。ショートカット経由でも name が _dispatch → cmd_project → _dispatch_lifecycle まで伝播する経路を通した (name の実解決は PR2、PR1 では未対応 warning を出す)。 3. [nit] commands リストへの project/container 登録順序 _expand_argv の commands リストの並び (_create_parser 登録順と一致、 group 直後に alias を隣接配置、project=推奨 を container=非推奨 より前) と prefix 解決が順序非依存である旨をコメントで明示。 回帰テスト: shortcut の [name] 受理・伝播、login/build の project/container 一致を test_project_dispatch.py に追加。pytest 全 suite 348 passed。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/cli.py | 82 ++++++++++++++++------- tests/cli/test_project_dispatch.py | 103 +++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 25 deletions(-) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index a3a6cc9..0143bd8 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -69,6 +69,30 @@ def _require_devbase_root() -> Path: return Path(root) +def _add_login_subparser(sub): + """`login` サブコマンドを登録する (project / container 共通)。 + + 単一 positional `index` の意味は両グループで完全に同一。`[name]` を足すと + `project login 2` を name='2' と誤解釈して index=1 にログインしてしまう曖昧さ + (旧 `container login ` との非互換) が生じるため、project でも name を + 受け付けない。PR2 で project name 解決を導入する際は曖昧さのない `--name` + オプションで対応する方針。 + """ + p = sub.add_parser('login', help='Login to container') + p.add_argument('index', nargs='?', default='1', help='Container index') + + +def _add_build_subparser(sub): + """`build` サブコマンドを登録する (project / container 共通)。 + + 単一 positional `image` の意味は両グループで同一。`[name]` を許すと + `project build web` が name='web', image=None となり image 指定ビルドが + compose build に化けるため、project でも name を受け付けない (login 参照)。 + """ + p = sub.add_parser('build', help='Build container images') + p.add_argument('image', nargs='?', default=None, help='Image name') + + def _add_container_parser(subparsers): """Container group parser""" ct_parser = subparsers.add_parser('container', aliases=['ct'], @@ -78,8 +102,7 @@ def _add_container_parser(subparsers): ct_sub.add_parser('up', help='Start containers') ct_sub.add_parser('down', help='Stop and remove containers') - ct_login = ct_sub.add_parser('login', help='Login to container') - ct_login.add_argument('index', nargs='?', default='1', help='Container index') + _add_login_subparser(ct_sub) ct_ps = ct_sub.add_parser('ps', help='Show container status') ct_ps.add_argument('--all', '-a', action='store_true', help='Show all containers') @@ -91,8 +114,7 @@ def _add_container_parser(subparsers): ct_scale = ct_sub.add_parser('scale', help='Scale containers online') ct_scale.add_argument('new_scale', type=int, help='New number of containers') - ct_build = ct_sub.add_parser('build', help='Build container images') - ct_build.add_argument('image', nargs='?', default=None, help='Image name') + _add_build_subparser(ct_sub) def _add_project_parser(subparsers): @@ -105,7 +127,8 @@ def _add_project_parser(subparsers): 例外: `login` / `build` は単一 positional が旧 `container` と同義 (index / image) であり、`[name]` を足すと `project login 2` / `project build web` が誤解釈される - ため name を受け付けない (各 add_parser のコメント参照)。 + ため name を受け付けない。両者は project / container で定義が完全に一致するので + `_add_login_subparser` / `_add_build_subparser` に共通化している。 """ pj_parser = subparsers.add_parser('project', help='Manage projects (CWD-independent)') pj_sub = pj_parser.add_subparsers(dest='subcommand') @@ -116,14 +139,7 @@ def _add_project_parser(subparsers): pj_down = pj_sub.add_parser('down', help='Stop and remove containers') pj_down.add_argument('name', nargs='?', default=None, help='Project name') - # login / build は単一 positional の意味を `container` と一致させるため - # `[name]` を受け付けない (他サブコマンドの `[name]` positional とは意図的に - # 不整合)。`project login 2` を name='2' と誤解釈して index=1 にログインして - # しまう曖昧さ (旧 `container login ` との非互換) を防ぐため。 - # PR2 で project name 解決を導入する際は、login / build にも曖昧さのない - # `--name` オプションを追加して他サブコマンドと整合させる方針。 - pj_login = pj_sub.add_parser('login', help='Login to container') - pj_login.add_argument('index', nargs='?', default='1', help='Container index') + _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') @@ -140,11 +156,7 @@ def _add_project_parser(subparsers): pj_scale.add_argument('name', nargs='?', default=None, help='Project name') pj_scale.add_argument('new_scale', type=int, help='New number of containers') - # build も単一 positional は `image` として扱う (container build と一致)。 - # `[name]` を許すと `project build web` が name='web', image=None となり - # image 指定ビルドが compose build に化けるため受け付けない (上記 login 参照)。 - pj_build = pj_sub.add_parser('build', help='Build container images') - pj_build.add_argument('image', nargs='?', default=None, help='Image name') + _add_build_subparser(pj_sub) def _add_env_parser(subparsers): @@ -343,21 +355,36 @@ def _add_snapshot_parser(subparsers): def _add_shortcuts(subparsers): - """Top-level shortcut parsers""" + """Top-level shortcut parsers. + + 委譲先の `project` サブコマンドと引数体系を揃えるため、`up` / `down` / `ps` / + `scale` は `project [name]` と同じく省略可能な `[name]` positional を + 受け付ける (`devbase up carmo` ≡ `devbase project up carmo`)。受理した name は + _dispatch でショートカット経由でも下流 (cmd_project → _dispatch_lifecycle) へ + 伝播する。name の実解決は PLAN06 Task 2 (PR2) で実装するため、PR1 では up/scale + も含め name 指定時に未対応 warning を出す (container.py 参照)。 + + `login` は project login と同様に単一 positional を `index` として扱い `[name]` + は受け付けない (曖昧さ回避)。`build` はショートカットに含めない (SHORTCUTS の + 注記参照): 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') - # NOTE: `build` はショートカットに含めない (SHORTCUTS の注記参照)。 - # bin/devbase が build を shell 実装 (cmd_build) に委譲するため、Python 側で - # トップレベル build を広告すると実経路と乖離する。 - ps_sc = subparsers.add_parser('ps', help='Show container status') + ps_sc.add_argument('name', nargs='?', default=None, help='Project name') ps_sc.add_argument('--all', '-a', action='store_true', help='Show all containers') - subparsers.add_parser('up', help='Start containers') - subparsers.add_parser('down', help='Stop and remove 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') + # `[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') scale_sc.add_argument('new_scale', type=int, help='New number of containers') @@ -432,6 +459,11 @@ def _resolve_prefix(input_cmd, candidates, preferences=None): def _expand_argv(): """Expand abbreviated command/subcommand names in sys.argv in-place.""" + # この `commands` リストの並びは _create_parser のグループ登録順と一致させる: + # トップレベル → グループ (各 group の直後にその alias を隣接配置: container/ct, + # plugin/pl, snapshot/ss) → ショートカット。`project` (推奨) を `container` + # (非推奨) より前に置くのは登録順と揃えた意図的な並びで、prefix 解決は + # _resolve_prefix が一意一致のみ採用するため順序に機能的影響はない。 # `build` はトップレベルショートカットから除外 (SHORTCUTS の注記参照)。 # bin/devbase が build を shell 実装に委譲するため Python 側には top-level # build parser が無い。project build / container build は引き続き利用可能。 diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index 2224093..a73eaed 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -210,3 +210,106 @@ def test_dispatch_shortcut_routes_to_cmd_project_not_container(monkeypatch): args = _args(command='up') assert cli._dispatch('up', args) == 0 assert calls == ['project'] + + +# --------------------------------------------------------------------------- +# parser: 共通サブコマンド (login / build) の project / container 一致 +# (重複定義を _add_login_subparser / _add_build_subparser に共通化した結果の検証) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('group', ['project', 'container']) +def test_login_positional_is_index_in_both_groups(group): + """login は project / container いずれでも単一 positional を index として扱う。""" + parser = cli._create_parser() + args = parser.parse_args([group, 'login', '2']) + assert args.subcommand == 'login' + assert args.index == '2' + # name positional は存在しない (曖昧さ回避) + assert not hasattr(args, 'name') + + +@pytest.mark.parametrize('group', ['project', 'container']) +def test_login_index_defaults_in_both_groups(group): + parser = cli._create_parser() + args = parser.parse_args([group, 'login']) + assert args.index == '1' + + +@pytest.mark.parametrize('group', ['project', 'container']) +def test_build_positional_is_image_in_both_groups(group): + """build は project / container いずれでも単一 positional を image として扱う。""" + parser = cli._create_parser() + args = parser.parse_args([group, 'build', 'web']) + assert args.subcommand == 'build' + assert args.image == 'web' + assert not hasattr(args, 'name') + + +# --------------------------------------------------------------------------- +# top-level ショートカットの [name] 受理と伝播 +# (up/down/ps/scale が project サブコマンドと同様に [name] を受理し、 +# ショートカット経由でも name が _dispatch_lifecycle まで伝播する) +# --------------------------------------------------------------------------- + +def test_shortcut_up_accepts_optional_name(): + parser = cli._create_parser() + with_name = parser.parse_args(['up', 'carmo']) + assert with_name.command == 'up' + assert with_name.name == 'carmo' + + without_name = parser.parse_args(['up']) + assert without_name.name is None + + +def test_shortcut_down_accepts_optional_name(): + parser = cli._create_parser() + args = parser.parse_args(['down', 'carmo']) + assert args.command == 'down' + assert args.name == 'carmo' + + +def test_shortcut_ps_accepts_optional_name(): + parser = cli._create_parser() + args = parser.parse_args(['ps', 'carmo', '--all']) + assert args.command == 'ps' + assert args.name == 'carmo' + assert args.all is True + + +def test_shortcut_scale_positional_is_unambiguous(): + """`scale [name] ` は project scale と同じく曖昧にならない。""" + parser = cli._create_parser() + + only_scale = parser.parse_args(['scale', '3']) + assert only_scale.name is None + assert only_scale.new_scale == 3 + + name_and_scale = parser.parse_args(['scale', 'carmo', '3']) + assert name_and_scale.name == 'carmo' + assert name_and_scale.new_scale == 3 + + +def test_shortcut_up_propagates_name_through_dispatch(monkeypatch): + """`devbase up ` の name がショートカット経由で cmd_up まで伝播する。""" + from devbase.commands import container + captured = {} + monkeypatch.setattr(container, 'cmd_up', + lambda project_name=None, scale=None: + captured.update(project_name=project_name) or 0) + # ショートカット parser が生成する namespace を再現 (name 属性を持つ) + args = _args(command='up', name='carmo', scale=None) + assert cli._dispatch('up', args) == 0 + assert captured['project_name'] == 'carmo' + + +def test_shortcut_scale_propagates_name_through_dispatch(monkeypatch): + """`devbase scale N` の name がショートカット経由で cmd_scale まで伝播する。""" + from devbase.commands import container + captured = {} + monkeypatch.setattr(container, 'cmd_scale', + lambda new_scale=None, project_name=None: + captured.update(project_name=project_name, new_scale=new_scale) or 0) + args = _args(command='scale', name='carmo', new_scale=3) + assert cli._dispatch('scale', args) == 0 + assert captured['project_name'] == 'carmo' + assert captured['new_scale'] == 3