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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 10 additions & 15 deletions lib/devbase/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ 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

if not projects_dir.exists():
Expand All @@ -80,31 +78,28 @@ def list_projects(projects_dir: Path) -> list[dict]:
entry for entry in sorted(projects_dir.iterdir())
if entry.is_symlink() or entry.is_dir()
]
if not entries:
return []

# コンテナ状態は docker ps 1 回で全プロジェクトぶん集計し (counts)、各 entry で
# 使い回す。プロジェクト数ぶん docker compose ps を起動していた旧実装の
# サブプロセスコストを N→1 に削減するため、並列化 (ThreadPoolExecutor) も不要。
counts = status_mod._running_counts_by_project()

def _status_for(entry: Path) -> str:
# is_dir() は symlink 先まで辿る。broken symlink は False → unknown のまま。
# _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)
st = status_mod._container_status_for(entry, counts)
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": _resolve_plugin_name(entry) or "-",
"status": status,
"status": _status_for(entry),
}
for entry, status in zip(entries, statuses)
for entry in entries
]


Expand Down
110 changes: 60 additions & 50 deletions lib/devbase/commands/status.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""devbase status - 環境ステータスの一覧表示"""

import json
import subprocess
from datetime import datetime
from pathlib import Path
Expand All @@ -16,82 +15,93 @@
logger = get_logger(__name__)


def _container_status_for(entry: Path) -> dict | None:
"""単一プロジェクトディレクトリのコンテナ状態を取得する。
_COMPOSE_PROJECT_LABEL = "com.docker.compose.project"

`projects/<name>` (実ディレクトリ or plugin への symlink) を受け取り、
``{"name", "status", "count"}`` を返す。対象外 (compose.yml が無い) や docker
コマンドが利用できない / タイムアウト / 異常終了の場合は ``None`` を返す。
# `counts` 引数の「未指定」を docker 不在 (None) と区別するための sentinel。
_UNSET = object()

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

def _running_counts_by_project() -> dict[str, int] | None:
"""全 running コンテナを単一の ``docker ps`` で取得し、compose project 名
ごとの running 数を返す。docker が使えない / 取得失敗時は ``None``。

プロジェクト数ぶん ``docker compose ps`` を起動する代わりに ``docker ps``
1 回で全コンテナのラベルを集計し、サブプロセス起動コストを N→1 に削減する。
compose project は ``com.docker.compose.project`` ラベル (= devbase up 時の
COMPOSE_PROJECT_NAME = プロジェクト名) で識別するため、呼び出し側プロセスが
継承する COMPOSE_PROJECT_NAME に一切影響されない (一覧が一律同一状態になる
回帰を構造的に回避する)。``docker ps`` は既定で running のみを列挙する。
"""
try:
proc = subprocess.run(
["docker", "compose", "ps", "--format", "json"],
cwd=str(entry),
["docker", "ps",
"--filter", f"label={_COMPOSE_PROJECT_LABEL}",
"--format", f'{{{{.Label "{_COMPOSE_PROJECT_LABEL}"}}}}'],
capture_output=True,
text=True,
timeout=10,
)
if proc.returncode != 0:
return None
except (subprocess.TimeoutExpired, OSError):
# docker コマンドが利用できない、またはタイムアウト
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
try:
parsed = json.loads(line)
if isinstance(parsed, list):
containers.extend(parsed)
else:
containers.append(parsed)
except json.JSONDecodeError:
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)
counts: dict[str, int] = {}
for line in proc.stdout.splitlines():
name = line.strip()
if name:
counts[name] = counts.get(name, 0) + 1
return counts

if running > 0:
status = f"running ({total} containers)"
else:
status = "stopped"

return {"name": entry.name, "status": status, "count": total}
def _container_status_for(entry: Path, counts=_UNSET) -> dict | None:
"""単一プロジェクトディレクトリのコンテナ状態を取得する。

except (subprocess.TimeoutExpired, OSError):
# dockerコマンドが利用できない、またはタイムアウト
`projects/<name>` (実ディレクトリ or plugin への symlink) を受け取り、
``{"name", "status", "count"}`` を返す。対象外 (compose.yml が無い) や docker
コマンドが利用できない場合は ``None`` を返す。

``counts`` には ``_running_counts_by_project()`` の戻り値 (compose project 名
→ running 数) を渡す。一覧表示では呼び出し側が 1 回だけ集計して全 entry で
使い回すことで docker サブプロセスの起動を 1 回に抑える。``counts`` を省略
した単発呼び出しでは本関数内で都度集計する。``None`` (docker 不在) が明示的に
渡された場合は再集計せず ``None`` を返す。
"""
compose_file = entry / "compose.yml"
if not compose_file.exists():
return None

if counts is _UNSET:
counts = _running_counts_by_project()
if counts is None:
# docker が利用できない / 取得失敗
return None

# devbase up は COMPOSE_PROJECT_NAME = entry.name でコンテナを起動するため、
# compose project ラベルが entry.name の running 数がこのプロジェクトの稼働数。
running = counts.get(entry.name, 0)
if running > 0:
status = f"running ({running} containers)"
else:
status = "stopped"

return {"name": entry.name, "status": status, "count": running}


def _get_container_status(projects_dir: Path) -> list[dict]:
"""projects/ 配下の各プロジェクトのコンテナ状態を取得する"""
results = []
if not projects_dir.exists():
return results

# docker ps は 1 回だけ実行し、全 entry で使い回す。
counts = _running_counts_by_project()

for entry in sorted(projects_dir.iterdir()):
if not entry.is_dir():
continue
status = _container_status_for(entry)
status = _container_status_for(entry, counts)
if status is not None:
results.append(status)

Expand Down
26 changes: 13 additions & 13 deletions tests/cli/test_project_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def test_list_projects_enumerates_name_plugin_status(tmp_path, monkeypatch):
_link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj")

# status は docker に依存させず固定値を返す
def fake_status(entry: Path):
def fake_status(entry, counts=None):
return {"name": entry.name, "status": "running (2 containers)", "count": 2}

monkeypatch.setattr(status_mod, "_container_status_for", fake_status)
Expand All @@ -165,7 +165,7 @@ def test_list_projects_unknown_status_when_none(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(status_mod, "_container_status_for", lambda entry, counts=None: None)

rows = project_mod.list_projects(tmp_path / "projects")
assert rows[0]["status"] == "unknown"
Expand All @@ -179,7 +179,7 @@ def test_list_projects_real_dir_plugin_dash(tmp_path, monkeypatch):
projects_dir.mkdir()
(projects_dir / "standalone").mkdir()

monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None)

rows = project_mod.list_projects(projects_dir)
assert rows[0]["name"] == "standalone"
Expand All @@ -202,7 +202,7 @@ def test_cmd_project_list_prints_table(tmp_path, monkeypatch, capsys):
_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})
lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0})

args = types.SimpleNamespace(interactive=False)
rc = project_mod.cmd_project_list(tmp_path, args)
Expand Down Expand Up @@ -231,7 +231,7 @@ def test_cmd_project_list_non_tty_falls_back_to_table(tmp_path, monkeypatch, cap
_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})
lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0})
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: False)

called = []
Expand All @@ -256,7 +256,7 @@ def test_cmd_project_list_stdout_non_tty_falls_back_to_table(tmp_path, monkeypat
_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})
lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0})
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: False)

Expand Down Expand Up @@ -285,7 +285,7 @@ def test_cmd_project_list_interactive_selects_and_ups(tmp_path, monkeypatch):
_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)
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None)

# 対話選択は TTY 環境でのみ起動するため isatty を True に固定する。
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
Expand Down Expand Up @@ -313,7 +313,7 @@ def test_cmd_project_list_interactive_empty_input_aborts(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(status_mod, "_container_status_for", lambda entry, counts=None: None)
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda *a, **k: "")
Expand All @@ -335,7 +335,7 @@ def test_cmd_project_list_interactive_non_tty_eof(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(status_mod, "_container_status_for", lambda entry, counts=None: None)

def raise_eof(*a, **k):
raise EOFError
Expand All @@ -360,7 +360,7 @@ def test_cmd_project_list_interactive_keyboard_interrupt_aborts(tmp_path, monkey

_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(status_mod, "_container_status_for", lambda entry, counts=None: None)

def raise_interrupt(*a, **k):
raise KeyboardInterrupt
Expand All @@ -385,7 +385,7 @@ def test_cmd_project_list_interactive_out_of_range_reprompts(tmp_path, monkeypat

_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(status_mod, "_container_status_for", lambda entry, counts=None: None)

monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
Expand All @@ -410,7 +410,7 @@ def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatc

_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(status_mod, "_container_status_for", lambda entry, counts=None: None)

monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
Expand Down Expand Up @@ -559,7 +559,7 @@ def test_get_container_status_uses_per_entry(tmp_path, monkeypatch):
(projects_dir / "b").mkdir()

monkeypatch.setattr(status_mod, "_container_status_for",
lambda entry: {"name": entry.name, "status": "stopped", "count": 0})
lambda entry, counts=None: {"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"]
Loading
Loading