dc8a630df3
支持不带前缀的版本标签,保留 Finger 资产随 wheel 交付。发布工具 97 项回归及 SDK 自带资产的场标定录像验证通过。
741 lines
29 KiB
Python
741 lines
29 KiB
Python
"""Tests for the publish gate, the GitHub plan and the PyPI command shape.
|
|
|
|
Nothing here touches the network, ``gh`` or an index: the mutating paths are
|
|
verified through their pure planning functions, which is exactly what the CLI
|
|
executes after the gate passes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
BUILD_ROOT = REPO_ROOT / "build"
|
|
if str(BUILD_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(BUILD_ROOT))
|
|
|
|
from release import github, publish, pypi # noqa: E402
|
|
|
|
|
|
def _wheel(tmp_path: Path, name: str, payload: bytes = b"wheel") -> Path:
|
|
path = tmp_path / name
|
|
path.write_bytes(payload)
|
|
return path
|
|
|
|
|
|
def _report_payload(version: str, wheels: list[Path], **overrides) -> dict:
|
|
payload = {
|
|
"schema": 1,
|
|
"version": version,
|
|
"ok": True,
|
|
"aborted": False,
|
|
"git": {"commit": "a" * 40},
|
|
"options": {"skip_tests": False, "allow_dirty": False},
|
|
"wheels": [
|
|
{"name": wheel.name, "sha256": publish.sha256_file(wheel), "size": wheel.stat().st_size}
|
|
for wheel in wheels
|
|
],
|
|
"steps": [{"name": "wheel-runtime", "ok": True, "data": {
|
|
"records": [{"wheel": wheel.name, "sha256": publish.sha256_file(wheel)} for wheel in wheels]
|
|
}}],
|
|
}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
|
|
# --- gate -------------------------------------------------------------------
|
|
|
|
|
|
def test_gate_passes_for_matching_wheels(tmp_path):
|
|
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
|
problems, warnings = publish.evaluate_gate(
|
|
_report_payload("1.2.3", wheels), "1.2.3", {w.name: w for w in wheels}
|
|
)
|
|
assert problems == []
|
|
assert warnings == []
|
|
|
|
|
|
def test_gate_rejects_version_mismatch(tmp_path):
|
|
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
|
problems, _ = publish.evaluate_gate(
|
|
_report_payload("9.9.9", wheels), "1.2.3", {w.name: w for w in wheels}
|
|
)
|
|
assert any("report is for version" in problem for problem in problems)
|
|
|
|
|
|
def test_gate_rejects_failed_report(tmp_path):
|
|
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
|
problems, _ = publish.evaluate_gate(
|
|
_report_payload("1.2.3", wheels, ok=False), "1.2.3", {w.name: w for w in wheels}
|
|
)
|
|
assert any("result is FAIL" in problem for problem in problems)
|
|
|
|
|
|
def test_gate_rejects_waived_guard_options(tmp_path):
|
|
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
|
payload = _report_payload("1.2.3", wheels, options={"skip_tests": True, "no_runtime": True})
|
|
problems, _ = publish.evaluate_gate(payload, "1.2.3", {w.name: w for w in wheels})
|
|
assert any("--skip-tests" in problem and "--no-runtime" in problem for problem in problems)
|
|
|
|
|
|
def test_gate_rejects_changed_wheel(tmp_path):
|
|
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
|
payload = _report_payload("1.2.3", wheels)
|
|
wheels[0].write_bytes(b"tampered")
|
|
problems, _ = publish.evaluate_gate(payload, "1.2.3", {w.name: w for w in wheels})
|
|
assert any("changed after preflight" in problem for problem in problems)
|
|
|
|
|
|
def test_gate_rejects_arm_wheel_without_runtime_evidence(tmp_path):
|
|
wheel = _wheel(tmp_path, "orisys-1.2.3-cp311-cp311-manylinux_2_28_aarch64.whl")
|
|
payload = _report_payload("1.2.3", [wheel], steps=[])
|
|
problems, _ = publish.evaluate_gate(payload, "1.2.3", {wheel.name: wheel})
|
|
assert any("runtime" in problem and wheel.name in problem for problem in problems)
|
|
|
|
|
|
def test_gate_rejects_runtime_evidence_for_different_bytes(tmp_path):
|
|
wheel = _wheel(tmp_path, "orisys-1.2.3-cp311-cp311-manylinux_2_28_aarch64.whl")
|
|
payload = _report_payload("1.2.3", [wheel])
|
|
payload["steps"][0]["data"]["records"][0]["sha256"] = "stale"
|
|
problems, _ = publish.evaluate_gate(payload, "1.2.3", {wheel.name: wheel})
|
|
assert any("runtime" in problem for problem in problems)
|
|
|
|
|
|
def test_gate_rejects_uncertified_and_missing_wheels(tmp_path):
|
|
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
|
payload = _report_payload("1.2.3", wheels)
|
|
extra = _wheel(tmp_path, "orisys-1.2.3-cp311-cp311-win_amd64.whl")
|
|
problems, _ = publish.evaluate_gate(
|
|
payload, "1.2.3", {w.name: w for w in [*wheels, extra]}
|
|
)
|
|
assert any("not covered by the report" in problem for problem in problems)
|
|
|
|
|
|
def test_gate_warns_on_commit_drift(tmp_path):
|
|
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
|
payload = _report_payload("1.2.3", wheels)
|
|
problems, warnings = publish.evaluate_gate(
|
|
payload, "1.2.3", {w.name: w for w in wheels}, head_commit="b" * 40
|
|
)
|
|
assert problems == []
|
|
assert any("HEAD is" in warning for warning in warnings)
|
|
|
|
|
|
def test_load_gate_requires_a_report(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(publish, "REPO_ROOT", tmp_path, raising=False)
|
|
with pytest.raises(publish.PublishGateError, match="no preflight report"):
|
|
publish.load_gate(tmp_path, "1.2.3")
|
|
|
|
|
|
def test_load_gate_explains_waived_report(tmp_path):
|
|
report_dir = tmp_path / "build" / "release" / "reports" / "1.2.3"
|
|
report_dir.mkdir(parents=True)
|
|
wheel_dir = tmp_path / "SDK" / "1.2.3"
|
|
wheel_dir.mkdir(parents=True)
|
|
wheel = wheel_dir / "orisys-1.2.3-cp310-cp310-win_amd64.whl"
|
|
wheel.write_bytes(b"wheel")
|
|
(report_dir / "preflight.json").write_text(
|
|
json.dumps(_report_payload("1.2.3", [wheel], options={"skip_tests": True})),
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(publish.PublishGateError, match="--skip-tests"):
|
|
publish.load_gate(tmp_path, "1.2.3")
|
|
|
|
|
|
def test_load_gate_returns_certified_wheels(tmp_path):
|
|
report_dir = tmp_path / "build" / "release" / "reports" / "1.2.3"
|
|
report_dir.mkdir(parents=True)
|
|
wheel_dir = tmp_path / "SDK" / "1.2.3"
|
|
wheel_dir.mkdir(parents=True)
|
|
wheel = wheel_dir / "orisys-1.2.3-cp310-cp310-win_amd64.whl"
|
|
wheel.write_bytes(b"wheel")
|
|
(report_dir / "preflight.json").write_text(
|
|
json.dumps(_report_payload("1.2.3", [wheel])), encoding="utf-8"
|
|
)
|
|
gate = publish.load_gate(tmp_path, "1.2.3")
|
|
assert [record.name for record in gate.wheels] == [wheel.name]
|
|
assert gate.wheels[0].sha256 == publish.sha256_file(wheel)
|
|
assert gate.git_commit == "a" * 40
|
|
|
|
|
|
# --- github -----------------------------------------------------------------
|
|
|
|
|
|
def test_github_steps_shape(tmp_path):
|
|
assets = [_wheel(tmp_path, f"orisys-1.2.3-cp3n{n}-cp3n{n}-win_amd64.whl") for n in (0, 1)]
|
|
steps = github.build_github_steps(
|
|
version="1.2.3",
|
|
branch="main",
|
|
tag="v1.2.3",
|
|
assets=assets,
|
|
notes_file=None,
|
|
)
|
|
by_description = {step.description: step.argv for step in steps}
|
|
assert by_description["create tag"] == ["git", "tag", "v1.2.3"]
|
|
assert by_description["push branch"] == ["git", "push", "origin", "main"]
|
|
assert by_description["push tag"] == ["git", "push", "origin", "v1.2.3"]
|
|
|
|
release_argv = by_description["create GitHub Release with assets"]
|
|
assert release_argv[:2] == ["gh", "release"]
|
|
assert "v1.2.3" in release_argv
|
|
assert "--verify-tag" in release_argv
|
|
assert "--latest" in release_argv
|
|
assert release_argv[-2:] == [str(asset) for asset in assets]
|
|
|
|
|
|
def test_github_steps_include_notes_file(tmp_path):
|
|
notes = tmp_path / "notes.md"
|
|
notes.write_text("hello", encoding="utf-8")
|
|
steps = github.build_github_steps(
|
|
version="1.2.3", branch="main", tag="v1.2.3", assets=[], notes_file=notes
|
|
)
|
|
release_argv = next(
|
|
step.argv for step in steps if step.description.startswith("create GitHub Release")
|
|
)
|
|
assert "--notes-file" in release_argv
|
|
assert str(notes) in release_argv
|
|
|
|
|
|
def test_release_notes_list_every_wheel():
|
|
notes = github.build_release_notes(
|
|
"1.2.3", [Path("orisys-1.2.3-cp311-cp311-win_amd64.whl"),
|
|
Path("orisys-1.2.3-cp311-cp311-manylinux_2_28_aarch64.whl")], "c" * 40
|
|
)
|
|
assert "win_amd64.whl" in notes and "aarch64.whl" in notes
|
|
assert "Linux ARM64" in notes
|
|
assert "Linux x86_64" not in notes
|
|
assert "cccccccccccc" in notes
|
|
|
|
|
|
def test_manifest_scope_excludes_internal_docs():
|
|
"""The public repo must not receive our internal process documents."""
|
|
entry = github.manifest_entry()
|
|
docs_entry = next(item for item in entry["sync"] if item["from"] == "docs")
|
|
assert set(docs_entry["include"]) == {"get_started.md", "api.md"}
|
|
assert "publishing.md" not in docs_entry["include"]
|
|
assert "pypi_readme.md" not in docs_entry["include"]
|
|
|
|
|
|
def test_manifest_scope_covers_examples_and_sensor_json_only():
|
|
entry = github.manifest_entry()
|
|
examples_entry = next(item for item in entry["sync"] if item["from"] == "examples")
|
|
# Whole tree (no include list), with caches excluded.
|
|
assert "include" not in examples_entry
|
|
assert set(examples_entry["exclude"]) >= {"__pycache__", "*.pyc"}
|
|
|
|
sensors_entry = next(item for item in entry["sync"] if item["from"] == "sensors")
|
|
assert sensors_entry["include"]
|
|
assert all(
|
|
name.endswith(".json") for name in sensors_entry["include"]
|
|
), "binary sensor assets belong in the wheel, not the public repo"
|
|
|
|
|
|
def test_github_steps_resume_skips_tag_creation(tmp_path):
|
|
"""A tag pushed by an earlier failed run must not block the release upload."""
|
|
assets = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
|
|
steps = github.build_github_steps(
|
|
version="1.2.3",
|
|
branch="main",
|
|
tag="v1.2.3",
|
|
assets=assets,
|
|
notes_file=None,
|
|
create_tag=False,
|
|
)
|
|
descriptions = [step.description for step in steps]
|
|
assert "create tag" not in descriptions
|
|
assert descriptions[-1] == "create GitHub Release with assets"
|
|
# The pushes stay: pushing an up-to-date ref is a safe no-op.
|
|
assert "push branch" in descriptions and "push tag" in descriptions
|
|
|
|
|
|
def test_non_github_remote_is_reported(tmp_path):
|
|
repo = tmp_path / "release"
|
|
repo.mkdir()
|
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, capture_output=True)
|
|
subprocess.run(
|
|
["git", "remote", "add", "origin", "ssh://git@internal.example:24928/orisys-tech/orisys-sdk.git"],
|
|
cwd=repo,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
problem = github.non_github_remote_problem(repo)
|
|
assert problem is not None and "not a GitHub host" in problem
|
|
|
|
subprocess.run(
|
|
["git", "remote", "set-url", "origin", "git@github.com:orisys-tech/orisys-sdk.git"],
|
|
cwd=repo,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
assert github.non_github_remote_problem(repo) is None
|
|
|
|
|
|
def _synthetic_publish_layout(tmp_path: Path, version: str, *, tag_exists: bool):
|
|
"""Build a fake SDK checkout + release repo and point the module at them."""
|
|
sdk = tmp_path / "sdk"
|
|
wheel_dir = sdk / "SDK" / version
|
|
wheel_dir.mkdir(parents=True)
|
|
wheels = [wheel_dir / f"orisys-{version}-cp310-cp310-win_amd64.whl"]
|
|
wheels[0].write_bytes(b"wheel")
|
|
|
|
report_dir = sdk / "build" / "release" / "reports" / version
|
|
report_dir.mkdir(parents=True)
|
|
(report_dir / "preflight.json").write_text(
|
|
json.dumps(_report_payload(version, wheels)), encoding="utf-8"
|
|
)
|
|
|
|
release = tmp_path / "orisys-sdk-release"
|
|
release.mkdir()
|
|
for command in (
|
|
["git", "init", "-q"],
|
|
["git", "config", "user.email", "test@example.invalid"],
|
|
["git", "config", "user.name", "test"],
|
|
["git", "remote", "add", "origin", "git@github.com:orisys-tech/orisys-sdk.git"],
|
|
):
|
|
subprocess.run(command, cwd=release, check=True, capture_output=True)
|
|
(release / "README.md").write_text("x", encoding="utf-8")
|
|
subprocess.run(["git", "add", "-A"], cwd=release, check=True, capture_output=True)
|
|
subprocess.run(["git", "commit", "-qm", "init"], cwd=release, check=True, capture_output=True)
|
|
if tag_exists:
|
|
subprocess.run(["git", "tag", f"v{version}"], cwd=release, check=True, capture_output=True)
|
|
|
|
manifest = sdk / "tools" / "downstream_sync_manifest.json"
|
|
manifest.parent.mkdir(parents=True)
|
|
manifest.write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"downstreams": [
|
|
{"name": "orisys-sdk-release", "path": "../orisys-sdk-release", "sync": []}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return sdk, release, wheels[0]
|
|
|
|
|
|
def test_publish_dry_run_resumes_when_tag_already_exists(tmp_path, monkeypatch, capsys):
|
|
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.3", tag_exists=True)
|
|
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
|
|
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
|
|
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
|
|
|
|
exit_code = github.publish(
|
|
github.Options(version="1.2.3", apply=False, skip_fetch=True)
|
|
)
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 0
|
|
assert "already exist" in output and "skipping tag creation" in output
|
|
assert "git tag v1.2.3" not in output
|
|
assert "gh release create v1.2.3" in output
|
|
|
|
|
|
@pytest.mark.parametrize("tag", [None, "1.2.4"])
|
|
def test_publish_dry_run_creates_tag_when_absent(tmp_path, monkeypatch, capsys, tag):
|
|
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.4", tag_exists=False)
|
|
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
|
|
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
|
|
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
|
|
|
|
exit_code = github.publish(
|
|
github.Options(version="1.2.4", apply=False, skip_fetch=True, tag=tag)
|
|
)
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 0
|
|
expected_tag = tag or "v1.2.4"
|
|
assert f"git tag {expected_tag}" in output
|
|
assert f"gh release create {expected_tag} --verify-tag --title {expected_tag}" in output
|
|
|
|
|
|
def test_publish_rejects_tag_for_another_version(capsys):
|
|
assert github.publish(github.Options(version="1.2.4", tag="1.2.5")) == 1
|
|
assert "tag must be" in capsys.readouterr().out
|
|
|
|
|
|
def test_publish_refuses_when_release_already_exists(tmp_path, monkeypatch, capsys):
|
|
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.5", tag_exists=True)
|
|
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
|
|
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
|
|
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: True)
|
|
|
|
exit_code = github.publish(
|
|
github.Options(version="1.2.5", apply=False, skip_fetch=True)
|
|
)
|
|
assert exit_code == 1
|
|
assert "already exists; nothing to publish" in capsys.readouterr().out
|
|
|
|
|
|
def _synthetic_report_layout(tmp_path: Path, version: str) -> tuple[Path, Path]:
|
|
"""A fake SDK checkout with wheels and a passing preflight report."""
|
|
sdk = tmp_path / "sdk"
|
|
wheel_dir = sdk / "SDK" / version
|
|
wheel_dir.mkdir(parents=True)
|
|
wheel = wheel_dir / f"orisys-{version}-cp310-cp310-win_amd64.whl"
|
|
wheel.write_bytes(b"wheel")
|
|
|
|
report_dir = sdk / "build" / "release" / "reports" / version
|
|
report_dir.mkdir(parents=True)
|
|
(report_dir / "preflight.json").write_text(
|
|
json.dumps(_report_payload(version, [wheel])), encoding="utf-8"
|
|
)
|
|
return sdk, wheel
|
|
|
|
|
|
def test_upload_phases_default_is_testpypi_only():
|
|
assert pypi.upload_phases(pypi.Options(version="1.2.3")) == ["testpypi"]
|
|
|
|
|
|
def test_upload_phases_confirm_pypi_adds_the_real_index():
|
|
options = pypi.Options(version="1.2.3", confirm_pypi=True)
|
|
assert pypi.upload_phases(options) == ["testpypi", "pypi"]
|
|
|
|
|
|
def test_upload_phases_pypi_only_skips_the_rehearsal():
|
|
# The relevant case: a production token cannot be used against TestPyPI.
|
|
assert pypi.upload_phases(pypi.Options(version="1.2.3", pypi_only=True)) == ["pypi"]
|
|
|
|
|
|
def test_pypi_only_dry_run_targets_pypi_only(tmp_path, monkeypatch, capsys):
|
|
sdk, wheel = _synthetic_report_layout(tmp_path, "1.2.3")
|
|
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(pypi, "index_state", lambda index, **k: pypi.IndexState(versions=[]))
|
|
from release import auth
|
|
|
|
auth.write_token("pypi", "pypi-" + "A" * 30, path=tmp_path / ".pypirc")
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
|
|
exit_code = pypi.publish(
|
|
pypi.Options(version="1.2.3", apply=False, pypi_only=True, skip_check=True)
|
|
)
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 0
|
|
assert "--pypi-only" in output
|
|
assert "upload 1 wheel(s) to pypi" in output
|
|
assert "testpypi" not in output
|
|
|
|
|
|
def test_pypi_only_and_confirm_pypi_are_mutually_exclusive():
|
|
from release.cli import _cmd_pypi
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_cmd_pypi(["--version", "1.2.3", "--pypi-only", "--confirm-pypi"])
|
|
assert excinfo.value.code == 2
|
|
|
|
|
|
def test_manifest_checks_use_the_python_placeholder():
|
|
entry = github.manifest_entry()
|
|
for command in entry["checks"]:
|
|
assert command.startswith("{python} "), command
|
|
|
|
|
|
# --- pypi -------------------------------------------------------------------
|
|
|
|
|
|
def test_twine_upload_argv_targets_the_repository():
|
|
argv = pypi.twine_upload_argv([Path("a.whl")], "testpypi")
|
|
assert argv[:3] == [sys.executable, "-m", "twine"]
|
|
assert "--repository" in argv and "testpypi" in argv
|
|
assert argv[-1] == "a.whl"
|
|
|
|
|
|
def test_twine_upload_argv_defaults_to_pypi():
|
|
argv = pypi.twine_upload_argv([Path("a.whl")], None)
|
|
assert "--repository" not in argv
|
|
|
|
|
|
def test_credentials_hint_points_at_the_setup_command(tmp_path, monkeypatch):
|
|
from release import auth
|
|
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
|
|
hint = pypi.credentials_hint("pypi")
|
|
assert hint is not None
|
|
assert "release auth --set-token pypi" in hint
|
|
|
|
|
|
def test_credentials_hint_for_testpypi_mentions_pypi_only(tmp_path, monkeypatch):
|
|
from release import auth
|
|
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
|
|
hint = pypi.credentials_hint("testpypi")
|
|
assert hint is not None
|
|
assert "--set-token testpypi" in hint
|
|
assert "--pypi-only" in hint
|
|
|
|
|
|
def test_credentials_hint_silent_when_configured(tmp_path, monkeypatch):
|
|
from release import auth
|
|
|
|
path = tmp_path / ".pypirc"
|
|
auth.write_token("pypi", "pypi-" + "A" * 30, path=path)
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: path)
|
|
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
|
|
assert pypi.credentials_hint("pypi") is None
|
|
|
|
|
|
def test_index_state_parses_versions(monkeypatch):
|
|
class Response:
|
|
def __init__(self, payload):
|
|
self._payload = json.dumps(payload).encode()
|
|
|
|
def read(self, *args):
|
|
return self._payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
monkeypatch.setattr(
|
|
pypi.urllib.request,
|
|
"urlopen",
|
|
lambda *a, **k: Response({"releases": {"1.2.3": [{"filename": "a"}], "0.0.0": []}}),
|
|
)
|
|
state = pypi.index_state("pypi")
|
|
assert state.known
|
|
assert state.versions == ["1.2.3"]
|
|
|
|
|
|
def test_index_state_reports_lookup_failure(monkeypatch):
|
|
def _raise(*args, **kwargs):
|
|
raise urllib.error.URLError("offline")
|
|
|
|
monkeypatch.setattr(pypi.urllib.request, "urlopen", _raise)
|
|
state = pypi.index_state("pypi")
|
|
assert not state.known
|
|
assert "offline" in state.error
|
|
|
|
|
|
def test_index_files_returns_none_when_version_absent(monkeypatch):
|
|
class Response:
|
|
def read(self, *args):
|
|
return json.dumps({"releases": {}}).encode()
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
monkeypatch.setattr(pypi.urllib.request, "urlopen", lambda *a, **k: Response())
|
|
assert pypi.index_files("pypi", "1.2.3") == (None, None)
|
|
|
|
|
|
def test_index_files_distinguishes_lookup_failure_from_absence(monkeypatch):
|
|
"""A network hiccup must not be reported as "not published yet"."""
|
|
|
|
def _raise(*args, **kwargs):
|
|
raise urllib.error.URLError("connection reset")
|
|
|
|
monkeypatch.setattr(pypi.urllib.request, "urlopen", _raise)
|
|
filenames, error = pypi.index_files("testpypi", "1.2.3")
|
|
assert filenames is None
|
|
assert error is not None and "connection reset" in error
|
|
|
|
|
|
def test_index_files_returns_filenames(monkeypatch):
|
|
class Response:
|
|
def read(self, *args):
|
|
return json.dumps(
|
|
{"releases": {"1.2.3": [{"filename": "b.whl"}, {"filename": "a.whl"}]}}
|
|
).encode()
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
monkeypatch.setattr(pypi.urllib.request, "urlopen", lambda *a, **k: Response())
|
|
assert pypi.index_files("pypi", "1.2.3") == (["a.whl", "b.whl"], None)
|
|
|
|
|
|
def test_wait_for_release_retries_until_indexing_finishes(monkeypatch):
|
|
"""TestPyPI lags the upload response; the check must poll, not give up."""
|
|
calls = {"n": 0}
|
|
slept: list[float] = []
|
|
|
|
def _fake(index, version, *, timeout=20.0):
|
|
calls["n"] += 1
|
|
if calls["n"] < 3:
|
|
return None, None
|
|
return ["a.whl"], None
|
|
|
|
monkeypatch.setattr(pypi, "index_files", _fake)
|
|
filenames, error = pypi.wait_for_release(
|
|
"testpypi", "1.2.3", attempts=5, delay=1.0, sleep=slept.append
|
|
)
|
|
assert filenames == ["a.whl"]
|
|
assert error is None
|
|
assert calls["n"] == 3
|
|
assert slept == [1.0, 1.0]
|
|
|
|
|
|
def test_wait_for_release_reports_the_last_error(monkeypatch):
|
|
def _fake(index, version, *, timeout=20.0):
|
|
return None, "HTTP 503"
|
|
|
|
monkeypatch.setattr(pypi, "index_files", _fake)
|
|
filenames, error = pypi.wait_for_release(
|
|
"pypi", "1.2.3", attempts=2, delay=0.0, sleep=lambda _: None
|
|
)
|
|
assert filenames is None
|
|
assert error == "HTTP 503"
|
|
|
|
|
|
def test_existing_release_state_satisfied_conflict_absent(monkeypatch, capsys):
|
|
expected = {"orisys-1.2.3-cp310-cp310-win_amd64.whl"}
|
|
monkeypatch.setattr(pypi, "index_files", lambda i, v, **k: (None, None))
|
|
assert pypi.existing_release_state("pypi", "1.2.3", expected) == "absent"
|
|
|
|
monkeypatch.setattr(pypi, "index_files", lambda i, v, **k: (sorted(expected), None))
|
|
assert pypi.existing_release_state("pypi", "1.2.3", expected) == "satisfied"
|
|
|
|
monkeypatch.setattr(
|
|
pypi, "index_files", lambda i, v, **k: (["orisys-1.2.3-cp311-cp311-win_amd64.whl"], None)
|
|
)
|
|
assert pypi.existing_release_state("pypi", "1.2.3", expected) == "conflict"
|
|
output = capsys.readouterr().out
|
|
assert "different file set" in output and "missing" in output
|
|
|
|
|
|
def test_publish_rerun_after_success_is_not_a_failure(tmp_path, monkeypatch, capsys):
|
|
"""Re-running a finished phase must succeed, not look like a new error."""
|
|
sdk, wheel = _synthetic_report_layout(tmp_path, "1.2.3")
|
|
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
|
|
from release import auth
|
|
|
|
auth.write_token("pypi", "pypi-" + "A" * 30, path=tmp_path / ".pypirc")
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
monkeypatch.setattr(
|
|
pypi, "index_files", lambda i, v, **k: ([wheel.name], None)
|
|
)
|
|
|
|
exit_code = pypi.publish(
|
|
pypi.Options(version="1.2.3", apply=True, pypi_only=True, skip_check=True)
|
|
)
|
|
output = capsys.readouterr().out
|
|
assert exit_code == 0
|
|
assert "already serves orisys 1.2.3" in output
|
|
assert "every requested index already has this release" in output
|
|
|
|
|
|
def test_publish_aborts_on_a_conflicting_existing_release(tmp_path, monkeypatch, capsys):
|
|
sdk, wheel = _synthetic_report_layout(tmp_path, "1.2.3")
|
|
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
|
|
from release import auth
|
|
|
|
auth.write_token("pypi", "pypi-" + "A" * 30, path=tmp_path / ".pypirc")
|
|
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
|
|
monkeypatch.setattr(pypi, "index_files", lambda i, v, **k: (["other.whl"], None))
|
|
|
|
exit_code = pypi.publish(
|
|
pypi.Options(version="1.2.3", apply=True, pypi_only=True, skip_check=True)
|
|
)
|
|
assert exit_code == 1
|
|
assert "different file set" in capsys.readouterr().out
|
|
|
|
|
|
def test_index_json_urls_cover_both_indexes():
|
|
assert pypi.INDEX_JSON["testpypi"].startswith("https://test.pypi.org/")
|
|
assert pypi.INDEX_JSON["pypi"].startswith("https://pypi.org/")
|
|
|
|
|
|
@pytest.mark.parametrize("abi", ["cp310", "cp311", "cp312"])
|
|
@pytest.mark.parametrize("platform", ["win_amd64", "manylinux_2_28_x86_64", "manylinux_2_28_aarch64"])
|
|
def test_fetch_back_targets_every_abi_and_platform(tmp_path, monkeypatch, abi, platform):
|
|
wheel = _wheel(tmp_path, f"orisys-1.2.3-{abi}-{abi}-{platform}.whl")
|
|
monkeypatch.setenv("PIP_EXTRA_INDEX_URL", "https://fallback.invalid/simple")
|
|
|
|
def run(argv, **kwargs):
|
|
assert "PIP_EXTRA_INDEX_URL" not in kwargs["env"]
|
|
assert kwargs["env"]["PIP_CONFIG_FILE"] == pypi.os.devnull
|
|
assert argv[argv.index("--python-version") + 1] == abi[2:]
|
|
assert argv[argv.index("--abi") + 1] == abi
|
|
assert argv[argv.index("--platform") + 1] == platform
|
|
assert argv[argv.index("--index-url") + 1] == pypi.INDEX_SIMPLE["testpypi"]
|
|
assert "--extra-index-url" not in argv
|
|
target = Path(argv[argv.index("--dest") + 1])
|
|
target.mkdir()
|
|
(target / wheel.name).write_bytes(wheel.read_bytes())
|
|
return subprocess.CompletedProcess(argv, 0)
|
|
|
|
monkeypatch.setattr(pypi.subprocess, "run", run)
|
|
assert pypi._fetch_back("testpypi", "1.2.3", wheel) == 0
|
|
|
|
|
|
def test_fetch_back_rejects_modified_download(tmp_path, monkeypatch):
|
|
wheel = _wheel(tmp_path, "orisys-1.2.3-cp311-cp311-manylinux_2_28_aarch64.whl")
|
|
|
|
def run(argv, **kwargs):
|
|
target = Path(argv[argv.index("--dest") + 1])
|
|
target.mkdir()
|
|
(target / wheel.name).write_bytes(b"tampered")
|
|
return subprocess.CompletedProcess(argv, 0)
|
|
|
|
monkeypatch.setattr(pypi.subprocess, "run", run)
|
|
with pytest.raises(publish.PublishGateError, match="different bytes"):
|
|
pypi._fetch_back("pypi", "1.2.3", wheel)
|
|
|
|
|
|
def test_pypi_upload_and_fetch_include_arm_wheels(tmp_path, monkeypatch):
|
|
sdk, original = _synthetic_report_layout(tmp_path, "1.2.3")
|
|
wheels = [original, _wheel(original.parent, "orisys-1.2.3-cp311-cp311-manylinux_2_28_aarch64.whl")]
|
|
report = sdk / "build/release/reports/1.2.3/preflight.json"
|
|
report.write_text(json.dumps(_report_payload("1.2.3", wheels)), encoding="utf-8")
|
|
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(pypi, "_ensure_credentials", lambda *a, **k: True)
|
|
monkeypatch.setattr(pypi, "index_files", lambda *a, **k: (None, None))
|
|
monkeypatch.setattr(pypi, "index_state", lambda *a, **k: pypi.IndexState(versions=[]))
|
|
monkeypatch.setattr(pypi, "wait_for_release", lambda *a, **k: ([w.name for w in wheels], None))
|
|
uploaded, fetched = [], []
|
|
monkeypatch.setattr(pypi, "_upload", lambda files, index: uploaded.extend(files) or 0)
|
|
monkeypatch.setattr(pypi, "_fetch_back", lambda index, version, wheel: fetched.append(wheel) or 0)
|
|
assert pypi.publish(pypi.Options(version="1.2.3", apply=True, pypi_only=True, skip_check=True)) == 0
|
|
assert set(uploaded) == set(fetched) == set(wheels)
|
|
|
|
|
|
def test_github_dry_run_includes_arm_asset_and_generated_notes(tmp_path, monkeypatch, capsys):
|
|
sdk, release, original = _synthetic_publish_layout(tmp_path, "1.2.3", tag_exists=False)
|
|
wheels = [original, _wheel(original.parent, "orisys-1.2.3-cp311-cp311-manylinux_2_28_aarch64.whl")]
|
|
report = sdk / "build/release/reports/1.2.3/preflight.json"
|
|
report.write_text(json.dumps(_report_payload("1.2.3", wheels)), encoding="utf-8")
|
|
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools/downstream_sync_manifest.json")
|
|
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
|
|
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
|
|
assert github.publish(github.Options(version="1.2.3", skip_fetch=True)) == 0
|
|
output = capsys.readouterr().out
|
|
assert wheels[1].name in output
|
|
assert "Linux ARM64" in output
|
|
assert "--notes-file" in output
|
|
assert not (release / "release").exists()
|
|
assert not (report.parent / "github-release-notes.md").exists()
|
|
|
|
|
|
def test_inconclusive_testpypi_download_stops_before_production(tmp_path, monkeypatch, capsys):
|
|
sdk, wheel = _synthetic_report_layout(tmp_path, "1.2.3")
|
|
monkeypatch.setattr(pypi, "REPO_ROOT", sdk)
|
|
monkeypatch.setattr(pypi, "_ensure_credentials", lambda *a, **k: True)
|
|
monkeypatch.setattr(pypi, "index_files", lambda *a, **k: (None, None))
|
|
monkeypatch.setattr(pypi, "index_state", lambda *a, **k: pypi.IndexState(versions=[]))
|
|
monkeypatch.setattr(pypi, "wait_for_release", lambda *a, **k: ([wheel.name], None))
|
|
uploaded = []
|
|
monkeypatch.setattr(pypi, "_upload", lambda files, index: uploaded.append(index) or 0)
|
|
monkeypatch.setattr(pypi, "_fetch_back", lambda *a: 1)
|
|
assert pypi.publish(pypi.Options(version="1.2.3", apply=True, confirm_pypi=True, skip_check=True)) == 0
|
|
assert uploaded == ["testpypi"]
|
|
assert "later indexes were not uploaded" in capsys.readouterr().out
|