399 lines
15 KiB
Python
399 lines
15 KiB
Python
"""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()
|