RepoFlow Team · May 18, 2025

Mirror the Entire PyPI Repository with Bash

Create a local, self-contained PyPI repository for air-gapped networks and secure environments.

Mirroring the entire PyPI repository can be essential for organizations with strict security requirements or air-gapped networks that need a complete, self-contained copy of PyPI. This approach can also be useful for enterprises that require local access to all available Python packages without relying on an external internet connection.
Why Mirror PyPI?
Mirroring a package repository like PyPI can be beneficial for the following reasons:
  1. Air-Gapped Networks: For secure environments where internet access is restricted or completely unavailable.
  2. Regulatory Compliance: Some organizations need complete control over their software supply chain for compliance purposes.
  3. Disaster Recovery: Ensures packages are always available, even if the external repository goes dow
Prerequisites
Before you start, make sure you have the following installed:
  1. Bash: Usually pre-installed on most Linux distributions and macOS. For Windows, you can use the Windows Subsystem for Linux (WSL) or a tool like Git Bash or Cygwin.
  2. wget
  3. curl
Understanding the Script
This Bash script is designed to mirror the entire PyPI repository to a local directory. It crawls the PyPI package index, retrieves the list of all available packages, and then downloads every available version of each package. This approach creates a local, self-contained copy of PyPI, which can be particularly useful for air-gapped networks or organizations with strict security requirements.
Consider the Storage Requirements
Keep in mind that this process can require a significant amount of storage, depending on the number of packages and versions you choose to mirror. Currently, PyPI hosts over 4 million packages, totaling around 27.6 TB of data. Be sure you have sufficient storage capacity before starting.
Consider the Storage Requirements
Here is a Bash script to mirror the entire PyPI repository:
#!/bin/bash

# Create the mirror directory
mkdir -p ./pypi_mirror

# Log file to track last mirrored package
LOG_FILE="./pypi_mirror/index.log"

# Get the list of all package names (strip "/simple/")
packages=($(curl -s https://pypi.org/simple/ | awk -F '"' '/href="/ {print $2}' | sed 's|/simple/||g' | sed 's|/$||'))

# Get the total number of packages
total_packages=${#packages[@]}
start_time=$SECONDS

echo "Total packages to download: $total_packages"
echo ""

# Read last completed package from log
if [[ -f "$LOG_FILE" ]]; then
    last_package=$(tail -n 1 "$LOG_FILE")
    echo "Resuming from package: $last_package"
    skip=true
else
    last_package=""
    skip=false
fi

# Loop through each package and download all available versions
for i in "${!packages[@]}"; do
    package="${packages[$i]}"

    # Skip previously completed packages
    if [[ "$skip" == true ]]; then
        if [[ "$package" == "$last_package" ]]; then
            skip=false  # Found the last completed package, start from the next one
        fi
        continue
    fi

    # Update progress
    progress=$(( (i + 1) * 100 / total_packages ))
    elapsed_time=$(( SECONDS - start_time ))
    avg_time_per_pkg=$(( elapsed_time / (i + 1) ))
    remaining_pkgs=$(( total_packages - i - 1 ))
    eta=$(( avg_time_per_pkg * remaining_pkgs ))

    # Prevent negative ETA
    if [[ $eta -lt 0 ]]; then eta=0; fi

    # Progress bar settings
    bar_length=40
    filled_length=$(( bar_length * (i + 1) / total_packages ))

    # Ensure at least 1 character for cut
    if [[ $filled_length -lt 1 ]]; then filled_length=1; fi

    # Construct progress bar
    bar=$(printf "%-${bar_length}s" "█████████████████████████████████████████" | cut -c1-"$filled_length")
    empty_bar=$(printf "%-${bar_length}s" "")

    # Print progress dynamically
    tput sc
    echo -ne "Progress: [$bar$empty_bar] $progress% | Elapsed: ${elapsed_time}s | ETA: ${eta}s | Downloading: $package\r"
    tput rc

    # Create a directory for the package
    mkdir -p "./pypi_mirror/$package"

    # Get the list of package files from PyPI
    package_page=$(curl -s "https://pypi.org/simple/$package/")

    # Extract all file URLs
    urls=$(echo "$package_page" | awk -F '"' '/href="https/ {print $2}')

    if [[ -z "$urls" ]]; then
        continue  # Skip if no files found
    fi

    # Download each file (silent mode to keep terminal clean)
    for url in $urls; do
        cleaned_url="${url%%#*}"
        file_name="./pypi_mirror/$package/$(basename "$cleaned_url")"
        
        # Check if the file already exists and is not empty
        if [[ -f "$file_name" && -s "$file_name" ]]; then
            echo "Skipping already downloaded file: $file_name"
            continue
        fi
        
        wget -q -P "./pypi_mirror/$package/" "$url"
    done

    # Log the completed package
    echo "$package" >> "$LOG_FILE"
done

# Final message
echo -e "\n\n🎉 PyPI mirroring complete! All $total_packages packages downloaded."
Key Features of the Script
  1. Resumable Downloads: The script can resume from the last completed package if interrupted.
  2. Progress Bar: Real-time progress bar to track the download status.
Alternative Methods
  1. bandersnatch: A PyPI package for mirroring Python packages. More details at bandersnatch on PyPI.
Final Thoughts
This is a simple example script to demonstrate how mirroring PyPI can be achieved. Feel free to modify it based on your specific needs, whether that's optimizing for speed, adding error handling, or integrating it with your existing infrastructure.

Happy mirroring!