fixed testpypi upload

This commit is contained in:
ykcheungab
2026-09-12 17:34:46 +08:00
parent 94f38bc389
commit 488c8b0bc1
7 changed files with 1173 additions and 68 deletions
+4
View File
@@ -77,14 +77,18 @@ Reports land in `build/release/reports/<version>/`.
Both default to a dry run; `--apply` is required for anything irreversible. Both default to a dry run; `--apply` is required for anything irreversible.
```bash ```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> # preview
python build/run.py release github --version <version> --apply # sync + tag + push + Release 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> --apply # TestPyPI only
python build/run.py release pypi --version <version> --confirm-pypi --apply # + PyPI 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 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. 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. See `docs/publishing.md` §3.
### GUI ### GUI
+398
View File
@@ -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
View File
@@ -1,8 +1,9 @@
"""Command line for ``python build/run.py release``. """Command line for ``python build/run.py release``.
Commands: ``preflight`` (local gate), ``audit`` (single wheel), ``github`` (sync Commands: ``preflight`` (local gate), ``audit`` (single wheel), ``auth`` (PyPI
the public repo + publish a GitHub Release), ``pypi`` (TestPyPI then PyPI). credentials in ~/.pypirc), ``github`` (sync the public repo + publish a GitHub
The publish commands are dry-run unless ``--apply`` is passed. 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 from __future__ import annotations
@@ -24,7 +25,7 @@ from common.verify.wheel import ( # noqa: E402
verify_wheel_runtime, verify_wheel_runtime,
) )
from common.verify.wheel_contents import inspect_wheel # noqa: E402 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 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: def _cmd_pypi(argv: list[str]) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="build/run.py release pypi", 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", help="after TestPyPI, also publish to the real PyPI",
) )
parser.add_argument( 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( parser.add_argument(
"--skip-install-verify", "--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`") parser.add_argument("--skip-check", action="store_true", help="skip `twine check`")
args = parser.parse_args(argv) 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( return pypi.publish(
pypi.Options( pypi.Options(
version=args.version, version=args.version,
apply=args.apply, apply=args.apply,
confirm_pypi=args.confirm_pypi, confirm_pypi=args.confirm_pypi,
repository=args.repository,
verify_install=not args.skip_install_verify, verify_install=not args.skip_install_verify,
skip_check=args.skip_check, 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) argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help"): if not argv or argv[0] in ("-h", "--help"):
print(__doc__) print(__doc__)
print("Commands: preflight | audit | github | pypi") print("Commands: preflight | audit | auth | github | pypi")
return 0 return 0
command, rest = argv[0], argv[1:] command, rest = argv[0], argv[1:]
@@ -274,13 +400,15 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_preflight(rest) return _cmd_preflight(rest)
if command == "audit": if command == "audit":
return _cmd_audit(rest) return _cmd_audit(rest)
if command == "auth":
return _cmd_auth(rest)
if command == "github": if command == "github":
return _cmd_github(rest) return _cmd_github(rest)
if command == "pypi": if command == "pypi":
return _cmd_pypi(rest) return _cmd_pypi(rest)
print(f"Unknown release command: {command!r}", file=sys.stderr) 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 return 2
+97 -37
View File
@@ -32,16 +32,11 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT)) sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402 from common.paths import REPO_ROOT # noqa: E402
from release import auth # noqa: E402
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402 from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
INDEX_JSON = { INDEX_JSON = {name: str(meta["json_url"]) for name, meta in auth.INDEXES.items()}
"testpypi": "https://test.pypi.org/pypi/orisys/json", INDEX_SIMPLE = {name: str(meta["simple_url"]) for name, meta in auth.INDEXES.items()}
"pypi": "https://pypi.org/pypi/orisys/json",
}
INDEX_SIMPLE = {
"testpypi": "https://test.pypi.org/simple/",
"pypi": "https://pypi.org/simple/",
}
@dataclass @dataclass
@@ -49,9 +44,22 @@ class Options:
version: str version: str
apply: bool = False apply: bool = False
confirm_pypi: bool = False confirm_pypi: bool = False
repository: str = "testpypi"
verify_install: bool = True verify_install: bool = True
skip_check: bool = False 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 @dataclass
@@ -76,24 +84,78 @@ def twine_upload_argv(wheels: list[Path], repository: str | None) -> list[str]:
return argv return argv
def credentials_hint() -> str | None: def credentials_hint(index: str) -> str | None:
"""Return guidance when twine has no obvious credentials, else ``None``. """Guidance when ``index`` has no usable credentials, else ``None``.
twine itself reads ``~/.pypirc`` or ``TWINE_USERNAME``/``TWINE_PASSWORD``; More than "does the file exist": a placeholder or the wrong ``username`` is
failing here is much better than failing halfway through an upload. treated as missing, because twine would only find out at upload time.
""" """
import os state = auth.credential_state(index)
if state.configured:
if os.environ.get("TWINE_PASSWORD") or os.environ.get("TWINE_USERNAME"):
return None return None
pypirc = Path.home() / ".pypirc" lines = [f"no usable credentials for [{index}]: {state.problem}"]
if pypirc.is_file(): lines.append(f" set it up with: python build/run.py release auth --set-token {index}")
return None if index == "testpypi":
return ( lines.append(
"no PyPI credentials found: create ~/.pypirc with [testpypi] and [pypi] " " (TestPyPI needs its own account and token; if you only hold a "
"sections, or set TWINE_USERNAME=__token__ and TWINE_PASSWORD=<token>. " "production token, use --pypi-only to skip the rehearsal)"
"Never commit the token."
) )
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: def index_state(index: str, *, timeout: float = 20.0) -> IndexState:
@@ -207,15 +269,14 @@ def publish(options: Options) -> int:
return 1 return 1
# --- 2. credentials + twine check (before anything irreversible) ---- # --- 2. credentials + twine check (before anything irreversible) ----
# Dry runs are previews, so missing credentials warn there and stop only when phases = upload_phases(options)
# an upload is actually about to happen. if options.pypi_only:
hint = credentials_hint() print(
if hint: "[warn] --pypi-only: uploading straight to PyPI with no TestPyPI "
print(f"[{'FAIL' if options.apply else 'warn'}] {hint}") "rehearsal. An index version can never be reused or replaced."
if options.apply: )
if not _ensure_credentials(phases, options):
return 1 return 1
else:
print("[ok ] PyPI credentials are configured (twine will read them)")
if not options.skip_check: if not options.skip_check:
argv = twine_check_argv(wheels) argv = twine_check_argv(wheels)
@@ -227,10 +288,6 @@ def publish(options: Options) -> int:
print("[ok ] twine check passed") print("[ok ] twine check passed")
# --- 3. phase plan ------------------------------------------------- # --- 3. phase plan -------------------------------------------------
phases = ["testpypi"]
if options.confirm_pypi:
phases.append("pypi")
print() print()
for index in phases: for index in phases:
state = index_state(index) state = index_state(index)
@@ -249,7 +306,7 @@ def publish(options: Options) -> int:
print() print()
for index in phases: for index in phases:
print(f"[plan] upload {len(wheels)} wheel(s) to {index}") print(f"[plan] upload {len(wheels)} wheel(s) to {index}")
if "pypi" not in phases: if phases == ["testpypi"]:
print( print(
"[plan] stop after TestPyPI. Re-run with " "[plan] stop after TestPyPI. Re-run with "
"`--confirm-pypi --apply` to publish to the real PyPI." "`--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") print(f"[FAIL] {index} artifact is not fetchable")
return 1 return 1
if "pypi" not in phases: if phases == ["testpypi"]:
print() print()
print("=== release pypi: TestPyPI stage PASSED; PyPI untouched ===") print("=== release pypi: TestPyPI stage PASSED; PyPI untouched ===")
print("next: python build/run.py release pypi --confirm-pypi --apply") print("next: python build/run.py release pypi --confirm-pypi --apply")
return 0 return 0
print() print()
if options.pypi_only:
print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
else:
print("=== release pypi PASSED (TestPyPI + PyPI) ===") print("=== release pypi PASSED (TestPyPI + PyPI) ===")
return 0 return 0
+69 -6
View File
@@ -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`。 其他开关:`--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 先行,确认后再上正式仓库 ### 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 # 预览
python build/run.py release pypi --version 0.5.2 --apply # 只发 TestPyPI 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 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,上传前就能发现问题) 2. `twine check` 全部 wheel(校验 METADATA/long description,上传前就能发现问题)
3. 只读查询目标索引,确认该版本不存在(TestPyPI 与 PyPI 各查各的) 3. 只读查询目标索引,确认该版本不存在(TestPyPI 与 PyPI 各查各的)
4. 上传 TestPyPI → 回读索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取 4. 上传 → 回读索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取
5. 只有加了 `--confirm-pypi` 才会对正式 PyPI 重复第 3-4 步;否则停在这里并打印后续命令 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 | | 版本 | 本地 `SDK/` | PyPI | GitHub Release |
| --- | --- | --- | --- | | --- | --- | --- | --- |
+387
View File
@@ -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
+80 -15
View File
@@ -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 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(): def test_manifest_checks_use_the_python_placeholder():
entry = github.manifest_entry() entry = github.manifest_entry()
for command in entry["checks"]: for command in entry["checks"]:
@@ -367,27 +424,35 @@ def test_twine_upload_argv_defaults_to_pypi():
assert "--repository" not in argv 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_PASSWORD", raising=False)
monkeypatch.delenv("TWINE_USERNAME", raising=False) hint = pypi.credentials_hint("pypi")
monkeypatch.setattr(pypi.Path, "home", lambda: tmp_path)
hint = pypi.credentials_hint()
assert hint is not None 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): def test_credentials_hint_for_testpypi_mentions_pypi_only(tmp_path, monkeypatch):
monkeypatch.setenv("TWINE_USERNAME", "__token__") from release import auth
monkeypatch.setenv("TWINE_PASSWORD", "pypi-secret")
assert pypi.credentials_hint() is None
monkeypatch.setattr(auth, "pypirc_path", lambda: tmp_path / ".pypirc")
def test_credentials_hint_accepts_pypirc(monkeypatch, tmp_path):
monkeypatch.delenv("TWINE_PASSWORD", raising=False) monkeypatch.delenv("TWINE_PASSWORD", raising=False)
monkeypatch.delenv("TWINE_USERNAME", raising=False) hint = pypi.credentials_hint("testpypi")
(tmp_path / ".pypirc").write_text("[pypi]\n", encoding="utf-8") assert hint is not None
monkeypatch.setattr(pypi.Path, "home", lambda: tmp_path) assert "--set-token testpypi" in hint
assert pypi.credentials_hint() is None 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): def test_index_state_parses_versions(monkeypatch):