Upstream mikf/gallery-dl state of the touched files before 8462417350

This commit is contained in:
upstream committed 2026-10-10 13:13:44 +02:00
commit b5186d86fa
2 files changed
+1025

No files matched your search

+575
View File
@@ -0,0 +1,575 @@
# -*- coding: utf-8 -*-
# Copyright 2014-2026 Mike Fährmann
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
"""Downloader module for http:// and https:// URLs"""
import time
import mimetypes
from requests.exceptions import RequestException, ConnectionError, Timeout
from .common import DownloaderBase
from .. import text, util, output, exception
from ssl import SSLError
FLAGS = util.FLAGS
class HttpDownloader(DownloaderBase):
scheme = "http"
def __init__(self, job):
DownloaderBase.__init__(self, job)
extractor = job.extractor
self.downloading = False
self.adjust_extension = self.config("adjust-extensions", True)
self.chunk_size = self.config("chunk-size", 32768)
self.metadata = extractor.config("http-metadata")
self.progress = self.config("progress", 3.0)
self.validate = self.config("validate", True)
self.validate_html = self.config("validate-html", True)
self.headers = self.config("headers")
self.minsize = self.config("filesize-min")
self.maxsize = self.config("filesize-max")
self.retries = self.config("retries", extractor._retries)
self.retry_codes = self.config("retry-codes", extractor._retry_codes)
self.timeout = self.config("timeout", extractor._timeout)
self.verify = self.config("verify", extractor._verify)
self.mtime = self.config("mtime", True)
self.rate = self.config("rate")
interval_429 = self.config("sleep-429")
if self.config("consume-content", False):
self.release_conn = self._release_conn_impl
else:
# this resets the underlying TCP connection, and therefore
# if the program makes another request to the same domain,
# a new connection (either TLS or plain TCP) must be made
self.release_conn = lambda resp: resp.close()
if self.retries < 0:
self.retries = float("inf")
if self.minsize:
minsize = text.parse_bytes(self.minsize)
if not minsize:
self.log.warning(
"Invalid minimum file size (%r)", self.minsize)
self.minsize = minsize
if self.maxsize:
maxsize = text.parse_bytes(self.maxsize)
if not maxsize:
self.log.warning(
"Invalid maximum file size (%r)", self.maxsize)
self.maxsize = maxsize
if isinstance(self.chunk_size, str):
chunk_size = text.parse_bytes(self.chunk_size)
if not chunk_size:
self.log.warning(
"Invalid chunk size (%r)", self.chunk_size)
chunk_size = 32768
self.chunk_size = chunk_size
if self.rate:
func = util.build_selection_func(self.rate, 0, text.parse_bytes)
if rmax := func.args[1] if hasattr(func, "args") else func():
if rmax < self.chunk_size:
# reduce chunk_size to allow for one iteration each second
self.chunk_size = rmax
self.rate = func
self.receive = self._receive_rate
else:
self.log.warning("Invalid rate limit (%r)", self.rate)
self.rate = False
if self.progress is not None:
self.receive = self._receive_rate
if self.progress < 0.0:
self.progress = 0.0
if interval_429 is None:
self.interval_429 = extractor._interval_429
else:
try:
self.interval_429 = util.build_duration_func_ex(interval_429)
except Exception as exc:
self.log.error("Invalid 'sleep-429' value '%s' (%s: %s)",
interval_429, exc.__class__.__name__, exc)
self.interval_429 = extractor._interval_429
def download(self, url, pathfmt):
try:
return self._download_impl(url, pathfmt)
except Exception as exc:
if self.downloading:
output.stderr_write("\n")
self.log.traceback(exc)
raise
finally:
# remove file from incomplete downloads
if self.downloading and not self.part:
util.remove_file(pathfmt.temppath)
def _download_impl(self, url, pathfmt):
response = None
tries = code = 0
msg = ""
metadata = self.metadata
kwdict = pathfmt.kwdict
expected_status = kwdict.get(
"_http_expected_status", ())
adjust_extension = kwdict.get(
"_http_adjust_extension", self.adjust_extension)
if self.part and not metadata:
pathfmt.part_enable(self.partdir)
while True:
if FLAGS.DOWNLOAD is not None:
return FLAGS.process("DOWNLOAD")
if tries:
if response:
self.release_conn(response)
response = None
self.log.warning("%s (%s/%s)", msg, tries, self.retries+1)
if tries > self.retries:
return False
if code == 429 and self.interval_429:
s = self.interval_429(tries)
time.sleep(s if s > tries else tries)
else:
time.sleep(tries)
code = 0
tries += 1
file_header = None
# collect HTTP headers
headers = {"Accept": "*/*"}
# file-specific headers
if extra := kwdict.get("_http_headers"):
headers.update(extra)
# general headers
if self.headers:
headers.update(self.headers)
# partial content
if file_size := pathfmt.part_size():
headers["Range"] = f"bytes={file_size}-"
# connect to (remote) source
try:
response = self.session.request(
kwdict.get("_http_method", "GET"), url,
stream=True,
headers=headers,
data=kwdict.get("_http_data"),
timeout=self.timeout,
proxies=self.proxies,
verify=self.verify,
)
except ConnectionError as exc:
try:
reason = exc.args[0].reason
cls = reason.__class__.__name__
pre, _, err = str(reason.args[-1]).partition(":")
msg = f"{cls}: {(err or pre).lstrip()}"
except Exception:
msg = str(exc)
continue
except Timeout as exc:
msg = str(exc)
continue
except Exception as exc:
self.log.warning(exc)
return False
# check response
code = response.status_code
if code == 200 or code in expected_status: # OK
offset = 0
size = response.headers.get("Content-Length")
elif code == 206: # Partial Content
offset = file_size
size = response.headers["Content-Range"].rpartition("/")[2]
elif code == 416 and file_size: # Range Not Satisfiable
self._release_conn_impl(response)
break
else:
msg = f"'{code} {response.reason}' for '{url}'"
challenge = util.detect_challenge(response)
if challenge is not None:
self.log.warning(challenge)
if code in self.retry_codes or 500 <= code < 600:
continue
retry = kwdict.get("_http_retry")
if retry and retry(response):
continue
self.release_conn(response)
self.log.warning(msg)
return False
# check for invalid responses
if self.validate and \
(validate := kwdict.get("_http_validate")) is not None:
try:
result = validate(response)
except Exception:
self.release_conn(response)
raise
if isinstance(result, str):
url = result
tries -= 1
continue
if not result:
self.release_conn(response)
self.log.warning("Invalid response")
return False
if self.validate_html and response.headers.get(
"content-type", "").startswith("text/html") and \
pathfmt.extension not in ("html", "htm"):
if response.history:
self.log.warning("HTTP redirect to '%s'", response.url)
else:
self.log.warning("HTML response")
return False
# check file size
size = text.parse_int(size, None)
if size is not None:
if not size:
self.release_conn(response)
self.log.warning("Empty file")
return False
if self.minsize and size < self.minsize:
self.release_conn(response)
self.log.warning(
"File size smaller than allowed minimum (%s < %s)",
size, self.minsize)
pathfmt.temppath = ""
return True
if self.maxsize and size > self.maxsize:
self.release_conn(response)
self.log.warning(
"File size larger than allowed maximum (%s > %s)",
size, self.maxsize)
pathfmt.temppath = ""
return True
build_path = False
# set missing filename extension from MIME type
if not pathfmt.extension:
pathfmt.set_extension(self._find_extension(response))
build_path = True
# set metadata from HTTP headers
if metadata:
kwdict[metadata] = util.extract_headers(response)
build_path = True
# build and check file path
if build_path:
pathfmt.build_path()
if pathfmt.exists():
pathfmt.temppath = ""
# release the connection back to pool by explicitly
# calling .close()
# see https://requests.readthedocs.io/en/latest/user
# /advanced/#body-content-workflow
# when the image size is on the order of megabytes,
# re-establishing a TLS connection will typically be faster
# than consuming the whole response
response.close()
return True
if self.part and metadata:
pathfmt.part_enable(self.partdir)
metadata = False
content = response.iter_content(self.chunk_size)
validate_sig = kwdict.get("_http_signature")
validate_ext = (adjust_extension and
pathfmt.extension in SIGNATURE_CHECKS)
# check filename extension against file header
if not offset and (validate_ext or validate_sig):
try:
file_header = next(
content if response.raw.chunked
else response.iter_content(16), b"")
except (RequestException, SSLError) as exc:
msg = str(exc)
continue
if validate_sig:
result = validate_sig(file_header)
if result is not True:
self.release_conn(response)
self.log.warning(
result or "Invalid file signature bytes")
return False
if validate_ext and self._adjust_extension(
pathfmt, file_header) and pathfmt.exists():
pathfmt.temppath = ""
response.close()
return True
# set open mode
if not offset:
mode = "w+b"
if file_size:
self.log.debug("Unable to resume partial download")
else:
mode = "r+b"
self.log.debug("Resuming download at byte %d", offset)
# download content
self.downloading = True
with pathfmt.open(mode) as fp:
if fp is None:
# '.part' file no longer exists
break
if file_header:
fp.write(file_header)
offset += len(file_header)
elif offset:
if adjust_extension and \
pathfmt.extension in SIGNATURE_CHECKS:
self._adjust_extension(pathfmt, fp.read(16))
fp.seek(offset)
self.out.start(pathfmt.path)
try:
self.receive(fp, content, size, offset)
except (RequestException, SSLError) as exc:
msg = str(exc)
output.stderr_write("\n")
continue
except exception.StopExtraction:
response.close()
return False
except exception.ControlException:
response.close()
raise
# check file size
if size and (fsize := fp.tell()) < size:
if (segmented := kwdict.get("_http_segmented")) and \
segmented is True or segmented == fsize:
tries -= 1
msg = "Resuming segmented download"
output.stdout_write("\r")
else:
msg = f"file size mismatch ({fsize} < {size})"
output.stderr_write("\n")
continue
break
self.downloading = False
if self.mtime:
if "_http_lastmodified" in kwdict:
kwdict["_mtime_http"] = kwdict["_http_lastmodified"]
else:
kwdict["_mtime_http"] = response.headers.get("Last-Modified")
else:
kwdict["_mtime_http"] = None
return True
def _release_conn_impl(self, response):
"""Release connection back to pool by consuming response body"""
try:
for _ in response.iter_content(self.chunk_size):
pass
except (RequestException, SSLError) as exc:
output.stderr_write("\n")
self.log.debug(
"Unable to consume response body (%s: %s); "
"closing the connection anyway", exc.__class__.__name__, exc)
response.close()
def receive(self, fp, content, bytes_total, bytes_start):
write = fp.write
for data in content:
if FLAGS.DOWNLOAD is not None:
return FLAGS.process("DOWNLOAD")
write(data)
def _receive_rate(self, fp, content, bytes_total, bytes_start):
rate = self.rate() if self.rate else None
write = fp.write
progress = self.progress
bytes_downloaded = 0
time_start = time.monotonic()
for data in content:
if FLAGS.DOWNLOAD is not None:
return FLAGS.process("DOWNLOAD")
time_elapsed = time.monotonic() - time_start
bytes_downloaded += len(data)
write(data)
if progress is not None:
if time_elapsed > progress:
self.out.progress(
bytes_total,
bytes_start + bytes_downloaded,
int(bytes_downloaded / time_elapsed),
)
if rate is not None:
time_expected = bytes_downloaded / rate
if time_expected > time_elapsed:
time.sleep(time_expected - time_elapsed)
def _find_extension(self, response):
"""Get filename extension from MIME type"""
mtype = response.headers.get("Content-Type", "image/jpeg")
mtype = mtype.partition(";")[0].lower()
if "/" not in mtype:
mtype = "image/" + mtype
if mtype in MIME_TYPES:
return MIME_TYPES[mtype]
if ext := mimetypes.guess_extension(mtype, strict=False):
return ext[1:]
self.log.warning("Unknown MIME type '%s'", mtype)
return "bin"
def _adjust_extension(self, pathfmt, file_header):
"""Check filename extension against file header"""
if not SIGNATURE_CHECKS[pathfmt.extension](file_header):
for ext, check in SIGNATURE_CHECKS.items():
if check(file_header):
self.log.debug(
"Adjusting filename extension of '%s' to '%s'",
pathfmt.filename, ext)
pathfmt.set_extension(ext)
pathfmt.build_path()
return True
return False
MIME_TYPES = {
"image/jpeg" : "jpg",
"image/jpg" : "jpg",
"image/png" : "png",
"image/gif" : "gif",
"image/bmp" : "bmp",
"image/x-bmp" : "bmp",
"image/x-ms-bmp": "bmp",
"image/webp" : "webp",
"image/avif" : "avif",
"image/heic" : "heic",
"image/heif" : "heif",
"image/svg+xml" : "svg",
"image/ico" : "ico",
"image/icon" : "ico",
"image/x-icon" : "ico",
"image/vnd.microsoft.icon" : "ico",
"image/x-photoshop" : "psd",
"application/x-photoshop" : "psd",
"image/vnd.adobe.photoshop": "psd",
"video/webm": "webm",
"video/ogg" : "ogg",
"video/mp4" : "mp4",
"video/m4v" : "m4v",
"video/x-m4v": "m4v",
"video/quicktime": "mov",
"audio/wav" : "wav",
"audio/x-wav": "wav",
"audio/webm" : "webm",
"audio/ogg" : "ogg",
"audio/mpeg" : "mp3",
"audio/aac" : "aac",
"audio/x-aac": "aac",
"application/vnd.apple.mpegurl": "m3u8",
"application/x-mpegurl" : "m3u8",
"application/dash+xml" : "mpd",
"application/zip" : "zip",
"application/x-zip": "zip",
"application/x-zip-compressed": "zip",
"application/rar" : "rar",
"application/x-rar": "rar",
"application/x-rar-compressed": "rar",
"application/x-7z-compressed" : "7z",
"application/pdf" : "pdf",
"application/x-pdf": "pdf",
"application/x-shockwave-flash": "swf",
"text/html": "html",
"application/ogg": "ogg",
# https://www.iana.org/assignments/media-types/model/obj
"model/obj": "obj",
"application/octet-stream": "bin",
}
def _signature_html(s):
s = s[:14].lstrip()
return s and b"<!doctype html".startswith(s.lower())
# https://en.wikipedia.org/wiki/List_of_file_signatures
SIGNATURE_CHECKS = {
"jpg" : lambda s: s[0:3] == b"\xFF\xD8\xFF",
"png" : lambda s: s[0:8] == b"\x89PNG\r\n\x1A\n",
"gif" : lambda s: s[0:6] in (b"GIF87a", b"GIF89a"),
"bmp" : lambda s: s[0:2] == b"BM",
"webp": lambda s: (s[0:4] == b"RIFF" and
s[8:12] == b"WEBP"),
"avif": lambda s: s[4:11] == b"ftypavi" and s[11] in b"fs",
"heic": lambda s: (s[4:10] == b"ftyphe" and s[10:12] in (
b"ic", b"im", b"is", b"ix", b"vc", b"vm", b"vs")),
"svg" : lambda s: s[0:5] == b"<?xml",
"ico" : lambda s: s[0:4] == b"\x00\x00\x01\x00",
"cur" : lambda s: s[0:4] == b"\x00\x00\x02\x00",
"psd" : lambda s: s[0:4] == b"8BPS",
"mp4" : lambda s: (s[4:8] == b"ftyp" and s[8:11] in (
b"mp4", b"avc", b"iso")),
"m4v" : lambda s: s[4:11] == b"ftypM4V",
"mov" : lambda s: s[4:12] == b"ftypqt ",
"webm": lambda s: s[0:4] == b"\x1A\x45\xDF\xA3",
"ogg" : lambda s: s[0:4] == b"OggS",
"wav" : lambda s: (s[0:4] == b"RIFF" and
s[8:12] == b"WAVE"),
"mp3" : lambda s: (s[0:3] == b"ID3" or
s[0:2] in (b"\xFF\xFB", b"\xFF\xF3", b"\xFF\xF2")),
"aac" : lambda s: s[0:2] in (b"\xFF\xF9", b"\xFF\xF1"),
"m3u8": lambda s: s[0:7] == b"#EXTM3U",
"mpd" : lambda s: b"<MPD" in s,
"zip" : lambda s: s[0:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"),
"rar" : lambda s: s[0:6] == b"Rar!\x1A\x07",
"7z" : lambda s: s[0:6] == b"\x37\x7A\xBC\xAF\x27\x1C",
"pdf" : lambda s: s[0:5] == b"%PDF-",
"swf" : lambda s: s[0:3] in (b"CWS", b"FWS"),
"html": _signature_html,
"htm" : _signature_html,
"blend": lambda s: s[0:7] == b"BLENDER",
# unfortunately the Wavefront .obj format doesn't have a signature,
# so we check for the existence of Blender's comment
"obj" : lambda s: s[0:11] == b"# Blender v",
# Celsys Clip Studio Paint format
# https://github.com/rasensuihei/cliputils/blob/master/README.md
"clip": lambda s: s[0:8] == b"CSFCHUNK",
# check 'bin' files against all other file signatures
"bin" : lambda s: False,
}
__downloader__ = HttpDownloader
+450
View File
@@ -0,0 +1,450 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright 2018-2025 Mike Fährmann
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
import os
import sys
import unittest
from unittest.mock import Mock, MagicMock, patch
import re
import logging
import os.path
import binascii
import tempfile
import threading
import http.server
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from gallery_dl import downloader, extractor, output, config, path # noqa E402
from gallery_dl.downloader.http import MIME_TYPES, SIGNATURE_CHECKS # noqa E402
class MockDownloaderModule(Mock):
__downloader__ = "mock"
class FakeJob():
def __init__(self):
self.extractor = extractor.find("generic:https://example.org/")
self.extractor.initialize()
self.pathfmt = path.PathFormat(self.extractor)
self.out = output.NullOutput()
self.get_logger = logging.getLogger
class TestDownloaderModule(unittest.TestCase):
@classmethod
def setUpClass(cls):
# allow import of ytdl downloader module without youtube_dl installed
cls._orig_ytdl = sys.modules.get("youtube_dl")
sys.modules["youtube_dl"] = MagicMock()
@classmethod
def tearDownClass(cls):
if cls._orig_ytdl:
sys.modules["youtube_dl"] = cls._orig_ytdl
else:
del sys.modules["youtube_dl"]
def setUp(self):
downloader._cache.clear()
def tearDown(self):
downloader._cache.clear()
def test_find(self):
cls = downloader.find("http")
self.assertEqual(cls.__name__, "HttpDownloader")
self.assertEqual(cls.scheme , "http")
cls = downloader.find("https")
self.assertEqual(cls.__name__, "HttpDownloader")
self.assertEqual(cls.scheme , "http")
cls = downloader.find("text")
self.assertEqual(cls.__name__, "TextDownloader")
self.assertEqual(cls.scheme , "text")
cls = downloader.find("ytdl")
self.assertEqual(cls.__name__, "YoutubeDLDownloader")
self.assertEqual(cls.scheme , "ytdl")
self.assertEqual(downloader.find("ftp"), None)
self.assertEqual(downloader.find("foo"), None)
self.assertEqual(downloader.find(1234) , None)
self.assertEqual(downloader.find(None) , None)
@patch("builtins.__import__")
def test_cache(self, import_module):
import_module.return_value = MockDownloaderModule()
downloader.find("http")
downloader.find("text")
downloader.find("ytdl")
self.assertEqual(import_module.call_count, 3)
downloader.find("http")
downloader.find("text")
downloader.find("ytdl")
self.assertEqual(import_module.call_count, 3)
@patch("builtins.__import__")
def test_cache_http(self, import_module):
import_module.return_value = MockDownloaderModule()
downloader.find("http")
downloader.find("https")
self.assertEqual(import_module.call_count, 1)
@patch("builtins.__import__")
def test_cache_https(self, import_module):
import_module.return_value = MockDownloaderModule()
downloader.find("https")
downloader.find("http")
self.assertEqual(import_module.call_count, 1)
class TestDownloaderConfig(unittest.TestCase):
def setUp(self):
config.clear()
def tearDown(self):
config.clear()
def test_default_http(self):
job = FakeJob()
extr = job.extractor
dl = downloader.find("http")(job)
self.assertEqual(dl.adjust_extension, True)
self.assertEqual(dl.chunk_size, 32768)
self.assertEqual(dl.metadata, None)
self.assertEqual(dl.progress, 3.0)
self.assertEqual(dl.validate, True)
self.assertEqual(dl.headers, None)
self.assertEqual(dl.minsize, None)
self.assertEqual(dl.maxsize, None)
self.assertEqual(dl.mtime, True)
self.assertEqual(dl.rate, None)
self.assertEqual(dl.part, True)
self.assertEqual(dl.partdir, None)
self.assertIs(dl.interval_429, extr._interval_429)
self.assertIs(dl.retry_codes, extr._retry_codes)
self.assertIs(dl.retries, extr._retries)
self.assertIs(dl.timeout, extr._timeout)
self.assertIs(dl.proxies, extr._proxies)
self.assertIs(dl.verify, extr._verify)
def test_config_http(self):
config.set((), "rate", 42)
config.set((), "mtime", False)
config.set((), "headers", {"foo": "bar"})
config.set(("downloader",), "retries", -1)
config.set(("downloader", "http"), "filesize-min", "10k")
config.set(("extractor", "generic"), "verify", False)
config.set(("extractor", "generic", "example.org"), "timeout", 10)
config.set(("extractor", "generic", "http"), "part", False)
config.set(
("extractor", "generic", "example.org", "http"), "headers", {})
job = FakeJob()
dl = downloader.find("http")(job)
self.assertEqual(dl.headers, {"foo": "bar"})
self.assertEqual(dl.minsize, 10240)
self.assertEqual(dl.retries, float("inf"))
self.assertEqual(dl.timeout, 10)
self.assertEqual(dl.verify, False)
self.assertEqual(dl.mtime, False)
self.assertEqual(dl.rate(), 42)
self.assertEqual(dl.part, False)
class TestDownloaderBase(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.dir = tempfile.TemporaryDirectory()
cls.fnum = 0
config.set((), "base-directory", cls.dir.name)
cls.job = FakeJob()
@classmethod
def tearDownClass(cls):
cls.dir.cleanup()
config.clear()
@classmethod
def _prepare_destination(cls, content=None, part=True, extension=None):
name = f"file-{cls.fnum}"
cls.fnum += 1
kwdict = {
"category" : "test",
"subcategory": "test",
"filename" : name,
"extension" : extension,
}
pathfmt = cls.job.pathfmt
pathfmt.set_directory(kwdict)
pathfmt.set_filename(kwdict)
pathfmt.build_path()
if content:
mode = "wb" if isinstance(content, bytes) else "w"
with pathfmt.open(mode) as fp:
fp.write(content)
return pathfmt
def _run_test(self, url, input, output,
extension, expected_extension=None):
pathfmt = self._prepare_destination(input, extension=extension)
success = self.downloader.download(url, pathfmt)
# test successful download
self.assertTrue(success, f"downloading '{url}' failed")
# test content
mode = "rb" if isinstance(output, bytes) else "r"
with pathfmt.open(mode) as fp:
content = fp.read()
self.assertEqual(content, output)
# test filename extension
self.assertEqual(
pathfmt.extension,
expected_extension,
content[0:16],
)
self.assertEqual(
os.path.splitext(pathfmt.realpath)[1][1:],
expected_extension,
)
class TestHTTPDownloader(TestDownloaderBase):
@classmethod
def setUpClass(cls):
TestDownloaderBase.setUpClass()
cls.downloader = downloader.find("http")(cls.job)
host = "127.0.0.1"
port = 0 # select random not-in-use port
try:
server = http.server.HTTPServer((host, port), HttpRequestHandler)
except OSError as exc:
raise unittest.SkipTest(
f"cannot spawn local HTTP server ({exc})")
host, port = server.server_address
cls.address = f"http://{host}:{port}"
threading.Thread(target=server.serve_forever, daemon=True).start()
def _run_test(self, ext, input, output,
extension, expected_extension=None):
TestDownloaderBase._run_test(
self, f"{self.address}/{ext}", input, output,
extension, expected_extension)
def tearDown(self):
self.downloader.minsize = self.downloader.maxsize = None
def test_http_download(self):
self._run_test("jpg", None, DATA["jpg"], "jpg", "jpg")
self._run_test("png", None, DATA["png"], "png", "png")
self._run_test("gif", None, DATA["gif"], "gif", "gif")
def test_http_offset(self):
self._run_test("jpg", DATA["jpg"][:123], DATA["jpg"], "jpg", "jpg")
self._run_test("png", DATA["png"][:12] , DATA["png"], "png", "png")
self._run_test("gif", DATA["gif"][:1] , DATA["gif"], "gif", "gif")
def test_http_extension(self):
self._run_test("jpg", None, DATA["jpg"], None, "jpg")
self._run_test("png", None, DATA["png"], None, "png")
self._run_test("gif", None, DATA["gif"], None, "gif")
def test_http_adjust_extension(self):
self._run_test("jpg", None, DATA["jpg"], "png", "jpg")
self._run_test("png", None, DATA["png"], "gif", "png")
self._run_test("gif", None, DATA["gif"], "jpg", "gif")
def test_http_filesize_min(self):
url = f"{self.address}/gif"
pathfmt = self._prepare_destination(None, extension=None)
self.downloader.minsize = 100
with self.assertLogs(self.downloader.log, "WARNING"):
success = self.downloader.download(url, pathfmt)
self.assertTrue(success)
self.assertEqual(pathfmt.temppath, "")
def test_http_filesize_max(self):
url = f"{self.address}/jpg"
pathfmt = self._prepare_destination(None, extension=None)
self.downloader.maxsize = 100
with self.assertLogs(self.downloader.log, "WARNING"):
success = self.downloader.download(url, pathfmt)
self.assertTrue(success)
self.assertEqual(pathfmt.temppath, "")
def test_http_empty(self):
url = f"{self.address}/~NUL"
pathfmt = self._prepare_destination(None, extension=None)
with self.assertLogs(self.downloader.log, "WARNING") as log_info:
success = self.downloader.download(url, pathfmt)
self.assertFalse(success)
self.assertEqual(log_info.output[0],
"WARNING:downloader.http:Empty file")
class TestTextDownloader(TestDownloaderBase):
@classmethod
def setUpClass(cls):
TestDownloaderBase.setUpClass()
cls.downloader = downloader.find("text")(cls.job)
def test_text_download(self):
self._run_test("text:foobar", None, "foobar", "txt", "txt")
def test_text_offset(self):
self._run_test("text:foobar", "foo", "foobar", "txt", "txt")
def test_text_empty(self):
self._run_test("text:", None, "", "txt", "txt")
class HttpRequestHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
try:
output = DATA[self.path[1:]]
except KeyError:
self.send_response(404)
self.wfile.write(self.path.encode())
return
headers = {"Content-Length": len(output)}
if "Range" in self.headers:
status = 206
match = re.match(r"bytes=(\d+)-", self.headers["Range"])
start = int(match[1])
headers["Content-Range"] = \
f"bytes {start}-{len(output) - 1}/{len(output)}"
output = output[start:]
else:
status = 200
self.send_response(status)
for key, value in headers.items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(output)
SAMPLES = {
("jpg" , binascii.a2b_base64(
"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB"
"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEB"
"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB"
"AQEBAQEBAQEBAQEBAQH/wAARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAA"
"AAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAA"
"AAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==")),
("png" , binascii.a2b_base64(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQIHWP4DwAB"
"AQEANl9ngAAAAABJRU5ErkJggg==")),
("gif" , binascii.a2b_base64(
"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=")),
("bmp" , b"BM"),
("webp", b"RIFF????WEBP"),
("avif", b"????ftypavif"),
("avif", b"????ftypavis"),
("heic", b"????ftypheic"),
("heic", b"????ftypheim"),
("heic", b"????ftypheis"),
("heic", b"????ftypheix"),
("svg" , b"<?xml"),
("htm" , b"<!DOCTYPE html><html>...</html>"),
("htm" , b" \n \n\r\t\n <!DOCTYPE html><html>...</html>"),
("ico" , b"\x00\x00\x01\x00"),
("cur" , b"\x00\x00\x02\x00"),
("psd" , b"8BPS"),
("mp4" , b"????ftypmp4"),
("mp4" , b"????ftypavc1"),
("mp4" , b"????ftypiso3"),
("m4v" , b"????ftypM4V"),
("mov" , b"????ftypqt "),
("webm", b"\x1A\x45\xDF\xA3"),
("ogg" , b"OggS"),
("wav" , b"RIFF????WAVE"),
("mp3" , b"ID3"),
("mp3" , b"\xFF\xFB"),
("mp3" , b"\xFF\xF3"),
("mp3" , b"\xFF\xF2"),
("aac" , b"\xFF\xF9"),
("aac" , b"\xFF\xF1"),
("m3u8", b"#EXTM3U\n#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=200000"),
("mpd" , b'<MPD xmlns="urn:mpeg:dash:schema:mpd:2011"'),
("zip" , b"PK\x03\x04"),
("zip" , b"PK\x05\x06"),
("zip" , b"PK\x07\x08"),
("rar" , b"Rar!\x1A\x07"),
("rar" , b"\x52\x61\x72\x21\x1A\x07"),
("7z" , b"\x37\x7A\xBC\xAF\x27\x1C"),
("pdf" , b"%PDF-"),
("swf" , b"FWS"),
("swf" , b"CWS"),
("blend", b"BLENDER-v303RENDH"),
("obj" , b"# Blender v3.2.0 OBJ File: 'foo.blend'"),
("clip", b"CSFCHUNK\x00\x00\x00\x00"),
("~NUL", b""),
}
DATA = {}
for ext, content in SAMPLES:
if ext not in DATA:
DATA[ext] = content
for idx, (_, content) in enumerate(SAMPLES):
DATA[f"S{idx:>02}"] = content
# reverse mime types mapping
MIME_TYPES = {
ext: mtype
for mtype, ext in MIME_TYPES.items()
}
def generate_tests():
def generate_test(idx, ext, content):
def test(self):
self._run_test(f"S{idx:>02}", None, content, "bin", ext)
test.__name__ = f"test_http_ext_{idx:>02}_{ext}"
return test
for idx, (ext, content) in enumerate(SAMPLES):
if ext[0].isalnum():
test = generate_test(idx, ext, content)
setattr(TestHTTPDownloader, test.__name__, test)
generate_tests()
if __name__ == "__main__":
unittest.main()