big upgrade UwU

This commit is contained in:
2026-07-27 15:16:01 +02:00
parent 9fb2bf0a74
commit 1ff979f1e5
30 changed files with 3304 additions and 183 deletions
View File
+142
View File
@@ -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")
+344
View File
@@ -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
+59
View File
@@ -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)
+270
View File
@@ -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()
+79
View 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)
+139
View File
@@ -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
+144
View File
@@ -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
+138
View File
@@ -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]
+93
View File
@@ -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]