fixed testpypi upload

This commit is contained in:
ykcheungab
2026-09-12 17:34:46 +08:00
parent 94f38bc389
commit 488c8b0bc1
7 changed files with 1173 additions and 68 deletions
+387
View File
@@ -0,0 +1,387 @@
"""Tests for PyPI credential setup (~/.pypirc handling).
The writer does text surgery to preserve the explanatory comments in the file, so
these tests check the result of that surgery rather than trusting it. No test
touches the real ``~/.pypirc``: every path is injected.
"""
from __future__ import annotations
import sys
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 auth # noqa: E402
TOKEN = "pypi-" + "A" * 30
COMMENTED_TEMPLATE = """\
# keep me: why this file exists
# second comment line
[distutils]
index-servers =
pypi
[pypi]
repository = https://upload.pypi.org/legacy/
username = __token__
password = pypi-PASTE-YOUR-PRODUCTION-TOKEN-HERE
# [testpypi] commented out on purpose
# password = pypi-OLD
"""
def test_parse_pypirc_reads_sections_and_ignores_comments():
sections = auth.parse_pypirc(COMMENTED_TEMPLATE)
assert sections["pypi"]["username"] == "__token__"
assert "testpypi" not in sections, "commented-out sections must stay inactive"
def test_placeholder_is_detected_as_not_configured(tmp_path):
path = tmp_path / ".pypirc"
path.write_text(COMMENTED_TEMPLATE, encoding="utf-8")
state = auth.credential_state("pypi", path=path, env={})
assert not state.configured
assert "placeholder" in state.problem
def test_missing_file_is_reported(tmp_path):
state = auth.credential_state("pypi", path=tmp_path / "nope", env={})
assert not state.configured
assert "does not exist" in state.problem
def test_env_variables_count_as_configured(tmp_path):
state = auth.credential_state(
"pypi", path=tmp_path / "nope", env={"TWINE_PASSWORD": "pypi-x"}
)
assert state.configured
assert state.source == "environment variables"
def test_wrong_username_is_rejected(tmp_path):
path = tmp_path / ".pypirc"
path.write_text(
"[pypi]\nusername = someone@example.com\npassword = pypi-real-token-value\n",
encoding="utf-8",
)
state = auth.credential_state("pypi", path=path, env={})
assert not state.configured
assert "__token__" in state.problem
def test_write_token_replaces_placeholder_and_keeps_comments(tmp_path):
path = tmp_path / ".pypirc"
path.write_text(COMMENTED_TEMPLATE, encoding="utf-8")
auth.write_token("pypi", TOKEN, path=path)
text = path.read_text(encoding="utf-8")
assert "# keep me: why this file exists" in text
assert "# [testpypi] commented out on purpose" in text
assert f"password = {TOKEN}" in text
assert "PASTE-YOUR-PRODUCTION-TOKEN-HERE" not in text
assert text.count("[pypi]") == 1
assert auth.credential_state("pypi", path=path, env={}).configured
def test_write_token_adds_missing_section_and_index_servers(tmp_path):
path = tmp_path / ".pypirc"
path.write_text(COMMENTED_TEMPLATE, encoding="utf-8")
auth.write_token("testpypi", TOKEN, path=path)
text = path.read_text(encoding="utf-8")
assert "[testpypi]" in text
assert auth.index_servers(text) == {"pypi", "testpypi"}
assert auth.credential_state("testpypi", path=path, env={}).configured
# The production entry must be untouched.
assert "PASTE-YOUR-PRODUCTION-TOKEN-HERE" in text
def test_write_token_creates_file_when_absent(tmp_path):
path = tmp_path / "nested" / ".pypirc"
auth.write_token("pypi", TOKEN, path=path)
assert path.is_file()
text = path.read_text(encoding="utf-8")
assert "index-servers" in text
assert auth.credential_state("pypi", path=path, env={}).configured
def test_write_token_updates_existing_password_in_place(tmp_path):
path = tmp_path / ".pypirc"
auth.write_token("pypi", TOKEN, path=path)
replacement = "pypi-" + "B" * 30
auth.write_token("pypi", replacement, path=path)
text = path.read_text(encoding="utf-8")
assert f"password = {replacement}" in text
assert TOKEN not in text
assert text.count("password") == 1
def test_upsert_token_does_not_leak_into_the_next_section():
text = "[pypi]\nusername = __token__\n\n[testpypi]\nusername = __token__\n"
result = auth.upsert_token(text, "pypi", TOKEN)
pypi_block, testpypi_block = result.split("[testpypi]")
assert TOKEN in pypi_block
assert TOKEN not in testpypi_block
def test_validate_token_rejects_empty_and_whitespace():
assert auth.validate_token("")
assert auth.validate_token("pypi-abc def")
assert not auth.validate_token(TOKEN)
def test_token_warnings_flag_suspicious_pastes():
assert any("pypi-" in warning for warning in auth.token_warnings("not-a-token"))
assert any("too short" in warning for warning in auth.token_warnings("pypi-abc"))
assert auth.token_warnings(TOKEN) == []
def test_prompt_never_echoes_the_token(capsys):
token = auth.prompt_for_token("pypi", prompt_fn=lambda label: TOKEN)
captured = capsys.readouterr()
assert token == TOKEN
assert TOKEN not in captured.out
assert "hidden" in captured.out
def test_prompt_abort_returns_none():
def _raise(label):
raise EOFError
assert auth.prompt_for_token("pypi", prompt_fn=_raise) is None
def test_ensure_credentials_prompts_when_interactive(tmp_path, monkeypatch, capsys):
from release import pypi
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
monkeypatch.setattr(auth, "prompt_for_token", lambda index, **k: TOKEN)
# _ensure_credentials resolves the path through auth.credential_state, so the
# monkeypatched pypirc_path applies there too.
options = pypi.Options(version="1.2.3", apply=True)
assert pypi._ensure_credentials(["pypi"], options, interactive=True) is True
assert "[ok ] wrote [pypi] credentials" in capsys.readouterr().out
assert auth.credential_state("pypi", path=tmp_path / ".pypirc", env={}).configured
def test_ensure_credentials_fails_without_a_terminal(tmp_path, monkeypatch, capsys):
from release import pypi
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
options = pypi.Options(version="1.2.3", apply=True)
assert pypi._ensure_credentials(["pypi"], options, interactive=False) is False
output = capsys.readouterr().out
assert "--set-token pypi" in output
assert "prompted" in output
assert not (tmp_path / ".pypirc").exists(), "must not write anything when it cannot prompt"
def test_ensure_credentials_dry_run_warns_but_continues(tmp_path, monkeypatch, capsys):
from release import pypi
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
options = pypi.Options(version="1.2.3", apply=False)
assert pypi._ensure_credentials(["pypi"], options, interactive=False) is True
output = capsys.readouterr().out
assert "[warn]" in output
assert "dry run continues" in output
def test_ensure_credentials_reports_testpypi_alternative(tmp_path, monkeypatch, capsys):
from release import pypi
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
options = pypi.Options(version="1.2.3", apply=True)
assert pypi._ensure_credentials(["testpypi"], options, interactive=False) is False
output = capsys.readouterr().out
assert "--set-token testpypi" in output
assert "--pypi-only" in output
def test_piped_token_is_used_only_once(tmp_path, monkeypatch, capsys):
from release import pypi
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
options = pypi.Options(version="1.2.3", apply=True, token=TOKEN)
assert pypi._ensure_credentials(["pypi", "testpypi"], options, interactive=True) is False
# Second index had no token left to consume, so it fell back to the prompt,
# which is unavailable in tests; nothing may be written for it.
assert options.token is None
assert auth.credential_state("pypi", path=tmp_path / ".pypirc", env={}).configured
assert not auth.credential_state(
"testpypi", path=tmp_path / ".pypirc", env={}
).configured
def test_section_not_listed_in_index_servers_is_not_configured(tmp_path):
"""twine only honours sections named in [distutils] index-servers.
Regression test for a real failure: a hand-added [testpypi] section looked
configured to us while twine answered "Missing 'testpypi' section".
"""
path = tmp_path / ".pypirc"
path.write_text(
"[distutils]\n"
"index-servers =\n"
" pypi\n"
"\n"
"[pypi]\n"
"username = __token__\n"
"password = pypi-" + "A" * 30 + "\n"
"\n"
"[testpypi]\n"
"username = __token__\n"
"password = pypi-" + "B" * 30 + "\n",
encoding="utf-8",
)
state = auth.credential_state("testpypi", path=path, env={})
assert not state.configured
assert "index-servers" in state.problem
assert "--repair" in state.problem
# The production index is unaffected.
assert auth.credential_state("pypi", path=path, env={}).configured
def test_credential_state_agrees_with_twine(tmp_path):
"""Our verdict must match twine's own resolver for the same file."""
twine_utils = pytest.importorskip("twine.utils")
path = tmp_path / ".pypirc"
path.write_text(
"[distutils]\n"
"index-servers =\n"
" pypi\n"
"\n"
"[pypi]\n"
"username = __token__\n"
"password = pypi-" + "A" * 30 + "\n"
"\n"
"[testpypi]\n"
"username = __token__\n"
"password = pypi-" + "B" * 30 + "\n",
encoding="utf-8",
)
twine_sees = set(twine_utils.get_config(str(path)))
for index in ("pypi", "testpypi"):
ours = auth.credential_state(index, path=path, env={})
# twine is the authority: we may only claim configured when twine agrees.
if ours.configured:
assert index in twine_sees, f"{index} accepted by us but ignored by twine"
def test_missing_index_servers_key_falls_back_to_both_indexes(tmp_path):
"""twine's fallback is 'pypi testpypi' only when the key is absent entirely."""
path = tmp_path / ".pypirc"
text = "[distutils]\nindex-servers =\n pypi\n\n[ pypi ]\n".replace("[ pypi ]", "[pypi]")
path.write_text(text, encoding="utf-8")
assert auth.index_servers(path.read_text(encoding="utf-8")) == {"pypi"}
without_key = path.read_text(encoding="utf-8").replace("index-servers =\n pypi\n", "")
path.write_text(without_key, encoding="utf-8", newline="")
assert auth.index_servers(path.read_text(encoding="utf-8")) == {"pypi", "testpypi"}
def test_empty_index_servers_honours_nothing(tmp_path):
"""A trap: `index-servers =` with no entries means twine honours no sections.
Unlike a missing key, an empty value does not trigger the pypi+testpypi
fallback, so a section that looks configured is ignored.
"""
path = tmp_path / ".pypirc"
path.write_text(
"[distutils]\nindex-servers =\n\n"
"[pypi]\nusername = __token__\npassword = pypi-" + "A" * 30 + "\n",
encoding="utf-8",
)
assert auth.index_servers(path.read_text(encoding="utf-8")) == set()
state = auth.credential_state("pypi", path=path, env={})
assert not state.configured
assert "index-servers" in state.problem
auth.repair_index_servers(path)
assert auth.credential_state("pypi", path=path, env={}).configured
def test_index_servers_reads_inline_and_continuation_forms():
assert auth.index_servers("[distutils]\nindex-servers = pypi testpypi\n") == {
"pypi",
"testpypi",
}
assert auth.index_servers("[distutils]\nindex-servers =\n pypi\n") == {"pypi"}
assert auth.index_servers("") == {"pypi", "testpypi"}
def test_unlisted_sections_reports_the_gap(tmp_path):
path = tmp_path / ".pypirc"
path.write_text(
"[distutils]\nindex-servers =\n pypi\n\n"
"[pypi]\npassword = pypi-x\n\n"
"[testpypi]\npassword = pypi-y\n",
encoding="utf-8",
)
assert auth.unlisted_sections(path) == ["testpypi"]
def test_repair_index_servers_fixes_a_hand_edited_file(tmp_path):
path = tmp_path / ".pypirc"
path.write_text(
"[distutils]\nindex-servers =\n pypi\n\n"
"[pypi]\nusername = __token__\npassword = pypi-" + "A" * 30 + "\n\n"
"[testpypi]\nusername = __token__\npassword = pypi-" + "B" * 30 + "\n",
encoding="utf-8",
)
assert not auth.credential_state("testpypi", path=path, env={}).configured
added = auth.repair_index_servers(path)
assert added == ["testpypi"]
assert auth.index_servers(path.read_text(encoding="utf-8")) == {"pypi", "testpypi"}
assert auth.credential_state("testpypi", path=path, env={}).configured
# Tokens and comments survive the repair.
assert "pypi-" + "B" * 30 in path.read_text(encoding="utf-8")
def test_repair_index_servers_is_a_no_op_when_correct(tmp_path):
path = tmp_path / ".pypirc"
auth.write_token("pypi", "pypi-" + "A" * 30, path=path)
before = path.read_text(encoding="utf-8")
assert auth.repair_index_servers(path) == []
assert path.read_text(encoding="utf-8") == before
def test_write_token_makes_the_result_visible_to_twine(tmp_path):
"""The writer must produce a file twine actually honours, not just one we parse."""
path = tmp_path / ".pypirc"
path.write_text("[distutils]\nindex-servers =\n pypi\n", encoding="utf-8")
auth.write_token("testpypi", "pypi-" + "B" * 30, path=path)
assert auth.credential_state("testpypi", path=path, env={}).configured
twine_utils = pytest.importorskip("twine.utils")
assert "testpypi" in set(twine_utils.get_config(str(path)))
@pytest.mark.parametrize("index", ["pypi", "testpypi"])
def test_write_then_state_round_trips(tmp_path, index):
path = tmp_path / ".pypirc"
auth.write_token(index, TOKEN, path=path)
state = auth.credential_state(index, path=path, env={})
assert state.configured, state.problem
+80 -15
View File
@@ -346,6 +346,63 @@ def test_publish_refuses_when_release_already_exists(tmp_path, monkeypatch, caps
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"]:
@@ -367,27 +424,35 @@ def test_twine_upload_argv_defaults_to_pypi():
assert "--repository" not in argv
def test_credentials_hint_reports_missing(monkeypatch, tmp_path):
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)
monkeypatch.delenv("TWINE_USERNAME", raising=False)
monkeypatch.setattr(pypi.Path, "home", lambda: tmp_path)
hint = pypi.credentials_hint()
hint = pypi.credentials_hint("pypi")
assert hint is not None
assert "TWINE_PASSWORD" in hint
assert "release auth --set-token pypi" in hint
def test_credentials_hint_accepts_env(monkeypatch):
monkeypatch.setenv("TWINE_USERNAME", "__token__")
monkeypatch.setenv("TWINE_PASSWORD", "pypi-secret")
assert pypi.credentials_hint() is None
def test_credentials_hint_for_testpypi_mentions_pypi_only(tmp_path, monkeypatch):
from release import auth
def test_credentials_hint_accepts_pypirc(monkeypatch, tmp_path):
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
monkeypatch.delenv("TWINE_USERNAME", raising=False)
(tmp_path / ".pypirc").write_text("[pypi]\n", encoding="utf-8")
monkeypatch.setattr(pypi.Path, "home", lambda: tmp_path)
assert pypi.credentials_hint() is None
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):