2026-08-25 17:54:13 +02:00

254 lines
7.3 KiB
Python

"""Publish stubs to PyPI with automatic version bumping.
Fetches the latest published version from PyPI, bumps the revision,
asks for confirmation, then builds and uploads.
"""
import argparse
import configparser
import json
import os
import subprocess
import sys
import tomllib
import urllib.error
import urllib.request
from pathlib import Path
from typing import cast
SCRIPT_DIR = Path(__file__).parent
DIST_DIR = SCRIPT_DIR / "dist"
PACKAGE_NAME = "blender-python-stubs"
PYPI_JSON_URL = "https://pypi.org/pypi/{package}/json"
TEST_PYPI_JSON_URL = "https://test.pypi.org/pypi/{package}/json"
TEST_PYPI_UPLOAD_URL = "https://test.pypi.org/legacy/"
def load_pypirc_token(use_test_pypi: bool) -> str | None:
"""Read a token from ~/.pypirc for the matching index, if present.
`uv publish` does not read ~/.pypirc (twine convention), so we surface it
through UV_PUBLISH_TOKEN. Returns None when no matching token is found.
"""
pypirc = Path.home() / ".pypirc"
if not pypirc.is_file():
return None
parser = configparser.ConfigParser()
try:
parser.read(pypirc)
except configparser.Error:
return None
section = "testpypi" if use_test_pypi else "pypi"
if not parser.has_section(section):
return None
return parser.get(section, "password", fallback=None)
def fetch_latest_revision(blender_version: str, use_test_pypi: bool) -> int | None:
"""Fetch the latest revision for a Blender version from PyPI.
Returns the revision number (the 4th component of X.Y.Z.R) or None
if no matching version is found.
"""
base_url = TEST_PYPI_JSON_URL if use_test_pypi else PYPI_JSON_URL
url = base_url.format(package=PACKAGE_NAME)
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
data: dict[str, object] = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
raw_releases = data.get("releases")
if not isinstance(raw_releases, dict):
return None
releases = cast(dict[str, object], raw_releases)
# Find versions matching this Blender version (e.g., 5.1.0.*)
prefix = blender_version + "."
max_revision = -1
for version_str in releases:
if not version_str.startswith(prefix):
continue
parts = version_str.split(".")
if len(parts) != 4:
continue
try:
revision = int(parts[3])
except ValueError:
continue
if revision > max_revision:
max_revision = revision
return max_revision if max_revision >= 0 else None
def read_generated_version(version_dir: Path) -> str:
"""Read the version from a generated dist pyproject.toml."""
pyproject_path = version_dir / "pyproject.toml"
if not pyproject_path.exists():
print(f"No pyproject.toml found in {version_dir}", file=sys.stderr)
sys.exit(1)
with pyproject_path.open("rb") as f:
data = tomllib.load(f)
version = data.get("project", {}).get("version", "")
if not isinstance(version, str) or not version:
print(f"No version found in {pyproject_path}", file=sys.stderr)
sys.exit(1)
return version
def update_version_in_pyproject(version_dir: Path, new_version: str) -> None:
"""Update the version in the generated pyproject.toml."""
pyproject_path = version_dir / "pyproject.toml"
content = pyproject_path.read_text()
# Replace the version line
lines: list[str] = []
for line in content.splitlines():
if line.startswith("version = "):
lines.append(f'version = "{new_version}"')
else:
lines.append(line)
pyproject_path.write_text("\n".join(lines) + "\n")
def publish_version(
blender_version: str,
publish_url: str,
revision: int | None,
yes: bool,
) -> None:
"""Publish stubs for a single Blender version."""
# Parse blender_version into the full version from the generated stubs
version_dir = DIST_DIR / blender_version
if not version_dir.is_dir():
print(
f"No generated stubs for {blender_version}. Run 'poe generate {blender_version}' first.",
file=sys.stderr,
)
sys.exit(1)
current_version = read_generated_version(version_dir)
parts = current_version.split(".")
if len(parts) != 4:
print(
f"Unexpected version format: {current_version}",
file=sys.stderr,
)
sys.exit(1)
base_version = ".".join(parts[:3]) # e.g., "5.1.0"
use_test_pypi = "test.pypi" in publish_url
if revision is not None:
new_revision = revision
else:
# Fetch latest from PyPI
pypi_label = "Test PyPI" if use_test_pypi else "PyPI"
print(f"Fetching latest version from {pypi_label}...")
latest = fetch_latest_revision(base_version, use_test_pypi)
if latest is not None:
new_revision = latest + 1
print(f" Latest published: {base_version}.{latest}")
else:
new_revision = 0
print(f" No published version found for {base_version}")
new_version = f"{base_version}.{new_revision}"
if not yes:
answer = input(f"Publish {new_version}? [y/N] ")
if answer.lower() not in ("y", "yes"):
print("Aborted.")
sys.exit(0)
# Update pyproject.toml with the new version
update_version_in_pyproject(version_dir, new_version)
print(f" Version set to {new_version}")
# Clean previous build artifacts
build_dist = version_dir / "dist"
if build_dist.exists():
import shutil
shutil.rmtree(build_dist)
# Build
print(" Building...")
result = subprocess.run(
["uv", "build", str(version_dir)],
cwd=str(SCRIPT_DIR),
)
if result.returncode != 0:
print("Build failed.", file=sys.stderr)
sys.exit(1)
# Publish
print(f" Publishing to {publish_url}...")
env = os.environ.copy()
if "UV_PUBLISH_TOKEN" not in env:
token = load_pypirc_token(use_test_pypi)
if token:
env["UV_PUBLISH_TOKEN"] = token
result = subprocess.run(
[
"uv",
"publish",
str(build_dist / "*"),
"--publish-url",
publish_url,
],
cwd=str(SCRIPT_DIR),
env=env,
)
if result.returncode != 0:
print("Publish failed.", file=sys.stderr)
sys.exit(1)
print(f" Published {new_version}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Publish Blender type stubs to PyPI")
parser.add_argument(
"version",
help="Blender version to publish (e.g., 5.1)",
)
parser.add_argument(
"--revision",
type=int,
default=None,
help="Override revision number instead of auto-detecting",
)
parser.add_argument(
"--publish-url",
default=TEST_PYPI_UPLOAD_URL,
help="PyPI upload URL (defaults to Test PyPI)",
)
parser.add_argument(
"-y",
"--yes",
action="store_true",
help="Skip confirmation prompt",
)
args = parser.parse_args()
publish_version(
args.version,
args.publish_url,
args.revision,
args.yes,
)