"""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 json 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 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}...") result = subprocess.run( [ "uv", "publish", str(build_dist / "*"), "--publish-url", publish_url, ], cwd=str(SCRIPT_DIR), ) 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, )