"""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 `//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