Files
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

611 lines
19 KiB
Python

# Copyright Facebook, Inc. and its affiliates.
# Copyright Advanced Micro Devices, Inc.
# SPDX-License-Identifier: BSD-3-Clause
#
# Forked from https://github.com/pytorch/test-infra/blob/1ffc7f7b3b421b57c380de469e11744f54399f09/s3_management/update_dependencies.py.
# Changes incorporated from https://github.com/pytorch/test-infra/blob/a87d94b148bbd2c68e69e542350099a971f4c8d3/s3_management/update_dependencies.py.
"""
Operational notes
-----------------
Architecture prefixes are no longer hardcoded in this script.
To preserve the previous "all architectures under a root" behavior,
use automatic prefix discovery:
python update_dependencies.py \
--package torch \
--auto-detect-prefixes \
--base-prefix v2/
This dynamically discovers child prefixes such as:
v2/gfx110X-all
v2/gfx120X-all
v2/gfx950-dcgpu
For narrow reconciliation of a single prefix, use:
python update_dependencies.py \
--package torch \
--prefix v2/gfx110X-all
"""
from typing import Dict, List
from os import getenv
import re
import boto3 # type: ignore[import-untyped]
from boto3.resources.base import ServiceResource
from botocore.exceptions import ClientError
# Whitelist of allowed wheel platform and Python tags.
# Wheels not matching both criteria are skipped (not uploaded to S3).
# Exact platform tags that are always allowed.
_ALLOWED_PLATFORM_TAGS: frozenset[str] = frozenset(
{
"linux_x86_64",
"win_amd64", # Windows x64 — not excluded by the blacklist
"any", # pure-Python / platform-independent wheels
}
)
# CPython version tags allowed for upload.
# Pure-Python wheels (python tag starting with "py") are also allowed
# regardless of version — they carry no CPython ABI dependency.
_ALLOWED_CPYTHON_TAGS: frozenset[str] = frozenset(
{
"cp310",
"cp311",
"cp312",
"cp313",
"cp314",
}
)
PACKAGES_PER_PROJECT = {
"dbus_python": {"versions": ["latest"], "project": "jax"},
"flatbuffers": {"versions": ["latest"], "project": "jax"},
"ml_dtypes": {"versions": ["latest"], "project": "jax"},
"opt_einsum": {"versions": ["latest"], "project": "jax"},
"tomli": {"versions": ["latest"], "project": "jax"},
"sympy": {"versions": ["latest"], "project": "torch"},
"mpmath": {"versions": ["1.3.0"], "project": "torch"},
"pillow": {"versions": ["latest"], "project": "torch"},
# 3.4.2 for Python 3.10, latest for Python 3.11+
"networkx": {"versions": ["3.4.2", "latest"], "project": "torch"},
"numpy": {"versions": ["latest"], "project": "torch"},
"jinja2": {"versions": ["latest"], "project": "torch"},
"markupsafe": {"versions": ["latest"], "project": "torch"},
"filelock": {"versions": ["latest"], "project": "torch"},
"fsspec": {"versions": ["latest"], "project": "torch"},
"typing-extensions": {"versions": ["latest"], "project": "torch"},
"rocm-bootstrap": {"versions": ["latest"], "project": "rocm"},
"setuptools": {"versions": ["81.0.0"], "project": "rocm"},
}
# Product-local index names for structured publishing. All mirrored
# dependencies land under the "core" product, keyed by one of these indexes.
_STRUCTURED_INDEX_NAMES: frozenset[str] = frozenset({"whl", "whl-next"})
_STRUCTURED_PRODUCT = "core"
def normalize_package_name(name: str) -> str:
"""Normalize a Python distribution name for comparison."""
return re.sub(r"[-_.]+", "-", name).lower()
def structured_dependency_key(index: str, pkg_name: str, filename: str) -> str:
"""Return the structured product-local S3 key for a dependency wheel.
The package directory is derived from ``pkg_name`` (normalized to the
canonical dashed form); ``filename`` is the wheel basename and keeps
its original PEP 427 escaping (underscores).
Example:
structured_dependency_key("whl", "ml_dtypes", "ml_dtypes-0.5.0-...whl")
-> "v5/rocm/core/whl/ml-dtypes/ml_dtypes-0.5.0-...whl"
"""
if index not in _STRUCTURED_INDEX_NAMES:
raise ValueError(
f"index={index!r} is invalid; "
f"expected one of {sorted(_STRUCTURED_INDEX_NAMES)}"
)
package_dir = normalize_package_name(pkg_name)
return f"v5/rocm/{_STRUCTURED_PRODUCT}/{index}/{package_dir}/{filename}"
def get_selected_packages(
*,
package: str = "torch",
dependency_names: frozenset[str] | None = None,
) -> dict[str, dict]:
"""Select entries from PACKAGES_PER_PROJECT for a project (or all).
``package`` is a project name (e.g. "torch") or "all" to span every
project. ``dependency_names`` optionally narrows the result to specific
package names; unknown names raise ValueError.
"""
if package != "all" and package not in get_project_paths():
raise ValueError(
f"Unsupported package '{package}'. Expected 'all' or one of: "
f"{', '.join(get_project_paths())}"
)
project_packages = {
pkg_name: pkg_info
for pkg_name, pkg_info in PACKAGES_PER_PROJECT.items()
if package == "all" or pkg_info["project"] == package
}
if dependency_names is None:
return project_packages
normalized_dependency_names = frozenset(
normalize_package_name(name) for name in dependency_names
)
available_names = frozenset(
normalize_package_name(name) for name in project_packages
)
unmatched = normalized_dependency_names - available_names
if unmatched:
raise ValueError(
f"Unknown --dependency-package value(s) for '{package}': "
f"{sorted(unmatched)}. Valid names: {sorted(available_names)}"
)
return {
pkg_name: pkg_info
for pkg_name, pkg_info in project_packages.items()
if normalize_package_name(pkg_name) in normalized_dependency_names
}
def get_project_paths() -> List[str]:
# Deduplicate project names from PACKAGES_PER_PROJECT and return them sorted.
return sorted(
set(pkg_info["project"] for pkg_info in PACKAGES_PER_PROJECT.values())
)
def get_dependency_package_names(project: str) -> frozenset[str]:
"""
Return dependency package names for the given project.
Used by Lambda-side dependency trigger filtering.
"""
return frozenset(
pkg_name
for pkg_name, pkg_info in PACKAGES_PER_PROJECT.items()
if pkg_info["project"] == project
)
def get_s3_bucket(bucket_name: str | None = None) -> ServiceResource:
s3 = boto3.resource("s3")
resolved_bucket_name = bucket_name or getenv("S3_BUCKET_PY")
if not resolved_bucket_name:
raise RuntimeError("Bucket must be provided via --bucket or S3_BUCKET_PY")
return s3.Bucket(resolved_bucket_name)
def detect_prefixes_from_bucket(bucket: ServiceResource, base_prefix: str) -> List[str]:
normalized_base_prefix = base_prefix.rstrip("/") + "/"
print(f"INFO: Auto-detecting prefixes under '{normalized_base_prefix}'")
# Reuse the bucket-associated client/session.
client = bucket.meta.client
paginator = client.get_paginator("list_objects_v2")
page_iterator = paginator.paginate(
Bucket=bucket.name,
Prefix=normalized_base_prefix,
Delimiter="/",
)
prefixes: set[str] = set()
for page in page_iterator:
for common_prefix in page.get("CommonPrefixes", []):
prefixes.add(common_prefix["Prefix"].rstrip("/"))
detected_prefixes = sorted(prefixes)
print(f"INFO: Detected prefixes: {detected_prefixes}")
return detected_prefixes
def resolve_target_prefixes(
*,
bucket: ServiceResource,
explicit_prefix: str | None = None,
auto_detect_prefixes: bool = False,
base_prefix: str | None = None,
) -> List[str]:
if explicit_prefix:
return [explicit_prefix.rstrip("/")]
if base_prefix and not auto_detect_prefixes:
raise RuntimeError(
"--auto-detect-prefixes must be provided when using --base-prefix"
)
if auto_detect_prefixes:
if not base_prefix:
raise RuntimeError(
"--base-prefix must be provided when using --auto-detect-prefixes"
)
return detect_prefixes_from_bucket(bucket, base_prefix)
raise RuntimeError(
"Must provide either --prefix or --auto-detect-prefixes with --base-prefix"
)
def download(url: str) -> bytes:
from urllib.request import urlopen
with urlopen(url) as conn:
return conn.read()
def is_stable(package_version: str) -> bool:
return bool(re.match(r"^([0-9]+\.)+[0-9]+$", package_version))
def parse_simple_idx(url: str) -> Dict[str, str]:
html = download(url).decode("ascii")
return {
name: url
for (url, name) in re.findall('<a href="([^"]+)"[^>]*>([^>]+)</a>', html)
}
def get_whl_versions(idx: Dict[str, str]) -> List[str]:
return [
k.split("-")[1]
for k in idx.keys()
if k.endswith(".whl") and is_stable(k.split("-")[1])
]
def get_wheels_of_version(idx: Dict[str, str], version: str) -> Dict[str, str]:
return {
k: v
for (k, v) in idx.items()
if k.endswith(".whl") and k.split("-")[1] == version
}
def is_wheel_allowed(pkg: str) -> bool:
"""Return True if this wheel filename should be uploaded to S3.
Both criteria must be satisfied:
1. Platform tag is "linux_x86_64", "win_amd64", "any", or starts with
"manylinux" and ends with "_x86_64" (e.g., "manylinux_2_17_x86_64").
This rejects win32, win_arm64, macOS, musllinux, ARM, RISC-V, iOS, etc.
2. Python tag is in _ALLOWED_CPYTHON_TAGS, or is exactly "py3"
(pure-Python wheels). This rejects PyPy (pp*), cp39, cp313t,
cp314t, py2, py2.py3, etc.
Per PEP 427, the wheel stem is:
{name}-{version}[-{build}]-{python}-{abi}-{platform}
The last three fields are always python, abi, platform — regardless of
whether the optional build tag is present.
"""
if not pkg.endswith(".whl"):
return False
parts = pkg[:-4].split("-")
if len(parts) < 5:
return False # Malformed — skip rather than guess
platform_tag = parts[-1]
python_tag = parts[-3]
platform_ok = platform_tag in _ALLOWED_PLATFORM_TAGS or (
platform_tag.startswith("manylinux") and platform_tag.endswith("_x86_64")
)
python_ok = python_tag in _ALLOWED_CPYTHON_TAGS or python_tag == "py3"
return platform_ok and python_ok
def s3_object_exists(bucket: ServiceResource, key: str) -> bool:
try:
bucket.meta.client.head_object(Bucket=bucket.name, Key=key)
return True
except ClientError as exc:
error_code = exc.response.get("Error", {}).get("Code")
if error_code in {"404", "NoSuchKey", "NotFound"}:
return False
raise
def upload_missing_whls(
bucket: ServiceResource,
pkg_name: str = "numpy",
prefix: str = "whl/test",
*,
dry_run: bool = False,
only_pypi: bool = False,
target_version: str = "latest",
structured: bool = False,
index: str = "whl",
) -> None:
pypi_idx = parse_simple_idx(f"https://pypi.org/simple/{pkg_name}")
pypi_versions = get_whl_versions(pypi_idx)
# Determine which version to use
if target_version == "latest" or not target_version:
selected_version = pypi_versions[-1] if pypi_versions else None
elif target_version in pypi_versions:
selected_version = target_version
else:
print(
f"Warning: Version {target_version} not found for {pkg_name}, using latest"
)
selected_version = pypi_versions[-1] if pypi_versions else None
if not selected_version:
print(f"No stable versions found for {pkg_name}")
return
pypi_latest_packages = get_wheels_of_version(pypi_idx, selected_version)
# if not only_pypi:
# download_idx = parse_simple_idx(
# f"https://download.pytorch.org/{prefix}/{pkg_name}"
# )
# Destination directory for status messages. In structured mode the flat
# prefix is unused, so report the actual package directory instead.
if structured:
location = f"{_STRUCTURED_PRODUCT}/{index}/{normalize_package_name(pkg_name)}"
else:
location = prefix
has_updates = False
uploaded_or_present = 0
for pkg in pypi_latest_packages:
if not is_wheel_allowed(pkg):
continue
if structured:
s3_key = structured_dependency_key(index, pkg_name, pkg)
else:
s3_key = f"{prefix}/{pkg}"
dest_dir = s3_key.rsplit("/", 1)[0]
if s3_object_exists(bucket, s3_key):
print(f"Skipping existing {pkg} at s3://{bucket.name}/{s3_key}")
uploaded_or_present += 1
continue
print(f"Downloading {pkg}")
if dry_run:
has_updates = True
uploaded_or_present += 1
print(f"Dry Run - not Uploading {pkg} to s3://{bucket.name}/{dest_dir}/")
continue
data = download(pypi_idx[pkg])
print(f"Uploading {pkg} to s3://{bucket.name}/{dest_dir}/")
bucket.Object(key=s3_key).put(ContentType="binary/octet-stream", Body=data)
has_updates = True
uploaded_or_present += 1
if uploaded_or_present == 0:
print(
f"No allowed wheels found for {pkg_name} version {selected_version} "
f"for {location}"
)
elif not has_updates:
print(
f"{pkg_name} is already at latest version {selected_version} "
f"for {location}"
)
def run_update_dependencies(
*,
package: str = "torch",
dry_run: bool = False,
only_pypi: bool = False,
bucket_name: str | None = None,
prefix: str | None = None,
auto_detect_prefixes: bool = False,
base_prefix: str | None = None,
dependency_names: frozenset[str] | None = None,
structured: bool = False,
index: str = "whl",
) -> None:
print(
f"Running update_dependencies for package={package}, "
f"structured={structured}, dry_run={dry_run}"
)
selected_packages = get_selected_packages(
package=package, dependency_names=dependency_names
)
if not selected_packages:
raise ValueError(f"No dependency packages selected for '{package}'")
bucket = get_s3_bucket(bucket_name)
if structured:
_run_structured(
bucket=bucket,
selected_packages=selected_packages,
index=index,
dry_run=dry_run,
only_pypi=only_pypi,
)
return
target_prefixes = resolve_target_prefixes(
bucket=bucket,
explicit_prefix=prefix,
auto_detect_prefixes=auto_detect_prefixes,
base_prefix=base_prefix,
)
for full_path in target_prefixes:
for pkg_name, pkg_info in selected_packages.items():
pkg_prefix = full_path
if "target" in pkg_info and pkg_info["target"] != "":
pkg_prefix = f"{full_path}/{pkg_info['target']}"
for target_version in pkg_info["versions"]:
upload_missing_whls(
bucket,
pkg_name,
pkg_prefix,
dry_run=dry_run,
only_pypi=only_pypi,
target_version=target_version,
)
def _run_structured(
*,
bucket: ServiceResource,
selected_packages: dict[str, dict],
index: str,
dry_run: bool,
only_pypi: bool,
) -> None:
"""Mirror selected dependencies into core/<index>/<package>/ directories.
The structured layout is not prefix-driven: each wheel's destination key is
computed from its package name, so there is no prefix fan-out.
"""
if index not in _STRUCTURED_INDEX_NAMES:
raise ValueError(
f"index={index!r} is invalid; "
f"expected one of {sorted(_STRUCTURED_INDEX_NAMES)}"
)
for pkg_name, pkg_info in selected_packages.items():
for target_version in pkg_info["versions"]:
upload_missing_whls(
bucket,
pkg_name,
dry_run=dry_run,
only_pypi=only_pypi,
target_version=target_version,
structured=True,
index=index,
)
def main() -> None:
from argparse import ArgumentParser
parser = ArgumentParser("Upload dependent packages to S3")
project_paths = get_project_paths()
parser.add_argument(
"--package",
choices=[*project_paths, "all"],
default=None,
help=(
"Project whose dependencies to mirror, or 'all' for every "
"project. Default: 'all' in structured mode, 'torch' otherwise."
),
)
parser.add_argument(
"--structured",
action="store_true",
help=(
"Mirror dependencies into product-local package directories "
"(core/<index>/<package>/) instead of a flat prefix. Incompatible "
"with --prefix, --auto-detect-prefixes, and --base-prefix."
),
)
parser.add_argument(
"--index",
default="whl",
choices=["whl", "whl-next"],
help=(
"Product-local index name for structured publishing (default: "
"whl). Selects the core/<index>/ path segment."
),
)
parser.add_argument("--bucket", type=str, help="S3 bucket name")
parser.add_argument(
"--prefix",
type=str,
help=(
"Explicit prefix to update "
"(e.g. v2/gfx110X-all, v2-staging/gfx110X-all, v4/whl)"
),
)
parser.add_argument(
"--auto-detect-prefixes",
action="store_true",
help=(
"Automatically detect architecture prefixes under the given base "
"path using S3 CommonPrefixes."
),
)
parser.add_argument(
"--base-prefix",
type=str,
help=(
"Base prefix for auto-detection (e.g. v2/, v2-staging/, v3/). "
"Required when using --auto-detect-prefixes."
),
)
parser.add_argument(
"--dependency-package",
action="append",
dest="dependency_packages",
help=(
"Limit reconciliation to one dependency package. "
"Can be passed multiple times."
),
)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--only-pypi", action="store_true")
args = parser.parse_args()
if args.structured:
conflicting = [
name
for name, value in (
("--prefix", args.prefix),
("--auto-detect-prefixes", args.auto_detect_prefixes),
("--base-prefix", args.base_prefix),
)
if value
]
if conflicting:
parser.error(
f"--structured is incompatible with flat-only flag(s): "
f"{', '.join(conflicting)}"
)
# Default package depends on mode: structured mirrors everything by default;
# flat keeps the historical single-project ("torch") default.
package = args.package
if package is None:
package = "all" if args.structured else "torch"
run_update_dependencies(
package=package,
dry_run=args.dry_run,
only_pypi=args.only_pypi,
bucket_name=args.bucket,
prefix=args.prefix,
auto_detect_prefixes=args.auto_detect_prefixes,
base_prefix=args.base_prefix,
dependency_names=(
frozenset(args.dependency_packages) if args.dependency_packages else None
),
structured=args.structured,
index=args.index,
)
if __name__ == "__main__":
main()