big upgrade UwU
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""Test doubles for the Telegram bot and the CDN fetcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from telegram.error import BadRequest
|
||||
|
||||
from sticker_downloader.config import DownloadConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeFile:
|
||||
file_path: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeThumbnail:
|
||||
file_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeSticker:
|
||||
file_id: str
|
||||
file_unique_id: str = "uniq"
|
||||
emoji: str | None = "😀"
|
||||
is_animated: bool = False
|
||||
is_video: bool = False
|
||||
thumbnail: FakeThumbnail | None = None
|
||||
width: int = 512
|
||||
height: int = 512
|
||||
type: str = "regular"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeStickerSet:
|
||||
name: str
|
||||
title: str = "A Pack"
|
||||
sticker_type: str = "regular"
|
||||
stickers: list[FakeSticker] = field(default_factory=list)
|
||||
thumbnail: FakeThumbnail | None = None
|
||||
|
||||
|
||||
class FakeBot:
|
||||
"""Implements just the two coroutines the downloader calls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sets: dict[str, FakeStickerSet] | None = None,
|
||||
*,
|
||||
extensions: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self._sets = sets or {}
|
||||
#: file_id -> extension served by the fake CDN URL.
|
||||
self._extensions = extensions or {}
|
||||
self.get_sticker_set_calls: list[str] = []
|
||||
self.get_file_calls: list[str] = []
|
||||
|
||||
async def get_sticker_set(self, name: str) -> FakeStickerSet:
|
||||
self.get_sticker_set_calls.append(name)
|
||||
try:
|
||||
return self._sets[name]
|
||||
except KeyError:
|
||||
raise BadRequest("Stickerset_invalid") from None
|
||||
|
||||
async def get_file(self, file_id: str) -> FakeFile:
|
||||
self.get_file_calls.append(file_id)
|
||||
extension = self._extensions.get(file_id, "webp")
|
||||
return FakeFile(file_path=f"https://cdn.example/file/{file_id}.{extension}")
|
||||
|
||||
|
||||
class FakeFetcher:
|
||||
"""Returns deterministic bytes and can be told to fail for some URLs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
payload: bytes = b"sticker-bytes",
|
||||
failures: dict[str, Exception] | None = None,
|
||||
) -> None:
|
||||
self._payload = payload
|
||||
self._failures = failures or {}
|
||||
self.urls: list[str] = []
|
||||
self.max_concurrent = 0
|
||||
self._in_flight = 0
|
||||
|
||||
async def fetch(self, url: str) -> bytes:
|
||||
self.urls.append(url)
|
||||
self._in_flight += 1
|
||||
self.max_concurrent = max(self.max_concurrent, self._in_flight)
|
||||
try:
|
||||
await asyncio.sleep(0)
|
||||
for needle, error in self._failures.items():
|
||||
if needle in url:
|
||||
raise error
|
||||
return self._payload
|
||||
finally:
|
||||
self._in_flight -= 1
|
||||
|
||||
|
||||
async def no_sleep(_delay: float) -> None:
|
||||
"""Drop-in for ``asyncio.sleep`` so retry tests run instantly."""
|
||||
return None
|
||||
|
||||
|
||||
def make_sticker(index: int, **overrides: Any) -> FakeSticker:
|
||||
"""A static sticker with a PNG thumbnail, unless overridden."""
|
||||
defaults: dict[str, Any] = {
|
||||
"file_id": f"file{index}",
|
||||
"file_unique_id": f"uniq{index}",
|
||||
"thumbnail": FakeThumbnail(file_id=f"thumb{index}"),
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return FakeSticker(**defaults)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pack() -> FakeStickerSet:
|
||||
return FakeStickerSet(
|
||||
name="TestPack",
|
||||
title="Test Pack",
|
||||
stickers=[make_sticker(1), make_sticker(2)],
|
||||
thumbnail=FakeThumbnail(file_id="packthumb"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bot(pack: FakeStickerSet) -> FakeBot:
|
||||
return FakeBot({pack.name: pack})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fetcher() -> FakeFetcher:
|
||||
return FakeFetcher()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(tmp_path) -> DownloadConfig:
|
||||
return DownloadConfig(output_dir=tmp_path / "downloads")
|
||||
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from sticker_downloader import cli
|
||||
from sticker_downloader.cli import (
|
||||
EXIT_FAILURES,
|
||||
EXIT_OK,
|
||||
EXIT_USAGE,
|
||||
UsageError,
|
||||
build_parser,
|
||||
collect_interactively,
|
||||
main,
|
||||
)
|
||||
from sticker_downloader.results import FileOutcome, FileStatus, PackResult
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_cwd(tmp_path, monkeypatch):
|
||||
"""Run each test in a clean directory with no ambient token."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured_run(monkeypatch):
|
||||
"""Replace the network layer and record what the CLI asked it to do."""
|
||||
recorded: dict = {}
|
||||
|
||||
def _install(results: list[PackResult]) -> dict:
|
||||
async def fake_download(token, references, config, progress):
|
||||
recorded["token"] = token
|
||||
recorded["references"] = list(references)
|
||||
recorded["config"] = config
|
||||
return results
|
||||
|
||||
monkeypatch.setattr(cli, "_download", fake_download)
|
||||
return recorded
|
||||
|
||||
return _install
|
||||
|
||||
|
||||
def ok_result(name: str = "Pack") -> PackResult:
|
||||
return PackResult(
|
||||
reference=name,
|
||||
name=name,
|
||||
outcomes=[FileOutcome(Path(f"{name}/001.webp"), FileStatus.DOWNLOADED, size=10)],
|
||||
)
|
||||
|
||||
|
||||
def failed_result(name: str = "Pack") -> PackResult:
|
||||
return PackResult(reference=name, name=name, error="sticker pack not found")
|
||||
|
||||
|
||||
# -- parser ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parser_defaults():
|
||||
args = build_parser().parse_args([])
|
||||
assert args.packs == []
|
||||
assert args.from_file is None
|
||||
assert args.types is None
|
||||
assert args.output == Path("downloads")
|
||||
assert args.concurrency == 8
|
||||
assert args.retries == 3
|
||||
assert args.write_metadata is True
|
||||
assert args.pack_thumbnail is True
|
||||
assert args.overwrite is False
|
||||
assert args.dry_run is False
|
||||
|
||||
|
||||
def test_version_exits_cleanly(capsys):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["--version"])
|
||||
assert excinfo.value.code == EXIT_OK
|
||||
assert "1.0.0" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_verbose_and_quiet_are_mutually_exclusive():
|
||||
with pytest.raises(SystemExit):
|
||||
build_parser().parse_args(["-v", "-q", "Pack"])
|
||||
|
||||
|
||||
# -- argument validation --------------------------------------------------
|
||||
|
||||
|
||||
def test_packs_and_from_file_conflict(capsys):
|
||||
assert main(["Pack", "--from-file", "urls.txt"]) == EXIT_USAGE
|
||||
assert "not both" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_missing_from_file_is_a_usage_error(capsys):
|
||||
assert main(["--from-file", "nope.txt"]) == EXIT_USAGE
|
||||
assert "not found" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_invalid_file_type_is_reported_before_connecting(capsys):
|
||||
assert main(["Pack", "--types", "gif"]) == EXIT_USAGE
|
||||
assert "gif" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_all_references_invalid_is_a_usage_error(capsys):
|
||||
assert main(["https://example.com/x"]) == EXIT_USAGE
|
||||
assert "no valid sticker pack references" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_missing_token_explains_how_to_set_one(capsys):
|
||||
assert main(["Pack"]) == EXIT_USAGE
|
||||
assert "BotFather" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_no_packs_and_no_tty_is_a_usage_error(capsys, monkeypatch):
|
||||
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False)
|
||||
assert main([]) == EXIT_USAGE
|
||||
assert "no packs given" in capsys.readouterr().err
|
||||
|
||||
|
||||
# -- wiring ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_successful_run_returns_zero(captured_run, monkeypatch):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "token-from-env")
|
||||
recorded = captured_run([ok_result()])
|
||||
|
||||
assert main(["https://t.me/addstickers/Pack", "--types", "webp,png"]) == EXIT_OK
|
||||
assert recorded["token"] == "token-from-env"
|
||||
assert recorded["references"] == ["https://t.me/addstickers/Pack"]
|
||||
assert recorded["config"].file_types == frozenset({"webp", "png"})
|
||||
|
||||
|
||||
def test_failing_pack_returns_one(captured_run, monkeypatch):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t")
|
||||
captured_run([ok_result("A"), failed_result("B")])
|
||||
assert main(["A", "B"]) == EXIT_FAILURES
|
||||
|
||||
|
||||
def test_token_flag_beats_environment(captured_run, monkeypatch):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "from-env")
|
||||
recorded = captured_run([ok_result()])
|
||||
assert main(["Pack", "--token", "from-flag"]) == EXIT_OK
|
||||
assert recorded["token"] == "from-flag"
|
||||
|
||||
|
||||
def test_token_is_read_from_env_file(captured_run, isolated_cwd):
|
||||
(isolated_cwd / "custom.env").write_text("TELEGRAM_BOT_TOKEN=from-file\n")
|
||||
recorded = captured_run([ok_result()])
|
||||
assert main(["Pack", "--env-file", "custom.env"]) == EXIT_OK
|
||||
assert recorded["token"] == "from-file"
|
||||
|
||||
|
||||
def test_flags_reach_the_config(captured_run, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t")
|
||||
recorded = captured_run([ok_result()])
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"Pack",
|
||||
"--output",
|
||||
str(tmp_path / "out"),
|
||||
"--concurrency",
|
||||
"4",
|
||||
"--retries",
|
||||
"5",
|
||||
"--overwrite",
|
||||
"--emoji-names",
|
||||
"--no-metadata",
|
||||
"--no-pack-thumbnail",
|
||||
"--dry-run",
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == EXIT_OK
|
||||
config = recorded["config"]
|
||||
assert config.output_dir == tmp_path / "out"
|
||||
assert config.concurrency == 4
|
||||
assert config.retries == 5
|
||||
assert config.overwrite is True
|
||||
assert config.emoji_names is True
|
||||
assert config.write_metadata is False
|
||||
assert config.pack_thumbnail is False
|
||||
assert config.dry_run is True
|
||||
|
||||
|
||||
def test_default_types_are_all_when_not_interactive(captured_run, monkeypatch):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t")
|
||||
recorded = captured_run([ok_result()])
|
||||
assert main(["Pack"]) == EXIT_OK
|
||||
assert recorded["config"].sorted_file_types == ["webp", "tgs", "webm", "png"]
|
||||
|
||||
|
||||
def test_references_come_from_file(captured_run, monkeypatch, isolated_cwd):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t")
|
||||
(isolated_cwd / "packs.txt").write_text(
|
||||
"# mine\nhttps://t.me/addstickers/A\n\nB\n", encoding="utf-8"
|
||||
)
|
||||
recorded = captured_run([ok_result()])
|
||||
|
||||
assert main(["--from-file", "packs.txt"]) == EXIT_OK
|
||||
assert recorded["references"] == ["https://t.me/addstickers/A", "B"]
|
||||
|
||||
|
||||
def test_references_come_from_stdin(captured_run, monkeypatch):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t")
|
||||
monkeypatch.setattr(cli.sys, "stdin", _FakeStdin("A\nB\n"))
|
||||
recorded = captured_run([ok_result()])
|
||||
|
||||
assert main(["--from-file", "-"]) == EXIT_OK
|
||||
assert recorded["references"] == ["A", "B"]
|
||||
|
||||
|
||||
def test_invalid_lines_are_warned_about_but_do_not_stop_the_run(
|
||||
captured_run, monkeypatch, isolated_cwd, capsys
|
||||
):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t")
|
||||
(isolated_cwd / "packs.txt").write_text("Good\nme/addstickers/Broken\n")
|
||||
captured_run([ok_result()])
|
||||
|
||||
assert main(["--from-file", "packs.txt", "--quiet"]) == EXIT_OK
|
||||
|
||||
|
||||
class _FakeStdin:
|
||||
def __init__(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
def read(self) -> str:
|
||||
return self._text
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# -- interactive mode -----------------------------------------------------
|
||||
|
||||
|
||||
def _scripted(answers: list[str]):
|
||||
remaining = list(answers)
|
||||
|
||||
def ask(_prompt: str) -> str:
|
||||
return remaining.pop(0)
|
||||
|
||||
return ask
|
||||
|
||||
|
||||
def test_interactive_single_url():
|
||||
types, references = collect_interactively(
|
||||
types_given=None,
|
||||
ask=_scripted(["webp,png", "1", "https://t.me/addstickers/A t.me/addstickers/B"]),
|
||||
say=lambda _msg: None,
|
||||
)
|
||||
assert types == "webp,png"
|
||||
assert references == ["https://t.me/addstickers/A", "t.me/addstickers/B"]
|
||||
|
||||
|
||||
def test_interactive_defaults_to_all_types_and_option_one():
|
||||
types, references = collect_interactively(
|
||||
types_given=None,
|
||||
ask=_scripted(["", "", "A"]),
|
||||
say=lambda _msg: None,
|
||||
)
|
||||
assert types == ""
|
||||
assert references == ["A"]
|
||||
|
||||
|
||||
def test_interactive_skips_the_type_prompt_when_given(tmp_path):
|
||||
types, references = collect_interactively(
|
||||
types_given="webp",
|
||||
ask=_scripted(["1", "A"]),
|
||||
say=lambda _msg: None,
|
||||
)
|
||||
assert types == "webp"
|
||||
assert references == ["A"]
|
||||
|
||||
|
||||
def test_interactive_reads_the_url_file(tmp_path):
|
||||
url_file = tmp_path / "urls.txt"
|
||||
url_file.write_text("https://t.me/addstickers/A\nB\n", encoding="utf-8")
|
||||
|
||||
_types, references = collect_interactively(
|
||||
types_given="all",
|
||||
url_file=url_file,
|
||||
ask=_scripted(["2"]),
|
||||
say=lambda _msg: None,
|
||||
)
|
||||
assert references == ["https://t.me/addstickers/A", "B"]
|
||||
|
||||
|
||||
def test_interactive_missing_url_file(tmp_path):
|
||||
with pytest.raises(UsageError):
|
||||
collect_interactively(
|
||||
types_given="all",
|
||||
url_file=tmp_path / "absent.txt",
|
||||
ask=_scripted(["2"]),
|
||||
say=lambda _msg: None,
|
||||
)
|
||||
|
||||
|
||||
def test_interactive_rejects_a_bad_choice():
|
||||
with pytest.raises(UsageError):
|
||||
collect_interactively(
|
||||
types_given="all", ask=_scripted(["9"]), say=lambda _msg: None
|
||||
)
|
||||
|
||||
|
||||
def test_interactive_rejects_an_empty_url():
|
||||
with pytest.raises(UsageError):
|
||||
collect_interactively(
|
||||
types_given="all", ask=_scripted(["1", " "]), say=lambda _msg: None
|
||||
)
|
||||
|
||||
|
||||
def test_interactive_validates_types_before_anything_else():
|
||||
with pytest.raises(Exception, match="gif"):
|
||||
collect_interactively(
|
||||
types_given=None, ask=_scripted(["gif"]), say=lambda _msg: None
|
||||
)
|
||||
|
||||
|
||||
def test_interactive_path_is_used_when_stdin_is_a_tty(
|
||||
captured_run, monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t")
|
||||
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
cli, "collect_interactively", lambda **_kwargs: ("webp", ["Pack"])
|
||||
)
|
||||
recorded = captured_run([ok_result()])
|
||||
|
||||
assert main([]) == EXIT_OK
|
||||
assert recorded["references"] == ["Pack"]
|
||||
assert recorded["config"].file_types == frozenset({"webp"})
|
||||
|
||||
|
||||
def test_ctrl_d_during_prompts_aborts_cleanly(monkeypatch, capsys):
|
||||
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
|
||||
|
||||
def raise_eof(**_kwargs):
|
||||
raise EOFError
|
||||
|
||||
monkeypatch.setattr(cli, "collect_interactively", raise_eof)
|
||||
assert main([]) == EXIT_USAGE
|
||||
assert "Aborted" in capsys.readouterr().err
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from sticker_downloader.config import ALL_FILE_TYPES, DownloadConfig, parse_file_types
|
||||
from sticker_downloader.errors import InvalidFileType
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", " ", None, "all", "ALL", " All "])
|
||||
def test_blank_and_all_mean_everything(raw: str | None) -> None:
|
||||
assert parse_file_types(raw) == frozenset(ALL_FILE_TYPES)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("webp", {"webp"}),
|
||||
("webp,png", {"webp", "png"}),
|
||||
(" WEBP , PNG ", {"webp", "png"}),
|
||||
("webp png", {"webp", "png"}),
|
||||
(".webp,.tgs", {"webp", "tgs"}),
|
||||
("png,png", {"png"}),
|
||||
],
|
||||
)
|
||||
def test_parses_explicit_type_lists(raw: str, expected: set[str]) -> None:
|
||||
assert parse_file_types(raw) == frozenset(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["gif", "webp,gif", "jpeg", ",,,"])
|
||||
def test_rejects_unsupported_types(raw: str) -> None:
|
||||
with pytest.raises(InvalidFileType):
|
||||
parse_file_types(raw)
|
||||
|
||||
|
||||
def test_sorted_file_types_is_canonical_order() -> None:
|
||||
config = DownloadConfig(file_types=frozenset({"png", "webp", "tgs"}))
|
||||
assert config.sorted_file_types == ["webp", "tgs", "png"]
|
||||
|
||||
|
||||
def test_wants_reports_membership() -> None:
|
||||
config = DownloadConfig(file_types=frozenset({"webp"}))
|
||||
assert config.wants("webp")
|
||||
assert not config.wants("png")
|
||||
|
||||
|
||||
def test_rejects_empty_type_set() -> None:
|
||||
with pytest.raises(InvalidFileType):
|
||||
DownloadConfig(file_types=frozenset())
|
||||
|
||||
|
||||
def test_rejects_unknown_type_in_config() -> None:
|
||||
with pytest.raises(InvalidFileType):
|
||||
DownloadConfig(file_types=frozenset({"gif"}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("concurrency", "retries"), [(0, 3), (-1, 3), (1, 0)])
|
||||
def test_rejects_nonsensical_limits(concurrency: int, retries: int) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
DownloadConfig(concurrency=concurrency, retries=retries)
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
from telegram.error import TimedOut
|
||||
|
||||
from sticker_downloader.config import METADATA_FILE_NAME, DownloadConfig
|
||||
from sticker_downloader.downloader import (
|
||||
StickerDownloader,
|
||||
primary_file_type,
|
||||
sanitize_filename_part,
|
||||
)
|
||||
from sticker_downloader.errors import FetchError
|
||||
from sticker_downloader.results import FileStatus
|
||||
from tests.conftest import (
|
||||
FakeBot,
|
||||
FakeFetcher,
|
||||
FakeSticker,
|
||||
FakeStickerSet,
|
||||
FakeThumbnail,
|
||||
make_sticker,
|
||||
no_sleep,
|
||||
)
|
||||
|
||||
|
||||
def build(bot, fetcher, config, **kwargs) -> StickerDownloader:
|
||||
return StickerDownloader(
|
||||
bot=bot, fetcher=fetcher, config=config, sleep=no_sleep, **kwargs
|
||||
)
|
||||
|
||||
|
||||
async def test_downloads_stickers_thumbnails_and_pack_cover(bot, fetcher, config):
|
||||
result = await build(bot, fetcher, config).download_pack(
|
||||
"https://t.me/addstickers/TestPack"
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert result.name == "TestPack"
|
||||
assert result.title == "Test Pack"
|
||||
assert result.total_stickers == 2
|
||||
|
||||
directory = config.output_dir / "TestPack"
|
||||
written = sorted(p.name for p in directory.iterdir())
|
||||
assert written == [
|
||||
"001.webp",
|
||||
"001_thumb.png",
|
||||
"002.webp",
|
||||
"002_thumb.png",
|
||||
"_pack_thumbnail.webp",
|
||||
METADATA_FILE_NAME,
|
||||
]
|
||||
assert (directory / "001.webp").read_bytes() == b"sticker-bytes"
|
||||
assert result.downloaded == 5
|
||||
assert result.bytes_written == 5 * len(b"sticker-bytes")
|
||||
|
||||
|
||||
async def test_file_type_filter_limits_what_is_written(bot, fetcher, config):
|
||||
config = replace(config, file_types=frozenset({"webp"}), pack_thumbnail=False)
|
||||
result = await build(bot, fetcher, config).download_pack("TestPack")
|
||||
|
||||
directory = config.output_dir / "TestPack"
|
||||
assert sorted(p.name for p in directory.iterdir()) == [
|
||||
"001.webp",
|
||||
"002.webp",
|
||||
METADATA_FILE_NAME,
|
||||
]
|
||||
assert result.downloaded == 2
|
||||
# No thumbnails were even looked up.
|
||||
assert bot.get_file_calls == ["file1", "file2"]
|
||||
|
||||
|
||||
async def test_extension_follows_sticker_kind(config, fetcher):
|
||||
animated = FakeSticker(file_id="anim", is_animated=True)
|
||||
video = FakeSticker(file_id="vid", is_video=True)
|
||||
pack = FakeStickerSet(name="Mixed", stickers=[animated, video])
|
||||
bot = FakeBot({"Mixed": pack}, extensions={"anim": "tgs", "vid": "webm"})
|
||||
|
||||
await build(bot, fetcher, config).download_pack("Mixed")
|
||||
|
||||
directory = config.output_dir / "Mixed"
|
||||
assert (directory / "001.tgs").is_file()
|
||||
assert (directory / "002.webm").is_file()
|
||||
|
||||
|
||||
async def test_pack_thumbnail_extension_comes_from_telegram(config, fetcher):
|
||||
pack = FakeStickerSet(
|
||||
name="Vid", stickers=[make_sticker(1)], thumbnail=FakeThumbnail("cover")
|
||||
)
|
||||
bot = FakeBot({"Vid": pack}, extensions={"cover": "webm"})
|
||||
|
||||
await build(bot, fetcher, config).download_pack("Vid")
|
||||
|
||||
assert (config.output_dir / "Vid" / "_pack_thumbnail.webm").is_file()
|
||||
|
||||
|
||||
async def test_existing_files_are_skipped_without_api_calls(bot, fetcher, config):
|
||||
directory = config.output_dir / "TestPack"
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "001.webp").write_bytes(b"old")
|
||||
|
||||
result = await build(bot, fetcher, config).download_pack("TestPack")
|
||||
|
||||
assert result.skipped == 1
|
||||
assert result.downloaded == 4
|
||||
assert (directory / "001.webp").read_bytes() == b"old"
|
||||
assert "file1" not in bot.get_file_calls
|
||||
|
||||
|
||||
async def test_overwrite_replaces_existing_files(bot, fetcher, config):
|
||||
directory = config.output_dir / "TestPack"
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "001.webp").write_bytes(b"old")
|
||||
|
||||
config = replace(config, overwrite=True)
|
||||
result = await build(bot, fetcher, config).download_pack("TestPack")
|
||||
|
||||
assert result.skipped == 0
|
||||
assert (directory / "001.webp").read_bytes() == b"sticker-bytes"
|
||||
|
||||
|
||||
async def test_dry_run_writes_nothing(bot, fetcher, config):
|
||||
config = replace(config, dry_run=True)
|
||||
result = await build(bot, fetcher, config).download_pack("TestPack")
|
||||
|
||||
assert result.planned == 5
|
||||
assert result.downloaded == 0
|
||||
assert not config.output_dir.exists()
|
||||
assert fetcher.urls == []
|
||||
assert bot.get_file_calls == []
|
||||
|
||||
|
||||
async def test_invalid_reference_never_touches_the_network(bot, fetcher, config):
|
||||
result = await build(bot, fetcher, config).download_pack("https://example.com/nope")
|
||||
|
||||
assert not result.ok
|
||||
assert result.error is not None
|
||||
assert bot.get_sticker_set_calls == []
|
||||
|
||||
|
||||
async def test_unknown_pack_reports_a_readable_error(bot, fetcher, config):
|
||||
result = await build(bot, fetcher, config).download_pack("NoSuchPack")
|
||||
|
||||
assert not result.ok
|
||||
assert result.error == "sticker pack not found"
|
||||
assert not config.output_dir.exists()
|
||||
|
||||
|
||||
async def test_one_bad_file_does_not_sink_the_pack(bot, config):
|
||||
fetcher = FakeFetcher(failures={"file1": FetchError("file1", 404, "Not Found")})
|
||||
result = await build(bot, fetcher, config).download_pack("TestPack")
|
||||
|
||||
assert not result.ok
|
||||
assert result.failed == 1
|
||||
assert result.downloaded == 4
|
||||
failure = next(o for o in result.outcomes if o.status is FileStatus.FAILED)
|
||||
assert failure.path.name == "001.webp"
|
||||
assert "404" in (failure.error or "")
|
||||
|
||||
|
||||
async def test_transient_failures_are_retried(bot, config):
|
||||
calls = {"n": 0}
|
||||
real_fetch = FakeFetcher().fetch
|
||||
|
||||
class FlakyFetcher:
|
||||
async def fetch(self, url: str) -> bytes:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise TimedOut
|
||||
return await real_fetch(url)
|
||||
|
||||
result = await build(bot, FlakyFetcher(), config).download_pack("TestPack")
|
||||
|
||||
assert result.ok
|
||||
assert result.downloaded == 5
|
||||
assert calls["n"] == 6 # five files plus the one retry
|
||||
|
||||
|
||||
async def test_no_partial_files_are_left_behind(bot, config):
|
||||
fetcher = FakeFetcher(failures={"file2": FetchError("file2", 500)})
|
||||
config = replace(config, retries=1)
|
||||
await build(bot, fetcher, config).download_pack("TestPack")
|
||||
|
||||
directory = config.output_dir / "TestPack"
|
||||
assert [p.name for p in directory.glob("*.part")] == []
|
||||
|
||||
|
||||
async def test_concurrency_is_bounded(config, fetcher):
|
||||
stickers = [make_sticker(i) for i in range(1, 11)]
|
||||
bot = FakeBot({"Big": FakeStickerSet(name="Big", stickers=stickers)})
|
||||
config = replace(config, concurrency=3)
|
||||
|
||||
await build(bot, fetcher, config).download_pack("Big")
|
||||
|
||||
assert fetcher.max_concurrent <= 3
|
||||
assert len(fetcher.urls) == 20
|
||||
|
||||
|
||||
async def test_emoji_names_are_used_when_requested(config, fetcher):
|
||||
pack = FakeStickerSet(name="Emo", stickers=[FakeSticker(file_id="a", emoji="🦊")])
|
||||
bot = FakeBot({"Emo": pack})
|
||||
config = replace(config, emoji_names=True, pack_thumbnail=False)
|
||||
|
||||
await build(bot, fetcher, config).download_pack("Emo")
|
||||
|
||||
assert (config.output_dir / "Emo" / "001_🦊.webp").is_file()
|
||||
|
||||
|
||||
async def test_metadata_describes_the_pack(bot, fetcher, config):
|
||||
await build(bot, fetcher, config).download_pack("https://t.me/addstickers/TestPack")
|
||||
|
||||
payload = json.loads(
|
||||
(config.output_dir / "TestPack" / METADATA_FILE_NAME).read_text(encoding="utf-8")
|
||||
)
|
||||
assert payload["pack"]["name"] == "TestPack"
|
||||
assert payload["pack"]["title"] == "Test Pack"
|
||||
assert payload["pack"]["reference"] == "https://t.me/addstickers/TestPack"
|
||||
assert payload["sticker_count"] == 2
|
||||
assert payload["requested_file_types"] == ["webp", "tgs", "webm", "png"]
|
||||
first = payload["stickers"][0]
|
||||
assert first["index"] == 1
|
||||
assert first["emoji"] == "😀"
|
||||
assert first["files"] == ["001.webp", "001_thumb.png"]
|
||||
|
||||
|
||||
async def test_metadata_can_be_disabled(bot, fetcher, config):
|
||||
config = replace(config, write_metadata=False)
|
||||
await build(bot, fetcher, config).download_pack("TestPack")
|
||||
assert not (config.output_dir / "TestPack" / METADATA_FILE_NAME).exists()
|
||||
|
||||
|
||||
async def test_download_all_reports_every_pack(fetcher, config):
|
||||
packs = {
|
||||
"One": FakeStickerSet(name="One", stickers=[make_sticker(1)]),
|
||||
"Two": FakeStickerSet(name="Two", stickers=[make_sticker(1)]),
|
||||
}
|
||||
bot = FakeBot(packs)
|
||||
|
||||
results = await build(bot, fetcher, config).download_all(["One", "Two", "Missing"])
|
||||
|
||||
assert [r.reference for r in results] == ["One", "Two", "Missing"]
|
||||
assert [r.ok for r in results] == [True, True, False]
|
||||
|
||||
|
||||
def test_primary_file_type_prefers_animated_over_video():
|
||||
assert primary_file_type(FakeSticker(file_id="a")) == "webp"
|
||||
assert primary_file_type(FakeSticker(file_id="a", is_animated=True)) == "tgs"
|
||||
assert primary_file_type(FakeSticker(file_id="a", is_video=True)) == "webm"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("🦊", "🦊"),
|
||||
("a/b", "ab"),
|
||||
("..", ""),
|
||||
('x<>:"|?*y', "xy"),
|
||||
("x" * 50, "x" * 32),
|
||||
],
|
||||
)
|
||||
def test_sanitize_filename_part(raw: str, expected: str):
|
||||
assert sanitize_filename_part(raw) == expected
|
||||
|
||||
|
||||
async def test_output_directory_is_created_lazily(bot, fetcher, tmp_path):
|
||||
nested = tmp_path / "a" / "b" / "c"
|
||||
config = DownloadConfig(output_dir=nested)
|
||||
await build(bot, fetcher, config).download_pack("TestPack")
|
||||
assert (nested / "TestPack" / "001.webp").is_file()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Exercises AiohttpFetcher against a real local HTTP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from aiohttp import web
|
||||
|
||||
from sticker_downloader.errors import FetchError
|
||||
from sticker_downloader.fetcher import AiohttpFetcher, Fetcher
|
||||
|
||||
|
||||
async def _handle(request: web.Request) -> web.Response:
|
||||
status = int(request.match_info["status"])
|
||||
if status == 200:
|
||||
return web.Response(body=b"file-bytes", content_type="image/webp")
|
||||
return web.Response(status=status, text="nope")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def server() -> AsyncIterator[str]:
|
||||
app = web.Application()
|
||||
app.router.add_get("/{status}", _handle)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = runner.addresses[0][1]
|
||||
try:
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session() -> AsyncIterator[aiohttp.ClientSession]:
|
||||
async with aiohttp.ClientSession() as client:
|
||||
yield client
|
||||
|
||||
|
||||
async def test_returns_the_body_on_success(server: str, session) -> None:
|
||||
fetcher = AiohttpFetcher(session)
|
||||
assert await fetcher.fetch(f"{server}/200") == b"file-bytes"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [404, 429, 500, 503])
|
||||
async def test_raises_fetch_error_with_the_status(
|
||||
server: str, session, status: int
|
||||
) -> None:
|
||||
fetcher = AiohttpFetcher(session)
|
||||
with pytest.raises(FetchError) as excinfo:
|
||||
await fetcher.fetch(f"{server}/{status}")
|
||||
assert excinfo.value.status == status
|
||||
assert str(status) in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_only_server_side_statuses_are_retriable(server: str, session) -> None:
|
||||
fetcher = AiohttpFetcher(session)
|
||||
with pytest.raises(FetchError) as not_found:
|
||||
await fetcher.fetch(f"{server}/404")
|
||||
with pytest.raises(FetchError) as rate_limited:
|
||||
await fetcher.fetch(f"{server}/429")
|
||||
|
||||
assert not_found.value.retriable is False
|
||||
assert rate_limited.value.retriable is True
|
||||
|
||||
|
||||
async def test_connection_failures_surface_as_client_errors(session) -> None:
|
||||
fetcher = AiohttpFetcher(session)
|
||||
# Port 1 is reserved and refuses connections.
|
||||
with pytest.raises(aiohttp.ClientError):
|
||||
await fetcher.fetch("http://127.0.0.1:1/200")
|
||||
|
||||
|
||||
def test_aiohttp_fetcher_satisfies_the_protocol(session) -> None:
|
||||
assert isinstance(AiohttpFetcher(session), Fetcher)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""End-to-end test: the real downloader and HTTP layer against a local server.
|
||||
|
||||
Only the Telegram Bot API itself is faked; everything else — planning, the
|
||||
concurrency limit, retries, atomic writes and metadata — is the real code path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from aiohttp import web
|
||||
|
||||
from sticker_downloader.config import METADATA_FILE_NAME, DownloadConfig
|
||||
from sticker_downloader.downloader import StickerDownloader
|
||||
from sticker_downloader.fetcher import AiohttpFetcher
|
||||
from tests.conftest import (
|
||||
FakeFile,
|
||||
FakeStickerSet,
|
||||
FakeThumbnail,
|
||||
make_sticker,
|
||||
no_sleep,
|
||||
)
|
||||
|
||||
#: file_id -> (extension, body). "flaky1" fails once before succeeding.
|
||||
FILES = {
|
||||
"file1": ("webp", b"one"),
|
||||
"thumb1": ("png", b"one-thumb"),
|
||||
"file2": ("webp", b"two"),
|
||||
"thumb2": ("png", b"two-thumb"),
|
||||
"packthumb": ("webp", b"cover"),
|
||||
}
|
||||
|
||||
|
||||
class _Server:
|
||||
def __init__(self) -> None:
|
||||
self.base = ""
|
||||
self.hits: list[str] = []
|
||||
self.transient_failures = {"file2"}
|
||||
|
||||
async def handle(self, request: web.Request) -> web.Response:
|
||||
file_id = request.match_info["file_id"]
|
||||
self.hits.append(file_id)
|
||||
if file_id in self.transient_failures:
|
||||
# Fail the first attempt only, so the retry path runs for real.
|
||||
self.transient_failures.discard(file_id)
|
||||
return web.Response(status=503, text="try again")
|
||||
extension, body = FILES[file_id]
|
||||
return web.Response(body=body, content_type=f"image/{extension}")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def server() -> AsyncIterator[_Server]:
|
||||
state = _Server()
|
||||
app = web.Application()
|
||||
app.router.add_get("/{file_id}.{extension}", state.handle)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
state.base = f"http://127.0.0.1:{runner.addresses[0][1]}"
|
||||
try:
|
||||
yield state
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
class ServingBot:
|
||||
"""A Bot stand-in whose file paths point at the local test server."""
|
||||
|
||||
def __init__(self, base_url: str, sets: dict[str, FakeStickerSet]) -> None:
|
||||
self._base_url = base_url
|
||||
self._sets = sets
|
||||
|
||||
async def get_sticker_set(self, name: str) -> FakeStickerSet:
|
||||
return self._sets[name]
|
||||
|
||||
async def get_file(self, file_id: str) -> FakeFile:
|
||||
extension, _body = FILES[file_id]
|
||||
return FakeFile(file_path=f"{self._base_url}/{file_id}.{extension}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pack_of_two() -> FakeStickerSet:
|
||||
return FakeStickerSet(
|
||||
name="RealPack",
|
||||
title="Real Pack",
|
||||
stickers=[make_sticker(1), make_sticker(2)],
|
||||
thumbnail=FakeThumbnail(file_id="packthumb"),
|
||||
)
|
||||
|
||||
|
||||
async def test_full_download_over_http(server, pack_of_two, tmp_path):
|
||||
config = DownloadConfig(output_dir=tmp_path / "out", concurrency=2)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
downloader = StickerDownloader(
|
||||
bot=ServingBot(server.base, {"RealPack": pack_of_two}),
|
||||
fetcher=AiohttpFetcher(session),
|
||||
config=config,
|
||||
sleep=no_sleep,
|
||||
)
|
||||
result = await downloader.download_pack("https://t.me/addstickers/RealPack")
|
||||
|
||||
assert result.ok, result.error
|
||||
assert result.downloaded == 5
|
||||
|
||||
directory = config.output_dir / "RealPack"
|
||||
assert (directory / "001.webp").read_bytes() == b"one"
|
||||
assert (directory / "001_thumb.png").read_bytes() == b"one-thumb"
|
||||
assert (directory / "002.webp").read_bytes() == b"two"
|
||||
assert (directory / "_pack_thumbnail.webp").read_bytes() == b"cover"
|
||||
assert list(directory.glob("*.part")) == []
|
||||
|
||||
# The 503 on file2 was retried, so that file was requested twice.
|
||||
assert server.hits.count("file2") == 2
|
||||
|
||||
metadata = json.loads((directory / METADATA_FILE_NAME).read_text(encoding="utf-8"))
|
||||
assert metadata["sticker_count"] == 2
|
||||
assert metadata["stickers"][1]["files"] == ["002.webp", "002_thumb.png"]
|
||||
|
||||
|
||||
async def test_second_run_skips_everything(server, pack_of_two, tmp_path):
|
||||
config = DownloadConfig(output_dir=tmp_path / "out")
|
||||
bot = ServingBot(server.base, {"RealPack": pack_of_two})
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
downloader = StickerDownloader(
|
||||
bot=bot, fetcher=AiohttpFetcher(session), config=config, sleep=no_sleep
|
||||
)
|
||||
await downloader.download_pack("RealPack")
|
||||
hits_after_first = len(server.hits)
|
||||
second = await downloader.download_pack("RealPack")
|
||||
|
||||
assert second.downloaded == 0
|
||||
assert second.skipped == 5
|
||||
assert len(server.hits) == hits_after_first # no new requests at all
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from sticker_downloader.progress import ConsoleProgress, NullProgress
|
||||
from sticker_downloader.results import (
|
||||
FileOutcome,
|
||||
FileStatus,
|
||||
PackResult,
|
||||
RunTotals,
|
||||
format_size,
|
||||
)
|
||||
|
||||
|
||||
def outcome(name: str, status: FileStatus, size: int = 0) -> FileOutcome:
|
||||
return FileOutcome(Path("downloads/Pack") / name, status, size=size)
|
||||
|
||||
|
||||
def reporter(**kwargs) -> tuple[ConsoleProgress, io.StringIO]:
|
||||
stream = io.StringIO()
|
||||
return ConsoleProgress(stream, use_ansi=False, **kwargs), stream
|
||||
|
||||
|
||||
def test_pack_lifecycle_is_reported():
|
||||
progress, stream = reporter()
|
||||
progress.pack_started("Pack", "Pack", 2, 3)
|
||||
progress.file_finished(outcome("001.webp", FileStatus.DOWNLOADED, 1024))
|
||||
result = PackResult(
|
||||
reference="Pack",
|
||||
name="Pack",
|
||||
directory=Path("downloads/Pack"),
|
||||
total_stickers=2,
|
||||
outcomes=[outcome("001.webp", FileStatus.DOWNLOADED, 1024)],
|
||||
)
|
||||
progress.pack_finished(result)
|
||||
|
||||
text = stream.getvalue()
|
||||
assert "Pack: 2 stickers, 3 files" in text
|
||||
assert "1 downloaded" in text
|
||||
assert "downloads/Pack" in text
|
||||
|
||||
|
||||
def test_quiet_files_are_not_listed_without_verbose():
|
||||
progress, stream = reporter()
|
||||
progress.file_finished(outcome("001.webp", FileStatus.DOWNLOADED, 10))
|
||||
assert stream.getvalue() == ""
|
||||
|
||||
|
||||
def test_verbose_lists_each_file_with_its_size():
|
||||
progress, stream = reporter(verbose=True)
|
||||
progress.file_finished(outcome("001.webp", FileStatus.DOWNLOADED, 2048))
|
||||
assert "downloaded: 001.webp (2.0 KiB)" in stream.getvalue()
|
||||
|
||||
|
||||
def test_failures_are_always_reported():
|
||||
progress, stream = reporter()
|
||||
progress.file_finished(
|
||||
FileOutcome(Path("001.webp"), FileStatus.FAILED, error="HTTP 404")
|
||||
)
|
||||
assert "error: 001.webp: HTTP 404" in stream.getvalue()
|
||||
|
||||
|
||||
def test_pack_error_is_reported_instead_of_a_summary():
|
||||
progress, stream = reporter()
|
||||
progress.pack_finished(PackResult(reference="Nope", error="sticker pack not found"))
|
||||
assert "error: Nope: sticker pack not found" in stream.getvalue()
|
||||
|
||||
|
||||
def test_run_summary_lists_failing_packs():
|
||||
progress, stream = reporter()
|
||||
good = PackResult(
|
||||
reference="A",
|
||||
name="A",
|
||||
outcomes=[outcome("001.webp", FileStatus.DOWNLOADED, 1024)],
|
||||
)
|
||||
bad = PackResult(reference="B", name="B", error="sticker pack not found")
|
||||
progress.run_finished([good, bad])
|
||||
|
||||
text = stream.getvalue()
|
||||
assert "Done: 1/2 packs" in text
|
||||
assert "files downloaded : 1 (1.0 KiB)" in text
|
||||
assert "packs failed : 1" in text
|
||||
assert "- B: sticker pack not found" in text
|
||||
|
||||
|
||||
def test_empty_run_says_so():
|
||||
progress, stream = reporter()
|
||||
progress.run_finished([])
|
||||
assert "Nothing to do." in stream.getvalue()
|
||||
|
||||
|
||||
def test_live_line_is_redrawn_on_a_terminal():
|
||||
stream = io.StringIO()
|
||||
progress = ConsoleProgress(stream, use_ansi=True)
|
||||
progress.pack_started("Pack", "Pack", 1, 2)
|
||||
progress.file_finished(outcome("001.webp", FileStatus.DOWNLOADED, 1))
|
||||
progress.file_finished(outcome("002.webp", FileStatus.DOWNLOADED, 1))
|
||||
assert "\r 1/2 files" in stream.getvalue()
|
||||
assert "\r 2/2 files" in stream.getvalue()
|
||||
|
||||
|
||||
def test_null_progress_accepts_every_call():
|
||||
progress = NullProgress()
|
||||
progress.info("x")
|
||||
progress.warn("x")
|
||||
progress.error("x")
|
||||
progress.pack_started("a", "b", 1, 1)
|
||||
progress.file_finished(outcome("001.webp", FileStatus.DOWNLOADED))
|
||||
progress.pack_finished(PackResult(reference="a"))
|
||||
progress.run_finished([])
|
||||
|
||||
|
||||
def test_run_totals_aggregate_every_status():
|
||||
results = [
|
||||
PackResult(
|
||||
reference="A",
|
||||
outcomes=[
|
||||
outcome("001.webp", FileStatus.DOWNLOADED, 100),
|
||||
outcome("002.webp", FileStatus.SKIPPED_EXISTING),
|
||||
outcome("003.webp", FileStatus.FAILED),
|
||||
],
|
||||
),
|
||||
PackResult(reference="B", outcomes=[outcome("001.webp", FileStatus.PLANNED)]),
|
||||
]
|
||||
totals = RunTotals.from_results(results)
|
||||
assert (totals.packs, totals.packs_ok, totals.packs_failed) == (2, 1, 1)
|
||||
assert (totals.downloaded, totals.skipped, totals.planned, totals.failed) == (
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
assert totals.bytes_written == 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("num_bytes", "expected"),
|
||||
[(0, "0 B"), (512, "512 B"), (2048, "2.0 KiB"), (5 * 1024**2, "5.0 MiB")],
|
||||
)
|
||||
def test_format_size(num_bytes: int, expected: str):
|
||||
assert format_size(num_bytes) == expected
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
from telegram.error import BadRequest, InvalidToken, NetworkError, RetryAfter, TimedOut
|
||||
|
||||
from sticker_downloader.errors import FetchError
|
||||
from sticker_downloader.retry import is_retriable, retry_delay, with_retries
|
||||
from tests.conftest import no_sleep
|
||||
|
||||
|
||||
async def test_returns_first_success() -> None:
|
||||
calls = 0
|
||||
|
||||
async def operation() -> str:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return "ok"
|
||||
|
||||
assert await with_retries(operation, sleep=no_sleep) == "ok"
|
||||
assert calls == 1
|
||||
|
||||
|
||||
async def test_retries_transient_failure_then_succeeds() -> None:
|
||||
attempts = 0
|
||||
|
||||
async def operation() -> str:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts < 3:
|
||||
raise TimedOut
|
||||
return "ok"
|
||||
|
||||
assert await with_retries(operation, attempts=3, sleep=no_sleep) == "ok"
|
||||
assert attempts == 3
|
||||
|
||||
|
||||
async def test_raises_after_exhausting_attempts() -> None:
|
||||
attempts = 0
|
||||
|
||||
async def operation() -> str:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise NetworkError("down")
|
||||
|
||||
with pytest.raises(NetworkError):
|
||||
await with_retries(operation, attempts=2, sleep=no_sleep)
|
||||
assert attempts == 2
|
||||
|
||||
|
||||
async def test_does_not_retry_bad_request() -> None:
|
||||
"""BadRequest subclasses NetworkError in python-telegram-bot: it must not retry."""
|
||||
attempts = 0
|
||||
|
||||
async def operation() -> str:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise BadRequest("Stickerset_invalid")
|
||||
|
||||
with pytest.raises(BadRequest):
|
||||
await with_retries(operation, attempts=5, sleep=no_sleep)
|
||||
assert attempts == 1
|
||||
|
||||
|
||||
async def test_reports_each_retry() -> None:
|
||||
seen: list[tuple[str, int, float]] = []
|
||||
|
||||
async def operation() -> str:
|
||||
raise TimedOut
|
||||
|
||||
def on_retry(error: BaseException, attempt: int, delay: float) -> None:
|
||||
seen.append((type(error).__name__, attempt, delay))
|
||||
|
||||
with pytest.raises(TimedOut):
|
||||
await with_retries(
|
||||
operation, attempts=3, base_delay=1.0, on_retry=on_retry, sleep=no_sleep
|
||||
)
|
||||
assert seen == [("TimedOut", 1, 1.0), ("TimedOut", 2, 2.0)]
|
||||
|
||||
|
||||
async def test_awaits_the_requested_delays() -> None:
|
||||
slept: list[float] = []
|
||||
|
||||
async def record(delay: float) -> None:
|
||||
slept.append(delay)
|
||||
|
||||
async def operation() -> str:
|
||||
raise TimedOut
|
||||
|
||||
with pytest.raises(TimedOut):
|
||||
await with_retries(operation, attempts=3, base_delay=0.5, sleep=record)
|
||||
assert slept == [0.5, 1.0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected"),
|
||||
[
|
||||
(TimedOut(), True),
|
||||
(NetworkError("x"), True),
|
||||
(aiohttp.ClientError(), True),
|
||||
(asyncio.TimeoutError(), True),
|
||||
(RetryAfter(3), True),
|
||||
(FetchError("u", 500), True),
|
||||
(FetchError("u", 429), True),
|
||||
(FetchError("u", 404), False),
|
||||
(FetchError("u", 403), False),
|
||||
(BadRequest("nope"), False),
|
||||
(InvalidToken(), False),
|
||||
(ValueError("nope"), False),
|
||||
],
|
||||
)
|
||||
def test_retriability_classification(error: BaseException, expected: bool) -> None:
|
||||
assert is_retriable(error) is expected
|
||||
|
||||
|
||||
def test_retry_after_delay_uses_telegram_hint() -> None:
|
||||
assert retry_delay(RetryAfter(7), attempt=1, base_delay=0.5) == pytest.approx(7.0)
|
||||
|
||||
|
||||
def test_retry_after_delay_accepts_a_timedelta() -> None:
|
||||
"""python-telegram-bot will switch ``retry_after`` to a timedelta in a future
|
||||
major version; handle both shapes."""
|
||||
|
||||
class FutureRetryAfter(RetryAfter):
|
||||
@property
|
||||
def retry_after(self) -> timedelta: # type: ignore[override]
|
||||
return timedelta(seconds=12)
|
||||
|
||||
error = FutureRetryAfter(1)
|
||||
assert retry_delay(error, attempt=1, base_delay=0.5) == pytest.approx(12.0)
|
||||
|
||||
|
||||
def test_backoff_is_exponential() -> None:
|
||||
delays = [retry_delay(TimedOut(), attempt=n, base_delay=0.5) for n in (1, 2, 3)]
|
||||
assert delays == [0.5, 1.0, 2.0]
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from sticker_downloader.errors import InvalidPackReference
|
||||
from sticker_downloader.urls import (
|
||||
parse_pack_name,
|
||||
parse_pack_reference_lines,
|
||||
read_pack_references,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reference",
|
||||
[
|
||||
"https://t.me/addstickers/MyPack",
|
||||
"http://t.me/addstickers/MyPack",
|
||||
"https://www.t.me/addstickers/MyPack",
|
||||
"t.me/addstickers/MyPack",
|
||||
"https://telegram.me/addstickers/MyPack",
|
||||
"https://telegram.dog/addstickers/MyPack",
|
||||
"https://t.me/addstickers/MyPack/",
|
||||
"https://t.me/addstickers/MyPack?utm_source=x",
|
||||
"tg://addstickers?set=MyPack",
|
||||
"addstickers/MyPack",
|
||||
"MyPack",
|
||||
" MyPack ",
|
||||
'"https://t.me/addstickers/MyPack"',
|
||||
],
|
||||
)
|
||||
def test_accepts_every_reasonable_form(reference: str) -> None:
|
||||
assert parse_pack_name(reference) == "MyPack"
|
||||
|
||||
|
||||
def test_accepts_custom_emoji_links() -> None:
|
||||
assert parse_pack_name("https://t.me/addemoji/MyPack") == "MyPack"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reference",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
# The original urls.txt had this typo on its first line.
|
||||
"me/addstickers/LokisStickers",
|
||||
"https://example.com/addstickers/MyPack",
|
||||
"https://t.me/MyPack",
|
||||
"https://t.me/joinchat/MyPack",
|
||||
"https://t.me/addstickers/",
|
||||
"My-Pack!",
|
||||
"tg://addstickers?other=MyPack",
|
||||
],
|
||||
)
|
||||
def test_rejects_non_sticker_references(reference: str) -> None:
|
||||
with pytest.raises(InvalidPackReference):
|
||||
parse_pack_name(reference)
|
||||
|
||||
|
||||
def test_reference_lines_skip_comments_blanks_and_duplicates() -> None:
|
||||
references, problems = parse_pack_reference_lines(
|
||||
[
|
||||
"# a comment",
|
||||
"",
|
||||
" ",
|
||||
"https://t.me/addstickers/One",
|
||||
"https://t.me/addstickers/Two",
|
||||
# Same pack, different spelling: only the first survives.
|
||||
"One",
|
||||
"t.me/addstickers/one",
|
||||
]
|
||||
)
|
||||
assert references == ["https://t.me/addstickers/One", "https://t.me/addstickers/Two"]
|
||||
assert problems == []
|
||||
|
||||
|
||||
def test_reference_lines_report_bad_lines_with_line_numbers() -> None:
|
||||
references, problems = parse_pack_reference_lines(
|
||||
["https://t.me/addstickers/Good", "not a link at all"], source="urls.txt"
|
||||
)
|
||||
assert references == ["https://t.me/addstickers/Good"]
|
||||
assert len(problems) == 1
|
||||
assert problems[0].startswith("urls.txt:2:")
|
||||
|
||||
|
||||
def test_read_pack_references_from_file(tmp_path) -> None:
|
||||
path = tmp_path / "urls.txt"
|
||||
path.write_text(
|
||||
"# packs\nhttps://t.me/addstickers/Alpha\nbroken line\n", encoding="utf-8"
|
||||
)
|
||||
references, problems = read_pack_references(path)
|
||||
assert references == ["https://t.me/addstickers/Alpha"]
|
||||
assert len(problems) == 1
|
||||
assert str(path) in problems[0]
|
||||
Reference in New Issue
Block a user