mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 14:39:00 +08:00
feat(scripts): add Python check-prerequisites PoC (#3302)
* feat(scripts): add Python check-prerequisites PoC * fix(scripts): address check-prerequisites parity feedback * test(scripts): label PowerShell prerequisite parity cases --------- Co-authored-by: root <kinsonnee@gmail.com>
This commit is contained in:
207
scripts/python/check_prerequisites.py
Normal file
207
scripts/python/check_prerequisites.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Consolidated prerequisite checking script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
--json Output in JSON format
|
||||
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
--paths-only Only output path variables (no prerequisite validation)
|
||||
--help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
./check_prerequisites.py --json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
./check_prerequisites.py --json --require-tasks --include-tasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
./check_prerequisites.py --paths-only
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Args:
|
||||
json_mode: bool = False
|
||||
require_tasks: bool = False
|
||||
include_tasks: bool = False
|
||||
paths_only: bool = False
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> Args:
|
||||
json_mode = False
|
||||
require_tasks = False
|
||||
include_tasks = False
|
||||
paths_only = False
|
||||
|
||||
for arg in argv:
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg == "--require-tasks":
|
||||
require_tasks = True
|
||||
elif arg == "--include-tasks":
|
||||
include_tasks = True
|
||||
elif arg == "--paths-only":
|
||||
paths_only = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(HELP_TEXT)
|
||||
raise SystemExit(0)
|
||||
else:
|
||||
print(
|
||||
f"ERROR: Unknown option '{arg}'. Use --help for usage information.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
return Args(
|
||||
json_mode=json_mode,
|
||||
require_tasks=require_tasks,
|
||||
include_tasks=include_tasks,
|
||||
paths_only=paths_only,
|
||||
)
|
||||
|
||||
|
||||
def _dir_has_entries(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_dir() and any(path.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _available_docs(paths: FeaturePaths, include_tasks: bool) -> list[str]:
|
||||
docs: list[str] = []
|
||||
if paths.research.is_file():
|
||||
docs.append("research.md")
|
||||
if paths.data_model.is_file():
|
||||
docs.append("data-model.md")
|
||||
if _dir_has_entries(paths.contracts_dir):
|
||||
docs.append("contracts/")
|
||||
if paths.quickstart.is_file():
|
||||
docs.append("quickstart.md")
|
||||
if include_tasks and paths.tasks.is_file():
|
||||
docs.append("tasks.md")
|
||||
return docs
|
||||
|
||||
|
||||
def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
|
||||
if json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"REPO_ROOT": str(paths.repo_root),
|
||||
"BRANCH": paths.current_branch,
|
||||
"FEATURE_DIR": str(paths.feature_dir),
|
||||
"FEATURE_SPEC": str(paths.feature_spec),
|
||||
"IMPL_PLAN": str(paths.impl_plan),
|
||||
"TASKS": str(paths.tasks),
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
print(f"REPO_ROOT: {paths.repo_root}")
|
||||
print(f"BRANCH: {paths.current_branch}")
|
||||
print(f"FEATURE_DIR: {paths.feature_dir}")
|
||||
print(f"FEATURE_SPEC: {paths.feature_spec}")
|
||||
print(f"IMPL_PLAN: {paths.impl_plan}")
|
||||
print(f"TASKS: {paths.tasks}")
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
|
||||
print(f"FEATURE_DIR:{paths.feature_dir}")
|
||||
print("AVAILABLE_DOCS:")
|
||||
_check_file(paths.research, "research.md")
|
||||
_check_file(paths.data_model, "data-model.md")
|
||||
_check_dir(paths.contracts_dir, "contracts/")
|
||||
_check_file(paths.quickstart, "quickstart.md")
|
||||
if include_tasks:
|
||||
_check_file(paths.tasks, "tasks.md")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
|
||||
try:
|
||||
paths = get_feature_paths(
|
||||
no_persist=args.paths_only,
|
||||
script_file=Path(__file__),
|
||||
)
|
||||
except SystemExit as exc:
|
||||
if exc.code == 0:
|
||||
return 0
|
||||
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
|
||||
return int(exc.code) if isinstance(exc.code, int) else 1
|
||||
|
||||
if args.paths_only:
|
||||
_print_paths_only(paths, args.json_mode)
|
||||
return 0
|
||||
|
||||
if not paths.feature_dir.is_dir():
|
||||
print(f"ERROR: Feature directory not found: {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not paths.impl_plan.is_file():
|
||||
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.require_tasks and not paths.tasks.is_file():
|
||||
print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('tasks', paths.repo_root)} first to create the task list.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
docs = _available_docs(paths, args.include_tasks)
|
||||
if args.json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
|
||||
)
|
||||
else:
|
||||
_print_text_results(paths, args.include_tasks)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
210
scripts/python/common.py
Normal file
210
scripts/python/common.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Shared helpers for Spec Kit Python scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _trim_trailing_separators(value: Path) -> str:
|
||||
text = str(value)
|
||||
while len(text) > 1 and text.endswith((os.sep, "/")):
|
||||
text = text[:-1]
|
||||
return text
|
||||
|
||||
|
||||
def find_specify_root(start_dir: Path | None = None) -> Path | None:
|
||||
current = (start_dir or Path.cwd()).resolve()
|
||||
while True:
|
||||
if (current / ".specify").is_dir():
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
return None
|
||||
current = parent
|
||||
|
||||
|
||||
def resolve_specify_init_dir() -> Path:
|
||||
raw = os.environ.get("SPECIFY_INIT_DIR", "")
|
||||
candidate = Path(raw)
|
||||
if not candidate.is_absolute():
|
||||
candidate = Path.cwd() / candidate
|
||||
try:
|
||||
init_root = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
print(
|
||||
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if not init_root.is_dir():
|
||||
print(
|
||||
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if not (init_root / ".specify").is_dir():
|
||||
print(
|
||||
"ERROR: SPECIFY_INIT_DIR is not a Spec Kit project "
|
||||
f"(no .specify/ directory): {init_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
return init_root
|
||||
|
||||
|
||||
def get_repo_root(script_file: Path | None = None) -> Path:
|
||||
if os.environ.get("SPECIFY_INIT_DIR"):
|
||||
return resolve_specify_init_dir()
|
||||
|
||||
specify_root = find_specify_root()
|
||||
if specify_root is not None:
|
||||
return specify_root
|
||||
|
||||
if script_file is not None:
|
||||
script_root = find_specify_root(script_file.resolve().parent)
|
||||
if script_root is not None:
|
||||
return script_root
|
||||
|
||||
# Installed scripts live at .specify/scripts/python/<script>.py.
|
||||
return script_file.resolve().parents[3]
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
def get_current_branch() -> str:
|
||||
return os.environ.get("SPECIFY_FEATURE", "")
|
||||
|
||||
|
||||
def read_feature_json_feature_directory(repo_root: Path) -> str:
|
||||
feature_json = repo_root / ".specify" / "feature.json"
|
||||
if not feature_json.is_file():
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return ""
|
||||
value = data.get("feature_directory") if isinstance(data, dict) else None
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _json_dump(data: dict[str, str]) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
||||
value = feature_dir_value
|
||||
try:
|
||||
relative = Path(value)
|
||||
if relative.is_absolute():
|
||||
try:
|
||||
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
value = str(relative)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
current = read_feature_json_feature_directory(repo_root)
|
||||
if current == value:
|
||||
return
|
||||
|
||||
specify_dir = repo_root / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "feature.json").write_text(
|
||||
_json_dump({"feature_directory": value}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeaturePaths:
|
||||
repo_root: Path
|
||||
current_branch: str
|
||||
feature_dir: Path
|
||||
feature_spec: Path
|
||||
impl_plan: Path
|
||||
tasks: Path
|
||||
research: Path
|
||||
data_model: Path
|
||||
quickstart: Path
|
||||
contracts_dir: Path
|
||||
|
||||
|
||||
def get_feature_paths(
|
||||
*, no_persist: bool = False, script_file: Path | None = None
|
||||
) -> FeaturePaths:
|
||||
repo_root = get_repo_root(script_file)
|
||||
current_branch = get_current_branch()
|
||||
|
||||
feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
|
||||
if feature_dir_raw:
|
||||
feature_dir = Path(feature_dir_raw)
|
||||
if not feature_dir.is_absolute():
|
||||
feature_dir = repo_root / feature_dir
|
||||
if not no_persist:
|
||||
persist_feature_json(repo_root, feature_dir_raw)
|
||||
elif (repo_root / ".specify" / "feature.json").is_file():
|
||||
stored = read_feature_json_feature_directory(repo_root)
|
||||
if not stored:
|
||||
print(
|
||||
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
|
||||
"or ensure .specify/feature.json contains feature_directory.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
feature_dir = Path(stored)
|
||||
if not feature_dir.is_absolute():
|
||||
feature_dir = repo_root / feature_dir
|
||||
else:
|
||||
print(
|
||||
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
|
||||
"or run the specify command to create .specify/feature.json.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not current_branch:
|
||||
current_branch = Path(_trim_trailing_separators(feature_dir)).name
|
||||
|
||||
return FeaturePaths(
|
||||
repo_root=repo_root,
|
||||
current_branch=current_branch,
|
||||
feature_dir=feature_dir,
|
||||
feature_spec=feature_dir / "spec.md",
|
||||
impl_plan=feature_dir / "plan.md",
|
||||
tasks=feature_dir / "tasks.md",
|
||||
research=feature_dir / "research.md",
|
||||
data_model=feature_dir / "data-model.md",
|
||||
quickstart=feature_dir / "quickstart.md",
|
||||
contracts_dir=feature_dir / "contracts",
|
||||
)
|
||||
|
||||
|
||||
def get_invoke_separator(repo_root: Path) -> str:
|
||||
integration_json = repo_root / ".specify" / "integration.json"
|
||||
if not integration_json.is_file():
|
||||
return "."
|
||||
try:
|
||||
state = json.loads(integration_json.read_text(encoding="utf-8"))
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
return entry["invoke_separator"]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return "."
|
||||
|
||||
|
||||
def format_speckit_command(command_name: str, repo_root: Path) -> str:
|
||||
separator = get_invoke_separator(repo_root)
|
||||
name = command_name.lstrip("/")
|
||||
if name.startswith("speckit."):
|
||||
name = name[len("speckit.") :]
|
||||
elif name.startswith("speckit-"):
|
||||
name = name[len("speckit-") :]
|
||||
name = name.replace(".", separator)
|
||||
return f"/speckit{separator}{name}"
|
||||
339
tests/test_check_prerequisites_python_parity.py
Normal file
339
tests/test_check_prerequisites_python_parity.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""Parity tests for the Python check-prerequisites PoC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
|
||||
CHECK_PREREQS_PY = PROJECT_ROOT / "scripts" / "python" / "check_prerequisites.py"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_WINDOWS_POWERSHELL = (
|
||||
shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
) if os.name == "nt" else None
|
||||
|
||||
|
||||
def _install_scripts(repo: Path) -> None:
|
||||
bash_dir = repo / ".specify" / "scripts" / "bash"
|
||||
bash_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, bash_dir / "common.sh")
|
||||
shutil.copy(CHECK_PREREQS_SH, bash_dir / "check-prerequisites.sh")
|
||||
|
||||
ps_dir = repo / ".specify" / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, ps_dir / "common.ps1")
|
||||
shutil.copy(CHECK_PREREQS_PS, ps_dir / "check-prerequisites.ps1")
|
||||
|
||||
py_dir = repo / ".specify" / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PY, py_dir / "common.py")
|
||||
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def _git_init(repo: Path) -> None:
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
|
||||
)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prereq_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_scripts(repo)
|
||||
return repo
|
||||
|
||||
|
||||
def _py_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "python" / "check_prerequisites.py"
|
||||
return [sys.executable, str(script), *args]
|
||||
|
||||
|
||||
def _repo_copy_py_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / "scripts" / "python" / "check_prerequisites.py"
|
||||
return [sys.executable, str(script), *args]
|
||||
|
||||
|
||||
def _bash_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
return ["bash", str(script), *args]
|
||||
|
||||
|
||||
def _ps_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
return [exe, "-NoProfile", "-File", str(script), *args]
|
||||
|
||||
|
||||
def _run(
|
||||
cmd: list[str], repo: Path, env: dict[str, str] | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env or _clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _normalize_status_text(text: str) -> str:
|
||||
return (
|
||||
text.replace(" ✓ ", " [OK] ")
|
||||
.replace(" ✗ ", " [FAIL] ")
|
||||
.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
|
||||
def _normalize_help_text(text: str) -> str:
|
||||
normalized = text.replace("\r\n", "\n").replace(
|
||||
"check-prerequisites.sh", "check_prerequisites.py"
|
||||
)
|
||||
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("--json",),
|
||||
("--json", "--include-tasks"),
|
||||
("--json", "--require-tasks", "--include-tasks"),
|
||||
("--json", "--paths-only"),
|
||||
],
|
||||
)
|
||||
def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...]) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feat / "contracts" / "v1").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, *args), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, *args), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _json_stdout(py) == _json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "contracts").mkdir()
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _normalize_help_text(py.stdout) == _normalize_help_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_unknown_option_matches_bash_error_shape(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--bogus"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--bogus"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.stderr == bash.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("py_args", "ps_args"),
|
||||
[
|
||||
(("--json",), ("-Json",)),
|
||||
(("--json", "--include-tasks"), ("-Json", "-IncludeTasks")),
|
||||
(
|
||||
("--json", "--require-tasks", "--include-tasks"),
|
||||
("-Json", "-RequireTasks", "-IncludeTasks"),
|
||||
),
|
||||
(("--json", "--paths-only"), ("-Json", "-PathsOnly")),
|
||||
],
|
||||
ids=[
|
||||
"json",
|
||||
"json_include_tasks",
|
||||
"json_require_tasks_include_tasks",
|
||||
"json_paths_only",
|
||||
],
|
||||
)
|
||||
def test_python_json_output_matches_powershell(
|
||||
prereq_repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
|
||||
) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feat / "contracts" / "v1").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
ps = _run(_ps_cmd(prereq_repo, *ps_args), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, *py_args), prereq_repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert py.stderr == ps.stderr == ""
|
||||
assert _json_stdout(py) == _json_stdout(ps)
|
||||
|
||||
|
||||
def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "proj"
|
||||
outside = tmp_path / "outside"
|
||||
repo.mkdir()
|
||||
outside.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_write_feature_json(repo)
|
||||
(repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
|
||||
py_dir = repo / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True)
|
||||
shutil.copy(COMMON_PY, py_dir / "common.py")
|
||||
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
||||
|
||||
py = _run(_repo_copy_py_cmd(repo, "--json", "--paths-only"), outside)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert Path(_json_stdout(py)["REPO_ROOT"]) == repo
|
||||
|
||||
|
||||
def test_python_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
(prereq_repo / "specs" / "002-other").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
feature_json = prereq_repo / ".specify" / "feature.json"
|
||||
before = feature_json.read_text(encoding="utf-8")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo, env=env)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert "002-other" in _json_stdout(py)["FEATURE_DIR"]
|
||||
assert feature_json.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
feat = prereq_repo / "specs" / "002-other"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
data = json.loads(
|
||||
(prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert data["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "expected"),
|
||||
[
|
||||
(("--json",), "Feature directory not found"),
|
||||
(("--json",), "plan.md not found"),
|
||||
(("--json", "--require-tasks"), "tasks.md not found"),
|
||||
],
|
||||
ids=["missing_feature_context", "missing_plan", "missing_tasks"],
|
||||
)
|
||||
def test_python_negative_errors_are_stderr_only(
|
||||
tmp_path: Path, args: tuple[str, ...], expected: str
|
||||
) -> None:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_scripts(repo)
|
||||
|
||||
if expected in {"plan.md not found", "tasks.md not found"}:
|
||||
feat = repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
_write_feature_json(repo)
|
||||
if expected == "tasks.md not found":
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
py = _run(_py_cmd(repo, *args), repo)
|
||||
|
||||
assert py.returncode != 0
|
||||
assert expected in py.stderr
|
||||
assert expected not in py.stdout
|
||||
assert py.stdout.strip() == ""
|
||||
|
||||
|
||||
def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
|
||||
Reference in New Issue
Block a user