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
57 changes: 47 additions & 10 deletions lib/devbase/commands/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,23 @@ def _env_var_keys(env_file: Path) -> set:
}


# env 値中の変数参照を shell `source ./env` 相当に展開する。
# - `$VAR` / `${VAR}` を environ から展開 (未定義は空文字 = shell source 準拠)
# - `\$` はリテラル `$` にデエスケープ (shell の `\$` と同じ。EnvFile が `$` を
# 保護するため書く `\$` 付き値を壊さない)
# - `$(...)` 等 変数名にならない `$` は素通し (コマンド置換は非対応のまま)
_ENV_VAR_REF = re.compile(r'\\\$|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)')


def _expand_env_vars(value: str, environ) -> str:
def _repl(m):
if m.group(0) == '\\$':
return '$'
name = m.group(1) or m.group(2)
return environ.get(name, '')
return _ENV_VAR_REF.sub(_repl, value)


def _load_project_env(env_file: Path) -> None:
"""プロジェクトの ``env`` ファイルを os.environ へ反映する (wrapper 同等)。

Expand All @@ -166,26 +183,32 @@ def _load_project_env(env_file: Path) -> None:

env は環境変数定義のみを想定したファイル (bin/devbase 冒頭コメント参照) の
ため、ここでは ``export`` 接頭辞付き / 無しの単純な ``KEY=VALUE`` 行のみを
解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。shell
の変数展開やコマンド置換は意図的にサポートしない (安全側に倒す)。
解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。

変数参照 (``$VAR`` / ``${VAR}``) は shell ``source ./env`` (wrapper 経路) と
同様に展開する。実 env が ``WORK_DIR=/work/$GIT_REPO`` のように同一ファイル内で
先に定義した変数を参照しており、展開しないと TUI (``list``) 経路でワークスペース
パスが ``$GIT_REPO`` 等の未展開文字列のまま VS Code で開いてしまうため
(行は file 順に ``os.environ`` へ載せるので、参照時には先行行の値が解決済み)。
単一引用符 ``'...'`` の値は shell 同様リテラル扱いで展開しない。

.. note:: shell ``source`` との仕様乖離について

本パーサは完全な POSIX shell パーサではなく、shell ``source ./env``
(wrapper 経路) とは以下のケースで挙動が乖離する。env は単純な
本パーサは完全な POSIX shell パーサではなく、変数展開はサポートするが
以下のケースでは shell ``source ./env`` と挙動が乖離する。env は単純な
``KEY=VALUE`` 定義に限定する運用前提のため、これらは意図的な制約として
受容し、ファイル側で利用しない方針とする (仕様統一ではなく制約の明示)::
受容する (仕様統一ではなく制約の明示)::

FOO=$BAR # shell: 展開 → 本実装: リテラル文字列 "$BAR"
FOO=$(cmd) # shell: コマンド置換 → 本実装: リテラル "$(cmd)"
# (_expand_env_vars は $(...) を変数とみなさず素通し)
FOO=a"b"c # shell: クォート除去で "abc" → 本実装: 行頭/行末以外の
# クォートは除去せず "a\"b\"c"
FOO=bar # x # shell: インラインコメント無効 (値は "bar # x") →
# 本実装も値は "bar # x" (行頭 # のみコメント扱い)
FOO=bar # x # shell: インラインコメント有効 (値は "bar") →
# 本実装: 行頭 # のみコメント扱いのため値は "bar # x"

いずれも wrapper を経ない直接起動 (例:
``python -m devbase.cli project up <name>``) のフォールバック時のみ影響し、
通常運用の wrapper 経路では shell が env を解釈するため差異は生じない
``python -m devbase.cli project up <name>`` / TUI ``list``) 経路で用いる。
通常の wrapper 経路では shell が env を解釈する
"""
if not env_file.is_file():
return
Expand All @@ -199,8 +222,22 @@ def _load_project_env(env_file: Path) -> None:
continue
key, value = assignment
value = value.strip()
single_quoted = False
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
single_quoted = value[0] == "'"
value = value[1:-1]
# shell `source ./env` 相当の変数展開 ($VAR / ${VAR}) を行う。実 env は
# `WORK_DIR=/work/$GIT_REPO` のように同一ファイル内で先に定義した変数を
# 参照しており (行順に os.environ へ載せるため参照時には解決済み)、展開
# しないと TUI (list) 経路でワークスペースパスが未展開のまま開いてしまう。
# 単一引用符はリテラル ($BAR を展開しない) という shell 規則に合わせ、
# `'...'` の場合のみ展開しない。展開は _expand_env_vars に委ね、`$VAR` /
# `${VAR}` のみ展開し (未定義は空文字 = shell source 準拠)、`\$` はリテラル
# `$` にデエスケープする (shell の `\$` と同じ。EnvFile が `$` を保護する
# ため書く `\$` 付き値を壊さない)。`$(...)` 等 変数名にならない `$` は素通し
# するため、コマンド置換は従来どおりリテラルのまま残る。
if not single_quoted:
value = _expand_env_vars(value, os.environ)
os.environ[key] = value


Expand Down
54 changes: 54 additions & 0 deletions lib/devbase/env/collectors/editor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""エディタ動作設定コレクター (VS Code 自動オープン)。

``devbase up`` / ``devbase list`` 後に dev コンテナへ接続した VS Code を自動で
開くか (``DEVBASE_OPEN_EDITOR``) を ``devbase env init`` 時に対話的に設定する。
既定は有効 (``1``)。プロジェクト個別の ``env`` で ``DEVBASE_OPEN_EDITOR=0`` を
指定すれば、このグローバル既定を上書きして個別に無効化できる
(プロジェクト env はグローバル ``.env`` より後に読まれるため優先される)。
"""

from devbase.log import get_logger
from devbase.env import keys
from devbase.env.store import EnvFile, safe_input
from devbase.env.collector import Collector

logger = get_logger(__name__)

# 応答の真偽解釈 (大小無視)。空入力は default に倒れる (safe_input が処理)。
_YES = {"1", "y", "yes", "true", "on"}
_NO = {"0", "n", "no", "false", "off"}


def _normalize(answer: str, default: str) -> str:
"""ユーザー応答を ``"1"`` / ``"0"`` に正規化する。未知の値は default。"""
a = answer.strip().lower()
if a in _YES:
return "1"
if a in _NO:
return "0"
return default


def collect_open_editor(env_file: EnvFile) -> None:
"""``DEVBASE_OPEN_EDITOR`` を対話的に設定する (既定: ``1`` = 有効)。

既存値 (``0`` / ``1``) があればそれを既定として提示し、空入力で維持する。
非対話 (EOF) 環境では default が確定する。
"""
existing = env_file.get(keys.DEVBASE_OPEN_EDITOR)
default = existing if existing in ("0", "1") else "1"
Comment thread
takemi-ohama marked this conversation as resolved.
answer = safe_input(
f"{keys.DEVBASE_OPEN_EDITOR}: devbase up/list 後に VS Code を自動オープンしますか? "
f"[Y/n] (既定={default}): ",
default,
)
value = _normalize(answer, default)
env_file.set(keys.DEVBASE_OPEN_EDITOR, value)
logger.info("%s = %s", keys.DEVBASE_OPEN_EDITOR, value)


COLLECTOR = Collector(
name="editor",
display_name="VS Code 自動オープン (DEVBASE_OPEN_EDITOR)",
collect_fn=collect_open_editor,
)
7 changes: 4 additions & 3 deletions lib/devbase/env/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ def gcp_credentials_key(profile: str) -> str:
HOST_SSH_HOST = "HOST_SSH_HOST" # 任意。default: host.docker.internal

# --- Editor (devbase up 後の自動オープン / PLAN31_3) ---
# いずれも env collection (env init) の対象外で、プロジェクト env / グローバル
# .env に手書きする devbase 動作設定。詳細: docs/user/environment-variables.md
DEVBASE_OPEN_EDITOR = "DEVBASE_OPEN_EDITOR" # 真偽。up 後にエディタを開くか (既定 OFF)
# DEVBASE_OPEN_EDITOR は env init (collectors/editor.py) で対話設定する (既定 1)。
# 他はプロジェクト env / グローバル .env に手書きする devbase 動作設定。
# 詳細: docs/user/environment-variables.md
DEVBASE_OPEN_EDITOR = "DEVBASE_OPEN_EDITOR" # 真偽。up 後にエディタを開くか (env init 既定 1)
DEVBASE_EDITOR = "DEVBASE_EDITOR" # 任意。起動コマンド (既定 code)
DEVBASE_OPEN_INDEX = "DEVBASE_OPEN_INDEX" # 任意。開く dev インスタンス番号 (既定 1)
# Remote-SSH 跨ホスト構成 (Windows VS Code → ssh → Mac のコンテナ) 用。
Expand Down
66 changes: 61 additions & 5 deletions tests/cli/test_project_name_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@
WRAPPER = REPO_ROOT / "bin" / "devbase"


@pytest.fixture(autouse=True)
def _isolate_os_environ():
"""各テストの os.environ 変更を in-place で退避・復元する (後続テストへの漏出防止)。

os.environ を os._Environ のまま扱う (dict で置換しない) ため putenv 同期は保たれ、
subprocess へ環境が伝わらなくなる問題を避ける。
"""
saved = dict(os.environ)
try:
yield
finally:
os.environ.clear()
os.environ.update(saved)


# ===========================================================================
# Python: _resolve_project_name
# ===========================================================================
Expand Down Expand Up @@ -192,27 +207,68 @@ def test_resolve_clears_caller_only_env_keys(fake_root, monkeypatch):
def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch):
"""shell ``source`` との仕様乖離を固定する回帰テスト (docstring の note 対応)。

本パーサは変数展開・コマンド置換・行中クォート除去・インラインコメントを
解釈せず、値を安全側にリテラルとして扱う。この意図的な制約を pin する。
変数展開 (``$VAR`` / ``${VAR}``) は shell ``source`` 同様にサポートするが、
コマンド置換・行中クォート除去・インラインコメントは解釈しない。この境界を pin する。
"""
for k in ("LIT_VAR", "LIT_CMD", "INNER_Q", "INLINE_C"):
for k in ("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"


def test_load_project_env_expands_variable_references(tmp_path, monkeypatch):
"""``$VAR`` / ``${VAR}`` を shell ``source`` 同様に展開する回帰テスト。

実 env の ``WORK_DIR=/work/$GIT_REPO`` (同一ファイル内で先に定義した変数を参照)
が TUI (``list``) 経路で未展開のまま VS Code に渡る不具合の回帰防止。
Comment thread
takemi-ohama marked this conversation as resolved.
単一引用符値はリテラル扱いで展開しないことも併せて pin する。
"""
for k in ("GIT_REPO", "WORK_DIR", "WORK_DIR_BRACE", "SINGLE_Q"):
monkeypatch.delenv(k, raising=False)
Comment thread
takemi-ohama marked this conversation as resolved.
env_path = tmp_path / "env"
env_path.write_text(
"GIT_REPO=adminer\n"
"WORK_DIR=/work/$GIT_REPO\n" # 行順に解決済みの GIT_REPO を展開
"WORK_DIR_BRACE=/work/${GIT_REPO}\n" # ${VAR} 形式も展開
"SINGLE_Q='/work/$GIT_REPO'\n" # 単一引用符はリテラル
)

container._load_project_env(env_path)

assert os.environ["GIT_REPO"] == "adminer"
assert os.environ["WORK_DIR"] == "/work/adminer"
assert os.environ["WORK_DIR_BRACE"] == "/work/adminer"
assert os.environ["SINGLE_Q"] == "/work/$GIT_REPO"


def test_load_project_env_escaped_dollar_and_undefined(tmp_path, monkeypatch):
"""`\\$` はリテラル `$`、未定義参照は空 (shell source 準拠)。$(...) は別テストで担保。"""
for k in ("DEFINED", "ESCAPED", "UNDEF_REF", "NOPE"):
monkeypatch.delenv(k, raising=False)
env_path = tmp_path / "env"
env_path.write_text(
"DEFINED=x\n"
"ESCAPED=a\\$DEFINED\n" # \\$ → リテラル $ (展開しない)
"UNDEF_REF=/p/$NOPE/q\n" # 未定義は空
)

container._load_project_env(env_path)

assert os.environ["DEFINED"] == "x"
assert os.environ["ESCAPED"] == "a$DEFINED"
assert os.environ["UNDEF_REF"] == "/p//q"


# ===========================================================================
# wrapper: cd + argv strip + 存在性ベースの曖昧性回避
# ===========================================================================
Expand Down
95 changes: 95 additions & 0 deletions tests/env/test_collector_editor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""collectors/editor.py: VS Code 自動オープン (DEVBASE_OPEN_EDITOR) コレクタ"""

from __future__ import annotations

import builtins

import pytest

from devbase.env import keys
from devbase.env.store import EnvFile
from devbase.env.collector import CollectorRegistry
from devbase.env.collectors import editor


@pytest.fixture
def env_file(tmp_path):
return EnvFile(tmp_path / ".env")


def _patch_input(monkeypatch, responses):
"""input() を順番に responses で返すモックに差し替える (尽きたら EOFError)。"""
it = iter(responses)

def fake_input(prompt=""):
try:
return next(it)
except StopIteration:
raise EOFError

monkeypatch.setattr(builtins, "input", fake_input)


def test_default_enabled_on_eof(monkeypatch, env_file):
"""入力 EOF (非対話/CI) → 既定 1 が設定される"""
_patch_input(monkeypatch, [])

editor.collect_open_editor(env_file)

assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "1"


def test_empty_input_keeps_default(monkeypatch, env_file):
"""空入力 (Enter のみ) → 既定 1 を維持"""
_patch_input(monkeypatch, [""])

editor.collect_open_editor(env_file)

assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "1"


@pytest.mark.parametrize("answer", ["n", "N", "no", "0", "false", "off"])
def test_can_disable(monkeypatch, env_file, answer):
"""否定的な応答 → 0 (無効) に設定できる (選択可能)"""
_patch_input(monkeypatch, [answer])

editor.collect_open_editor(env_file)

assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "0"


@pytest.mark.parametrize("answer", ["y", "Y", "yes", "1", "true", "on"])
def test_can_enable(monkeypatch, env_file, answer):
"""肯定的な応答 → 1 (有効) に設定できる"""
_patch_input(monkeypatch, [answer])

editor.collect_open_editor(env_file)

assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "1"


def test_existing_value_used_as_default(monkeypatch, env_file):
"""既存値 (0) があれば空入力でそれを既定として維持する"""
env_file.set(keys.DEVBASE_OPEN_EDITOR, "0")
_patch_input(monkeypatch, [""]) # Enter → 既存の 0 を維持

editor.collect_open_editor(env_file)

assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "0"


def test_unknown_answer_falls_back_to_default(monkeypatch, env_file):
"""未知の応答は既定 (1) にフォールバック"""
_patch_input(monkeypatch, ["maybe"])

editor.collect_open_editor(env_file)

assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "1"


def test_collector_registered():
"""CollectorRegistry が editor コレクタを自動検出する"""
registry = CollectorRegistry()
registry.discover()
names = {c.name for c in registry.collectors}
assert "editor" in names
Loading