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