feat: add APK download progress bar
This commit is contained in:
110
src/sideload.py
110
src/sideload.py
@@ -12,6 +12,7 @@ import sys
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -140,6 +141,8 @@ def _atomic_download(
|
|||||||
conditional_headers: dict[str, str] | None = None,
|
conditional_headers: dict[str, str] | None = None,
|
||||||
same_origin: bool = False,
|
same_origin: bool = False,
|
||||||
return_metadata: bool = False,
|
return_metadata: bool = False,
|
||||||
|
expected_size: int | None = None,
|
||||||
|
progress: Callable[[int, int | None], None] | None = None,
|
||||||
) -> Path | tuple[Path | None, str, dict[str, str], int]:
|
) -> Path | tuple[Path | None, str, dict[str, str], int]:
|
||||||
parsed = urllib.parse.urlparse(url)
|
parsed = urllib.parse.urlparse(url)
|
||||||
if parsed.scheme != "https":
|
if parsed.scheme != "https":
|
||||||
@@ -165,8 +168,25 @@ def _atomic_download(
|
|||||||
if return_metadata:
|
if return_metadata:
|
||||||
return None, final_url, response_headers, status
|
return None, final_url, response_headers, status
|
||||||
raise ValueError("received HTTP 304 without a reusable cache entry")
|
raise ValueError("received HTTP 304 without a reusable cache entry")
|
||||||
|
content_length = next(
|
||||||
|
(value for key, value in response_headers.items() if key.lower() == "content-length"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
total_size = int(content_length) if content_length is not None else expected_size
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
total_size = expected_size
|
||||||
|
if total_size is not None and total_size <= 0:
|
||||||
|
total_size = None
|
||||||
|
downloaded_size = 0
|
||||||
|
if progress:
|
||||||
|
progress(downloaded_size, total_size)
|
||||||
with tmp.open("wb") as fh:
|
with tmp.open("wb") as fh:
|
||||||
shutil.copyfileobj(resp, fh)
|
while chunk := resp.read(65536):
|
||||||
|
fh.write(chunk)
|
||||||
|
downloaded_size += len(chunk)
|
||||||
|
if progress:
|
||||||
|
progress(downloaded_size, total_size)
|
||||||
tmp.rename(dest)
|
tmp.rename(dest)
|
||||||
if return_metadata:
|
if return_metadata:
|
||||||
return dest, final_url, response_headers, status
|
return dest, final_url, response_headers, status
|
||||||
@@ -448,7 +468,13 @@ class GenericSource:
|
|||||||
expected_sha256=self.app_entry.config.get("sha256"),
|
expected_sha256=self.app_entry.config.get("sha256"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def download_apk(self, release: ReleaseInfo, cache: DownloadCache) -> Path:
|
def download_apk(
|
||||||
|
self,
|
||||||
|
release: ReleaseInfo,
|
||||||
|
cache: DownloadCache,
|
||||||
|
*,
|
||||||
|
progress: Callable[[int, int | None], None] | None = None,
|
||||||
|
) -> Path:
|
||||||
if release.expected_sha256:
|
if release.expected_sha256:
|
||||||
cached = cache.find_cached(release.app_entry.app_id, release.expected_sha256)
|
cached = cache.find_cached(release.app_entry.app_id, release.expected_sha256)
|
||||||
if cached:
|
if cached:
|
||||||
@@ -481,6 +507,8 @@ class GenericSource:
|
|||||||
conditional_headers=conditional,
|
conditional_headers=conditional,
|
||||||
same_origin=True,
|
same_origin=True,
|
||||||
return_metadata=True,
|
return_metadata=True,
|
||||||
|
expected_size=release.file_size,
|
||||||
|
progress=progress,
|
||||||
)
|
)
|
||||||
if status == 304 and cached:
|
if status == 304 and cached:
|
||||||
return cached
|
return cached
|
||||||
@@ -598,7 +626,13 @@ class FdroidSource:
|
|||||||
file_size=file_info.get("size"),
|
file_size=file_info.get("size"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def download_apk(self, release: ReleaseInfo, cache: DownloadCache) -> Path:
|
def download_apk(
|
||||||
|
self,
|
||||||
|
release: ReleaseInfo,
|
||||||
|
cache: DownloadCache,
|
||||||
|
*,
|
||||||
|
progress: Callable[[int, int | None], None] | None = None,
|
||||||
|
) -> Path:
|
||||||
if release.expected_sha256:
|
if release.expected_sha256:
|
||||||
cached = cache.find_cached(
|
cached = cache.find_cached(
|
||||||
release.app_entry.app_id, release.expected_sha256
|
release.app_entry.app_id, release.expected_sha256
|
||||||
@@ -608,7 +642,9 @@ class FdroidSource:
|
|||||||
dest = cache.cached_path(
|
dest = cache.cached_path(
|
||||||
release.app_entry.app_id, release.expected_sha256 or "downloading"
|
release.app_entry.app_id, release.expected_sha256 or "downloading"
|
||||||
)
|
)
|
||||||
dest = _atomic_download(release.download_url, dest)
|
dest = _atomic_download(
|
||||||
|
release.download_url, dest, expected_size=release.file_size, progress=progress
|
||||||
|
)
|
||||||
actual = _sha256_file(dest)
|
actual = _sha256_file(dest)
|
||||||
if release.expected_sha256 and actual != release.expected_sha256:
|
if release.expected_sha256 and actual != release.expected_sha256:
|
||||||
dest.unlink()
|
dest.unlink()
|
||||||
@@ -673,11 +709,19 @@ class GitHubSource:
|
|||||||
file_size=apk_assets[0].get("size"),
|
file_size=apk_assets[0].get("size"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def download_apk(self, release: ReleaseInfo, cache: DownloadCache) -> Path:
|
def download_apk(
|
||||||
|
self,
|
||||||
|
release: ReleaseInfo,
|
||||||
|
cache: DownloadCache,
|
||||||
|
*,
|
||||||
|
progress: Callable[[int, int | None], None] | None = None,
|
||||||
|
) -> Path:
|
||||||
stem = _make_apk_stem(release)
|
stem = _make_apk_stem(release)
|
||||||
dest = cache.root / release.app_entry.app_id / f"{stem}.apk"
|
dest = cache.root / release.app_entry.app_id / f"{stem}.apk"
|
||||||
if not dest.is_file():
|
if not dest.is_file():
|
||||||
dest = _atomic_download(release.download_url, dest)
|
dest = _atomic_download(
|
||||||
|
release.download_url, dest, expected_size=release.file_size, progress=progress
|
||||||
|
)
|
||||||
sha = _sha256_file(dest)
|
sha = _sha256_file(dest)
|
||||||
if release.expected_sha256 and sha.lower() != release.expected_sha256.lower():
|
if release.expected_sha256 and sha.lower() != release.expected_sha256.lower():
|
||||||
dest.unlink(missing_ok=True)
|
dest.unlink(missing_ok=True)
|
||||||
@@ -733,11 +777,19 @@ class GitLabSource:
|
|||||||
file_size=None,
|
file_size=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def download_apk(self, release: ReleaseInfo, cache: DownloadCache) -> Path:
|
def download_apk(
|
||||||
|
self,
|
||||||
|
release: ReleaseInfo,
|
||||||
|
cache: DownloadCache,
|
||||||
|
*,
|
||||||
|
progress: Callable[[int, int | None], None] | None = None,
|
||||||
|
) -> Path:
|
||||||
stem = _make_apk_stem(release)
|
stem = _make_apk_stem(release)
|
||||||
dest = cache.root / release.app_entry.app_id / f"{stem}.apk"
|
dest = cache.root / release.app_entry.app_id / f"{stem}.apk"
|
||||||
if not dest.is_file():
|
if not dest.is_file():
|
||||||
dest = _atomic_download(release.download_url, dest)
|
dest = _atomic_download(
|
||||||
|
release.download_url, dest, expected_size=release.file_size, progress=progress
|
||||||
|
)
|
||||||
sha = _sha256_file(dest)
|
sha = _sha256_file(dest)
|
||||||
if release.expected_sha256 and sha.lower() != release.expected_sha256.lower():
|
if release.expected_sha256 and sha.lower() != release.expected_sha256.lower():
|
||||||
dest.unlink(missing_ok=True)
|
dest.unlink(missing_ok=True)
|
||||||
@@ -799,11 +851,19 @@ class CodebergSource:
|
|||||||
file_size=asset.get("size"),
|
file_size=asset.get("size"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def download_apk(self, release: ReleaseInfo, cache: DownloadCache) -> Path:
|
def download_apk(
|
||||||
|
self,
|
||||||
|
release: ReleaseInfo,
|
||||||
|
cache: DownloadCache,
|
||||||
|
*,
|
||||||
|
progress: Callable[[int, int | None], None] | None = None,
|
||||||
|
) -> Path:
|
||||||
stem = _make_apk_stem(release)
|
stem = _make_apk_stem(release)
|
||||||
dest = cache.root / release.app_entry.app_id / f"{stem}.apk"
|
dest = cache.root / release.app_entry.app_id / f"{stem}.apk"
|
||||||
if not dest.is_file():
|
if not dest.is_file():
|
||||||
dest = _atomic_download(release.download_url, dest)
|
dest = _atomic_download(
|
||||||
|
release.download_url, dest, expected_size=release.file_size, progress=progress
|
||||||
|
)
|
||||||
sha = _sha256_file(dest)
|
sha = _sha256_file(dest)
|
||||||
if release.expected_sha256 and sha.lower() != release.expected_sha256.lower():
|
if release.expected_sha256 and sha.lower() != release.expected_sha256.lower():
|
||||||
dest.unlink(missing_ok=True)
|
dest.unlink(missing_ok=True)
|
||||||
@@ -961,6 +1021,20 @@ def _make_source(app: AppEntry) -> FdroidSource | GitHubSource | GitLabSource |
|
|||||||
# ── orchestration ─────────────────────────────────────────────────────────────
|
# ── orchestration ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _download_progress(downloaded: int, total: int | None) -> None:
|
||||||
|
"""Render one compact download update on stderr."""
|
||||||
|
if total is not None:
|
||||||
|
percent = min(100, downloaded * 100 // total)
|
||||||
|
width = 20
|
||||||
|
filled = min(width, downloaded * width // total)
|
||||||
|
bar = "#" * filled + "-" * (width - filled)
|
||||||
|
message = f"\rDownloading APK: [{bar}] {percent:3d}%"
|
||||||
|
else:
|
||||||
|
message = f"\rDownloading APK: {downloaded:,} bytes"
|
||||||
|
sys.stderr.write(message)
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
def sideload(
|
def sideload(
|
||||||
app: AppEntry,
|
app: AppEntry,
|
||||||
config: Config,
|
config: Config,
|
||||||
@@ -987,11 +1061,25 @@ def sideload(
|
|||||||
|
|
||||||
# 2 — download
|
# 2 — download
|
||||||
print("Downloading APK…")
|
print("Downloading APK…")
|
||||||
|
progress_seen = False
|
||||||
|
|
||||||
|
def report_progress(downloaded: int, total: int | None) -> None:
|
||||||
|
nonlocal progress_seen
|
||||||
|
progress_seen = True
|
||||||
|
_download_progress(downloaded, total)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
apk_path = source.download_apk(release, cache)
|
apk_path = source.download_apk(release, cache, progress=report_progress)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if progress_seen:
|
||||||
|
sys.stderr.write("\n")
|
||||||
|
sys.stderr.flush()
|
||||||
print(f"Error downloading: {exc}", file=sys.stderr)
|
print(f"Error downloading: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
if progress_seen:
|
||||||
|
# Leave the terminal cursor at the next line after the last update.
|
||||||
|
sys.stderr.write("\n")
|
||||||
|
sys.stderr.flush()
|
||||||
print(f" -> cached at {apk_path}")
|
print(f" -> cached at {apk_path}")
|
||||||
sha = _sha256_file(apk_path)
|
sha = _sha256_file(apk_path)
|
||||||
print(f" -> sha256: {sha}")
|
print(f" -> sha256: {sha}")
|
||||||
|
|||||||
@@ -393,8 +393,10 @@ class TestFdroidSource:
|
|||||||
download_url="https://example.com/x.apk",
|
download_url="https://example.com/x.apk",
|
||||||
expected_sha256=sha,
|
expected_sha256=sha,
|
||||||
)
|
)
|
||||||
result = source.download_apk(release, cache)
|
progress = mock.Mock()
|
||||||
|
result = source.download_apk(release, cache, progress=progress)
|
||||||
assert result == cache.cached_path(app_entry.app_id, sha)
|
assert result == cache.cached_path(app_entry.app_id, sha)
|
||||||
|
progress.assert_not_called()
|
||||||
|
|
||||||
def test_download_apk_sha256_mismatch(self, app_entry: sideload.AppEntry, cache: sideload.DownloadCache):
|
def test_download_apk_sha256_mismatch(self, app_entry: sideload.AppEntry, cache: sideload.DownloadCache):
|
||||||
apk_data = b"real-content"
|
apk_data = b"real-content"
|
||||||
@@ -1293,6 +1295,52 @@ class TestUtilities:
|
|||||||
assert dest.read_bytes() == data
|
assert dest.read_bytes() == data
|
||||||
assert not dest.with_suffix(".apk.tmp").exists()
|
assert not dest.with_suffix(".apk.tmp").exists()
|
||||||
|
|
||||||
|
def test_atomic_download_reports_content_length_progress(self, tmp_path: Path):
|
||||||
|
data = b"downloaded-content"
|
||||||
|
response = _mock_urlopen_response(data)
|
||||||
|
response.headers = {"Content-Length": str(len(data))}
|
||||||
|
updates = []
|
||||||
|
with _mock_download_opener(response):
|
||||||
|
result = sideload._atomic_download(
|
||||||
|
"https://example.com/x.apk",
|
||||||
|
tmp_path / "test.apk",
|
||||||
|
progress=lambda downloaded, total: updates.append((downloaded, total)),
|
||||||
|
)
|
||||||
|
assert result.read_bytes() == data
|
||||||
|
assert updates[0] == (0, len(data))
|
||||||
|
assert updates[-1] == (len(data), len(data))
|
||||||
|
|
||||||
|
def test_atomic_download_reports_indeterminate_progress(self, tmp_path: Path):
|
||||||
|
data = b"downloaded-content"
|
||||||
|
response = _mock_urlopen_response(data)
|
||||||
|
response.headers = {}
|
||||||
|
updates = []
|
||||||
|
with _mock_download_opener(response):
|
||||||
|
result = sideload._atomic_download(
|
||||||
|
"https://example.com/x.apk",
|
||||||
|
tmp_path / "test.apk",
|
||||||
|
expected_size=None,
|
||||||
|
progress=lambda downloaded, total: updates.append((downloaded, total)),
|
||||||
|
)
|
||||||
|
assert result.read_bytes() == data
|
||||||
|
assert updates[0] == (0, None)
|
||||||
|
assert updates[-1] == (len(data), None)
|
||||||
|
|
||||||
|
def test_atomic_download_uses_expected_size_without_content_length(self, tmp_path: Path):
|
||||||
|
data = b"downloaded-content"
|
||||||
|
response = _mock_urlopen_response(data)
|
||||||
|
response.headers = {}
|
||||||
|
updates = []
|
||||||
|
with _mock_download_opener(response):
|
||||||
|
sideload._atomic_download(
|
||||||
|
"https://example.com/x.apk",
|
||||||
|
tmp_path / "test.apk",
|
||||||
|
expected_size=len(data),
|
||||||
|
progress=lambda downloaded, total: updates.append((downloaded, total)),
|
||||||
|
)
|
||||||
|
assert updates[0] == (0, len(data))
|
||||||
|
assert updates[-1] == (len(data), len(data))
|
||||||
|
|
||||||
def test_atomic_download_cleans_up_on_error(self, tmp_path: Path):
|
def test_atomic_download_cleans_up_on_error(self, tmp_path: Path):
|
||||||
dest = tmp_path / "file.apk"
|
dest = tmp_path / "file.apk"
|
||||||
with _mock_download_opener(error=RuntimeError("fail")):
|
with _mock_download_opener(error=RuntimeError("fail")):
|
||||||
|
|||||||
Reference in New Issue
Block a user