fixed testpypi upload
This commit is contained in:
@@ -77,14 +77,18 @@ Reports land in `build/release/reports/<version>/`.
|
||||
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 <version> # preview
|
||||
python build/run.py release github --version <version> --apply # sync + tag + push + Release
|
||||
python build/run.py release pypi --version <version> --apply # TestPyPI only
|
||||
python build/run.py release pypi --version <version> --confirm-pypi --apply # + PyPI
|
||||
python build/run.py release pypi --version <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
|
||||
|
||||
@@ -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()
|
||||
+136
-8
@@ -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
|
||||
|
||||
|
||||
|
||||
+97
-37
@@ -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=<token>. "
|
||||
"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 <index>` 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:
|
||||
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
|
||||
else:
|
||||
print("[ok ] PyPI credentials are configured (twine will read them)")
|
||||
|
||||
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()
|
||||
if options.pypi_only:
|
||||
print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
|
||||
else:
|
||||
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
|
||||
return 0
|
||||
|
||||
+69
-6
@@ -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<version>`(注意历史里那个 typo tag `0.50` 指向 `v0.5.1`)。命令会在动手前拒绝已存在的 tag,所以不会重复发布。
|
||||
> tag 拼写沿用历史风格 `v<version>`(注意历史里那个 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>`。命令会在**任何写操作之前**检查凭证是否配置好并给出提示。**不要把 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 <index>`;也可显式加 `--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 是**独立服务、独立账号**,注册在 <https://test.pypi.org/account/register/>,你的 PyPI 账号在那里不存在
|
||||
- 正式 token 建议限定到 `orisys` 项目(项目已存在,可以建 project-scoped token)
|
||||
- 该文件必须在**任何 git 仓库之外**(放在用户目录里);token 一旦提交进仓库,即使后来删文件也还在历史里——那属于安全事故
|
||||
- 临时替代方案:会话内设 `TWINE_USERNAME=__token__` + `TWINE_PASSWORD=<token>`,命令会识别这种情形
|
||||
- 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 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user