test_cli.py
1 # -*- coding: utf-8 -*- 2 """Tests for the unified pyod CLI.""" 3 import contextlib 4 import io 5 import os 6 import subprocess 7 import sys 8 from pathlib import Path 9 10 11 def test_pyod_cli_help(): 12 """Running `pyod --help` lists the three subcommands.""" 13 result = subprocess.run( 14 [sys.executable, "-m", "pyod.cli", "--help"], 15 capture_output=True, text=True, 16 ) 17 assert result.returncode == 0 18 assert "install" in result.stdout 19 assert "info" in result.stdout 20 assert "mcp" in result.stdout 21 22 23 def test_pyod_info_runs(): 24 """`pyod info` prints version and detector counts, exit 0 in core install.""" 25 result = subprocess.run( 26 [sys.executable, "-m", "pyod.cli", "info"], 27 capture_output=True, text=True, 28 ) 29 assert result.returncode == 0, f"stderr={result.stderr}" 30 assert "PyOD version" in result.stdout 31 assert "detectors" in result.stdout.lower() or "Detectors" in result.stdout 32 33 34 def test_pyod_info_does_not_exit_without_mcp(): 35 """`pyod info` must not crash in a core install without the mcp extra. 36 37 Regression test for the Task 1 mcp_server refactor: if 38 pyod.mcp_server ever regresses to exiting at import time, `pyod info` 39 would inherit the exit and this test would fail. 40 """ 41 result = subprocess.run( 42 [sys.executable, "-c", 43 "import sys; sys.modules['mcp'] = None; " 44 "from pyod.cli import main; sys.exit(main(['info']))"], 45 capture_output=True, text=True, 46 ) 47 assert result.returncode == 0, ( 48 f"pyod info exited non-zero with mcp blocked: " 49 f"stdout={result.stdout!r} stderr={result.stderr!r}" 50 ) 51 52 53 _REPO_ROOT = str(Path(__file__).resolve().parents[2]) 54 55 56 def _isolated_home_env(home: Path) -> dict: 57 """Return an env dict with HOME/USERPROFILE pointing at a clean directory. 58 59 Used by tests that need to exercise the ``pyod info`` skill-install-state 60 branches without leaking state from the test runner's real home dir 61 (which may have ``~/.claude`` or ``~/.codex`` installed for unrelated 62 reasons). 63 64 Also injects the repo root into ``PYTHONPATH`` so subprocesses that 65 run ``python -m pyod.cli`` from a temp cwd can still import ``pyod``. 66 On developer workstations with an editable install this is redundant; 67 on CI the workflow does not install pyod, so this is load-bearing. 68 """ 69 env = os.environ.copy() 70 env["HOME"] = str(home) 71 env["USERPROFILE"] = str(home) # Windows equivalent of HOME 72 existing_pp = env.get("PYTHONPATH", "") 73 env["PYTHONPATH"] = ( 74 _REPO_ROOT + os.pathsep + existing_pp if existing_pp else _REPO_ROOT 75 ) 76 return env 77 78 79 def test_pyod_info_project_local_only(tmp_path): 80 """`pyod info` distinguishes project-local-only from user-global install. 81 82 Regression test for the expanded skill-install-state rendering in 83 `_cmd_info`. Sets up: 84 - a fake project-local install at `tmp_path/skills/od-expert/SKILL.md` 85 - a clean HOME (no ~/.claude/ or ~/.codex/ under it) 86 then runs `pyod info` with `cwd=tmp_path` and asserts the output 87 reports the project-local install without claiming a user-global 88 install or detecting any agent stack. 89 """ 90 project_skill = tmp_path / "skills" / "od-expert" 91 project_skill.mkdir(parents=True) 92 (project_skill / "SKILL.md").write_text("---\nname: od-expert\n---\n") 93 94 fake_home = tmp_path / "fake_home" 95 fake_home.mkdir() 96 97 result = subprocess.run( 98 [sys.executable, "-m", "pyod.cli", "info"], 99 capture_output=True, text=True, 100 cwd=str(tmp_path), 101 env=_isolated_home_env(fake_home), 102 ) 103 assert result.returncode == 0, f"stderr={result.stderr}" 104 assert "INSTALLED (project)" in result.stdout 105 assert "INSTALLED (user-global)" not in result.stdout 106 # No agent dirs in fake_home → no "Active for" or "Detected agents" line 107 assert "Detected agents:" not in result.stdout 108 109 110 def test_pyod_info_codex_detected_no_skill(tmp_path): 111 """`pyod info` detects Codex via ~/.codex/ and advises --project install. 112 113 Sets up a fake HOME containing only ``~/.codex/`` (no Claude Code, no 114 project-local skill). `pyod info` must report Codex as detected and 115 recommend ``pyod install skill --project`` rather than the Claude-Code- 116 specific user-global command. 117 """ 118 fake_home = tmp_path / "fake_home" 119 (fake_home / ".codex").mkdir(parents=True) 120 121 # cwd is a pristine directory with no ./skills/od-expert/ install 122 work = tmp_path / "work" 123 work.mkdir() 124 125 result = subprocess.run( 126 [sys.executable, "-m", "pyod.cli", "info"], 127 capture_output=True, text=True, 128 cwd=str(work), 129 env=_isolated_home_env(fake_home), 130 ) 131 assert result.returncode == 0, f"stderr={result.stderr}" 132 assert "NOT INSTALLED" in result.stdout 133 assert "Codex" in result.stdout 134 assert "pyod install skill --project" in result.stdout 135 # Must NOT fall into the Claude-Code-specific branch 136 assert "Claude Code (user-global)" not in result.stdout 137 assert "no agent stacks detected" not in result.stdout 138 139 140 def test_pyod_info_codex_and_claude_both_detected(tmp_path): 141 """Both ~/.claude/ and ~/.codex/ present, neither skill installed. 142 143 Output must list both agents and show both install commands so the 144 user knows which option fits their workflow. 145 """ 146 fake_home = tmp_path / "fake_home" 147 (fake_home / ".claude").mkdir(parents=True) 148 (fake_home / ".codex").mkdir(parents=True) 149 150 work = tmp_path / "work" 151 work.mkdir() 152 153 result = subprocess.run( 154 [sys.executable, "-m", "pyod.cli", "info"], 155 capture_output=True, text=True, 156 cwd=str(work), 157 env=_isolated_home_env(fake_home), 158 ) 159 assert result.returncode == 0, f"stderr={result.stderr}" 160 assert "NOT INSTALLED" in result.stdout 161 # Pin the exact recommendation lines so a regression that drops one 162 # branch cannot pass on a substring match (``pyod install skill`` is 163 # a substring of ``pyod install skill --project``). 164 assert "Claude Code (user-global): run `pyod install skill`" in result.stdout 165 assert "Codex (project-local):" in result.stdout 166 assert "`pyod install skill --project`" in result.stdout 167 168 169 def test_pyod_info_user_global_claude_plus_codex_detected(tmp_path): 170 """User-global Claude install + Codex detected + no project install. 171 172 The subtle branch flagged in Round 2 review. Claude's user-global 173 skill does not help Codex, so the output must recommend `--project` 174 for Codex even though Claude Code is already satisfied. 175 """ 176 fake_home = tmp_path / "fake_home" 177 # Pre-install od-expert user-globally for Claude Code 178 user_skill = fake_home / ".claude" / "skills" / "od-expert" 179 user_skill.mkdir(parents=True) 180 (user_skill / "SKILL.md").write_text("---\nname: od-expert\n---\n") 181 # Codex also present 182 (fake_home / ".codex").mkdir(parents=True) 183 184 # Pristine cwd (no project-local install) 185 work = tmp_path / "work" 186 work.mkdir() 187 188 result = subprocess.run( 189 [sys.executable, "-m", "pyod.cli", "info"], 190 capture_output=True, text=True, 191 cwd=str(work), 192 env=_isolated_home_env(fake_home), 193 ) 194 assert result.returncode == 0, f"stderr={result.stderr}" 195 assert "INSTALLED (user-global)" in result.stdout 196 # Must explicitly flag the Codex gap and the --project remedy. 197 assert "Codex detected but does not read the user-global path" in result.stdout 198 assert "`pyod install skill --project`" in result.stdout 199 200 201 def test_install_skill_project_message_is_agent_neutral(tmp_path): 202 """`pyod install skill --project` must not claim Claude-only activation. 203 204 Regression test for the Round 2 finding that `_run_install` hardcoded 205 a Claude-specific success message. For a project-local install, the 206 output should use agent-neutral wording that covers both Claude Code 207 and Codex. 208 209 Uses explicit ``cwd`` + ``_isolated_home_env`` so the subprocess 210 imports ``pyod`` via PYTHONPATH even on CI (no editable install). 211 """ 212 fake_home = tmp_path / "fake_home" 213 fake_home.mkdir() 214 work = tmp_path / "work" 215 work.mkdir() 216 217 result = subprocess.run( 218 [sys.executable, "-m", "pyod.cli", "install", "skill", "--project"], 219 capture_output=True, text=True, 220 cwd=str(work), 221 env=_isolated_home_env(fake_home), 222 ) 223 assert result.returncode == 0, f"stderr={result.stderr}" 224 assert (work / "skills" / "od-expert" / "SKILL.md").is_file() 225 # The Claude-only restart hint must NOT appear for project-local installs. 226 assert "Restart your Claude Code session" not in result.stdout 227 assert "Claude Code will auto-activate" not in result.stdout 228 # The agent-neutral wording must appear. 229 assert "project-local skill" in result.stdout 230 assert "Codex" in result.stdout 231 232 233 def test_pyod_install_skill_to_target(tmp_path): 234 """`pyod install skill --target <path>` writes od-expert/SKILL.md.""" 235 result = subprocess.run( 236 [sys.executable, "-m", "pyod.cli", "install", "skill", 237 "--target", str(tmp_path)], 238 capture_output=True, text=True, 239 ) 240 assert result.returncode == 0 241 assert (tmp_path / "od-expert" / "SKILL.md").is_file() 242 # Output should use the canonical hyphenated name. 243 assert "od-expert" in result.stdout 244 245 246 def test_pyod_install_skill_canonical_name_on_underscore_input(tmp_path): 247 """Passing `--skill od_expert` still prints the canonical `od-expert`.""" 248 result = subprocess.run( 249 [sys.executable, "-m", "pyod.cli", "install", "skill", 250 "--skill", "od_expert", "--target", str(tmp_path)], 251 capture_output=True, text=True, 252 ) 253 assert result.returncode == 0 254 assert "Installed od-expert skill" in result.stdout 255 assert "Installed od_expert skill" not in result.stdout 256 257 258 def test_pyod_install_skill_list(): 259 """`pyod install skill --list` prints available skills with canonical names.""" 260 result = subprocess.run( 261 [sys.executable, "-m", "pyod.cli", "install", "skill", "--list"], 262 capture_output=True, text=True, 263 ) 264 assert result.returncode == 0 265 assert "od-expert" in result.stdout 266 267 268 def test_legacy_and_unified_install_match_in_process(tmp_path): 269 """`pyod-install-skill ...` and `pyod install skill ...` match in-process. 270 271 Compares return code, stdout (with the path line scrubbed), and 272 stderr. An entry-point regression that changes exit behavior would 273 fail here. 274 """ 275 from pyod.skills import install_cli 276 from pyod.cli import main as cli_main 277 278 tmp_legacy = tmp_path / "legacy" 279 tmp_unified = tmp_path / "unified" 280 281 buf_legacy_out = io.StringIO() 282 buf_legacy_err = io.StringIO() 283 with contextlib.redirect_stdout(buf_legacy_out), \ 284 contextlib.redirect_stderr(buf_legacy_err): 285 rc_legacy = install_cli(["--target", str(tmp_legacy)]) 286 287 buf_unified_out = io.StringIO() 288 buf_unified_err = io.StringIO() 289 with contextlib.redirect_stdout(buf_unified_out), \ 290 contextlib.redirect_stderr(buf_unified_err): 291 rc_unified = cli_main(["install", "skill", "--target", str(tmp_unified)]) 292 293 assert rc_legacy == 0 294 assert rc_unified == 0 295 assert rc_legacy == rc_unified 296 297 def _scrub(text): 298 return "\n".join( 299 line for line in text.splitlines() 300 if "Installed od-expert skill to:" not in line 301 ) 302 303 assert _scrub(buf_legacy_out.getvalue()) == _scrub(buf_unified_out.getvalue()) 304 assert buf_legacy_err.getvalue() == buf_unified_err.getvalue() 305 306 307 def test_legacy_and_unified_install_match_subprocess(tmp_path): 308 """Subprocess parity test: real console scripts produce matching output. 309 310 Runs the real `pyod` and `pyod-install-skill` commands through the 311 entry-point shims rather than importing the functions directly. 312 This catches wiring regressions the in-process test would miss 313 (e.g., a console_scripts entry pointing at the wrong function). 314 Skipped if either executable is not on PATH. 315 """ 316 import shutil 317 318 pyod_exe = shutil.which("pyod") 319 legacy_exe = shutil.which("pyod-install-skill") 320 if not pyod_exe or not legacy_exe: 321 import pytest 322 pytest.skip("pyod and/or pyod-install-skill not on PATH (editable install not wired up)") 323 324 tmp_legacy = tmp_path / "legacy" 325 tmp_unified = tmp_path / "unified" 326 327 r_legacy = subprocess.run( 328 [legacy_exe, "--target", str(tmp_legacy)], 329 capture_output=True, text=True, 330 ) 331 r_unified = subprocess.run( 332 [pyod_exe, "install", "skill", "--target", str(tmp_unified)], 333 capture_output=True, text=True, 334 ) 335 336 assert r_legacy.returncode == 0, r_legacy.stderr 337 assert r_unified.returncode == 0, r_unified.stderr 338 assert r_legacy.returncode == r_unified.returncode 339 340 def _scrub(text): 341 return "\n".join( 342 line for line in text.splitlines() 343 if "Installed od-expert skill to:" not in line 344 ) 345 346 assert _scrub(r_legacy.stdout) == _scrub(r_unified.stdout) 347 assert r_legacy.stderr == r_unified.stderr 348 assert (tmp_legacy / "od-expert" / "SKILL.md").is_file() 349 assert (tmp_unified / "od-expert" / "SKILL.md").is_file() 350 351 352 def test_pyod_install_skill_copies_references_tree(tmp_path): 353 """`pyod install skill --target <path>` must copy references/ subdir too. 354 355 Regression test for the v3.2.0 tree-aware installer. The skill ships 356 with a references/ subdirectory containing depth files; the installer 357 must copy them alongside SKILL.md. 358 359 Skipped automatically until the references/ subdir exists in the 360 package source tree (Phase 2 of the v3.2.0 plan, Tasks 6-12). Once 361 workflow.md lands, the skip evaporates and this becomes a hard 362 regression check. 363 """ 364 source_refs = ( 365 Path(__file__).resolve().parents[1] 366 / "skills" / "od_expert" / "references" 367 ) 368 if not (source_refs / "workflow.md").is_file(): 369 import pytest 370 pytest.skip( 371 "pyod/skills/od_expert/references/workflow.md not yet shipped " 372 "(v3.2.0 Phase 2 deliverable). Test auto-promotes once it exists." 373 ) 374 fake_home = tmp_path / "fake_home" 375 fake_home.mkdir(exist_ok=True) 376 result = subprocess.run( 377 [sys.executable, "-m", "pyod.cli", "install", "skill", 378 "--target", str(tmp_path)], 379 capture_output=True, text=True, 380 env=_isolated_home_env(fake_home), 381 ) 382 assert result.returncode == 0, f"stderr={result.stderr}" 383 skill_dir = tmp_path / "od-expert" 384 assert (skill_dir / "SKILL.md").is_file() 385 references_dir = skill_dir / "references" 386 assert references_dir.is_dir(), ( 387 f"references/ subdir not copied to {references_dir}" 388 ) 389 # Spot-check at least one expected reference file exists 390 assert (references_dir / "workflow.md").is_file(), ( 391 "workflow.md not copied — installer is not tree-aware" 392 )