-
Notifications
You must be signed in to change notification settings - Fork 142
Expand file tree
/
Copy pathmanage_v2.py
More file actions
executable file
·1946 lines (1749 loc) · 72.9 KB
/
Copy pathmanage_v2.py
File metadata and controls
executable file
·1946 lines (1749 loc) · 72.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
#
# manage_v2.py - Manage S3 and Cloudflare R2 HTML package indices for PyTorch
#
# This script generates and uploads PEP 503-compliant HTML index pages for
# PyTorch wheel packages, libtorch archives, and source code tarballs hosted
# on S3 (download.pytorch.org) and optionally mirrored to Cloudflare R2.
#
# Core functionality:
# - Reads package listings from the S3 "pytorch" bucket, builds per-package
# and per-subdirectory index.html pages, and uploads them back to S3/R2.
# - For wheel (whl) prefixes, generates PEP 503 simple repository HTML that
# includes sha256 checksums and PEP 658 metadata links when available.
# - For libtorch prefixes, generates a flat HTML listing of archives.
# - For source_code prefixes, generates a flat HTML listing of tarballs.
# - Copies flash-attn-3 indices from whl/ to whl/nightly/ for nightly builds.
# - Nightly packages are pruned to keep only the N most recent versions
# (controlled by KEEP_THRESHOLD).
# - PACKAGE_ALLOW_LIST controls which packages are indexed; packages not in
# the allow list are excluded from generated indices.
# - PACKAGE_LINKS_ALLOW_LIST packages have their index.html copied from
# parent directories rather than regenerated from wheel listings, so they
# can point to external package sources.
#
# SHA256 checksum management:
# - --compute-sha256: download each package, compute its SHA256, and store
# the digest as S3 object metadata (x-amz-meta-checksum-sha256).
# - --set-checksum: compute and set SHA256 metadata for a specific
# package/version combination (requires --package-name and --package-version).
# - --recompute-sha256-pattern PATTERN: compute SHA256 for all .whl files
# matching PATTERN under the given prefix that are missing checksums.
# - --recompute-missing-sha256: scan the entire prefix for .whl files that
# are missing x-amz-meta-checksum-sha256 metadata and compute/set it.
# Example: python s3_management/manage_v2.py channel --recompute-missing-sha256
# where "channel" is one of: whl, whl/nightly, whl/test, libtorch,
# libtorch/nightly, whl/test/variant, whl/variant, whl/preview/forge,
# source_code/test, or "all" to process every prefix.
#
# Dual-backend upload:
# When R2 credentials are configured (R2_ACCOUNT_ID, R2_ACCESS_KEY_ID,
# R2_SECRET_ACCESS_KEY), all index uploads are written to both the S3
# "pytorch" bucket and the Cloudflare R2 bucket in parallel.
#
# Usage examples:
# # Generate and upload indices for all prefixes:
# python s3_management/manage_v2.py all
#
# # Generate indices locally without uploading (dry run):
# python s3_management/manage_v2.py whl/nightly --do-not-upload
#
# # Compute SHA256 checksums for all packages in a prefix:
# python s3_management/manage_v2.py whl/test --compute-sha256
#
# # Set checksum for a specific package and version:
# python s3_management/manage_v2.py whl/test --set-checksum \
# --package-name torch --package-version 2.5.0+cu121
#
# # Recompute missing SHA256 checksums for a channel:
# python s3_management/manage_v2.py whl/nightly --recompute-missing-sha256
#
# # Recompute SHA256 for a specific subdir pattern:
# python s3_management/manage_v2.py whl/test --recompute-sha256-pattern rocm6.4
import argparse
import base64
import concurrent.futures
import dataclasses
import functools
import hashlib
import os
import time
from collections import defaultdict
from os import makedirs, path
from re import match, sub
from typing import Dict, Iterable, List, Optional, Set, TypeVar
import boto3 # type: ignore[import]
import botocore # type: ignore[import]
from packaging.version import InvalidVersion, parse as _parse_version, Version
# S3 client for reading
S3 = boto3.resource("s3")
CLIENT = boto3.client("s3")
# bucket for download.pytorch.org (reading only)
BUCKET = S3.Bucket("pytorch")
# Cloudflare R2 configuration for writing indexes
# Set these environment variables:
# - R2_ACCOUNT_ID
# - R2_ACCESS_KEY_ID
# - R2_SECRET_ACCESS_KEY
# - R2_BUCKET_NAME (e.g., "pytorch-downloads")
R2_ACCOUNT_ID = os.environ.get("R2_ACCOUNT_ID", "")
R2_ACCESS_KEY_ID = os.environ.get("R2_ACCESS_KEY_ID", "")
R2_SECRET_ACCESS_KEY = os.environ.get("R2_SECRET_ACCESS_KEY", "")
R2_BUCKET_NAME = os.environ.get("R2_BUCKET_NAME", "pytorch-downloads")
# Create R2 client with custom endpoint
R2_BUCKET = None
if R2_ACCOUNT_ID and R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY:
R2_CLIENT = boto3.client(
"s3",
endpoint_url=f"https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com",
aws_access_key_id=R2_ACCESS_KEY_ID,
aws_secret_access_key=R2_SECRET_ACCESS_KEY,
region_name="auto", # R2 uses 'auto' as region
)
R2_RESOURCE = boto3.resource(
"s3",
endpoint_url=f"https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com",
aws_access_key_id=R2_ACCESS_KEY_ID,
aws_secret_access_key=R2_SECRET_ACCESS_KEY,
region_name="auto",
)
R2_BUCKET = R2_RESOURCE.Bucket(R2_BUCKET_NAME)
print(
f"INFO: Will upload indexes to both S3 'pytorch' bucket and R2 '{R2_BUCKET_NAME}' bucket"
)
else:
print("WARNING: R2 credentials not configured, will only upload to S3")
ACCEPTED_FILE_EXTENSIONS = ("whl", "zip", "tar.gz", "json")
ACCEPTED_SUBDIR_PATTERNS = [
r"cu[0-9]+", # for cuda
r"rocm[0-9]+\.[0-9]+", # for rocm
"cpu",
"xpu",
]
# These are legacy build todo: delete these
NOT_ACCEPTED_SUBDIR_PATTERNS = [
"cpu-cxx11-abi",
"cpu_pypi_pkg",
"cu[0-9]+_full",
"cu[0-9]+_pypi_cudnn",
]
PREFIXES = [
"whl",
"whl/nightly",
"whl/test",
"libtorch",
"libtorch/nightly",
"whl/test/variant",
"whl/variant",
"whl/preview/forge",
"source_code/test",
]
# NOTE: This refers to the name on the wheels themselves and not the name of
# package as specified by setuptools, for packages with "-" (hyphens) in their
# names you need to convert them to "_" (underscores) in order for them to be
# allowed here since the name of the wheels is compared here
PACKAGE_ALLOW_LIST = {
x.lower()
for x in [
# ---- torchtune additional packages ----
"aiohttp",
"aiosignal",
"aiohappyeyeballs",
"antlr4_python3_runtime",
"antlr4-python3-runtime",
"async_timeout",
"attrs",
"blobfile",
"datasets",
"dill",
"frozenlist",
"huggingface_hub",
"llnl_hatchet",
"lxml",
"jinja2",
"multidict",
"multiprocess",
"omegaconf",
"pandas",
"psutil",
"pyarrow",
"pyarrow_hotfix",
"pycryptodomex",
"python_dateutil",
"pytz",
"PyYAML",
"regex",
"safetensors",
"sentencepiece",
"six",
"tiktoken",
"torchao",
"torchao_nightly",
"tzdata",
"xxhash",
"yarl",
"pep_xxx_wheel_variants",
"nvidia_variant_provider",
# ---- triton additional packages ----
"Arpeggio",
"caliper_reader",
"contourpy",
"cycler",
"dill",
"fonttools",
"kiwisolver",
"llnl-hatchet",
"matplotlib",
"pandas",
"pydot",
"pyparsing",
"pytz",
"textx",
"tzdata",
"importlib_metadata",
"importlib_resources",
"zipp",
# ----
"certifi",
"charset_normalizer",
"cmake",
"colorama",
"fbgemm_gpu",
"fbgemm_gpu_genai",
"idna",
"iopath",
"lit",
"lightning_utilities",
"MarkupSafe",
"mypy_extensions",
"nestedtensor",
"nvidia_cublas_cu11",
"nvidia_cuda_cupti_cu11",
"nvidia_cuda_nvrtc_cu11",
"nvidia_cuda_runtime_cu11",
"nvidia_cufft_cu11",
"nvidia_curand_cu11",
"nvidia_cusolver_cu11",
"nvidia_cusparse_cu11",
"nvidia_nccl_cu11",
"nvidia_nvtx_cu11",
"packaging",
"portalocker",
"pyre_extensions",
"pytorch_triton",
"pytorch_triton_rocm",
"pytorch_triton_xpu",
"triton_rocm",
"triton_xpu",
"requests",
"torch_no_python",
"torch",
"torch_tensorrt",
"torch_tensorrt_rtx",
"torcharrow",
"torchaudio",
"torchcodec",
"torchcsprng",
"torchdata",
"torchdistx",
"torchmetrics",
"torchrec",
"torchtext",
"torchtune",
"torchtitan",
"torchvision",
"torchcomms",
"torchvision_extra_decoders",
"triton",
"tqdm",
"typing_inspect",
"urllib3",
"xformers",
"executorch",
"setuptools",
"setuptools_scm",
"wheel",
"flash_attn_3",
# vllm
"ninja",
"cuda_python",
"cuda_bindings",
"cuda_pathfinder",
"cuda_toolkit",
"pynvml",
"nvidia_ml_py",
"einops",
"packaging",
"nvidia_cudnn_frontend",
"cachetools",
"blake3",
"py_cpuinfo",
"transformers",
"hf_xet",
"tokenizers",
"protobuf",
"fastapi",
"annotated_types",
"anyio",
"pydantic",
"pydantic_core",
"sniffio",
"starlette",
"typing_inspection",
"openai",
"distro",
"h11",
"httpcore",
"httpx",
"jiter",
"prometheus_client",
"prometheus_fastapi_instrumentator",
"lm_format_enforcer",
"interegular",
"llguidance",
"outlines_core",
"diskcache",
"lark",
"xgrammar",
"partial_json_parser",
"pyzmq",
"msgspec",
"gguf",
"mistral_common",
"rpds_py",
"pycountry",
"referencing",
"pydantic_extra_types",
"jsonschema_specifications",
"jsonschema",
"opencv_python_headless",
"compressed_tensors",
"frozendict",
"depyf",
"astor",
"cloudpickle",
"watchfiles",
"python_json_logger",
"scipy",
"pybase64",
"cbor2",
"setproctitle",
"openai_harmony",
"numba",
"llvmlite",
"ray",
"click",
"msgpack",
"fastapi_cli",
"fastapi_cloud_cli",
"httptools",
"markdown_it_py",
"pygments",
"python_dotenv",
"rich",
"rich_toolkit",
"shellingham",
"typer",
"uvicorn",
"uvloop",
"websockets",
"python_multipart",
"email_validator",
"dnspython",
"mdurl",
"rignore",
"sentry_sdk",
"cupy_cuda12x",
"fastrlock",
"soundfile",
"cffi",
"pycparser",
"vllm",
"flashinfer_python",
# ---- forge additional packages ----
"absl_py",
"annotated_types",
"docker",
"docstring_parser",
"exceptiongroup",
"torchforge",
"gitdb",
"GitPython",
"grpcio",
"hf_transfer",
"Markdown",
"MarkupSafe",
"monarch",
"opentelemetry_api",
"pip",
"platformdirs",
"propcache",
"Pygments",
"pygtrie",
"rignore",
"shtab",
"smmap",
"soxr",
"tabulate",
"tensorboard",
"tensorboard_data_server",
"tomli",
"torchshow",
"torchstore",
"torchx_nightly",
"tqdm",
"transformers",
"typeguard",
"tyro",
"wandb",
"Werkzeug",
"yarl",
"zipp",
"mslk",
]
}
# PyTorch foundation packages and required dependencies
# such as triton that should use relative URLs
# All other packages will use CloudFront absolute URLs
PT_FOUNDATION_PACKAGES = {
"torch",
"torchaudio",
"torchvision",
"fbgemm_gpu",
"fbgemm_gpu_genai",
"triton",
"triton_rocm",
"triton_xpu",
"pytorch_triton",
"pytorch_triton_rocm",
"pytorch_triton_xpu",
}
# Packages that should use R2 (download-r2.pytorch.org) for nightly builds
# These packages will have their URLs point to R2 instead of S3/CloudFront
# when the path is whl/nightly
PT_R2_PACKAGES = {
"torch",
"torchvision",
"torchaudio",
"fbgemm_gpu",
"fbgemm_gpu_genai",
"triton",
"triton_rocm",
"triton_xpu",
"pytorch_triton",
"pytorch_triton_rocm",
"pytorch_triton_xpu",
}
# Packages that should use R2 (download-r2.pytorch.org) for prod/stable builds
# These packages will have their URLs point to R2 instead of S3/CloudFront
# when the path is NOT whl/test and NOT whl/nightly (i.e., prod)
PT_R2_PACKAGES_PROD = {
"torchaudio",
"torchvision",
"fbgemm_gpu",
"fbgemm_gpu_genai",
}
# Packages that should have their root index.html copied to subdirectories
# instead of processing wheels in subdirectories
# For example: whl/nightly/filelock/index.html -> whl/nightly/cu128/filelock/index.html
PACKAGE_LINKS_ALLOW_LIST = {
x.lower()
for x in [
"filelock",
"sympy",
"mpmath",
"pillow",
"networkx",
"numpy",
"fsspec",
"typing-extensions",
"cuda-bindings",
"cuda-toolkit",
"nvidia-cuda-nvrtc-cu12",
"nvidia-cuda-nvrtc",
"nvidia-cuda-runtime-cu12",
"nvidia-cuda-runtime",
"nvidia-cuda-cupti-cu12",
"nvidia-cuda-cupti",
"nvidia-cuda-cccl-cu12",
"nvidia-cuda-cccl",
"nvidia-cudnn-cu12",
"nvidia-cudnn-cu13",
"nvidia-cublas-cu12",
"nvidia-cublas",
"nvidia-cufft-cu12",
"nvidia-cufft",
"nvidia-curand-cu12",
"nvidia-curand",
"nvidia-cusolver-cu12",
"nvidia-cusolver",
"nvidia-cusparse-cu12",
"nvidia-cusparse",
"nvidia-cusparselt-cu12",
"nvidia-cusparselt-cu13",
"nvidia-nccl-cu12",
"nvidia-nccl-cu13",
"nvidia-nvshmem-cu12",
"nvidia-nvshmem-cu13",
"nvidia-nvtx-cu12",
"nvidia-nvtx",
"nvidia-nvjitlink-cu12",
"nvidia-nvjitlink",
"nvidia-cufile-cu12",
"nvidia-cufile",
# torch_xpu packages
"dpcpp-cpp-rt",
"intel-cmplr-lib-rt",
"intel-cmplr-lib-ur",
"intel-cmplr-lic-rt",
"intel-opencl-rt",
"intel-sycl-rt",
"intel-openmp",
"tcmlib",
"umf",
"intel-pti",
"tbb",
"oneccl-devel",
"oneccl",
"impi-rt",
"onemkl-sycl-blas",
"onemkl-sycl-dft",
"onemkl-sycl-lapack",
"onemkl-sycl-sparse",
"onemkl-sycl-rng",
"onemkl-license",
"mkl",
"pyelftools",
]
}
# How many packages should we keep of a specific package?
KEEP_THRESHOLD = 60
# Package index files to copy from whl/ to whl/nightly/ during nightly updates.
# Copies the root-level index and all cu* subdirectory indexes.
FLASH_ATTN_3_COPY_PACKAGE = "flash-attn-3"
S3IndexType = TypeVar("S3IndexType", bound="S3Index")
@dataclasses.dataclass(frozen=False)
@functools.total_ordering
class S3Object:
key: str
orig_key: str
checksum: Optional[str]
size: Optional[int]
pep658: Optional[str]
def __hash__(self):
return hash(self.key)
def __str__(self):
return self.key
def __eq__(self, other):
return self.key == other.key
def __lt__(self, other):
return self.key < other.key
def safe_parse_version(ver_str: str) -> Version:
try:
return _parse_version(ver_str) # type: ignore[return-value]
except InvalidVersion:
return Version("0.0.0")
class S3Index:
def __init__(self, objects: List[S3Object], prefix: str) -> None:
self.objects = objects
self.prefix = prefix.rstrip("/")
self.html_name = "index.html"
# should dynamically grab subdirectories like whl/test/cu101
# so we don't need to add them manually anymore
self.subdirs = {
path.dirname(obj.key) for obj in objects if path.dirname != prefix
}
# Cache for expensive computations
self._package_name_cache: Dict[str, str] = {}
self._parent_packages_cache: Dict[str, Set[str]] = {}
# Cache for S3 bucket object listings to avoid repeated API calls
self._bucket_listing_cache: Dict[str, List] = {}
def packages_by_allow_list(self) -> List[S3Object]:
"""Filter packages to only include those in PACKAGE_ALLOW_LIST
This method filters packages without applying version thresholds,
keeping all versions of allowed packages.
"""
return [
obj
for obj in self.objects
if self.obj_to_package_name(obj) in PACKAGE_ALLOW_LIST
]
def nightly_packages_to_show(self) -> List[S3Object]:
"""Finding packages to show based on a threshold we specify
Basically takes our S3 packages, normalizes the version for easier
comparisons, then iterates over normalized versions until we reach a
threshold and then starts adding package to delete after that threshold
has been reached
After figuring out what versions we'd like to hide we iterate over
our original object list again and pick out the full paths to the
packages that are included in the list of versions to delete
"""
# also includes versions without GPU specifier (i.e. cu102) for easier
# sorting, sorts in reverse to put the most recent versions first
all_sorted_packages = sorted(
{self.normalize_package_version(obj) for obj in self.objects},
key=lambda name_ver: safe_parse_version(name_ver.split("-", 1)[-1]),
reverse=True,
)
packages: Dict[str, int] = defaultdict(int)
to_hide: Set[str] = set()
for obj in all_sorted_packages:
full_package_name = path.basename(obj)
package_name = full_package_name.split("-")[0]
# Hard pass on packages that are included in our allow list
if package_name.lower() not in PACKAGE_ALLOW_LIST:
to_hide.add(obj)
continue
if packages[package_name] >= KEEP_THRESHOLD:
to_hide.add(obj)
else:
packages[package_name] += 1
return list(
set(self.objects).difference(
{
obj
for obj in self.objects
if self.normalize_package_version(obj) in to_hide
}
)
)
def is_obj_at_root(self, obj: S3Object) -> bool:
return path.dirname(obj.key) == self.prefix
def _resolve_subdir(self, subdir: Optional[str] = None) -> str:
if not subdir:
subdir = self.prefix
# make sure we strip any trailing slashes
return subdir.rstrip("/")
def gen_file_list(
self, subdir: Optional[str] = None, package_name: Optional[str] = None
) -> Iterable[S3Object]:
objects = self.objects
subdir = self._resolve_subdir(subdir) + "/"
for obj in objects:
if (
package_name is not None
and self.obj_to_package_name(obj) != package_name
):
continue
if self.is_obj_at_root(obj) or obj.key.startswith(subdir):
yield obj
def get_package_names(self, subdir: Optional[str] = None) -> List[str]:
return sorted(
{self.obj_to_package_name(obj) for obj in self.gen_file_list(subdir)}
)
def _get_bucket_listing(self, prefix: str) -> List:
"""Get bucket listing with caching to avoid repeated S3 API calls"""
if prefix not in self._bucket_listing_cache:
self._bucket_listing_cache[prefix] = list(
BUCKET.objects.filter(Prefix=prefix)
)
return self._bucket_listing_cache[prefix]
def get_packages_to_copy_from_parent(
self, subdir: str, parent_prefix: str
) -> Set[str]:
"""Get packages from PACKAGE_LINKS_ALLOW_LIST that exist in parent but not in subdir
Args:
subdir: The subdirectory being processed (e.g., "whl/nightly/cu128")
parent_prefix: The parent prefix to copy from (e.g., "whl/nightly")
Returns:
Set of package names that should be copied from parent
"""
# Use cache to avoid repeated S3 API calls
cache_key = f"{parent_prefix}:{subdir}"
if cache_key in self._parent_packages_cache:
return self._parent_packages_cache[cache_key]
packages_to_copy = set()
# Get packages in the subdirectory
packages_in_subdir = set(self.get_package_names(subdir=subdir))
# Batch process all objects with a single cached filter call
prefix_to_search = f"{parent_prefix}/"
# Collect all package index files in one pass using cached bucket listing
parent_packages = set()
for obj in self._get_bucket_listing(prefix_to_search):
# Check if this is a packagename/index.html file at the parent level
relative_key = obj.key[len(prefix_to_search) :]
parts = relative_key.split("/")
if len(parts) == 2 and parts[1] == "index.html":
# Convert from URL format (dashes) to package name format (underscores)
pkg_name_with_underscores = parts[0].replace("-", "_")
parent_packages.add(pkg_name_with_underscores)
# Now filter for PACKAGE_LINKS_ALLOW_LIST packages not in subdirectory
for pkg_name in parent_packages:
if pkg_name.lower() in PACKAGE_LINKS_ALLOW_LIST:
if pkg_name.lower() not in {p.lower() for p in packages_in_subdir}:
packages_to_copy.add(pkg_name)
print(
f"INFO: Found PACKAGE_LINKS_ALLOW_LIST package '{pkg_name}' in '{parent_prefix}' to copy to '{subdir}'"
)
# Cache the result
self._parent_packages_cache[cache_key] = packages_to_copy
return packages_to_copy
def normalize_package_version(self, obj: S3Object) -> str:
# removes the GPU specifier from the package name as well as
# unnecessary things like the file extension, architecture name, etc.
return sub(r"%2B.*", "", "-".join(path.basename(obj.key).split("-")[:2]))
def obj_to_package_name(self, obj: S3Object) -> str:
# Use cache to avoid repeated string operations
if obj.key not in self._package_name_cache:
self._package_name_cache[obj.key] = (
path.basename(obj.key).split("-", 1)[0].lower()
)
return self._package_name_cache[obj.key]
def to_libtorch_html(self, subdir: Optional[str] = None) -> str:
"""Generates a string that can be used as the HTML index
Takes our objects and transforms them into HTML that have historically
been used by pip for installing pytorch, but now only used to generate libtorch browseable folder.
"""
out: List[str] = []
subdir = self._resolve_subdir(subdir)
is_root = subdir == self.prefix
for obj in self.gen_file_list(subdir, "libtorch"):
# Skip root objs, as they are irrelevant for libtorch indexes
if not is_root and self.is_obj_at_root(obj):
continue
# Strip our prefix
sanitized_obj = obj.key.replace(subdir, "", 1)
if sanitized_obj.startswith("/"):
sanitized_obj = sanitized_obj.lstrip("/")
out.append(f'<a href="/{obj.key}">{sanitized_obj}</a><br/>')
return "\n".join(sorted(out))
def to_source_code_html(self, subdir: Optional[str] = None) -> str:
"""Generates a string that can be used as the HTML index for source code packages
Creates a simple browseable index for pytorch-*.tar.gz source code packages.
"""
out: List[str] = []
subdir = self._resolve_subdir(subdir)
for obj in self.gen_file_list(subdir):
# Strip our prefix
sanitized_obj = obj.key.replace(subdir, "", 1)
if sanitized_obj.startswith("/"):
sanitized_obj = sanitized_obj.lstrip("/")
out.append(f'<a href="/{obj.key}">{sanitized_obj}</a><br/>')
return "\n".join(sorted(out))
def to_simple_package_html(
self,
subdir: Optional[str],
package_name: str,
use_cloudfront_for_non_foundation: bool = False,
) -> str:
"""Generates a string that can be used as the package simple HTML index
Args:
subdir: The subdirectory to generate HTML for
package_name: The package name
use_cloudfront_for_non_foundation: If True, use CloudFront URLs for packages
not in PT_FOUNDATION_PACKAGES. If False, always use relative URLs.
"""
out: List[str] = []
# Adding html header
out.append("<!DOCTYPE html>")
out.append("<html>")
out.append(" <body>")
out.append(
" <h1>Links for {}</h1>".format(package_name.lower().replace("_", "-"))
)
# Determine URL strategy once before the loop
resolved_subdir = self._resolve_subdir(subdir)
# Check if this package should use R2 for nightly builds
if package_name.lower() in PT_R2_PACKAGES and resolved_subdir.startswith(
"whl/nightly"
):
# Use R2 absolute URL for PT_R2_PACKAGES in nightly builds
base_url = "https://download-r2.pytorch.org"
elif (
package_name.lower() in PT_R2_PACKAGES_PROD
and not resolved_subdir.startswith("whl/test")
and not resolved_subdir.startswith("whl/nightly")
):
# Use R2 absolute URL for PT_R2_PACKAGES_PROD in prod/stable builds
base_url = "https://download-r2.pytorch.org"
elif (
use_cloudfront_for_non_foundation
and package_name.lower() not in PT_FOUNDATION_PACKAGES
):
# Use CloudFront absolute URL for non-foundation packages when requested
base_url = "https://d21usjoq99fcb9.cloudfront.net"
else:
# Use relative URL for S3 index or foundation packages in R2 index
base_url = ""
# Pre-check if this is a nightly package to avoid repeated startswith checks
is_nightly = any(
obj.orig_key.startswith("whl/nightly")
for obj in self.gen_file_list(subdir, package_name)
)
for obj in sorted(self.gen_file_list(subdir, package_name)):
# Do not include checksum for nightly packages, see
# https://github.com/pytorch/test-infra/pull/6307
maybe_fragment = (
f"#sha256={obj.checksum}" if obj.checksum and not is_nightly else ""
)
attributes = ""
if obj.pep658:
pep658_sha = f"sha256={obj.pep658}"
# pep714 renames the attribute to data-core-metadata
attributes = f' data-dist-info-metadata="{pep658_sha}" data-core-metadata="{pep658_sha}"'
out.append(
f' <a href="{base_url}/{obj.key}{maybe_fragment}"{attributes}>{path.basename(obj.key).replace("%2B", "+")}</a><br/>'
)
# Adding html footer
out.append(" </body>")
out.append("</html>")
out.append(f"<!--TIMESTAMP {int(time.time())}-->")
return "\n".join(out)
def to_simple_packages_html(
self,
subdir: Optional[str],
) -> str:
"""Generates a string that can be used as the simple HTML index"""
out: List[str] = []
# Adding html header
out.append("<!DOCTYPE html>")
out.append("<html>")
out.append(" <body>")
# Get packages from wheel files
packages_from_wheels = set(self.get_package_names(subdir))
# Also find packages that have index.html but no wheels
packages_with_index_only = set()
resolved_subdir = self._resolve_subdir(subdir)
# List all objects in the subdir to find packagename/index.html patterns
prefix_to_search = f"{resolved_subdir}/"
# Optimize: collect package names in a single pass using cached bucket listing
for obj in self._get_bucket_listing(prefix_to_search):
# Check if this is a packagename/index.html file
relative_key = obj.key[len(prefix_to_search) :]
parts = relative_key.split("/")
if len(parts) == 2 and parts[1] == "index.html":
package_name = parts[0].replace("-", "_")
# Convert back to the format used in wheel names (use _ not -)
# But we need to check if this package already has wheels
package_name_lower = package_name.lower()
if package_name_lower not in {p.lower() for p in packages_from_wheels}:
packages_with_index_only.add(package_name)
# Only print if there are packages with index only
if packages_with_index_only:
for pkg in packages_with_index_only:
print(
f"INFO: Including package '{pkg}' in {prefix_to_search} (has index.html but no wheels)"
)
# Combine both sets of packages
all_packages = packages_from_wheels | packages_with_index_only
for pkg_name in sorted(all_packages):
out.append(
f' <a href="{pkg_name.lower().replace("_", "-")}/">{pkg_name.replace("_", "-")}</a><br/>'
)
# Adding html footer
out.append(" </body>")
out.append("</html>")
out.append(f"<!--TIMESTAMP {int(time.time())}-->")
return "\n".join(out)
def upload_libtorch_html(self) -> None:
"""Upload libtorch indexes to S3 and R2 with same relative URLs"""
for subdir in self.subdirs:
index_html = self.to_libtorch_html(subdir=subdir)
# Upload to S3
print(
f"INFO Uploading {subdir}/{self.html_name} to S3 bucket {BUCKET.name}"
)
BUCKET.Object(key=f"{subdir}/{self.html_name}").put(
ACL="public-read",
CacheControl="no-cache,no-store,must-revalidate",
ContentType="text/html",
Body=index_html,
)
# Upload to R2 if configured (same content with relative URLs)
if R2_BUCKET:
print(
f"INFO Uploading {subdir}/{self.html_name} to R2 bucket {R2_BUCKET.name}"
)
R2_BUCKET.Object(key=f"{subdir}/{self.html_name}").put(
ACL="public-read",
CacheControl="no-cache,no-store,must-revalidate",
ContentType="text/html",
Body=index_html,
)
def upload_source_code_html(self) -> None:
"""Upload source code index to S3 and R2"""
# For source_code/test, it has a flat structure, so we only upload to the prefix directory
index_html = self.to_source_code_html(subdir=self.prefix)
# Upload to S3
print(
f"INFO Uploading {self.prefix}/{self.html_name} to S3 bucket {BUCKET.name}"
)
BUCKET.Object(key=f"{self.prefix}/{self.html_name}").put(
ACL="public-read",
CacheControl="no-cache,no-store,must-revalidate",
ContentType="text/html",
Body=index_html,
)
# Upload to R2 if configured
if R2_BUCKET:
print(
f"INFO Uploading {self.prefix}/{self.html_name} to R2 bucket {R2_BUCKET.name}"
)
R2_BUCKET.Object(key=f"{self.prefix}/{self.html_name}").put(
ACL="public-read",
CacheControl="no-cache,no-store,must-revalidate",
ContentType="text/html",
Body=index_html,
)
def upload_pep503_htmls(self) -> None:
# Pre-fetch bucket listings for all subdirectories to optimize S3 API calls
print("INFO: Pre-fetching S3 bucket listings for optimization...")
for subdir in self.subdirs:
prefix_to_search = f"{self._resolve_subdir(subdir)}/"
self._get_bucket_listing(prefix_to_search)
print(f"INFO: Pre-fetched listings for {len(self.subdirs)} subdirectories")
for subdir in self.subdirs:
# Generate the package list index (same for both S3 and R2)
index_html = self.to_simple_packages_html(subdir=subdir)
# Upload package list to S3
print(f"INFO Uploading {subdir}/index.html to S3 bucket {BUCKET.name}")
BUCKET.Object(key=f"{subdir}/index.html").put(
ACL="public-read",
CacheControl="no-cache,no-store,must-revalidate",
ContentType="text/html",
Body=index_html,
)
# Upload package list to R2 if configured
if R2_BUCKET:
print(
f"INFO Uploading {subdir}/index.html to R2 bucket {R2_BUCKET.name}"
)
R2_BUCKET.Object(key=f"{subdir}/index.html").put(
ACL="public-read",
CacheControl="no-cache,no-store,must-revalidate",
ContentType="text/html",
Body=index_html,
)
# Generate and upload per-package indexes