From 488c8b0bc1d6930fd99326c4bcbafe7839b5c48c Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sat, 12 Sep 2026 17:34:46 +0800 Subject: [PATCH] fixed testpypi upload --- build/README.md | 4 + build/release/auth.py | 398 ++++++++++++++++++++++++++++++++++ build/release/cli.py | 144 +++++++++++- build/release/pypi.py | 138 ++++++++---- docs/publishing.md | 75 ++++++- tests/test_release_auth.py | 387 +++++++++++++++++++++++++++++++++ tests/test_release_publish.py | 95 ++++++-- 7 files changed, 1173 insertions(+), 68 deletions(-) create mode 100644 build/release/auth.py create mode 100644 tests/test_release_auth.py diff --git a/build/README.md b/build/README.md index 87ab493..4521fab 100644 --- a/build/README.md +++ b/build/README.md @@ -77,14 +77,18 @@ Reports land in `build/release/reports//`. Both default to a dry run; `--apply` is required for anything irreversible. ```bash +python build/run.py release auth # credential status +python build/run.py release auth --set-token pypi # hidden prompt, writes ~/.pypirc python build/run.py release github --version # preview python build/run.py release github --version --apply # sync + tag + push + Release python build/run.py release pypi --version --apply # TestPyPI only python build/run.py release pypi --version --confirm-pypi --apply # + PyPI +python build/run.py release pypi --version --pypi-only --apply # skip TestPyPI ``` Both refuse to act unless `release preflight` passed on a clean commit with no waived options, and every wheel still hashes to what that report recorded. +Tokens are only ever read from a hidden prompt or stdin, never from argv. See `docs/publishing.md` §3. ### GUI diff --git a/build/release/auth.py b/build/release/auth.py new file mode 100644 index 0000000..e7a5023 --- /dev/null +++ b/build/release/auth.py @@ -0,0 +1,398 @@ +"""PyPI credential setup: locate, inspect and write ``~/.pypirc``. + +Editing the config from a command rather than by hand avoids the two mistakes +that actually happen: putting something other than ``__token__`` in ``username``, +and pasting a TestPyPI token into the ``[pypi]`` section (the two indexes reject +each other's tokens with 403, and both token kinds start with ``pypi-``). + +The writer does targeted text edits rather than rewriting through configparser, +so the explanatory comments in the file survive. +""" + +from __future__ import annotations + +import configparser +import getpass +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path + +INDEXES: dict[str, dict[str, str | None]] = { + "pypi": { + "upload_url": "https://upload.pypi.org/legacy/", + "json_url": "https://pypi.org/pypi/orisys/json", + "simple_url": "https://pypi.org/simple/", + "token_url": "https://pypi.org/manage/account/token/", + "register_url": None, + }, + "testpypi": { + "upload_url": "https://test.pypi.org/legacy/", + "json_url": "https://test.pypi.org/pypi/orisys/json", + "simple_url": "https://test.pypi.org/simple/", + "token_url": "https://test.pypi.org/manage/account/token/", + "register_url": "https://test.pypi.org/account/register/", + }, +} + +# Substrings that mark a token slot that was never filled in. +PLACEHOLDER_MARKERS = ("PASTE", "REPLACE", "TOKEN-HERE", "YOUR-TOKEN", "XXXX") + +DEFAULT_TEMPLATE = """\ +# PyPI / TestPyPI credentials for twine. +# +# Keep this file out of any git repository: a token committed once stays in the +# history even after you delete it. +# +# * username is literally "__token__" - never an account name or email +# * PyPI and TestPyPI tokens both start with "pypi-" but are NOT +# interchangeable: each index rejects the other's with 403 +# * TestPyPI is a separate service with a separate account + +[distutils] +index-servers = +""" + + +@dataclass +class CredentialState: + index: str + configured: bool + source: str | None = None + problem: str | None = None + + +@dataclass +class TwineLookup: + """What twine's own resolver says about one index.""" + + available: bool + known: bool + config: dict = field(default_factory=dict) + error: str | None = None + + +def pypirc_path() -> Path: + """Where twine looks by default: ``~/.pypirc`` (same expansion twine uses).""" + return Path(os.path.expanduser("~")) / ".pypirc" + + +def looks_like_placeholder(token: str) -> bool: + upper = token.upper() + return any(marker in upper for marker in PLACEHOLDER_MARKERS) + + +def parse_pypirc(text: str) -> dict[str, dict[str, str]]: + """Section -> key -> value, tolerating comments and continuation lines.""" + sections: dict[str, dict[str, str]] = {} + current: str | None = None + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith(("#", ";", "%")): + continue + if line.startswith("[") and line.endswith("]"): + current = line[1:-1].strip() + sections.setdefault(current, {}) + continue + if current is not None and "=" in line: + key, _, value = line.partition("=") + sections[current][key.strip()] = value.strip() + return sections + + +def credential_state( + index: str, + *, + path: Path | None = None, + env: dict | None = None, +) -> CredentialState: + """Is ``index`` usable by twine, and why not when it is not. + + Delegates to ``twine.utils.get_config`` when twine is importable, because the + only definition that matters is twine's. The important subtlety that a naive + check misses: **twine ignores any section not listed in + ``[distutils] index-servers``** (with a fallback of ``pypi testpypi`` when the + key is absent). A ``[testpypi]`` section without that listing looks configured + but twine reports "Missing 'testpypi' section". + """ + if index not in INDEXES: + raise ValueError(f"unknown index {index!r}; expected one of {sorted(INDEXES)}") + + env = os.environ if env is None else env + if env.get("TWINE_PASSWORD"): + return CredentialState(index, True, "environment variables") + + path = path or pypirc_path() + if not path.is_file(): + return CredentialState(index, False, None, f"{path} does not exist yet") + + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError as exc: + return CredentialState(index, False, None, f"cannot read {path}: {exc}") + + sections = parse_pypirc(text) + if index not in sections and index not in index_servers(text): + # Do not blame index-servers for a section that simply is not there. + return CredentialState(index, False, None, f"{path} has no [{index}] section") + + lookup = _twine_lookup(index, path) + if lookup.error: + return CredentialState(index, False, None, f"twine cannot read {path}: {lookup.error}") + + if lookup.available: + if not lookup.known: + return CredentialState(index, False, None, _ignored_section(index, path, text, sections)) + username = str(lookup.config.get("username") or "").strip() + password = str(lookup.config.get("password") or "").strip() + if not password: + # twine knows the index but resolved no password for it: almost always + # a section that index-servers does not list. + detail = _ignored_section(index, path, text, sections) + return CredentialState(index, False, None, detail) + else: + # twine is not installed: replicate its rule locally. + if index not in index_servers(text): + return CredentialState(index, False, None, _ignored_section(index, path, text, sections)) + section = sections[index] + username = section.get("username", "").strip() + password = section.get("password", "").strip() + + if looks_like_placeholder(password): + return CredentialState( + index, False, str(path), f"[{index}] still contains a placeholder token" + ) + if username != "__token__": + return CredentialState( + index, + False, + str(path), + f"[{index}] username is {username!r}; API tokens need the literal '__token__'", + ) + return CredentialState(index, True, str(path)) + + +def _ignored_section( + index: str, path: Path, text: str, sections: dict[str, dict[str, str]] +) -> str: + """Name the real cause: absent section, or present but not listed by twine.""" + if index not in sections: + return f"{path} has no [{index}] section" + if index not in index_servers(text): + return ( + f"[{index}] exists in {path} but is not listed in " + "[distutils] index-servers, so twine ignores it entirely; " + "fix with `release auth --repair`" + ) + return f"twine resolves no password for [{index}] from {path}" + + +def _twine_lookup(index: str, path: Path) -> TwineLookup: + """Ask twine itself; ``available=False`` when twine is not installed. + + ``get_config`` is not enough on its own: it always contains a ``pypi`` entry + (and a ``testpypi`` one when listed) even when it never copied any + credentials, so the resolved username/password are what matter. + """ + try: + from twine import utils + except ImportError: + return TwineLookup(available=False, known=False) + try: + config = utils.get_config(str(path)) + except Exception as exc: # twine reports malformed files as exceptions + return TwineLookup(available=True, known=False, error=f"{type(exc).__name__}: {exc}") + section = config.get(index) + if section is None: + return TwineLookup(available=True, known=False) + return TwineLookup(available=True, known=True, config=dict(section)) + + +def index_servers(text: str) -> set[str]: + """Names twine honours from ``[distutils] index-servers``. + + Replicates ``twine.utils.get_config``, including its surprising asymmetry: + + * key **absent** -> falls back to ``pypi testpypi`` + * key **present but empty** -> an empty list, so *no* section is honoured + (the fallback does not apply), and every section looks configured while + twine silently ignores it + * continuation lines are part of the value, so ``index-servers =`` followed + by indented names is the normal form + """ + parser = configparser.RawConfigParser() + try: + parser.read_string(text) + except configparser.Error: + return set() + raw = parser.get("distutils", "index-servers", fallback="pypi testpypi") + return set(raw.split()) + + +def validate_token(token: str) -> list[str]: + """Problems with a pasted token. Empty and whitespace are fatal; the rest warn.""" + return [problem for problem in _token_problems(token) if problem.startswith("ERROR")] + + +def token_warnings(token: str) -> list[str]: + return [problem for problem in _token_problems(token) if problem.startswith("WARN")] + + +def _token_problems(token: str) -> list[str]: + problems: list[str] = [] + if not token: + problems.append("ERROR the token is empty") + return problems + if any(char.isspace() for char in token): + problems.append("ERROR the token contains whitespace (copy/paste artefact?)") + if not token.startswith("pypi-"): + problems.append( + "WARN PyPI API tokens start with 'pypi-'; a legacy account password also " + "works but needs username != '__token__'" + ) + if len(token) < 20: + problems.append("WARN the token looks too short; check the paste was complete") + return problems + + +def unlisted_sections(path: Path | None = None) -> list[str]: + """Known indexes whose section exists but is missing from ``index-servers``.""" + path = path or pypirc_path() + if not path.is_file(): + return [] + text = path.read_text(encoding="utf-8", errors="ignore") + present = set(parse_pypirc(text)) + listed = index_servers(text) + return [index for index in sorted(INDEXES) if index in present and index not in listed] + + +def repair_index_servers(path: Path | None = None) -> list[str]: + """List sections that exist but are not in ``index-servers``. + + This is the state a hand-edited file lands in: the section is present, so it + looks configured, but twine ignores it completely. Returns the indexes added. + """ + path = path or pypirc_path() + if not path.is_file(): + return [] + text = path.read_text(encoding="utf-8") + present = set(parse_pypirc(text)) + added: list[str] = [] + for index in sorted(INDEXES): + if index in present and index not in index_servers(text): + text = ensure_index_server(text, index) + added.append(index) + if added: + path.write_text(text, encoding="utf-8", newline="") + _restrict_permissions(path) + return added + + +def upsert_token(text: str, index: str, token: str) -> str: + """Set ``password`` inside ``[index]``, creating the section if needed.""" + lines = text.splitlines() + start = next((i for i, line in enumerate(lines) if line.strip() == f"[{index}]"), None) + + if start is None: + if lines and lines[-1].strip(): + lines.append("") + lines.extend( + [ + f"[{index}]", + f"repository = {INDEXES[index]['upload_url']}", + "username = __token__", + f"password = {token}", + ] + ) + return "\n".join(lines) + "\n" + + end = len(lines) + for offset in range(start + 1, len(lines)): + stripped = lines[offset].strip() + if stripped.startswith("[") and stripped.endswith("]"): + end = offset + break + + insert_at = end + for offset in range(start + 1, end): + stripped = lines[offset].strip() + if stripped.startswith("password"): + lines[offset] = f"password = {token}" + return "\n".join(lines) + "\n" + if stripped.startswith(("username", "repository")): + insert_at = offset + 1 + lines.insert(insert_at, f"password = {token}") + return "\n".join(lines) + "\n" + + +def ensure_index_server(text: str, index: str) -> str: + """Make sure ``[distutils] index-servers`` lists ``index``.""" + if index in index_servers(text): + return text + + lines = text.splitlines() + if "[distutils]" not in (line.strip() for line in lines): + block = ["[distutils]", "index-servers =", f" {index}", ""] + return "\n".join(block + lines) + "\n" + + start = next(i for i, line in enumerate(lines) if line.strip() == "[distutils]") + for offset in range(start + 1, len(lines)): + if lines[offset].strip().startswith("index-servers"): + lines.insert(offset + 1, f" {index}") + return "\n".join(lines) + "\n" + lines.insert(start + 1, f" {index}") + return "\n".join(lines) + "\n" + + +def _restrict_permissions(path: Path) -> None: + """Best effort: owner-only. Windows ACLs need icacls, not chmod.""" + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def write_token(index: str, token: str, *, path: Path | None = None) -> Path: + """Write ``token`` into ``[index]`` of the pypirc file, preserving comments.""" + if index not in INDEXES: + raise ValueError(f"unknown index {index!r}; expected one of {sorted(INDEXES)}") + + path = path or pypirc_path() + if path.is_file(): + text = path.read_text(encoding="utf-8") + else: + text = DEFAULT_TEMPLATE + text = upsert_token(text, index, token) + text = ensure_index_server(text, index) + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="") + _restrict_permissions(path) + return path + + +def prompt_for_token(index: str, *, prompt_fn=None, out=None) -> str | None: + """Ask for a token with hidden input. Returns ``None`` if input is unavailable.""" + out = out or sys.stdout + meta = INDEXES[index] + print(f"PyPI API token for [{index}]", file=out) + print(f" create one at: {meta['token_url']}", file=out) + if meta.get("register_url"): + print(f" needs an account first: {meta['register_url']}", file=out) + print(" input is hidden and is written only to your pypirc file", file=out) + + ask = prompt_fn or getpass.getpass + try: + return ask("token: ").strip() + except (EOFError, KeyboardInterrupt, OSError): + # OSError covers environments where stdin cannot be read at all (pytest + # capture, a detached CI stdin): treat that as "cannot prompt", never as a + # crash. + print("", file=out) + print("aborted: no token entered", file=out) + return None + + +def read_token_from_stdin() -> str: + """For CI: read the token from stdin instead of a prompt or the command line.""" + return (sys.stdin.readline() or "").strip() diff --git a/build/release/cli.py b/build/release/cli.py index 55923ec..752a0f6 100644 --- a/build/release/cli.py +++ b/build/release/cli.py @@ -1,8 +1,9 @@ """Command line for ``python build/run.py release``. -Commands: ``preflight`` (local gate), ``audit`` (single wheel), ``github`` (sync -the public repo + publish a GitHub Release), ``pypi`` (TestPyPI then PyPI). -The publish commands are dry-run unless ``--apply`` is passed. +Commands: ``preflight`` (local gate), ``audit`` (single wheel), ``auth`` (PyPI +credentials in ~/.pypirc), ``github`` (sync the public repo + publish a GitHub +Release), ``pypi`` (TestPyPI then PyPI). The publish commands are dry-run unless +``--apply`` is passed, and tokens are never accepted as command-line arguments. """ from __future__ import annotations @@ -24,7 +25,7 @@ from common.verify.wheel import ( # noqa: E402 verify_wheel_runtime, ) from common.verify.wheel_contents import inspect_wheel # noqa: E402 -from release import github, pypi # noqa: E402 +from release import auth, github, pypi # noqa: E402 from release.preflight import PreflightOptions, run_preflight # noqa: E402 @@ -223,6 +224,106 @@ def _cmd_github(argv: list[str]) -> int: ) +def _cmd_auth(argv: list[str]) -> int: + parser = argparse.ArgumentParser( + prog="build/run.py release auth", + description=( + "Inspect or set up PyPI credentials in ~/.pypirc. " + "Tokens are read from a hidden prompt or stdin, never from argv." + ), + ) + parser.add_argument( + "--set-token", + choices=sorted(auth.INDEXES), + default=None, + help="store a token for this index (prompts unless --token-stdin)", + ) + parser.add_argument( + "--token-stdin", + action="store_true", + help="read the token from stdin instead of prompting (for scripts/CI)", + ) + parser.add_argument( + "--repair", + action="store_true", + help=( + "add existing sections to [distutils] index-servers; twine ignores " + "sections that are not listed there" + ), + ) + parser.add_argument( + "--index", + choices=sorted(auth.INDEXES), + default="pypi", + help="index to report on when not setting a token (default: pypi)", + ) + args = parser.parse_args(argv) + + if args.repair: + path = auth.pypirc_path() + added = auth.repair_index_servers(path) + if added: + print(f"[ok ] added {added} to [distutils] index-servers in {path}") + else: + print(f"[ok ] nothing to repair in {path}") + for index in sorted(auth.INDEXES): + state = auth.credential_state(index) + flag = "[ok ]" if state.configured else "[-- ]" + detail = state.source if state.configured else state.problem + print(f"{flag} [{index}] {detail}") + return 0 if auth.credential_state(args.index).configured else 1 + + if args.set_token: + if args.token_stdin: + token = auth.read_token_from_stdin() + else: + token = auth.prompt_for_token(args.set_token) + if not token: + print("[FAIL] no token entered") + return 1 + for problem in auth.validate_token(token): + print(f"[FAIL] {problem.removeprefix('ERROR ')}") + return 1 + for warning in auth.token_warnings(token): + print(f"[warn] {warning.removeprefix('WARN ')}") + path = auth.write_token(args.set_token, token) + print(f"[ok ] wrote [{args.set_token}] credentials to {path}") + state = auth.credential_state(args.set_token) + if not state.configured: + print(f"[FAIL] the file still does not validate: {state.problem}") + return 1 + print(f"[ok ] [{args.set_token}] credentials verified ({state.source})") + return 0 + + path = auth.pypirc_path() + print(f"config file: {path} ({'exists' if path.is_file() else 'missing'})") + for index in sorted(auth.INDEXES): + state = auth.credential_state(index) + if state.configured: + print(f"[ok ] [{index}] configured ({state.source})") + else: + print(f"[-- ] [{index}] not usable: {state.problem}") + + unlisted = auth.unlisted_sections(path) + if unlisted: + print() + print( + f"[warn] {unlisted} have sections that [distutils] index-servers does not " + "list; twine ignores those entirely" + ) + print(" fix with: python build/run.py release auth --repair") + + print() + print("set a token with:") + for index in sorted(auth.INDEXES): + if not auth.credential_state(index).configured: + print(f" python build/run.py release auth --set-token {index}") + print(" (TestPyPI needs its own account; if you only hold a production token,") + print(" skip it and publish with `release pypi --pypi-only`)") + + return 0 if auth.credential_state(args.index).configured else 1 + + def _cmd_pypi(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="build/run.py release pypi", @@ -240,7 +341,22 @@ def _cmd_pypi(argv: list[str]) -> int: help="after TestPyPI, also publish to the real PyPI", ) parser.add_argument( - "--repository", default="testpypi", help="twine repository for the first phase" + "--pypi-only", + action="store_true", + help=( + "upload straight to PyPI, skipping the TestPyPI rehearsal " + "(for when you only hold a production token)" + ), + ) + parser.add_argument( + "--prompt-credentials", + action="store_true", + help="if a token is missing, prompt for it and store it in ~/.pypirc", + ) + parser.add_argument( + "--token-stdin", + action="store_true", + help="read a missing token from stdin instead of prompting (scripts/CI)", ) parser.add_argument( "--skip-install-verify", @@ -250,14 +366,24 @@ def _cmd_pypi(argv: list[str]) -> int: parser.add_argument("--skip-check", action="store_true", help="skip `twine check`") args = parser.parse_args(argv) + if args.pypi_only and args.confirm_pypi: + parser.error("--pypi-only and --confirm-pypi are mutually exclusive") + + if args.token_stdin and not args.prompt_credentials: + parser.error("--token-stdin requires --prompt-credentials") + + token = auth.read_token_from_stdin() if args.token_stdin else None + return pypi.publish( pypi.Options( version=args.version, apply=args.apply, confirm_pypi=args.confirm_pypi, - repository=args.repository, verify_install=not args.skip_install_verify, skip_check=args.skip_check, + pypi_only=args.pypi_only, + prompt_credentials=args.prompt_credentials, + token=token, ) ) @@ -266,7 +392,7 @@ def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) if not argv or argv[0] in ("-h", "--help"): print(__doc__) - print("Commands: preflight | audit | github | pypi") + print("Commands: preflight | audit | auth | github | pypi") return 0 command, rest = argv[0], argv[1:] @@ -274,13 +400,15 @@ def main(argv: list[str] | None = None) -> int: return _cmd_preflight(rest) if command == "audit": return _cmd_audit(rest) + if command == "auth": + return _cmd_auth(rest) if command == "github": return _cmd_github(rest) if command == "pypi": return _cmd_pypi(rest) print(f"Unknown release command: {command!r}", file=sys.stderr) - print("Choose from: preflight, audit, github, pypi", file=sys.stderr) + print("Choose from: preflight, audit, auth, github, pypi", file=sys.stderr) return 2 diff --git a/build/release/pypi.py b/build/release/pypi.py index 649aff5..6f205f4 100644 --- a/build/release/pypi.py +++ b/build/release/pypi.py @@ -32,16 +32,11 @@ if str(_BUILD_ROOT) not in sys.path: sys.path.insert(0, str(_BUILD_ROOT)) from common.paths import REPO_ROOT # noqa: E402 +from release import auth # noqa: E402 from release.publish import GateResult, PublishGateError, load_gate # noqa: E402 -INDEX_JSON = { - "testpypi": "https://test.pypi.org/pypi/orisys/json", - "pypi": "https://pypi.org/pypi/orisys/json", -} -INDEX_SIMPLE = { - "testpypi": "https://test.pypi.org/simple/", - "pypi": "https://pypi.org/simple/", -} +INDEX_JSON = {name: str(meta["json_url"]) for name, meta in auth.INDEXES.items()} +INDEX_SIMPLE = {name: str(meta["simple_url"]) for name, meta in auth.INDEXES.items()} @dataclass @@ -49,9 +44,22 @@ class Options: version: str apply: bool = False confirm_pypi: bool = False - repository: str = "testpypi" verify_install: bool = True skip_check: bool = False + # Upload straight to PyPI, skipping the TestPyPI rehearsal. Needed when you + # only hold a production token, since the two indexes reject each other's. + pypi_only: bool = False + # Offer to enter a missing token now instead of just failing. + prompt_credentials: bool = False + # Token supplied non-interactively (e.g. --token-stdin), used once. + token: str | None = None + + +def upload_phases(options: Options) -> list[str]: + """The indexes to upload to, in order.""" + if options.pypi_only: + return ["pypi"] + return ["testpypi", "pypi"] if options.confirm_pypi else ["testpypi"] @dataclass @@ -76,24 +84,78 @@ def twine_upload_argv(wheels: list[Path], repository: str | None) -> list[str]: return argv -def credentials_hint() -> str | None: - """Return guidance when twine has no obvious credentials, else ``None``. +def credentials_hint(index: str) -> str | None: + """Guidance when ``index`` has no usable credentials, else ``None``. - twine itself reads ``~/.pypirc`` or ``TWINE_USERNAME``/``TWINE_PASSWORD``; - failing here is much better than failing halfway through an upload. + More than "does the file exist": a placeholder or the wrong ``username`` is + treated as missing, because twine would only find out at upload time. """ - import os - - if os.environ.get("TWINE_PASSWORD") or os.environ.get("TWINE_USERNAME"): + state = auth.credential_state(index) + if state.configured: return None - pypirc = Path.home() / ".pypirc" - if pypirc.is_file(): - return None - return ( - "no PyPI credentials found: create ~/.pypirc with [testpypi] and [pypi] " - "sections, or set TWINE_USERNAME=__token__ and TWINE_PASSWORD=. " - "Never commit the token." + lines = [f"no usable credentials for [{index}]: {state.problem}"] + lines.append(f" set it up with: python build/run.py release auth --set-token {index}") + if index == "testpypi": + lines.append( + " (TestPyPI needs its own account and token; if you only hold a " + "production token, use --pypi-only to skip the rehearsal)" + ) + lines.append( + " or set TWINE_USERNAME=__token__ and TWINE_PASSWORD for this session only" ) + return "\n".join(lines) + + +def _ensure_credentials( + indexes: list[str], options: Options, *, interactive: bool | None = None +) -> bool: + """Make sure every index that will be uploaded to has credentials. + + Prompts for a missing token when there is a terminal to prompt on; a piped or + CI run fails fast instead of hanging on a hidden-input prompt. + """ + missing = [index for index in indexes if not auth.credential_state(index).configured] + if not missing: + for index in indexes: + state = auth.credential_state(index) + print(f"[ok ] credentials for [{index}] ({state.source})") + return True + + if interactive is None: + interactive = sys.stdin.isatty() + allow_prompt = options.prompt_credentials or (interactive and options.apply) + + if not allow_prompt: + for index in missing: + print(f"[{'FAIL' if options.apply else 'warn'}] {credentials_hint(index)}") + if not options.apply: + print( + "[info] dry run continues so you can review the plan; " + "--apply would stop here until the credentials exist" + ) + return True + if not interactive: + print( + "[info] stdin is not a terminal, so nothing was prompted; run " + "`release auth --set-token ` or pass --prompt-credentials" + ) + return False + + for index in missing: + print(f"[warn] {credentials_hint(index)}") + token = options.token or auth.prompt_for_token(index) + if not token: + print(f"[FAIL] no token entered for [{index}]") + return False + for problem in auth.validate_token(token): + print(f"[FAIL] {problem.removeprefix('ERROR ')}") + return False + for warning in auth.token_warnings(token): + print(f"[warn] {warning.removeprefix('WARN ')}") + path = auth.write_token(index, token) + print(f"[ok ] wrote [{index}] credentials to {path}") + options.token = None # only the first index may consume a piped token + return True def index_state(index: str, *, timeout: float = 20.0) -> IndexState: @@ -207,15 +269,14 @@ def publish(options: Options) -> int: return 1 # --- 2. credentials + twine check (before anything irreversible) ---- - # Dry runs are previews, so missing credentials warn there and stop only when - # an upload is actually about to happen. - hint = credentials_hint() - if hint: - print(f"[{'FAIL' if options.apply else 'warn'}] {hint}") - if options.apply: - return 1 - else: - print("[ok ] PyPI credentials are configured (twine will read them)") + phases = upload_phases(options) + if options.pypi_only: + print( + "[warn] --pypi-only: uploading straight to PyPI with no TestPyPI " + "rehearsal. An index version can never be reused or replaced." + ) + if not _ensure_credentials(phases, options): + return 1 if not options.skip_check: argv = twine_check_argv(wheels) @@ -227,10 +288,6 @@ def publish(options: Options) -> int: print("[ok ] twine check passed") # --- 3. phase plan ------------------------------------------------- - phases = ["testpypi"] - if options.confirm_pypi: - phases.append("pypi") - print() for index in phases: state = index_state(index) @@ -249,7 +306,7 @@ def publish(options: Options) -> int: print() for index in phases: print(f"[plan] upload {len(wheels)} wheel(s) to {index}") - if "pypi" not in phases: + if phases == ["testpypi"]: print( "[plan] stop after TestPyPI. Re-run with " "`--confirm-pypi --apply` to publish to the real PyPI." @@ -288,12 +345,15 @@ def publish(options: Options) -> int: print(f"[FAIL] {index} artifact is not fetchable") return 1 - if "pypi" not in phases: + if phases == ["testpypi"]: print() print("=== release pypi: TestPyPI stage PASSED; PyPI untouched ===") print("next: python build/run.py release pypi --confirm-pypi --apply") return 0 print() - print("=== release pypi PASSED (TestPyPI + PyPI) ===") + if options.pypi_only: + print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===") + else: + print("=== release pypi PASSED (TestPyPI + PyPI) ===") return 0 diff --git a/docs/publishing.md b/docs/publishing.md index 77731fa..cda6179 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -155,7 +155,7 @@ python build/run.py release github --version 0.5.2 --apply # 执行 其他开关:`--notes-file`(自定义 release notes)、`--attach-preflight`(把 `preflight.json` 也作为附件上传,便于审计;注意报告里有本机绝对路径)、`--allow-dirty-release`、`--skip-fetch`、`--skip-checks`。 -> tag 拼写沿用历史风格 `v`(注意历史里那个 typo tag `0.50` 指向 `v0.5.1`)。命令会在动手前拒绝已存在的 tag,所以不会重复发布。 +> tag 拼写沿用历史风格 `v`(注意历史里那个 typo tag `0.50` 指向 `v0.5.1`)。命令会在动手前拒绝已存在的 tag,所以不会重复发布。**如果 tag 已经 push 但 Release 上传失败(例如 `gh` 没配好),直接重跑同一条 `--apply` 命令即可续跑**:它会跳过创建 tag,只补做剩下的步骤;只有「GitHub Release 已存在」才拒绝。 ### 3.2 PyPI:TestPyPI 先行,确认后再上正式仓库 @@ -163,19 +163,82 @@ python build/run.py release github --version 0.5.2 --apply # 执行 python build/run.py release pypi --version 0.5.2 # 预览 python build/run.py release pypi --version 0.5.2 --apply # 只发 TestPyPI python build/run.py release pypi --version 0.5.2 --confirm-pypi --apply # TestPyPI + 正式 PyPI + +# 只有正式 token、没有 TestPyPI 账号时:跳过彩排直接发正式库 +python build/run.py release pypi --version 0.5.2 --pypi-only --apply ``` +`--pypi-only` 与 `--confirm-pypi` 互斥。**注意它的代价**:没有 TestPyPI 彩排,而正式库的版本号一旦占用就无法复用、无法替换。它仍然会做完整门禁、`twine check`、上传前查重、上传后回读索引核对文件、以及把产物重新拉回来验证。 + 执行顺序: -1. 同样的证书校验 +1. 认证书校验(与 `release github` 同一份报告) 2. `twine check` 全部 wheel(校验 METADATA/long description,上传前就能发现问题) 3. 只读查询目标索引,确认该版本不存在(TestPyPI 与 PyPI 各查各的) -4. 上传 TestPyPI → 回读索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取 -5. 只有加了 `--confirm-pypi` 才会对正式 PyPI 重复第 3-4 步;否则停在这里并打印后续命令 +4. 上传 → 回读索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取 +5. 未加 `--confirm-pypi` 时只发 TestPyPI,然后停在这里并打印后续命令 -凭证由 twine 自己解析:`~/.pypirc` 的 `[testpypi]` / `[pypi]` 段,或 `TWINE_USERNAME=__token__` + `TWINE_PASSWORD=`。命令会在**任何写操作之前**检查凭证是否配置好并给出提示。**不要把 token 写进仓库。** +### 3.3 凭证:`release auth` 与 `~/.pypirc` -### 3.3 已知的历史发布空洞 +```powershell +python build/run.py release auth # 查看两个索引的凭证状态 +python build/run.py release auth --set-token pypi # 隐藏输入,写入 ~/.pypirc +python build/run.py release auth --set-token testpypi +python build/run.py release auth --repair # 修「有段但没列进 index-servers」 +``` + +`--set-token` 的 token 只从**隐藏输入**或 `--token-stdin`(供脚本/CI)读取,**不接受命令行参数**——避免落进 shell 历史和进程列表。写入采用定点文本编辑,文件里的说明注释会保留;同时会把该索引补进 `[distutils] index-servers`(这一步很关键,见下)。 + +`release auth` 只有在你真的运行 `--set-token` 时才写文件;不带参数时只读并报告,状态判断比「文件是否存在」严格: + +``` +config file: C:\Users\cheun\.pypirc (exists) +[-- ] [pypi] not usable: [pypi] still contains a placeholder token +[-- ] [testpypi] not usable: C:\Users\cheun\.pypirc has no [testpypi] section +``` + +**占位符、空密码、`username` 不是 `__token__`,都会被判定为「不可用」**——否则 twine 只会在真正上传时才发现,而那时已经动过索引了。 + +`release pypi` 在 `--apply` 时若凭证缺失:有终端就地提示输入(隐藏),无终端则直接失败并打印 `release auth --set-token `;也可显式加 `--prompt-credentials`。dry run 只告警并继续展示计划,不会卡在输入上。 + +#### twine 直接读取的格式 + +默认路径 `~/.pypirc`(Windows 上即 `C:\Users\<你>\.pypirc`,由 `USERPROFILE` 展开)。**没有**环境变量可以改这个路径;要用别的文件就传 `--config-file`。 + +```ini +[distutils] +index-servers = + pypi + testpypi + +[pypi] +repository = https://upload.pypi.org/legacy/ +username = __token__ +password = pypi-你的正式token + +[testpypi] +repository = https://test.pypi.org/legacy/ +username = __token__ +password = pypi-你的TestPyPI token +``` + +容易搞错的点: + +- **`[distutils] index-servers` 必须列出该索引,否则 twine 完全忽略那一节** —— 这是最常见的坑:文件里明明有 `[testpypi]` 段,twine 却报 `InvalidConfiguration: Missing 'testpypi' section`。用 `release auth --repair` 修(把已存在但未列出的段补进 `index-servers`)。 +- 更细的语义差别:`index-servers =` **值为空**表示「一个都不认」;只有**整行缺失**时 twine 才会回退到默认的 `pypi testpypi`。所以手工编辑时别把名字删空。 +- `username` 就是字面量 `__token__`,不是账号名或邮箱 +- 两类 token **都以 `pypi-` 开头**,但**不能互换**:拿正式 token 打 `test.pypi.org` 会被 403 拒绝 +- TestPyPI 是**独立服务、独立账号**,注册在 ,你的 PyPI 账号在那里不存在 +- 正式 token 建议限定到 `orisys` 项目(项目已存在,可以建 project-scoped token) +- 该文件必须在**任何 git 仓库之外**(放在用户目录里);token 一旦提交进仓库,即使后来删文件也还在历史里——那属于安全事故 +- 临时替代方案:会话内设 `TWINE_USERNAME=__token__` + `TWINE_PASSWORD=`,命令会识别这种情形 +- PyPI token **没有只读的校验方式**,有效性只能在真正上传时才会被索引确认;所以所有本地检查、查重都排在写操作之前 + +`release auth` 的「可用」判定**直接问 twine 自己**(`twine.utils.get_config` 解析出的 username/password),而不是自己解析 ini,这样报告结果与 twine 的行为不会分叉。twine 未安装时才回退到本地复刻同一套规则。 + +只需要 TestPyPI 时才把 `testpypi` 写进 `index-servers` 并填对应 token;只发正式库时 `--pypi-only` 用不到 `[testpypi]` 段。 + +### 3.4 已知的历史发布空洞 | 版本 | 本地 `SDK/` | PyPI | GitHub Release | | --- | --- | --- | --- | diff --git a/tests/test_release_auth.py b/tests/test_release_auth.py new file mode 100644 index 0000000..e600231 --- /dev/null +++ b/tests/test_release_auth.py @@ -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 diff --git a/tests/test_release_publish.py b/tests/test_release_publish.py index 4e69c25..2fde341 100644 --- a/tests/test_release_publish.py +++ b/tests/test_release_publish.py @@ -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):