Files
orisys-sdk/build/release/auth.py
T
2026-09-12 17:34:46 +08:00

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