"""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