Joseph HENRY f4df542dd1 Clean up dead code, fix type errors, and add publish workflow
Remove unused _append_ops_call_method, fix _property_data_from_member
call signature, replace sys.version_info guard with hasattr check for
__buffer__ protocol, fix implicit string concatenations, add msgbus
overrides for 4.0-4.4, add conformance tests, and add publish script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:07:52 +02:00

226 lines
6.4 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 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,
)