-
Notifications
You must be signed in to change notification settings - Fork 0
feat(s0): self-contained wheel + PyPI substrate (PR1) #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d9c7693
e1f3190
34b28a1
5d90611
b704d54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| name: publish | ||
|
|
||
| on: | ||
| push: | ||
| tags: ["v*"] | ||
|
|
||
| jobs: | ||
| build: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: "3.12" | ||
|
|
||
| - name: Tag matches pyproject version | ||
| run: | | ||
| tag="${GITHUB_REF_NAME#v}" | ||
| version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" | ||
| if [ "$tag" != "$version" ]; then | ||
| echo "tag v$tag != pyproject version $version" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| - name: Build sdist + wheel | ||
| run: | | ||
| python -m pip install --upgrade build | ||
| python -m build | ||
|
|
||
| - name: Smoke-test the wheel from a temp dir | ||
| run: | | ||
| python -m venv /tmp/smoke | ||
| /tmp/smoke/bin/pip install --no-index dist/*.whl | ||
| cd /tmp | ||
| /tmp/smoke/bin/loop-engineer --version | ||
| /tmp/smoke/bin/loop scaffold /tmp/smoke-loop | ||
| /tmp/smoke/bin/loop doctor /tmp/smoke-loop | ||
| /tmp/smoke/bin/loop inspect /tmp/smoke-loop || true | ||
|
|
||
| - uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: dist | ||
| path: dist/ | ||
|
|
||
| publish: | ||
| needs: build | ||
| runs-on: ubuntu-latest | ||
| environment: pypi | ||
| permissions: | ||
| id-token: write | ||
| steps: | ||
| - uses: actions/download-artifact@v4 | ||
| with: | ||
| name: dist | ||
| path: dist/ | ||
|
|
||
| - uses: pypa/gh-action-pypi-publish@release/v1 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,7 +77,7 @@ def _print_json(report: dict) -> int: | |
|
|
||
| def _run_metrics(argv: list[str]) -> int: | ||
| """`metrics [--baseline] <target>` — parses its own flag, then delegates to | ||
| scripts/metrics.py (imported repo-relative, the QW8 editable-install path).""" | ||
| scripts/metrics.py (resolved bundle-first, repo-relative fallback).""" | ||
| unknown = [a for a in argv if a.startswith("-") and a != "--baseline"] | ||
| if unknown: | ||
| print(f"metrics: unknown option: {unknown[0]}", file=sys.stderr) | ||
|
|
@@ -96,7 +96,9 @@ def _run_metrics(argv: list[str]) -> int: | |
| file=sys.stderr, | ||
| ) | ||
| return 2 | ||
| scripts_dir = Path(__file__).resolve().parent.parent / "scripts" | ||
| from ._resources import tools_dir | ||
|
|
||
| scripts_dir = tools_dir() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the wheel path is selected here, the imported Useful? React with 👍 / 👎. |
||
| sys.path.insert(0, str(scripts_dir)) | ||
| import metrics # type: ignore | ||
|
|
||
|
|
@@ -159,7 +161,9 @@ def main(argv: list[str] | None = None) -> int: | |
| # command == "inspect": keep the historical inspector script as the scoring | ||
| # UI over the same contract artifacts; import lazily to avoid making | ||
| # scripts/ a package. | ||
| scripts_dir = Path(__file__).resolve().parent.parent / "scripts" | ||
| from ._resources import tools_dir | ||
|
|
||
| scripts_dir = tools_dir() | ||
| sys.path.insert(0, str(scripts_dir)) | ||
| import inspect_loop # type: ignore | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| """Bundle-first resource resolution (S0). | ||
|
|
||
| A built wheel carries schemas/, templates/, and the CLI-needed tool scripts as | ||
| package data under loop/_bundle/ (see [tool.hatch.build.targets.wheel.force-include] | ||
| in pyproject.toml). An editable install / repo checkout has no _bundle, so each | ||
| resolver falls back to the historical repo-relative layout. Wheels install as | ||
| real directories, so Traversable -> Path is safe here. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from importlib import resources | ||
| from pathlib import Path | ||
|
|
||
| _REPO_FALLBACKS = {"schemas": "schemas", "templates": "templates", "tools": "scripts"} | ||
|
|
||
|
|
||
| def _bundle_root() -> Path | None: | ||
| try: | ||
| return Path(str(resources.files("loop") / "_bundle")) | ||
| except Exception: | ||
| return None | ||
|
|
||
|
|
||
| def _data_dir(kind: str) -> Path: | ||
| root = _bundle_root() | ||
| if root is not None: | ||
| bundled = root / kind | ||
| if bundled.is_dir(): | ||
| return bundled | ||
| return Path(__file__).resolve().parent.parent / _REPO_FALLBACKS[kind] | ||
|
|
||
|
|
||
| def schemas_dir() -> Path: | ||
| return _data_dir("schemas") | ||
|
|
||
|
|
||
| def templates_dir() -> Path: | ||
| return _data_dir("templates") | ||
|
|
||
|
|
||
| def tools_dir() -> Path: | ||
| return _data_dir("tools") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| """PR1/S0: resource resolution must be importlib.resources-first (wheel) with the | ||
| repo-relative checkout as the editable-install fallback. In this checkout no | ||
| loop/_bundle exists, so every resolver must land on the repo directories.""" | ||
|
|
||
| from pathlib import Path | ||
|
|
||
| REPO_ROOT = Path(__file__).resolve().parent.parent | ||
|
|
||
|
|
||
| def test_repo_checkout_resolves_to_repo_dirs(): | ||
| from loop import _resources | ||
|
|
||
| assert _resources.schemas_dir() == REPO_ROOT / "schemas" | ||
| assert _resources.templates_dir() == REPO_ROOT / "templates" | ||
| assert _resources.tools_dir() == REPO_ROOT / "scripts" | ||
|
|
||
|
|
||
| def test_resolved_dirs_hold_the_expected_artifacts(): | ||
| from loop import _resources | ||
|
|
||
| assert (_resources.schemas_dir() / "terminal.schema.json").is_file() | ||
| assert (_resources.templates_dir() / "manifest.yaml.tmpl").is_file() | ||
| for tool in ("inspect_loop.py", "metrics.py", "holdout_gate.py", "anticheat_scan.py"): | ||
| assert (_resources.tools_dir() / tool).is_file() | ||
|
|
||
|
|
||
| def test_bundle_wins_when_present(tmp_path, monkeypatch): | ||
| """When loop/_bundle/<kind> exists (the wheel layout), it wins over the repo path.""" | ||
| from loop import _resources | ||
|
|
||
| bundle = tmp_path / "_bundle" / "schemas" | ||
| bundle.mkdir(parents=True) | ||
| monkeypatch.setattr(_resources, "_bundle_root", lambda: tmp_path / "_bundle") | ||
| assert _resources.schemas_dir() == bundle |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| # scripts/test_wheel_selfcontained.py | ||
| """S0 acceptance: a built wheel must be self-contained. Build the wheel, install | ||
| it into a fresh venv, and run scaffold/doctor/inspect from a temp cwd where the | ||
| repo checkout is not importable. Env-guarded: building needs pip + network for | ||
| the hatchling backend, so this skips in offline/pip-less local envs; in CI a | ||
| wheel build failure fails the test rather than skipping it. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| import venv | ||
| import zipfile | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
| REPO_ROOT = Path(__file__).resolve().parent.parent | ||
|
|
||
|
|
||
| def _pip_available() -> bool: | ||
| proc = subprocess.run( | ||
| [sys.executable, "-m", "pip", "--version"], capture_output=True, text=True | ||
| ) | ||
| return proc.returncode == 0 | ||
|
|
||
|
|
||
| pytestmark = pytest.mark.skipif( | ||
| not _pip_available(), reason="pip unavailable in this interpreter (wheel build env guard)" | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture(scope="module") | ||
| def wheel_env(tmp_path_factory): | ||
| tmp = tmp_path_factory.mktemp("wheel") | ||
| build = subprocess.run( | ||
| [sys.executable, "-m", "pip", "wheel", "--no-deps", "-w", str(tmp), str(REPO_ROOT)], | ||
| capture_output=True, text=True, | ||
| ) | ||
| if build.returncode != 0: | ||
| if os.environ.get("CI"): | ||
| pytest.fail(f"wheel build failed in CI: {build.stderr[-1500:]}") | ||
| pytest.skip(f"wheel build unavailable here (offline?): {build.stderr[-400:]}") | ||
| wheel = next(tmp.glob("loop_engineer-*.whl")) | ||
|
|
||
| names = zipfile.ZipFile(wheel).namelist() | ||
| for expected in ( | ||
| "loop/_bundle/schemas/", | ||
| "loop/_bundle/templates/", | ||
| "loop/_bundle/tools/", | ||
| ): | ||
| assert any(n.startswith(expected) for n in names), f"wheel missing {expected}" | ||
|
|
||
| venv_dir = tmp / "venv" | ||
| venv.EnvBuilder(with_pip=True).create(venv_dir) | ||
| py = venv_dir / ("Scripts" if sys.platform == "win32" else "bin") / "python" | ||
| install = subprocess.run( | ||
| [str(py), "-m", "pip", "install", "--no-index", str(wheel)], | ||
| capture_output=True, text=True, | ||
| ) | ||
| assert install.returncode == 0, install.stderr | ||
| return py | ||
|
|
||
|
|
||
| def _run(py: Path, args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: | ||
| # cwd is OUTSIDE the repo, so the checkout is absent from sys.path. | ||
| return subprocess.run([str(py), "-m", "loop", *args], cwd=cwd, capture_output=True, text=True) | ||
|
|
||
|
|
||
| def test_scaffold_doctor_inspect_from_wheel_only(wheel_env, tmp_path): | ||
| workspace = tmp_path / "fresh-loop" | ||
|
|
||
| scaffolded = _run(wheel_env, ["scaffold", str(workspace)], cwd=tmp_path) | ||
| assert scaffolded.returncode == 0, scaffolded.stderr | ||
|
|
||
| doctored = _run(wheel_env, ["doctor", str(workspace)], cwd=tmp_path) | ||
| assert doctored.returncode == 0, doctored.stdout + doctored.stderr | ||
| assert json.loads(doctored.stdout)["ok"] is True | ||
|
|
||
| inspected = _run(wheel_env, ["inspect", str(workspace)], cwd=tmp_path) | ||
| report = json.loads(inspected.stdout) | ||
| assert report["verdict"] in ("weak", "ok", "strong") | ||
|
|
||
|
|
||
| def test_both_console_scripts_are_installed(wheel_env, tmp_path): | ||
| bindir = wheel_env.parent | ||
| for name in ("loop", "loop-engineer"): | ||
| exe = bindir / name | ||
| proc = subprocess.run([str(exe), "--version"], cwd=tmp_path, capture_output=True, text=True) | ||
| assert proc.returncode == 0, f"{name}: {proc.stderr}" | ||
| assert proc.stdout.strip() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the tag publish workflow this masks every
loop inspectfailure, including the exact missing-bundled-script/import errors the wheel smoke test is meant to catch. The scaffolded contract currently produces a successful inspect exit, so on av*tag this|| truecan let a broken wheel artifact upload and publish instead of stopping the release.Useful? React with 👍 / 👎.