soup/soup_cli/utils/vscode_setup.py

105 lines
3.6 KiB
Python

"""v0.43.0 Part C — `soup doctor --vscode` writer for `.vscode/launch.json`.
Generates a minimal but useful Python debug config for `soup train` plus a
pytest config. Path containment via shared `is_under_cwd`; refuses to
overwrite an existing launch.json without `force=True` (matches v0.40.2
register_data policy).
"""
from __future__ import annotations
import json
import os
import stat
from soup_cli.utils.paths import is_under_cwd
def build_launch_json(*, config_path: str = "soup.yaml") -> dict:
"""Build the launch.json contents.
`config_path` is the YAML config to pass to `soup train --config`.
Validates the path so a crafted argument cannot inject arbitrary
args into the generated JSON.
"""
if not isinstance(config_path, str) or not config_path:
raise ValueError("config_path must be a non-empty string")
if "\x00" in config_path:
raise ValueError("config_path must not contain null bytes")
if "\n" in config_path or "\r" in config_path:
raise ValueError("config_path must not contain newlines")
if len(config_path) > 512:
raise ValueError("config_path too long (>512 chars)")
return {
"version": "0.2.0",
"configurations": [
{
"name": "soup train",
"type": "python",
"request": "launch",
"module": "soup_cli.cli",
"args": ["train", "--config", config_path],
"console": "integratedTerminal",
"justMyCode": False,
"env": {"PYTHONUTF8": "1"},
},
{
"name": "pytest (current file)",
"type": "python",
"request": "launch",
"module": "pytest",
"args": ["${file}", "-v", "--no-cov"],
"console": "integratedTerminal",
"justMyCode": False,
},
],
}
def write_vscode_launch(
*,
config_path: str = "soup.yaml",
target_dir: str = ".vscode",
force: bool = False,
) -> str:
"""Write `<cwd>/<target_dir>/launch.json`.
Returns the absolute path written.
Raises ValueError on containment violation.
Raises FileExistsError when launch.json already exists and force=False.
"""
if not isinstance(target_dir, str) or not target_dir:
raise ValueError("target_dir must be a non-empty string")
if "\x00" in target_dir:
raise ValueError("target_dir must not contain null bytes")
if not isinstance(force, bool):
raise ValueError("force must be a bool")
real = os.path.realpath(target_dir)
if not is_under_cwd(real):
raise ValueError("target_dir must stay under cwd")
payload = build_launch_json(config_path=config_path)
os.makedirs(real, exist_ok=True)
out_path = os.path.join(real, "launch.json")
# TOCTOU defence: reject symlinks at the target path regardless of
# `force` (mirrors v0.33.0 #22 prune_checkpoints policy). Without this
# guard, force=True would follow a pre-placed symlink and overwrite a
# file outside cwd.
try:
st = os.lstat(out_path)
except FileNotFoundError:
st = None
if st is not None:
if stat.S_ISLNK(st.st_mode):
raise ValueError(
"launch.json target is a symlink; aborting"
)
if not force:
raise FileExistsError(
f"{out_path} already exists; pass force=True to overwrite"
)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
f.write("\n")
return out_path