diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9838778..e83b813 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,3 +123,8 @@ jobs: - name: Run test suite run: python -m pytest -q --tb=short + + - name: Build and install package artifacts + run: | + python -m pip install build + python benchmarks/package_smoke.py diff --git a/README.md b/README.md index 4e299b0..d2a4363 100644 --- a/README.md +++ b/README.md @@ -227,10 +227,10 @@ This repository is past the prototype stage. It already behaves like a usable lo The active package is the root `minicode/` package configured by `pyproject.toml` as `minicode-py`. -Current local verification result: +Current cross-platform CI verification result: ```text -1290 passed, 2 skipped +1311 passed, 2 skipped ``` Verification command: diff --git a/README.zh-CN.md b/README.zh-CN.md index a314eef..651c1dd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -196,10 +196,10 @@ flowchart LR 当前生效的主包是根目录 `minicode/`,由 `pyproject.toml` 里的 `minicode-py` 配置驱动。 -最近一次本地验证结果: +最近一次跨平台 CI 验证结果: ```text -1290 passed, 2 skipped +1311 passed, 2 skipped ``` 验证命令: diff --git a/benchmarks/package_smoke.py b/benchmarks/package_smoke.py new file mode 100644 index 0000000..30d9cce --- /dev/null +++ b/benchmarks/package_smoke.py @@ -0,0 +1,128 @@ +"""Build and install package artifacts, then smoke-test their CLI entry points.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +import os +import shutil +import subprocess +import sys +import tempfile +import venv +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +_BUILD_OUTPUT_NAMES = ("build", "minicode_py.egg-info") + + +def _run(command: list[str], *, cwd: Path) -> None: + subprocess.run(command, cwd=cwd, check=True) + + +def _remove_path(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + + +@contextmanager +def _preserve_build_outputs(work: Path) -> Iterator[None]: + """Keep PEP 517's source-tree build outputs out of the checkout.""" + backup_dir = work / "source-build-output-backups" + paths = [ROOT / name for name in _BUILD_OUTPUT_NAMES] + backups: dict[Path, Path] = {} + + for path in paths: + if path.exists() or path.is_symlink(): + backup = backup_dir / path.name + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(path), str(backup)) + backups[path] = backup + + try: + yield + finally: + for path in paths: + _remove_path(path) + for path, backup in backups.items(): + shutil.move(str(backup), str(path)) + + +def _venv_python(venv_dir: Path) -> Path: + return venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +def _venv_entrypoint(venv_dir: Path, name: str) -> Path: + suffix = ".exe" if os.name == "nt" else "" + directory = "Scripts" if os.name == "nt" else "bin" + return venv_dir / directory / f"{name}{suffix}" + + +def main() -> int: + temp_dir = "/tmp" if os.name != "nt" and Path("/tmp").is_dir() else None + with tempfile.TemporaryDirectory( + prefix="minicode-package-smoke-", + dir=temp_dir, + ) as raw: + work = Path(raw) + dist = work / "dist" + dist.mkdir() + + with _preserve_build_outputs(work): + _run( + [ + sys.executable, + "-m", + "build", + "--wheel", + "--sdist", + "--outdir", + str(dist), + ], + cwd=ROOT, + ) + + wheels = sorted(dist.glob("*.whl")) + sdists = sorted(dist.glob("*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + raise RuntimeError( + f"expected one wheel and one sdist, found {len(wheels)} wheels " + f"and {len(sdists)} sdists" + ) + + for label, artifact in (("wheel", wheels[0]), ("sdist", sdists[0])): + env_dir = work / f"{label}-venv" + venv.EnvBuilder( + symlinks=os.name != "nt", + with_pip=True, + ).create(env_dir) + python = _venv_python(env_dir) + _run( + [str(python), "-m", "pip", "install", "--no-deps", str(artifact)], + cwd=work, + ) + + for entrypoint in ("minicode-py", "minicode-headless", "minicode-readiness"): + command = _venv_entrypoint(env_dir, entrypoint) + completed = subprocess.run( + [str(command), "--help"], + cwd=work, + check=False, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + raise RuntimeError( + f"{label} {entrypoint} --help failed:\n" + f"{completed.stdout}\n{completed.stderr}" + ) + + print("package smoke passed: wheel, sdist, and three CLI entrypoints") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/minicode/headless.py b/minicode/headless.py index 59536b1..1d2eed9 100644 --- a/minicode/headless.py +++ b/minicode/headless.py @@ -16,6 +16,7 @@ from __future__ import annotations +import argparse import json import os import sys @@ -273,14 +274,30 @@ def run_headless(prompt: str | None = None, allow_edits: bool = False) -> str: pass +def _build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="minicode-headless", + description="Run one MiniCode agent turn without a TTY.", + ) + parser.add_argument( + "--allow-edits", + action="store_true", + help="Auto-approve edits, commands, and out-of-cwd access for this run.", + ) + parser.add_argument( + "prompt", + nargs="*", + help="Prompt to send; reads stdin when omitted.", + ) + return parser + + def main(argv: list[str] | None = None) -> int: """CLI entry point for headless mode.""" - args = list(sys.argv[1:] if argv is None else argv) - # Strip the --allow-edits flag (handled separately); everything else is the prompt. - allow_edits = "--allow-edits" in args - prompt_args = [arg for arg in args if arg != "--allow-edits"] - prompt = " ".join(prompt_args) if prompt_args else None - response = run_headless(prompt, allow_edits=allow_edits) + parser = _build_argument_parser() + parsed = parser.parse_args(list(sys.argv[1:] if argv is None else argv)) + prompt = " ".join(parsed.prompt) if parsed.prompt else None + response = run_headless(prompt, allow_edits=parsed.allow_edits) print(response) return _headless_response_exit_code(response) diff --git a/tests/test_headless.py b/tests/test_headless.py index bcc6fa1..ab4752f 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -169,6 +169,22 @@ def test_headless_main_returns_zero_for_success(monkeypatch, capsys) -> None: assert capsys.readouterr().out.strip() == "OK" +def test_headless_main_help_does_not_load_config(monkeypatch, capsys) -> None: + import minicode.headless + + monkeypatch.setattr( + minicode.headless, + "run_headless", + lambda *args, **kwargs: pytest.fail("--help must not run headless"), + ) + + with pytest.raises(SystemExit) as raised: + minicode.headless.main(["--help"]) + + assert raised.value.code == 0 + assert "usage: minicode-headless" in capsys.readouterr().out + + def test_run_headless_writes_messages_trace_when_requested(monkeypatch, tmp_path: Path) -> None: import minicode.headless diff --git a/tests/test_packaging.py b/tests/test_packaging.py index bb0066e..16a7ae9 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -142,6 +142,9 @@ def test_ci_workflow_runs_release_quality_gates() -> None: assert "AGENTS structure artifact gate" in content assert "Run AGENTS mirror tests" in content assert "StructureCompliance.Test.py" in content + assert "Build and install package artifacts" in content + assert "python -m pip install build" in content + assert "python benchmarks/package_smoke.py" in content mypy_step = content.split("- name: Type check (mypy baseline)", 1)[1] mypy_step = mypy_step.split("- name: Run packaging smoke tests", 1)[0] assert "shell: bash" in mypy_step