mirror of
https://github.com/esphome/esphome.git
synced 2025-11-05 09:31:54 +00:00
Compare commits
8 Commits
cache_comp
...
cache_gith
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c9e4d30e9 | ||
|
|
db42983f0c | ||
|
|
20c65f70ed | ||
|
|
38e31e328c | ||
|
|
58cecff778 | ||
|
|
1946656ea8 | ||
|
|
c9700a0450 | ||
|
|
0eab64ffe5 |
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -432,6 +432,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||||
|
|
||||||
|
- name: Cache platformio
|
||||||
|
if: github.ref == 'refs/heads/dev'
|
||||||
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: ~/.platformio
|
||||||
|
key: platformio-test-${{ hashFiles('platformio.ini') }}
|
||||||
|
|
||||||
|
- name: Cache platformio
|
||||||
|
if: github.ref != 'refs/heads/dev'
|
||||||
|
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: ~/.platformio
|
||||||
|
key: platformio-test-${{ hashFiles('platformio.ini') }}
|
||||||
|
|
||||||
- name: Validate and compile components with intelligent grouping
|
- name: Validate and compile components with intelligent grouping
|
||||||
run: |
|
run: |
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
|
|||||||
362
esphome/github_cache.py
Normal file
362
esphome/github_cache.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
"""GitHub download cache for ESPHome.
|
||||||
|
|
||||||
|
This module provides caching functionality for GitHub release downloads
|
||||||
|
to avoid redundant network I/O when switching between platforms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubCache:
|
||||||
|
"""Manages caching of GitHub release downloads."""
|
||||||
|
|
||||||
|
# Cache expiration time in seconds (30 days)
|
||||||
|
CACHE_EXPIRATION_SECONDS = 30 * 24 * 60 * 60
|
||||||
|
|
||||||
|
def __init__(self, cache_dir: Path | None = None):
|
||||||
|
"""Initialize the cache manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_dir: Directory to store cached files.
|
||||||
|
Defaults to ~/.esphome_cache/github
|
||||||
|
"""
|
||||||
|
if cache_dir is None:
|
||||||
|
cache_dir = Path.home() / ".esphome_cache" / "github"
|
||||||
|
self.cache_dir = cache_dir
|
||||||
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.metadata_file = self.cache_dir / "cache_metadata.json"
|
||||||
|
# Prune old files on initialization
|
||||||
|
try:
|
||||||
|
self._prune_old_files()
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.debug("Failed to prune old cache files: %s", e)
|
||||||
|
|
||||||
|
def _load_metadata(self) -> dict:
|
||||||
|
"""Load cache metadata from disk."""
|
||||||
|
if self.metadata_file.exists():
|
||||||
|
try:
|
||||||
|
with open(self.metadata_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_metadata(self, metadata: dict) -> None:
|
||||||
|
"""Save cache metadata to disk."""
|
||||||
|
try:
|
||||||
|
with open(self.metadata_file, "w") as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
except OSError as e:
|
||||||
|
_LOGGER.debug("Failed to save cache metadata: %s", e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_github_url(url: str) -> bool:
|
||||||
|
"""Check if URL is a GitHub release download."""
|
||||||
|
return "github.com" in url.lower() and url.endswith(".zip")
|
||||||
|
|
||||||
|
def _get_cache_key(self, url: str) -> str:
|
||||||
|
"""Get cache key (hash) for a URL."""
|
||||||
|
return hashlib.sha256(url.encode()).hexdigest()
|
||||||
|
|
||||||
|
def _get_cache_path(self, url: str) -> Path:
|
||||||
|
"""Get cache file path for a URL."""
|
||||||
|
cache_key = self._get_cache_key(url)
|
||||||
|
ext = Path(url.split("?")[0]).suffix
|
||||||
|
return self.cache_dir / f"{cache_key}{ext}"
|
||||||
|
|
||||||
|
def _check_if_modified(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
last_modified: str | None = None,
|
||||||
|
etag: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a URL has been modified using HTTP 304.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to check
|
||||||
|
last_modified: Last-Modified header from previous response
|
||||||
|
etag: ETag header from previous response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if modified, False if not modified (or offline/unreachable)
|
||||||
|
"""
|
||||||
|
if not last_modified and not etag:
|
||||||
|
# No cache headers available, assume modified
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(url)
|
||||||
|
request.get_method = lambda: "HEAD"
|
||||||
|
|
||||||
|
if last_modified:
|
||||||
|
request.add_header("If-Modified-Since", last_modified)
|
||||||
|
if etag:
|
||||||
|
request.add_header("If-None-Match", etag)
|
||||||
|
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(request, timeout=10)
|
||||||
|
# 200 OK = file was modified
|
||||||
|
return True
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 304:
|
||||||
|
# Not modified
|
||||||
|
_LOGGER.debug("File not modified (HTTP 304): %s", url)
|
||||||
|
return False
|
||||||
|
# Other errors, assume modified to be safe
|
||||||
|
return True
|
||||||
|
except (OSError, urllib.error.URLError):
|
||||||
|
# If check fails (offline/network error), assume not modified (use cache)
|
||||||
|
_LOGGER.info("Cannot reach server (offline?), using cached file: %s", url)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_cached_path(self, url: str, check_updates: bool = True) -> Path | None:
|
||||||
|
"""Get path to cached file if available and valid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to check
|
||||||
|
check_updates: Whether to check for updates using HTTP 304
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to cached file if valid, None if needs download
|
||||||
|
"""
|
||||||
|
if not self.is_github_url(url):
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_path = self._get_cache_path(url)
|
||||||
|
if not cache_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Load metadata
|
||||||
|
metadata = self._load_metadata()
|
||||||
|
cache_key = self._get_cache_key(url)
|
||||||
|
|
||||||
|
# Check if file should be re-downloaded
|
||||||
|
should_redownload = False
|
||||||
|
if check_updates and cache_key in metadata:
|
||||||
|
last_modified = metadata[cache_key].get("last_modified")
|
||||||
|
etag = metadata[cache_key].get("etag")
|
||||||
|
if self._check_if_modified(url, last_modified, etag):
|
||||||
|
# File was modified, need to re-download
|
||||||
|
_LOGGER.debug("Cached file is outdated: %s", url)
|
||||||
|
should_redownload = True
|
||||||
|
|
||||||
|
if should_redownload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# File is valid, update cached_at timestamp to keep it fresh
|
||||||
|
if cache_key in metadata:
|
||||||
|
metadata[cache_key]["cached_at"] = time.time()
|
||||||
|
self._save_metadata(metadata)
|
||||||
|
|
||||||
|
# Log appropriate message
|
||||||
|
if not check_updates:
|
||||||
|
_LOGGER.debug("Using cached file (no update check): %s", url)
|
||||||
|
elif cache_key not in metadata:
|
||||||
|
_LOGGER.debug("Using cached file (no metadata): %s", url)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Using cached file: %s", url)
|
||||||
|
|
||||||
|
return cache_path
|
||||||
|
|
||||||
|
def save_to_cache(self, url: str, source_path: Path) -> None:
|
||||||
|
"""Save a downloaded file to cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL the file was downloaded from
|
||||||
|
source_path: Path to the downloaded file
|
||||||
|
"""
|
||||||
|
if not self.is_github_url(url):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache_path = self._get_cache_path(url)
|
||||||
|
# Only copy if source and destination are different
|
||||||
|
if source_path.resolve() != cache_path.resolve():
|
||||||
|
shutil.copy2(source_path, cache_path)
|
||||||
|
|
||||||
|
# Try to get HTTP headers for caching
|
||||||
|
last_modified = None
|
||||||
|
etag = None
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(url)
|
||||||
|
request.get_method = lambda: "HEAD"
|
||||||
|
response = urllib.request.urlopen(request, timeout=10)
|
||||||
|
last_modified = response.headers.get("Last-Modified")
|
||||||
|
etag = response.headers.get("ETag")
|
||||||
|
except (OSError, urllib.error.URLError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Update metadata
|
||||||
|
metadata = self._load_metadata()
|
||||||
|
cache_key = self._get_cache_key(url)
|
||||||
|
|
||||||
|
metadata[cache_key] = {
|
||||||
|
"url": url,
|
||||||
|
"size": cache_path.stat().st_size,
|
||||||
|
"cached_at": time.time(),
|
||||||
|
"last_modified": last_modified,
|
||||||
|
"etag": etag,
|
||||||
|
}
|
||||||
|
self._save_metadata(metadata)
|
||||||
|
|
||||||
|
_LOGGER.debug("Saved to cache: %s", url)
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
_LOGGER.debug("Failed to save to cache: %s", e)
|
||||||
|
|
||||||
|
def copy_from_cache(self, url: str, destination: Path) -> bool:
|
||||||
|
"""Copy a cached file to destination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL of the cached file
|
||||||
|
destination: Where to copy the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
cached_path = self.get_cached_path(url, check_updates=True)
|
||||||
|
if not cached_path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copy2(cached_path, destination)
|
||||||
|
_LOGGER.info("Using cached download for %s", url)
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
_LOGGER.warning("Failed to use cache: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cache_size(self) -> int:
|
||||||
|
"""Get total size of cached files in bytes."""
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
for file_path in self.cache_dir.glob("*"):
|
||||||
|
if file_path.is_file() and file_path != self.metadata_file:
|
||||||
|
total += file_path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return total
|
||||||
|
|
||||||
|
def list_cached(self) -> list[dict]:
|
||||||
|
"""List all cached files with metadata."""
|
||||||
|
cached_files = []
|
||||||
|
metadata = self._load_metadata()
|
||||||
|
|
||||||
|
for cache_key, meta in metadata.items():
|
||||||
|
cache_path = (
|
||||||
|
self.cache_dir / f"{cache_key}{Path(meta['url'].split('?')[0]).suffix}"
|
||||||
|
)
|
||||||
|
if cache_path.exists():
|
||||||
|
cached_files.append(
|
||||||
|
{
|
||||||
|
"url": meta["url"],
|
||||||
|
"path": cache_path,
|
||||||
|
"size": meta["size"],
|
||||||
|
"cached_at": meta.get("cached_at"),
|
||||||
|
"last_modified": meta.get("last_modified"),
|
||||||
|
"etag": meta.get("etag"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return cached_files
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear all cached files."""
|
||||||
|
try:
|
||||||
|
for file_path in self.cache_dir.glob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
file_path.unlink()
|
||||||
|
_LOGGER.info("Cache cleared: %s", self.cache_dir)
|
||||||
|
except OSError as e:
|
||||||
|
_LOGGER.warning("Failed to clear cache: %s", e)
|
||||||
|
|
||||||
|
def _prune_old_files(self) -> None:
|
||||||
|
"""Remove cache files older than CACHE_EXPIRATION_SECONDS."""
|
||||||
|
current_time = time.time()
|
||||||
|
metadata = self._load_metadata()
|
||||||
|
removed_count = 0
|
||||||
|
removed_size = 0
|
||||||
|
|
||||||
|
# Check each file in metadata
|
||||||
|
for cache_key, meta in list(metadata.items()):
|
||||||
|
cached_at = meta.get("cached_at", 0)
|
||||||
|
age_seconds = current_time - cached_at
|
||||||
|
|
||||||
|
if age_seconds > self.CACHE_EXPIRATION_SECONDS:
|
||||||
|
# File is too old, remove it
|
||||||
|
cache_path = (
|
||||||
|
self.cache_dir
|
||||||
|
/ f"{cache_key}{Path(meta['url'].split('?')[0]).suffix}"
|
||||||
|
)
|
||||||
|
if cache_path.exists():
|
||||||
|
file_size = cache_path.stat().st_size
|
||||||
|
cache_path.unlink()
|
||||||
|
removed_size += file_size
|
||||||
|
removed_count += 1
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Pruned old cache file (age: %.1f days): %s",
|
||||||
|
age_seconds / (24 * 60 * 60),
|
||||||
|
meta["url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove from metadata
|
||||||
|
del metadata[cache_key]
|
||||||
|
|
||||||
|
# Also check for orphaned files (files without metadata)
|
||||||
|
for file_path in self.cache_dir.glob("*.zip"):
|
||||||
|
if file_path == self.metadata_file:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if file is in metadata
|
||||||
|
found_in_metadata = False
|
||||||
|
for cache_key in metadata:
|
||||||
|
if file_path.name.startswith(cache_key):
|
||||||
|
found_in_metadata = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found_in_metadata:
|
||||||
|
# Orphaned file - check age by modification time
|
||||||
|
file_age = current_time - file_path.stat().st_mtime
|
||||||
|
if file_age > self.CACHE_EXPIRATION_SECONDS:
|
||||||
|
file_size = file_path.stat().st_size
|
||||||
|
file_path.unlink()
|
||||||
|
removed_size += file_size
|
||||||
|
removed_count += 1
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Pruned orphaned cache file (age: %.1f days): %s",
|
||||||
|
file_age / (24 * 60 * 60),
|
||||||
|
file_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save updated metadata if anything was removed
|
||||||
|
if removed_count > 0:
|
||||||
|
self._save_metadata(metadata)
|
||||||
|
removed_mb = removed_size / (1024 * 1024)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Pruned %d old cache file(s), freed %.2f MB",
|
||||||
|
removed_count,
|
||||||
|
removed_mb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global cache instance
|
||||||
|
_cache: GitHubCache | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache() -> GitHubCache:
|
||||||
|
"""Get the global GitHub cache instance."""
|
||||||
|
global _cache # noqa: PLW0603
|
||||||
|
if _cache is None:
|
||||||
|
_cache = GitHubCache()
|
||||||
|
return _cache
|
||||||
@@ -5,7 +5,6 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
|
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
|
||||||
from esphome.core import CORE, EsphomeError
|
from esphome.core import CORE, EsphomeError
|
||||||
@@ -44,32 +43,168 @@ def patch_structhash():
|
|||||||
|
|
||||||
|
|
||||||
def patch_file_downloader():
|
def patch_file_downloader():
|
||||||
"""Patch PlatformIO's FileDownloader to retry on PackageException errors."""
|
"""Patch PlatformIO's FileDownloader to add caching and retry on PackageException errors.
|
||||||
from platformio.package.download import FileDownloader
|
|
||||||
from platformio.package.exception import PackageException
|
|
||||||
|
|
||||||
original_init = FileDownloader.__init__
|
This function attempts to patch PlatformIO's internal download mechanism.
|
||||||
|
If patching fails (due to API changes), it gracefully falls back to no caching.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from platformio.package.download import FileDownloader
|
||||||
|
from platformio.package.exception import PackageException
|
||||||
|
except ImportError as e:
|
||||||
|
_LOGGER.debug("Could not import PlatformIO modules for patching: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
def patched_init(self, *args: Any, **kwargs: Any) -> None:
|
# Import our cache module
|
||||||
max_retries = 3
|
from esphome.github_cache import GitHubCache
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
_LOGGER.debug("Applying GitHub download cache patch...")
|
||||||
try:
|
|
||||||
return original_init(self, *args, **kwargs)
|
# Verify the classes have the expected methods before patching
|
||||||
except PackageException as e:
|
if not hasattr(FileDownloader, "__init__") or not hasattr(FileDownloader, "start"):
|
||||||
if attempt < max_retries - 1:
|
_LOGGER.warning(
|
||||||
_LOGGER.warning(
|
"PlatformIO FileDownloader API has changed, skipping cache patch"
|
||||||
"Package download failed: %s. Retrying... (attempt %d/%d)",
|
)
|
||||||
str(e),
|
return
|
||||||
attempt + 1,
|
|
||||||
max_retries,
|
try:
|
||||||
|
original_init = FileDownloader.__init__
|
||||||
|
original_start = FileDownloader.start
|
||||||
|
|
||||||
|
# Initialize cache in .platformio directory so it benefits from GitHub Actions cache
|
||||||
|
platformio_dir = Path.home() / ".platformio"
|
||||||
|
cache_dir = platformio_dir / "esphome_download_cache"
|
||||||
|
cache_dir_existed = cache_dir.exists()
|
||||||
|
cache = GitHubCache(cache_dir=cache_dir)
|
||||||
|
if not cache_dir_existed:
|
||||||
|
_LOGGER.info("Created GitHub download cache at: %s", cache.cache_dir)
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.warning("Failed to initialize GitHub download cache: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
def patched_init(self, *args, **kwargs):
|
||||||
|
"""Patched init that checks cache before making HTTP connection."""
|
||||||
|
try:
|
||||||
|
# Extract URL from args (first positional argument)
|
||||||
|
url = args[0] if args else kwargs.get("url")
|
||||||
|
dest_dir = args[1] if len(args) > 1 else kwargs.get("dest_dir")
|
||||||
|
|
||||||
|
# Debug: Log all downloads
|
||||||
|
_LOGGER.debug("[GitHub Cache] Download request for: %s", url)
|
||||||
|
|
||||||
|
# Store URL for later use (original FileDownloader doesn't store it)
|
||||||
|
self._esphome_cache_url = url if cache.is_github_url(url) else None
|
||||||
|
|
||||||
|
# Check cache for GitHub URLs BEFORE making HTTP request
|
||||||
|
if self._esphome_cache_url:
|
||||||
|
_LOGGER.debug("[GitHub Cache] This is a GitHub URL, checking cache...")
|
||||||
|
self._esphome_use_cache = cache.get_cached_path(url, check_updates=True)
|
||||||
|
if self._esphome_use_cache:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Found %s in cache, will restore instead of downloading",
|
||||||
|
Path(url.split("?")[0]).name,
|
||||||
|
)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"[GitHub Cache] Found in cache: %s", self._esphome_use_cache
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Final attempt - re-raise
|
_LOGGER.debug(
|
||||||
raise
|
"[GitHub Cache] Not in cache, will download and cache"
|
||||||
return None
|
)
|
||||||
|
else:
|
||||||
|
self._esphome_use_cache = None
|
||||||
|
if url and str(url).startswith("http"):
|
||||||
|
_LOGGER.debug("[GitHub Cache] Not a GitHub URL, skipping cache")
|
||||||
|
|
||||||
FileDownloader.__init__ = patched_init
|
# Only make HTTP connection if we don't have cached file
|
||||||
|
if self._esphome_use_cache:
|
||||||
|
# Skip HTTP connection, we'll handle this in start()
|
||||||
|
# Set minimal attributes to satisfy FileDownloader
|
||||||
|
# Create a mock session that can be safely closed in __del__
|
||||||
|
class MockSession:
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._http_session = MockSession()
|
||||||
|
self._http_response = None
|
||||||
|
self._fname = Path(url.split("?")[0]).name
|
||||||
|
self._destination = self._fname
|
||||||
|
if dest_dir:
|
||||||
|
from os.path import join
|
||||||
|
|
||||||
|
self._destination = join(dest_dir, self._fname)
|
||||||
|
# Note: Actual restoration logged in patched_start
|
||||||
|
return None # Don't call original_init
|
||||||
|
|
||||||
|
# Normal initialization with retry logic
|
||||||
|
max_retries = 3
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
return original_init(self, *args, **kwargs)
|
||||||
|
except PackageException as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Package download failed: %s. Retrying... (attempt %d/%d)",
|
||||||
|
str(e),
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Final attempt - re-raise
|
||||||
|
raise
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
# If anything goes wrong in our cache logic, fall back to normal download
|
||||||
|
_LOGGER.debug("Cache check failed, falling back to normal download: %s", e)
|
||||||
|
self._esphome_cache_url = None
|
||||||
|
self._esphome_use_cache = None
|
||||||
|
return original_init(self, *args, **kwargs)
|
||||||
|
|
||||||
|
def patched_start(self, *args, **kwargs):
|
||||||
|
"""Patched start that uses cache when available."""
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
# Get the cache URL and path that were set in __init__
|
||||||
|
cache_url = getattr(self, "_esphome_cache_url", None)
|
||||||
|
cached_file = getattr(self, "_esphome_use_cache", None)
|
||||||
|
|
||||||
|
# If we're using cache, copy file instead of downloading
|
||||||
|
if cached_file:
|
||||||
|
try:
|
||||||
|
shutil.copy2(cached_file, self._destination)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Restored %s from cache (avoided download)",
|
||||||
|
Path(cached_file).name,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
_LOGGER.warning("Failed to copy from cache: %s", e)
|
||||||
|
# Fall through to re-download
|
||||||
|
|
||||||
|
# Perform normal download
|
||||||
|
result = original_start(self, *args, **kwargs)
|
||||||
|
|
||||||
|
# Save to cache if it was a GitHub URL
|
||||||
|
if cache_url:
|
||||||
|
try:
|
||||||
|
cache.save_to_cache(cache_url, Path(self._destination))
|
||||||
|
except OSError as e:
|
||||||
|
_LOGGER.debug("Failed to save to cache: %s", e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
# If anything goes wrong, fall back to normal download
|
||||||
|
_LOGGER.debug("Cache restoration failed, using normal download: %s", e)
|
||||||
|
return original_start(self, *args, **kwargs)
|
||||||
|
|
||||||
|
# Apply the patches
|
||||||
|
try:
|
||||||
|
FileDownloader.__init__ = patched_init
|
||||||
|
FileDownloader.start = patched_start
|
||||||
|
_LOGGER.debug("GitHub download cache patch applied successfully")
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.warning("Failed to apply GitHub download cache patch: %s", e)
|
||||||
|
|
||||||
|
|
||||||
IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})"
|
IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})"
|
||||||
@@ -87,6 +222,8 @@ FILTER_PLATFORMIO_LINES = [
|
|||||||
r"Memory Usage -> https://bit.ly/pio-memory-usage",
|
r"Memory Usage -> https://bit.ly/pio-memory-usage",
|
||||||
r"Found: https://platformio.org/lib/show/.*",
|
r"Found: https://platformio.org/lib/show/.*",
|
||||||
r"Using cache: .*",
|
r"Using cache: .*",
|
||||||
|
# Don't filter our cache messages - let users see when cache is being used
|
||||||
|
# r"Using cached download for .*",
|
||||||
r"Installing dependencies",
|
r"Installing dependencies",
|
||||||
r"Library Manager: Already installed, built-in library",
|
r"Library Manager: Already installed, built-in library",
|
||||||
r"Building in .* mode",
|
r"Building in .* mode",
|
||||||
|
|||||||
164
script/cache_platformio_downloads.py
Executable file
164
script/cache_platformio_downloads.py
Executable file
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pre-cache PlatformIO GitHub Downloads
|
||||||
|
|
||||||
|
This script extracts GitHub URLs from platformio.ini and pre-caches them
|
||||||
|
to avoid redundant downloads when switching between ESP8266 and ESP32 builds.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 script/cache_platformio_downloads.py [platformio.ini]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import configparser
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Import the cache manager
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
from esphome.github_cache import GitHubCache
|
||||||
|
|
||||||
|
|
||||||
|
def extract_github_urls(platformio_ini: Path) -> list[str]:
|
||||||
|
"""Extract all GitHub URLs from platformio.ini.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
platformio_ini: Path to platformio.ini file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of GitHub URLs found
|
||||||
|
"""
|
||||||
|
config = configparser.ConfigParser(inline_comment_prefixes=(";",))
|
||||||
|
config.read(platformio_ini)
|
||||||
|
|
||||||
|
urls = []
|
||||||
|
github_pattern = re.compile(r"https://github\.com/[^\s;]+\.zip")
|
||||||
|
|
||||||
|
for section in config.sections():
|
||||||
|
conf = config[section]
|
||||||
|
|
||||||
|
# Check platform
|
||||||
|
if "platform" in conf:
|
||||||
|
platform_value = conf["platform"]
|
||||||
|
matches = github_pattern.findall(platform_value)
|
||||||
|
urls.extend(matches)
|
||||||
|
|
||||||
|
# Check platform_packages
|
||||||
|
if "platform_packages" in conf:
|
||||||
|
for line in conf["platform_packages"].splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
matches = github_pattern.findall(line)
|
||||||
|
urls.extend(matches)
|
||||||
|
|
||||||
|
# Remove duplicates while preserving order using dict
|
||||||
|
return list(dict.fromkeys(urls))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Pre-cache PlatformIO GitHub downloads",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
This script scans platformio.ini for GitHub URLs and pre-caches them.
|
||||||
|
This avoids redundant downloads when switching between platforms (e.g., ESP8266 and ESP32).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Cache downloads from default platformio.ini
|
||||||
|
%(prog)s
|
||||||
|
|
||||||
|
# Cache downloads from specific file
|
||||||
|
%(prog)s custom_platformio.ini
|
||||||
|
|
||||||
|
# Show what would be cached without downloading
|
||||||
|
%(prog)s --dry-run
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"platformio_ini",
|
||||||
|
nargs="?",
|
||||||
|
default="platformio.ini",
|
||||||
|
help="Path to platformio.ini (default: platformio.ini)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would be cached without downloading",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--cache-dir",
|
||||||
|
type=Path,
|
||||||
|
help="Cache directory (default: ~/.platformio/esphome_download_cache)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Force re-download even if cached",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
platformio_ini = Path(args.platformio_ini)
|
||||||
|
|
||||||
|
if not platformio_ini.exists():
|
||||||
|
print(f"Error: {platformio_ini} not found", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Extract URLs
|
||||||
|
print(f"Scanning {platformio_ini} for GitHub URLs...")
|
||||||
|
urls = extract_github_urls(platformio_ini)
|
||||||
|
|
||||||
|
if not urls:
|
||||||
|
print("No GitHub URLs found in platformio.ini")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Found {len(urls)} unique GitHub URL(s):")
|
||||||
|
for url in urls:
|
||||||
|
print(f" - {url}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("Dry run - not downloading")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Initialize cache (use PlatformIO directory by default)
|
||||||
|
cache_dir = args.cache_dir
|
||||||
|
if cache_dir is None:
|
||||||
|
cache_dir = Path.home() / ".platformio" / "esphome_download_cache"
|
||||||
|
cache = GitHubCache(cache_dir)
|
||||||
|
|
||||||
|
# Cache each URL
|
||||||
|
success_count = 0
|
||||||
|
for i, url in enumerate(urls, 1):
|
||||||
|
print(f"[{i}/{len(urls)}] Checking {url}")
|
||||||
|
try:
|
||||||
|
# Use the download_with_progress from github_download_cache CLI
|
||||||
|
from script.github_download_cache import download_with_progress
|
||||||
|
|
||||||
|
download_with_progress(cache, url, force=args.force, check_updates=True)
|
||||||
|
success_count += 1
|
||||||
|
print()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error caching {url}: {e}", file=sys.stderr)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Show cache stats
|
||||||
|
total_size = cache.cache_size()
|
||||||
|
size_mb = total_size / (1024 * 1024)
|
||||||
|
print("\nCache summary:")
|
||||||
|
print(f" Successfully cached: {success_count}/{len(urls)}")
|
||||||
|
print(f" Total cache size: {size_mb:.2f} MB")
|
||||||
|
print(f" Cache location: {cache.cache_dir}")
|
||||||
|
|
||||||
|
return 0 if success_count == len(urls) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
195
script/github_download_cache.py
Executable file
195
script/github_download_cache.py
Executable file
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
GitHub Download Cache CLI
|
||||||
|
|
||||||
|
This script provides a command-line interface to the GitHub download cache.
|
||||||
|
The actual caching logic is in esphome/github_cache.py.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 script/github_download_cache.py download URL
|
||||||
|
python3 script/github_download_cache.py list
|
||||||
|
python3 script/github_download_cache.py stats
|
||||||
|
python3 script/github_download_cache.py clear
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Add parent directory to path to import esphome modules
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from esphome.github_cache import GitHubCache
|
||||||
|
|
||||||
|
|
||||||
|
def download_with_progress(
|
||||||
|
cache: GitHubCache, url: str, force: bool = False, check_updates: bool = True
|
||||||
|
) -> Path:
|
||||||
|
"""Download a URL with progress indicator and caching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache: GitHubCache instance
|
||||||
|
url: URL to download
|
||||||
|
force: Force re-download even if cached
|
||||||
|
check_updates: Check for updates using HTTP 304
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to cached file
|
||||||
|
"""
|
||||||
|
# If force, skip cache check
|
||||||
|
if not force:
|
||||||
|
cached_path = cache.get_cached_path(url, check_updates=check_updates)
|
||||||
|
if cached_path:
|
||||||
|
print(f"Using cached file for {url}")
|
||||||
|
print(f" Cache: {cached_path}")
|
||||||
|
return cached_path
|
||||||
|
|
||||||
|
# Need to download
|
||||||
|
print(f"Downloading {url}")
|
||||||
|
cache_path = cache._get_cache_path(url)
|
||||||
|
print(f" Cache: {cache_path}")
|
||||||
|
|
||||||
|
# Download with progress
|
||||||
|
temp_path = cache_path.with_suffix(cache_path.suffix + ".tmp")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url) as response:
|
||||||
|
total_size = int(response.headers.get("Content-Length", 0))
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
with open(temp_path, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = response.read(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
|
||||||
|
if total_size > 0:
|
||||||
|
percent = (downloaded / total_size) * 100
|
||||||
|
print(f"\r Progress: {percent:.1f}%", end="", flush=True)
|
||||||
|
|
||||||
|
print() # New line after progress
|
||||||
|
|
||||||
|
# Move to final location
|
||||||
|
temp_path.replace(cache_path)
|
||||||
|
|
||||||
|
# Let cache handle metadata
|
||||||
|
cache.save_to_cache(url, cache_path)
|
||||||
|
|
||||||
|
return cache_path
|
||||||
|
|
||||||
|
except (OSError, urllib.error.URLError) as e:
|
||||||
|
if temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
raise RuntimeError(f"Failed to download {url}: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""CLI entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="GitHub Download Cache Manager",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Download and cache a URL
|
||||||
|
%(prog)s download https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.1.0.zip
|
||||||
|
|
||||||
|
# List cached files
|
||||||
|
%(prog)s list
|
||||||
|
|
||||||
|
# Show cache statistics
|
||||||
|
%(prog)s stats
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
%(prog)s clear
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--cache-dir",
|
||||||
|
type=Path,
|
||||||
|
help="Cache directory (default: ~/.platformio/esphome_download_cache)",
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
|
||||||
|
|
||||||
|
# Download command
|
||||||
|
download_parser = subparsers.add_parser("download", help="Download and cache a URL")
|
||||||
|
download_parser.add_argument("url", help="URL to download")
|
||||||
|
download_parser.add_argument(
|
||||||
|
"--force", action="store_true", help="Force re-download even if cached"
|
||||||
|
)
|
||||||
|
download_parser.add_argument(
|
||||||
|
"--no-check-updates",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip checking for updates (don't use HTTP 304)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# List command
|
||||||
|
subparsers.add_parser("list", help="List cached files")
|
||||||
|
|
||||||
|
# Stats command
|
||||||
|
subparsers.add_parser("stats", help="Show cache statistics")
|
||||||
|
|
||||||
|
# Clear command
|
||||||
|
subparsers.add_parser("clear", help="Clear all cached files")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Use PlatformIO cache directory by default
|
||||||
|
if args.cache_dir is None:
|
||||||
|
args.cache_dir = Path.home() / ".platformio" / "esphome_download_cache"
|
||||||
|
|
||||||
|
cache = GitHubCache(args.cache_dir)
|
||||||
|
|
||||||
|
if args.command == "download":
|
||||||
|
try:
|
||||||
|
check_updates = not args.no_check_updates
|
||||||
|
cache_path = download_with_progress(
|
||||||
|
cache, args.url, force=args.force, check_updates=check_updates
|
||||||
|
)
|
||||||
|
print(f"\nCached at: {cache_path}")
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
elif args.command == "list":
|
||||||
|
cached = cache.list_cached()
|
||||||
|
if not cached:
|
||||||
|
print("No cached files")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Cached files ({len(cached)}):")
|
||||||
|
for item in cached:
|
||||||
|
size_mb = item["size"] / (1024 * 1024)
|
||||||
|
print(f" {item['url']}")
|
||||||
|
print(f" Size: {size_mb:.2f} MB")
|
||||||
|
print(f" Path: {item['path']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
elif args.command == "stats":
|
||||||
|
total_size = cache.cache_size()
|
||||||
|
cached_count = len(cache.list_cached())
|
||||||
|
size_mb = total_size / (1024 * 1024)
|
||||||
|
|
||||||
|
print(f"Cache directory: {cache.cache_dir}")
|
||||||
|
print(f"Cached files: {cached_count}")
|
||||||
|
print(f"Total size: {size_mb:.2f} MB")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
elif args.command == "clear":
|
||||||
|
cache.clear_cache()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
138
script/platformio_download_wrapper.py
Executable file
138
script/platformio_download_wrapper.py
Executable file
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PlatformIO Download Wrapper with Caching
|
||||||
|
|
||||||
|
This script can be used as a wrapper around PlatformIO downloads to add caching.
|
||||||
|
It intercepts download operations and uses the GitHub download cache.
|
||||||
|
|
||||||
|
This is designed to be called from PlatformIO's extra_scripts if needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Import the cache manager
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from github_download_cache import GitHubDownloadCache
|
||||||
|
|
||||||
|
|
||||||
|
def is_github_url(url: str) -> bool:
|
||||||
|
"""Check if a URL is a GitHub URL."""
|
||||||
|
return "github.com" in url.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def cached_download_handler(source, target, env):
|
||||||
|
"""PlatformIO download handler that uses caching for GitHub URLs.
|
||||||
|
|
||||||
|
This function can be registered as a custom download handler in PlatformIO.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Source URL
|
||||||
|
target: Target file path
|
||||||
|
env: SCons environment
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
url = str(source[0])
|
||||||
|
target_path = Path(str(target[0]))
|
||||||
|
|
||||||
|
# Only cache GitHub URLs
|
||||||
|
if not is_github_url(url):
|
||||||
|
# Fall back to default download
|
||||||
|
print(f"Downloading (no cache): {url}")
|
||||||
|
with (
|
||||||
|
urllib.request.urlopen(url) as response,
|
||||||
|
open(target_path, "wb") as out_file,
|
||||||
|
):
|
||||||
|
shutil.copyfileobj(response, out_file)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use cache for GitHub URLs
|
||||||
|
cache = GitHubDownloadCache()
|
||||||
|
print(f"Downloading with cache: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cached_path = cache.download_with_cache(url, check_updates=True)
|
||||||
|
|
||||||
|
# Copy from cache to target
|
||||||
|
shutil.copy2(cached_path, target_path)
|
||||||
|
print(f" Copied to: {target_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Cache download failed, using direct download: {e}")
|
||||||
|
# Fall back to direct download
|
||||||
|
with (
|
||||||
|
urllib.request.urlopen(url) as response,
|
||||||
|
open(target_path, "wb") as out_file,
|
||||||
|
):
|
||||||
|
shutil.copyfileobj(response, out_file)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_platformio_caching():
|
||||||
|
"""Setup PlatformIO to use cached downloads.
|
||||||
|
|
||||||
|
This should be called from an extra_scripts file in platformio.ini.
|
||||||
|
|
||||||
|
Example extra_scripts file (e.g., platformio_hooks.py):
|
||||||
|
Import("env")
|
||||||
|
from script.platformio_download_wrapper import setup_platformio_caching
|
||||||
|
setup_platformio_caching()
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from SCons.Script import DefaultEnvironment
|
||||||
|
|
||||||
|
DefaultEnvironment()
|
||||||
|
|
||||||
|
# Register custom download handler
|
||||||
|
# Note: This may not work with all PlatformIO versions
|
||||||
|
# as the download mechanism is internal
|
||||||
|
print("Note: Direct download interception is not fully supported.")
|
||||||
|
print("Please use the cache_platformio_downloads.py script instead.")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print("Warning: SCons not available, cannot setup download caching")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# CLI mode - can be used to manually download a URL with caching
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Download a URL with caching")
|
||||||
|
parser.add_argument("url", help="URL to download")
|
||||||
|
parser.add_argument("target", help="Target file path")
|
||||||
|
parser.add_argument("--cache-dir", type=Path, help="Cache directory")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
cache = GitHubDownloadCache(args.cache_dir)
|
||||||
|
target_path = Path(args.target)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_github_url(args.url):
|
||||||
|
print(f"Downloading with cache: {args.url}")
|
||||||
|
cached_path = cache.download_with_cache(args.url)
|
||||||
|
|
||||||
|
# Copy to target
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(cached_path, target_path)
|
||||||
|
print(f"Copied to: {target_path}")
|
||||||
|
else:
|
||||||
|
print(f"Downloading directly (not a GitHub URL): {args.url}")
|
||||||
|
import shutil
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with (
|
||||||
|
urllib.request.urlopen(args.url) as response,
|
||||||
|
open(target_path, "wb") as out_file,
|
||||||
|
):
|
||||||
|
shutil.copyfileobj(response, out_file)
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
Reference in New Issue
Block a user