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
|
||||
|
||||
|
||||
|
||||
+99
-39
@@ -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:
|
||||
return 1
|
||||
else:
|
||||
print("[ok ] PyPI credentials are configured (twine will read them)")
|
||||
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
|
||||
|
||||
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()
|
||||
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
|
||||
if options.pypi_only:
|
||||
print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
|
||||
else:
|
||||
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user