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
570 lines
19 KiB
Python
570 lines
19 KiB
Python
#!/usr/bin/env python
|
|
# Copyright Advanced Micro Devices, Inc.
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""Structured product-local PEP 503 index generator.
|
|
|
|
Generates pip-compatible PEP 503 simple indexes for the stream-subdomain
|
|
structured layout, where each package lives in its own directory:
|
|
|
|
v5/rocm/<product>/<index>/<normalized-package>/<filename>
|
|
v5/rocm/<product>/<index>/<normalized-package>/index.html
|
|
v5/rocm/<product>/<index>/index.html (product-local root listing)
|
|
|
|
`<index>` is `whl` or `whl-next`. Release streams are selected by hostname
|
|
(e.g. nightly.repo.amd.com), never encoded in the path, so this generator is
|
|
stream-agnostic and knows nothing about CloudFront routing, aggregate
|
|
`/rocm/whl/` roots, or S3 bucket -> URL mapping.
|
|
|
|
Unlike the legacy flat generator (`manage.py`), package identity comes from the
|
|
directory name (authoritative, PEP 503-normalized), package pages use
|
|
same-directory artifact links (no `../`), and no allow-list or version-threshold
|
|
filtering is applied: the directory contents are trusted.
|
|
|
|
Example usage:
|
|
|
|
# Render indexes locally without uploading (the default; writes index.html
|
|
# files under the cwd for inspection):
|
|
python manage_structured.py v5/rocm/core/whl --bucket my-python-bucket
|
|
|
|
# Generate and upload indexes for every package under a product-local root:
|
|
python manage_structured.py v5/rocm/core/whl --bucket my-python-bucket --upload
|
|
|
|
# Regenerate a single package directory (skips the product root index):
|
|
python manage_structured.py v5/rocm/core/whl --bucket my-python-bucket \
|
|
--package numpy --upload
|
|
|
|
The bucket may also be supplied via the `S3_BUCKET_PY` environment variable;
|
|
an explicit `--bucket` takes precedence.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import concurrent.futures
|
|
import dataclasses
|
|
import html
|
|
import re
|
|
import time
|
|
from os import getenv, makedirs, path
|
|
from urllib.parse import quote
|
|
|
|
import boto3
|
|
import botocore.client
|
|
import botocore.exceptions
|
|
from packaging.utils import (
|
|
InvalidSdistFilename,
|
|
InvalidWheelFilename,
|
|
parse_sdist_filename,
|
|
parse_wheel_filename,
|
|
)
|
|
|
|
|
|
# File extensions surfaced in the generated index. Anything else in a package
|
|
# directory (logs, READMEs, PEP 658 .metadata sidecars) is ignored.
|
|
ACCEPTED_FILE_EXTENSIONS = ("whl", "zip", "tar.gz")
|
|
|
|
# S3 multipart uploads produce composite checksums in the form base64==-N.
|
|
# These are not a valid SHA256 of the object and must be discarded.
|
|
_MULTIPART_CHECKSUM_RE = re.compile(r"^[A-Za-z0-9+/=]+=-[0-9]+$")
|
|
|
|
# Metadata fetch concurrency for S3 HEAD requests.
|
|
_METADATA_MAX_WORKERS = 6
|
|
|
|
|
|
def _package_name_from_filename(filename: str) -> str:
|
|
"""Return the PEP 503-normalized package name for a distribution artifact.
|
|
|
|
Uses spec-aware parsing (packaging.utils) rather than a first-hyphen
|
|
heuristic so that sdist/zip filenames with hyphens in the project name
|
|
are handled correctly (e.g. ``llnl-hatchet-2024.1.tar.gz`` ->
|
|
``llnl-hatchet``).
|
|
|
|
Raises:
|
|
ValueError: if the filename cannot be parsed as a wheel or sdist.
|
|
"""
|
|
if filename.endswith(".whl"):
|
|
try:
|
|
name, _, _, _ = parse_wheel_filename(filename)
|
|
return name
|
|
except InvalidWheelFilename as e:
|
|
raise ValueError(f"Cannot parse wheel filename {filename!r}: {e}") from e
|
|
try:
|
|
name, _ = parse_sdist_filename(filename)
|
|
return name
|
|
except InvalidSdistFilename as e:
|
|
raise ValueError(f"Cannot parse sdist filename {filename!r}: {e}") from e
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class PackageFile:
|
|
"""A single distribution artifact inside a package directory.
|
|
|
|
Attributes:
|
|
key: Full S3 key (raw, unescaped). '+' is escaped to '%2B' only when
|
|
rendered into an href.
|
|
filename: Basename of the key (the same-directory link target).
|
|
checksum: SHA256 hex digest, or None until fetched.
|
|
pep658: SHA256 hex digest of the PEP 658 .metadata sidecar, or None.
|
|
size: Object size in bytes, or None until fetched.
|
|
"""
|
|
|
|
key: str
|
|
filename: str
|
|
checksum: str | None
|
|
pep658: str | None
|
|
size: int | None
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class PackageDir:
|
|
"""A discovered package directory and its artifacts.
|
|
|
|
Attributes:
|
|
name: PEP 503-normalized package name (the directory name).
|
|
files: Artifacts contained directly in the directory.
|
|
"""
|
|
|
|
name: str
|
|
files: list[PackageFile]
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class IndexPage:
|
|
"""A rendered index.html and the S3 key it should be written to.
|
|
|
|
Attributes:
|
|
key: Destination S3 key (e.g. "v5/rocm/pytorch/whl/torch/index.html").
|
|
html: Rendered HTML body.
|
|
"""
|
|
|
|
key: str
|
|
html: str
|
|
|
|
|
|
def pep503_normalize(name: str) -> str:
|
|
"""Normalize a package name per PEP 503.
|
|
|
|
Lowercase and collapse runs of "-", "_", and "." to a single "-".
|
|
"""
|
|
return re.sub(r"[-_.]+", "-", name.lower())
|
|
|
|
|
|
def _accepted_file(filename: str) -> bool:
|
|
return filename.endswith(ACCEPTED_FILE_EXTENSIONS)
|
|
|
|
|
|
def discover_packages(
|
|
keys: list[str],
|
|
root: str,
|
|
package: str | None = None,
|
|
) -> list[PackageDir]:
|
|
"""Group S3 keys under a product root into package directories.
|
|
|
|
Only keys of the exact shape `<root>/<package-dir>/<filename>` are
|
|
considered (one level below the root). Files directly under the root,
|
|
more deeply nested files, and index.html pages are ignored.
|
|
|
|
Args:
|
|
keys: Raw S3 keys to group. '+' is escaped to '%2B' only at render
|
|
time, so filenames here carry the literal '+' local-version
|
|
separator that the packaging parsers expect.
|
|
root: The `v5/rocm/<product>/<index>` prefix (no trailing slash
|
|
required).
|
|
package: If set, restrict discovery to this package directory only.
|
|
|
|
Returns:
|
|
Package directories sorted by name, each with files sorted by filename.
|
|
|
|
Raises:
|
|
ValueError: if a package directory name is not PEP 503-normalized, or
|
|
if a contained file's normalized package token does not match its
|
|
directory (a misfiled artifact).
|
|
"""
|
|
root = root.rstrip("/")
|
|
prefix = root + "/"
|
|
grouped: dict[str, list[PackageFile]] = {}
|
|
|
|
for key in keys:
|
|
if not key.startswith(prefix):
|
|
continue
|
|
remainder = key[len(prefix) :]
|
|
parts = remainder.split("/")
|
|
# Require exactly <package-dir>/<filename>: one level below the root.
|
|
if len(parts) != 2:
|
|
continue
|
|
pkg_dir, filename = parts
|
|
if not _accepted_file(filename):
|
|
continue
|
|
|
|
normalized = pep503_normalize(pkg_dir)
|
|
if normalized != pkg_dir:
|
|
raise ValueError(
|
|
f"Package directory name is not normalized: {pkg_dir!r} "
|
|
f"(expected {normalized!r}) in key {key!r}"
|
|
)
|
|
if package is not None and pkg_dir != package:
|
|
continue
|
|
|
|
# parse_wheel_filename / parse_sdist_filename return names already
|
|
# canonicalized to the PEP 503 form (lowercase, dashes), matching
|
|
# pep503_normalize, so this equality holds for any correctly filed
|
|
# artifact regardless of the filename's original underscores/case.
|
|
file_pkg = _package_name_from_filename(filename)
|
|
if file_pkg != pkg_dir:
|
|
raise ValueError(
|
|
f"File {filename!r} does not match package directory "
|
|
f"{pkg_dir!r} (parsed package name {file_pkg!r}) in key {key!r}"
|
|
)
|
|
|
|
grouped.setdefault(pkg_dir, []).append(
|
|
PackageFile(
|
|
key=key,
|
|
filename=filename,
|
|
checksum=None,
|
|
pep658=None,
|
|
size=None,
|
|
)
|
|
)
|
|
|
|
return [
|
|
PackageDir(name=name, files=sorted(grouped[name], key=lambda f: f.filename))
|
|
for name in sorted(grouped)
|
|
]
|
|
|
|
|
|
def _artifact_attributes(file: PackageFile) -> str:
|
|
"""Build the PEP 658 / requires-python attributes for an artifact link."""
|
|
attributes = ""
|
|
if file.pep658:
|
|
pep658_sha = f"sha256={file.pep658}"
|
|
# PEP 714 renames the attribute to data-core-metadata; emit both.
|
|
attributes = (
|
|
f' data-dist-info-metadata="{pep658_sha}"'
|
|
f' data-core-metadata="{pep658_sha}"'
|
|
)
|
|
# networkx version/python constraints (pytorch/pytorch#152191):
|
|
# 3.4.2 for Python 3.10, 3.5+ for Python 3.11+.
|
|
if file.filename == "networkx-3.4.2-py3-none-any.whl":
|
|
attributes += ' data-requires-python=">=3.10"'
|
|
elif file.filename.startswith("networkx-") and file.filename.endswith(
|
|
"-py3-none-any.whl"
|
|
):
|
|
attributes += ' data-requires-python=">=3.11"'
|
|
return attributes
|
|
|
|
|
|
def render_package_page(pkg: PackageDir, skip_checksum: bool = True) -> str:
|
|
"""Render the PEP 503 simple page for a single package directory.
|
|
|
|
Artifact links are same-directory (just the filename), so the generated
|
|
page never depends on `../filename` or public CloudFront path mapping.
|
|
"""
|
|
out: list[str] = [
|
|
"<!DOCTYPE html>",
|
|
"<html>",
|
|
" <body>",
|
|
f" <h1>Links for {pkg.name}</h1>",
|
|
]
|
|
for file in pkg.files:
|
|
# Same-directory invariant: a link must not escape the package dir.
|
|
if "/" in file.filename:
|
|
raise ValueError(
|
|
f"Artifact link would escape package directory: {file.filename!r}"
|
|
)
|
|
maybe_fragment = ""
|
|
if file.checksum and not skip_checksum:
|
|
maybe_fragment = f"#sha256={file.checksum}"
|
|
attributes = _artifact_attributes(file)
|
|
# Escape only at render: quote() percent-encodes '+' (and any other
|
|
# unsafe char) in the href, while html.escape() guards the display text.
|
|
# keeping '/' as a path separator is safe because we rejected it above.
|
|
href = f"{quote(file.filename)}{maybe_fragment}"
|
|
out.append(
|
|
f' <a href="{href}"{attributes}>{html.escape(file.filename)}</a><br/>'
|
|
)
|
|
out.append(" </body>")
|
|
out.append("</html>")
|
|
out.append(f"<!--TIMESTAMP {int(time.time())}-->")
|
|
return "\n".join(out)
|
|
|
|
|
|
def render_root_page(packages: list[PackageDir]) -> str:
|
|
"""Render the product-local root page listing local package directories.
|
|
|
|
This is the product-local root (e.g. v5/rocm/pytorch/whl/index.html), not the
|
|
aggregate /rocm/whl/ root, which is owned by separate infrastructure.
|
|
"""
|
|
out: list[str] = ["<!DOCTYPE html>", "<html>", " <body>"]
|
|
for pkg in sorted(packages, key=lambda p: p.name):
|
|
out.append(f' <a href="{pkg.name}/">{pkg.name}</a><br/>')
|
|
out.append(" </body>")
|
|
out.append("</html>")
|
|
out.append(f"<!--TIMESTAMP {int(time.time())}-->")
|
|
return "\n".join(out)
|
|
|
|
|
|
def build_index_pages(
|
|
packages: list[PackageDir],
|
|
root: str,
|
|
write_root: bool,
|
|
skip_checksum: bool = True,
|
|
) -> list[IndexPage]:
|
|
"""Build all index pages for a set of package directories.
|
|
|
|
Args:
|
|
packages: Discovered package directories.
|
|
root: The `v5/rocm/<product>/<index>` prefix.
|
|
write_root: If True, also generate the product-local root index page.
|
|
Must be False for package-scoped regeneration: a scoped run does
|
|
not see the full package set and would clobber the root listing.
|
|
skip_checksum: If True (default), omit the #sha256 fragment from
|
|
artifact links. Pass False (via --include-checksums) to include.
|
|
|
|
Returns:
|
|
Index pages, one per package directory plus optionally the root.
|
|
"""
|
|
root = root.rstrip("/")
|
|
pages: list[IndexPage] = []
|
|
if write_root:
|
|
pages.append(
|
|
IndexPage(key=f"{root}/index.html", html=render_root_page(packages))
|
|
)
|
|
for pkg in packages:
|
|
pages.append(
|
|
IndexPage(
|
|
key=f"{root}/{pkg.name}/index.html",
|
|
html=render_package_page(pkg, skip_checksum=skip_checksum),
|
|
)
|
|
)
|
|
return pages
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# S3 I/O (CLI path)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def list_keys(
|
|
client: botocore.client.BaseClient,
|
|
bucket_name: str,
|
|
root: str,
|
|
package: str | None,
|
|
) -> list[str]:
|
|
"""List raw object keys under a product root (optionally one package dir).
|
|
|
|
Keys are returned verbatim (with any literal '+'); percent-encoding is
|
|
applied only when a key is rendered into an href, never at storage time.
|
|
"""
|
|
root = root.rstrip("/")
|
|
list_prefix = f"{root}/{package}/" if package else f"{root}/"
|
|
keys: list[str] = []
|
|
paginator = client.get_paginator("list_objects_v2")
|
|
for page in paginator.paginate(Bucket=bucket_name, Prefix=list_prefix):
|
|
for obj in page.get("Contents", []):
|
|
keys.append(obj["Key"])
|
|
return keys
|
|
|
|
|
|
def fetch_metadata(
|
|
client: botocore.client.BaseClient,
|
|
bucket_name: str,
|
|
files: list[PackageFile],
|
|
) -> None:
|
|
"""Populate checksum and size for each file via S3 HEAD requests.
|
|
|
|
Falls back to checksum-sha256 object metadata for older files without a
|
|
native checksum.
|
|
|
|
A single HEAD populates both size and checksum, so a file is fetched
|
|
whenever either field is still missing.
|
|
"""
|
|
with concurrent.futures.ThreadPoolExecutor(
|
|
max_workers=_METADATA_MAX_WORKERS
|
|
) as executor:
|
|
futures = {
|
|
idx: executor.submit(
|
|
lambda key: client.head_object(
|
|
Bucket=bucket_name, Key=key, ChecksumMode="Enabled"
|
|
),
|
|
file.key,
|
|
)
|
|
for idx, file in enumerate(files)
|
|
if file.size is None or file.checksum is None
|
|
}
|
|
for idx, future in futures.items():
|
|
response = future.result()
|
|
raw = response.get("ChecksumSHA256")
|
|
if raw and _MULTIPART_CHECKSUM_RE.match(raw):
|
|
# Composite checksum from a multipart upload — not a valid
|
|
# SHA256 of the object; discard it.
|
|
print(f"WARNING: {files[idx].key} has multipart checksum: {raw}")
|
|
raw = None
|
|
sha256 = base64.b64decode(raw).hex() if raw else None
|
|
if sha256 is None:
|
|
sha256 = response.get("Metadata", {}).get("checksum-sha256")
|
|
if sha256 is None:
|
|
sha256 = response.get("Metadata", {}).get("x-amz-meta-checksum-sha256")
|
|
files[idx].checksum = sha256
|
|
content_length = response.get("ContentLength")
|
|
if content_length is not None:
|
|
files[idx].size = int(content_length)
|
|
|
|
|
|
def fetch_pep658(
|
|
client: botocore.client.BaseClient,
|
|
bucket_name: str,
|
|
files: list[PackageFile],
|
|
) -> None:
|
|
"""Populate the PEP 658 .metadata sidecar checksum for each file."""
|
|
|
|
def _fetch(key: str) -> str | None:
|
|
try:
|
|
response = client.head_object(
|
|
Bucket=bucket_name, Key=f"{key}.metadata", ChecksumMode="Enabled"
|
|
)
|
|
raw = response.get("ChecksumSHA256")
|
|
if raw and _MULTIPART_CHECKSUM_RE.match(raw):
|
|
print(f"WARNING: {key}.metadata has multipart checksum: {raw}")
|
|
return None
|
|
return base64.b64decode(raw).hex() if raw else None
|
|
except botocore.exceptions.ClientError as e:
|
|
if e.response["Error"]["Code"] == "404":
|
|
return None
|
|
raise
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(
|
|
max_workers=_METADATA_MAX_WORKERS
|
|
) as executor:
|
|
futures = {
|
|
idx: executor.submit(_fetch, file.key) for idx, file in enumerate(files)
|
|
}
|
|
for idx, future in futures.items():
|
|
result = future.result()
|
|
if result is not None:
|
|
files[idx].pep658 = result
|
|
|
|
|
|
def upload_pages(
|
|
client: botocore.client.BaseClient,
|
|
bucket_name: str,
|
|
pages: list[IndexPage],
|
|
) -> None:
|
|
"""Upload rendered index pages to S3."""
|
|
for page in pages:
|
|
print(f"INFO Uploading {page.key} to {bucket_name}")
|
|
client.put_object(
|
|
Bucket=bucket_name,
|
|
Key=page.key,
|
|
CacheControl="no-cache,no-store,must-revalidate",
|
|
ContentType="text/html",
|
|
Body=page.html.encode("utf-8"),
|
|
)
|
|
|
|
|
|
def save_pages(pages: list[IndexPage]) -> None:
|
|
"""Write rendered index pages to the local filesystem for inspection."""
|
|
for page in pages:
|
|
print(f"INFO Saving {page.key}")
|
|
makedirs(path.dirname(page.key), exist_ok=True)
|
|
with open(page.key, mode="w", encoding="utf-8") as f:
|
|
f.write(page.html)
|
|
|
|
|
|
def generate_structured_index(
|
|
client: botocore.client.BaseClient,
|
|
bucket_name: str,
|
|
root: str,
|
|
package: str | None = None,
|
|
upload: bool = True,
|
|
skip_checksum: bool = True,
|
|
) -> int:
|
|
"""Regenerate structured product-local indexes for a product root.
|
|
|
|
Args:
|
|
client: A boto3 S3 client.
|
|
bucket_name: Target S3 bucket.
|
|
root: The `v5/rocm/<product>/<index>` prefix.
|
|
package: If set, regenerate only this package directory and skip the
|
|
product-local root index (the scoped listing cannot rebuild the
|
|
root without clobbering it).
|
|
upload: If True, upload to S3; otherwise save pages to local disk.
|
|
skip_checksum: If True (default), omit the #sha256 fragment from
|
|
artifact links. Pass False (via --include-checksums) to include.
|
|
|
|
Returns:
|
|
Number of package directories processed.
|
|
"""
|
|
keys = list_keys(client, bucket_name, root, package)
|
|
packages = discover_packages(keys, root=root, package=package)
|
|
|
|
all_files = [file for pkg in packages for file in pkg.files]
|
|
fetch_metadata(client, bucket_name, all_files)
|
|
fetch_pep658(client, bucket_name, all_files)
|
|
|
|
pages = build_index_pages(
|
|
packages, root=root, write_root=package is None, skip_checksum=skip_checksum
|
|
)
|
|
if upload:
|
|
upload_pages(client, bucket_name, pages)
|
|
else:
|
|
save_pages(pages)
|
|
return len(packages)
|
|
|
|
|
|
def create_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
"Generate structured product-local PEP 503 S3 indexes"
|
|
)
|
|
parser.add_argument(
|
|
"prefix",
|
|
help="Product-local root prefix, e.g. v5/rocm/pytorch/whl or "
|
|
"v5/rocm/core/whl-next",
|
|
)
|
|
parser.add_argument("--bucket", type=str, help="S3 bucket name")
|
|
parser.add_argument(
|
|
"--package",
|
|
type=str,
|
|
default=None,
|
|
help=(
|
|
"Regenerate only this package directory and skip the product root "
|
|
"index (used for targeted single-package regeneration)."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--upload",
|
|
action="store_true",
|
|
help=(
|
|
"Upload the generated index pages to S3 (default: write them to "
|
|
"local disk without uploading)."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--include-checksums",
|
|
action="store_true",
|
|
help="Include #sha256 fragments in artifact links (default: omitted).",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main() -> None:
|
|
parser = create_parser()
|
|
args = parser.parse_args()
|
|
bucket_name = args.bucket or getenv("S3_BUCKET_PY")
|
|
if not bucket_name:
|
|
parser.error("Bucket must be provided via --bucket or S3_BUCKET_PY")
|
|
|
|
client = boto3.client("s3")
|
|
count = generate_structured_index(
|
|
client=client,
|
|
bucket_name=bucket_name,
|
|
root=args.prefix,
|
|
package=args.package,
|
|
upload=args.upload,
|
|
skip_checksum=not args.include_checksums,
|
|
)
|
|
print(f"INFO Processed {count} package directories under {args.prefix}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|