From bd31ac92326d7224d5311d0cea3343c420d877be Mon Sep 17 00:00:00 2001 From: "sveeraraghavan@FRLPMC3060" Date: Mon, 29 Jun 2026 12:36:09 +0200 Subject: [PATCH 1/7] Adding more animals APIs cause why not --- prod-config.toml | 16 +++--- src/europython_discord/bot.py | 12 +++++ src/europython_discord/cat/__init__.py | 1 + src/europython_discord/cat/catclient.py | 39 ++++++++++++++ src/europython_discord/cat/cog.py | 63 +++++++++++++++++++++++ src/europython_discord/cat/config.py | 7 +++ src/europython_discord/duck/__init__.py | 1 + src/europython_discord/duck/cog.py | 63 +++++++++++++++++++++++ src/europython_discord/duck/config.py | 7 +++ src/europython_discord/duck/duckclient.py | 21 ++++++++ src/europython_discord/fox/__init__.py | 1 + src/europython_discord/fox/cog.py | 63 +++++++++++++++++++++++ src/europython_discord/fox/config.py | 7 +++ src/europython_discord/fox/foxclient.py | 25 +++++++++ test-config.toml | 16 +++--- tests/cat/__init__.py | 0 tests/cat/test_cat.py | 38 ++++++++++++++ tests/duck/__init__.py | 0 tests/duck/test_duck.py | 38 ++++++++++++++ tests/fox/__init__.py | 0 tests/fox/test_fox.py | 38 ++++++++++++++ 21 files changed, 442 insertions(+), 14 deletions(-) create mode 100644 src/europython_discord/cat/__init__.py create mode 100644 src/europython_discord/cat/catclient.py create mode 100644 src/europython_discord/cat/cog.py create mode 100644 src/europython_discord/cat/config.py create mode 100644 src/europython_discord/duck/__init__.py create mode 100644 src/europython_discord/duck/cog.py create mode 100644 src/europython_discord/duck/config.py create mode 100644 src/europython_discord/duck/duckclient.py create mode 100644 src/europython_discord/fox/__init__.py create mode 100644 src/europython_discord/fox/cog.py create mode 100644 src/europython_discord/fox/config.py create mode 100644 src/europython_discord/fox/foxclient.py create mode 100644 tests/cat/__init__.py create mode 100644 tests/cat/test_cat.py create mode 100644 tests/duck/__init__.py create mode 100644 tests/duck/test_duck.py create mode 100644 tests/fox/__init__.py create mode 100644 tests/fox/test_fox.py diff --git a/prod-config.toml b/prod-config.toml index fc331c77..40b2c212 100644 --- a/prod-config.toml +++ b/prod-config.toml @@ -66,10 +66,12 @@ required_role = "Organizers" [dog] channel_name = "animal-appreciation" -cooldown_seconds = 10 -error_messages = [ - "The dogs are on strike today! Try again later. 🐾πŸͺ§", - "A wild error appeared! The dog got away... πŸ•πŸ’¨", - "Dog API is fetching a stick. Throw it again! 🦴", - "404: Dog not found. Have you checked under the couch? πŸ›‹οΈ", -] + +[cat] +channel_name = "animal-appreciation" + +[duck] +channel_name = "animal-appreciation" + +[fox] +channel_name = "animal-appreciation" diff --git a/src/europython_discord/bot.py b/src/europython_discord/bot.py index b29e41ac..9574e9be 100644 --- a/src/europython_discord/bot.py +++ b/src/europython_discord/bot.py @@ -14,6 +14,12 @@ from pydantic import BaseModel from europython_discord.cogs.guild_statistics import GuildStatisticsCog, GuildStatisticsConfig +from europython_discord.cat.cog import CatCog +from europython_discord.cat.config import CatConfig +from europython_discord.duck.cog import DuckCog +from europython_discord.duck.config import DuckConfig +from europython_discord.fox.cog import FoxCog +from europython_discord.fox.config import FoxConfig from europython_discord.cogs.ping import PingCog from europython_discord.dog.cog import DogCog from europython_discord.dog.config import DogConfig @@ -35,6 +41,9 @@ class Config(BaseModel): program_notifications: ProgramNotificationsConfig guild_statistics: GuildStatisticsConfig dog: DogConfig + cat: CatConfig + duck: DuckConfig + fox: FoxConfig async def run_bot(config: Config, auth_token: str) -> None: @@ -48,6 +57,9 @@ async def run_bot(config: Config, auth_token: str) -> None: async with commands.Bot(intents=intents, command_prefix="$") as bot: await bot.add_cog(PingCog(bot)) await bot.add_cog(DogCog(bot, config.dog)) + await bot.add_cog(CatCog(bot, config.cat)) + await bot.add_cog(DuckCog(bot, config.duck)) + await bot.add_cog(FoxCog(bot, config.fox)) await bot.add_cog(RegistrationCog(bot, config.registration)) await bot.add_cog(ProgramNotificationsCog(bot, config.program_notifications)) await bot.add_cog(GuildStatisticsCog(bot, config.guild_statistics)) diff --git a/src/europython_discord/cat/__init__.py b/src/europython_discord/cat/__init__.py new file mode 100644 index 00000000..5e7a42f9 --- /dev/null +++ b/src/europython_discord/cat/__init__.py @@ -0,0 +1 @@ +# Cat module diff --git a/src/europython_discord/cat/catclient.py b/src/europython_discord/cat/catclient.py new file mode 100644 index 00000000..cf68f7f8 --- /dev/null +++ b/src/europython_discord/cat/catclient.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import logging + +import aiohttp + +_logger = logging.getLogger(__name__) + +CAT_API_URL = "https://cataas.com/cat/says/I%20love%20EuroPython" + + +class CatClient: + def __init__(self) -> None: + self._session = aiohttp.ClientSession() + + async def fetch_random_cat(self) -> str | None: + params = { + "position": "center", + "font": "Impact", + "fontSize": "50", + "fontColor": "#fff", + "fontBackground": "none", + "json": "true", + } + try: + async with self._session.get(CAT_API_URL, params=params) as response: + response.raise_for_status() + data = await response.json() + except Exception: + _logger.exception("Failed to fetch cat image") + return None + + url = data.get("url") + if url: + if url.startswith("http"): + return url + return f"https://cataas.com{url}" + + return None diff --git a/src/europython_discord/cat/cog.py b/src/europython_discord/cat/cog.py new file mode 100644 index 00000000..84ec1c80 --- /dev/null +++ b/src/europython_discord/cat/cog.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import logging +import random +import time +from collections import OrderedDict + +import discord +from discord.ext import commands + +from europython_discord.cat.config import CatConfig +from europython_discord.cat.catclient import CatClient + +_logger = logging.getLogger(__name__) + +_MAX_COOLDOWN_TRACKING = 100 + + +class CatCog(commands.Cog): + def __init__( + self, + bot: commands.Bot, + config: CatConfig, + client: CatClient | None = None, + ) -> None: + self.bot = bot + self.config = config + self._client = client or CatClient() + self._last_usage_timestamp_by_user_id: OrderedDict[int, float] = OrderedDict() + _logger.info("Cog 'Cat' has been initialized") + + @commands.hybrid_command(name="cat", description="Get a random cat picture") + async def cat_command(self, ctx: commands.Context) -> None: + if ctx.channel.name != self.config.channel_name: + return + + if self._is_rate_limited(ctx.author.id): + return + + if (image_url := await self._client.fetch_random_cat()) is None: + message = random.choice(self.config.error_messages) # noqa: S311 + await ctx.send(message) + return + + embed = discord.Embed() + embed.description = "A random cat image from https://cataas.com" + embed.set_image(url=image_url) + + self._update_rate_limit_cache(ctx.author.id) + await ctx.send(embed=embed) + + def _is_rate_limited(self, user_id: int) -> bool: + last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0) + return last_usage_timestamp + self.config.cooldown_seconds > time.time() + + def _update_rate_limit_cache(self, user_id: int) -> None: + # update cache + self._last_usage_timestamp_by_user_id[user_id] = time.time() + + # trim cache + self._last_usage_timestamp_by_user_id.move_to_end(user_id) + if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING: + self._last_usage_timestamp_by_user_id.popitem(last=False) diff --git a/src/europython_discord/cat/config.py b/src/europython_discord/cat/config.py new file mode 100644 index 00000000..69c2e7d4 --- /dev/null +++ b/src/europython_discord/cat/config.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class CatConfig(BaseModel): + channel_name: str + cooldown_seconds: int = 10 + error_messages: list[str] = ["404: Cat not found. Have you checked inside your closet?"] diff --git a/src/europython_discord/duck/__init__.py b/src/europython_discord/duck/__init__.py new file mode 100644 index 00000000..89a733ee --- /dev/null +++ b/src/europython_discord/duck/__init__.py @@ -0,0 +1 @@ +# Duck module diff --git a/src/europython_discord/duck/cog.py b/src/europython_discord/duck/cog.py new file mode 100644 index 00000000..54c095dc --- /dev/null +++ b/src/europython_discord/duck/cog.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import logging +import random +import time +from collections import OrderedDict + +import discord +from discord.ext import commands + +from europython_discord.duck.config import DuckConfig +from europython_discord.duck.duckclient import DuckClient + +_logger = logging.getLogger(__name__) + +_MAX_COOLDOWN_TRACKING = 100 + + +class DuckCog(commands.Cog): + def __init__( + self, + bot: commands.Bot, + config: DuckConfig, + client: DuckClient | None = None, + ) -> None: + self.bot = bot + self.config = config + self._client = client or DuckClient() + self._last_usage_timestamp_by_user_id: OrderedDict[int, float] = OrderedDict() + _logger.info("Cog 'Duck' has been initialized") + + @commands.hybrid_command(name="duck", description="Get a random duck picture") + async def duck_command(self, ctx: commands.Context) -> None: + if ctx.channel.name != self.config.channel_name: + return + + if self._is_rate_limited(ctx.author.id): + return + + if (image_url := await self._client.fetch_random_duck()) is None: + message = random.choice(self.config.error_messages) # noqa: S311 + await ctx.send(message) + return + + embed = discord.Embed() + embed.description = "A random duck image from https://random-d.uk" + embed.set_image(url=image_url) + + self._update_rate_limit_cache(ctx.author.id) + await ctx.send(embed=embed) + + def _is_rate_limited(self, user_id: int) -> bool: + last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0) + return last_usage_timestamp + self.config.cooldown_seconds > time.time() + + def _update_rate_limit_cache(self, user_id: int) -> None: + # update cache + self._last_usage_timestamp_by_user_id[user_id] = time.time() + + # trim cache + self._last_usage_timestamp_by_user_id.move_to_end(user_id) + if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING: + self._last_usage_timestamp_by_user_id.popitem(last=False) diff --git a/src/europython_discord/duck/config.py b/src/europython_discord/duck/config.py new file mode 100644 index 00000000..0f2ff631 --- /dev/null +++ b/src/europython_discord/duck/config.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class DuckConfig(BaseModel): + channel_name: str + cooldown_seconds: int = 10 + error_messages: list[str] = ["404: Duck not found. Have you checked the pond? πŸ¦†"] diff --git a/src/europython_discord/duck/duckclient.py b/src/europython_discord/duck/duckclient.py new file mode 100644 index 00000000..d8576e1c --- /dev/null +++ b/src/europython_discord/duck/duckclient.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import logging +import time + +_logger = logging.getLogger(__name__) + +DUCK_API_URL = "https://random-d.uk/api/randomimg" + + +class DuckClient: + def __init__(self) -> None: + pass + + async def fetch_random_duck(self) -> str | None: + # Since the API directly returns the image and we don't need to parse JSON, + # we can just return the URL with a cache-busting timestamp so Discord fetches a new one. + # Alternatively, if we wanted to verify it's up, we could do a HEAD request. + # For simplicity and performance, we'll just return the URL. + timestamp = int(time.time() * 1000) + return f"{DUCK_API_URL}?t={timestamp}" diff --git a/src/europython_discord/fox/__init__.py b/src/europython_discord/fox/__init__.py new file mode 100644 index 00000000..db8b94eb --- /dev/null +++ b/src/europython_discord/fox/__init__.py @@ -0,0 +1 @@ +# Fox module diff --git a/src/europython_discord/fox/cog.py b/src/europython_discord/fox/cog.py new file mode 100644 index 00000000..ccedcad9 --- /dev/null +++ b/src/europython_discord/fox/cog.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import logging +import random +import time +from collections import OrderedDict + +import discord +from discord.ext import commands + +from europython_discord.fox.config import FoxConfig +from europython_discord.fox.foxclient import FoxClient + +_logger = logging.getLogger(__name__) + +_MAX_COOLDOWN_TRACKING = 100 + + +class FoxCog(commands.Cog): + def __init__( + self, + bot: commands.Bot, + config: FoxConfig, + client: FoxClient | None = None, + ) -> None: + self.bot = bot + self.config = config + self._client = client or FoxClient() + self._last_usage_timestamp_by_user_id: OrderedDict[int, float] = OrderedDict() + _logger.info("Cog 'Fox' has been initialized") + + @commands.hybrid_command(name="fox", description="Get a random fox picture") + async def fox_command(self, ctx: commands.Context) -> None: + if ctx.channel.name != self.config.channel_name: + return + + if self._is_rate_limited(ctx.author.id): + return + + if (image_url := await self._client.fetch_random_fox()) is None: + message = random.choice(self.config.error_messages) # noqa: S311 + await ctx.send(message) + return + + embed = discord.Embed() + embed.description = "A random fox image from https://randomfox.ca" + embed.set_image(url=image_url) + + self._update_rate_limit_cache(ctx.author.id) + await ctx.send(embed=embed) + + def _is_rate_limited(self, user_id: int) -> bool: + last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0) + return last_usage_timestamp + self.config.cooldown_seconds > time.time() + + def _update_rate_limit_cache(self, user_id: int) -> None: + # update cache + self._last_usage_timestamp_by_user_id[user_id] = time.time() + + # trim cache + self._last_usage_timestamp_by_user_id.move_to_end(user_id) + if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING: + self._last_usage_timestamp_by_user_id.popitem(last=False) diff --git a/src/europython_discord/fox/config.py b/src/europython_discord/fox/config.py new file mode 100644 index 00000000..c065ac4c --- /dev/null +++ b/src/europython_discord/fox/config.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class FoxConfig(BaseModel): + channel_name: str + cooldown_seconds: int = 10 + error_messages: list[str] = ["404: Fox not found. Have you checked the den? 🦊"] diff --git a/src/europython_discord/fox/foxclient.py b/src/europython_discord/fox/foxclient.py new file mode 100644 index 00000000..181be7ca --- /dev/null +++ b/src/europython_discord/fox/foxclient.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import logging + +import aiohttp + +_logger = logging.getLogger(__name__) + +FOX_API_URL = "https://randomfox.ca/floof/" + + +class FoxClient: + def __init__(self) -> None: + self._session = aiohttp.ClientSession() + + async def fetch_random_fox(self) -> str | None: + try: + async with self._session.get(FOX_API_URL) as response: + response.raise_for_status() + data = await response.json() + except Exception: + _logger.exception("Failed to fetch fox image") + return None + + return data.get("image") diff --git a/test-config.toml b/test-config.toml index df33d084..2a1fac68 100644 --- a/test-config.toml +++ b/test-config.toml @@ -67,10 +67,12 @@ required_role = "Organizers" [dog] channel_name = "animal-appreciation" -cooldown_seconds = 10 -error_messages = [ - "The dogs are on strike today! Try again later. 🐾πŸͺ§", - "A wild error appeared! The dog got away... πŸ•πŸ’¨", - "Dog API is fetching a stick. Throw it again! 🦴", - "404: Dog not found. Have you checked under the couch? πŸ›‹οΈ", -] + +[cat] +channel_name = "animal-appreciation" + +[duck] +channel_name = "animal-appreciation" + +[fox] +channel_name = "animal-appreciation" diff --git a/tests/cat/__init__.py b/tests/cat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cat/test_cat.py b/tests/cat/test_cat.py new file mode 100644 index 00000000..bbdaf4ab --- /dev/null +++ b/tests/cat/test_cat.py @@ -0,0 +1,38 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from discord.ext import commands + +from europython_discord.cat.cog import CatCog +from europython_discord.cat.config import CatConfig +from europython_discord.cat.catclient import CatClient + + +@pytest.fixture +def mock_client() -> CatClient: + client = MagicMock(spec=CatClient) + client.fetch_random_cat.return_value = "https://cataas.com/cat/mockid" + return client + + +@pytest.fixture +def cog(mock_client: CatClient) -> CatCog: + bot = MagicMock(spec=commands.Bot) + config = CatConfig(channel_name="animal-appreciation") + return CatCog(bot, config, client=mock_client) + + +@pytest.fixture +def ctx() -> AsyncMock: + mock = AsyncMock(spec=commands.Context) + mock.channel.name = "animal-appreciation" + mock.send = AsyncMock() + return mock + + +async def test_cat_command_success(cog: CatCog, ctx: AsyncMock) -> None: + await cog.cat_command.callback(cog, ctx) + + ctx.send.assert_awaited_once() + embed = ctx.send.call_args.kwargs["embed"] + assert embed.image.url == "https://cataas.com/cat/mockid" diff --git a/tests/duck/__init__.py b/tests/duck/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/duck/test_duck.py b/tests/duck/test_duck.py new file mode 100644 index 00000000..f1aac96b --- /dev/null +++ b/tests/duck/test_duck.py @@ -0,0 +1,38 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from discord.ext import commands + +from europython_discord.duck.cog import DuckCog +from europython_discord.duck.config import DuckConfig +from europython_discord.duck.duckclient import DuckClient + + +@pytest.fixture +def mock_client() -> DuckClient: + client = MagicMock(spec=DuckClient) + client.fetch_random_duck.return_value = "https://random-d.uk/api/randomimg?t=123" + return client + + +@pytest.fixture +def cog(mock_client: DuckClient) -> DuckCog: + bot = MagicMock(spec=commands.Bot) + config = DuckConfig(channel_name="animal-appreciation") + return DuckCog(bot, config, client=mock_client) + + +@pytest.fixture +def ctx() -> AsyncMock: + mock = AsyncMock(spec=commands.Context) + mock.channel.name = "animal-appreciation" + mock.send = AsyncMock() + return mock + + +async def test_duck_command_success(cog: DuckCog, ctx: AsyncMock) -> None: + await cog.duck_command.callback(cog, ctx) + + ctx.send.assert_awaited_once() + embed = ctx.send.call_args.kwargs["embed"] + assert embed.image.url == "https://random-d.uk/api/randomimg?t=123" diff --git a/tests/fox/__init__.py b/tests/fox/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fox/test_fox.py b/tests/fox/test_fox.py new file mode 100644 index 00000000..70c95471 --- /dev/null +++ b/tests/fox/test_fox.py @@ -0,0 +1,38 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from discord.ext import commands + +from europython_discord.fox.cog import FoxCog +from europython_discord.fox.config import FoxConfig +from europython_discord.fox.foxclient import FoxClient + + +@pytest.fixture +def mock_client() -> FoxClient: + client = MagicMock(spec=FoxClient) + client.fetch_random_fox.return_value = "https://randomfox.ca/images/123.jpg" + return client + + +@pytest.fixture +def cog(mock_client: FoxClient) -> FoxCog: + bot = MagicMock(spec=commands.Bot) + config = FoxConfig(channel_name="animal-appreciation") + return FoxCog(bot, config, client=mock_client) + + +@pytest.fixture +def ctx() -> AsyncMock: + mock = AsyncMock(spec=commands.Context) + mock.channel.name = "animal-appreciation" + mock.send = AsyncMock() + return mock + + +async def test_fox_command_success(cog: FoxCog, ctx: AsyncMock) -> None: + await cog.fox_command.callback(cog, ctx) + + ctx.send.assert_awaited_once() + embed = ctx.send.call_args.kwargs["embed"] + assert embed.image.url == "https://randomfox.ca/images/123.jpg" From ed2e0105e1f8d7ffcd14f9fc2c332ee1526f7fd4 Mon Sep 17 00:00:00 2001 From: "sveeraraghavan@FRLPMC3060" Date: Mon, 29 Jun 2026 12:45:35 +0200 Subject: [PATCH 2/7] Tedious comments are removed --- prod-config.toml | 28 +++++++++++++++++++++++ src/europython_discord/duck/duckclient.py | 5 +--- test-config.toml | 28 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/prod-config.toml b/prod-config.toml index 40b2c212..11b2e853 100644 --- a/prod-config.toml +++ b/prod-config.toml @@ -66,12 +66,40 @@ required_role = "Organizers" [dog] channel_name = "animal-appreciation" +cooldown_seconds = 10 +error_messages = [ + "The dogs are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The dog got away... πŸ•πŸ’¨", + "Dog API is fetching a stick. Throw it again! 🦴", + "404: Dog not found. Have you checked under the couch? πŸ›‹οΈ", +] [cat] channel_name = "animal-appreciation" +cooldown_seconds = 10 +error_messages = [ + "The cats are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The cat got away... πŸ˜ΏπŸ’¨", + "Cat API is fetching a mouse. Throw it again! 🦴", + "404: Cat not found. Have you checked inside a cardboard box? πŸ“¦", +] [duck] channel_name = "animal-appreciation" +cooldown_seconds = 10 +error_messages = [ + "The ducks are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The duck got away... πŸ¦†πŸ’¨", + "Duck API is in a bit of a pond situation. Try again later! πŸ¦†", + "Quack! The duck API seems to be having an off day.", +] [fox] channel_name = "animal-appreciation" +cooldown_seconds = 10 +error_messages = [ + "The foxes are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The fox got away... πŸ¦ŠπŸ’¨", + "Fox API is fetching a chicken. Try again later! 🦴", + "404: Fox not found. Have you checked under the bush? 🌳", +] diff --git a/src/europython_discord/duck/duckclient.py b/src/europython_discord/duck/duckclient.py index d8576e1c..65ed637a 100644 --- a/src/europython_discord/duck/duckclient.py +++ b/src/europython_discord/duck/duckclient.py @@ -13,9 +13,6 @@ def __init__(self) -> None: pass async def fetch_random_duck(self) -> str | None: - # Since the API directly returns the image and we don't need to parse JSON, - # we can just return the URL with a cache-busting timestamp so Discord fetches a new one. - # Alternatively, if we wanted to verify it's up, we could do a HEAD request. - # For simplicity and performance, we'll just return the URL. + # Get a random duck image from random-d.uk timestamp = int(time.time() * 1000) return f"{DUCK_API_URL}?t={timestamp}" diff --git a/test-config.toml b/test-config.toml index 2a1fac68..cd901a00 100644 --- a/test-config.toml +++ b/test-config.toml @@ -67,12 +67,40 @@ required_role = "Organizers" [dog] channel_name = "animal-appreciation" +cooldown_seconds = 10 +error_messages = [ + "The dogs are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The dog got away... πŸ•πŸ’¨", + "Dog API is fetching a stick. Throw it again! 🦴", + "404: Dog not found. Have you checked under the couch? πŸ›‹οΈ", +] [cat] channel_name = "animal-appreciation" +cooldown_seconds = 10 +error_messages = [ + "The cats are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The cat got away... πŸ˜ΏπŸ’¨", + "Cat API is fetching a mouse. Throw it again! 🦴", + "404: Cat not found. Have you checked inside a cardboard box? πŸ“¦", +] [duck] channel_name = "animal-appreciation" +cooldown_seconds = 10 +error_messages = [ + "The ducks are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The duck got away... πŸ¦†πŸ’¨", + "Duck API is in a bit of a pond situation. Try again later! πŸ¦†", + "Quack! The duck API seems to be having an off day.", +] [fox] channel_name = "animal-appreciation" +cooldown_seconds = 10 +error_messages = [ + "The foxes are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The fox got away... πŸ¦ŠπŸ’¨", + "Fox API is fetching a chicken. Try again later! 🦴", + "404: Fox not found. Have you checked under the bush? 🌳", +] From 65491f8633acf4753ca6cccb8d165347afd9bfdc Mon Sep 17 00:00:00 2001 From: "sveeraraghavan@FRLPMC3060" Date: Mon, 29 Jun 2026 12:46:58 +0200 Subject: [PATCH 3/7] Follow ruff --- src/europython_discord/bot.py | 8 ++++---- src/europython_discord/cat/catclient.py | 2 +- src/europython_discord/cat/cog.py | 2 +- tests/cat/test_cat.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/europython_discord/bot.py b/src/europython_discord/bot.py index 9574e9be..b9798790 100644 --- a/src/europython_discord/bot.py +++ b/src/europython_discord/bot.py @@ -13,16 +13,16 @@ from discord.ext import commands from pydantic import BaseModel -from europython_discord.cogs.guild_statistics import GuildStatisticsCog, GuildStatisticsConfig from europython_discord.cat.cog import CatCog from europython_discord.cat.config import CatConfig +from europython_discord.cogs.guild_statistics import GuildStatisticsCog, GuildStatisticsConfig +from europython_discord.cogs.ping import PingCog +from europython_discord.dog.cog import DogCog +from europython_discord.dog.config import DogConfig from europython_discord.duck.cog import DuckCog from europython_discord.duck.config import DuckConfig from europython_discord.fox.cog import FoxCog from europython_discord.fox.config import FoxConfig -from europython_discord.cogs.ping import PingCog -from europython_discord.dog.cog import DogCog -from europython_discord.dog.config import DogConfig from europython_discord.program_notifications.cog import ProgramNotificationsCog from europython_discord.program_notifications.config import ProgramNotificationsConfig from europython_discord.registration.cog import RegistrationCog diff --git a/src/europython_discord/cat/catclient.py b/src/europython_discord/cat/catclient.py index cf68f7f8..3bf5b0db 100644 --- a/src/europython_discord/cat/catclient.py +++ b/src/europython_discord/cat/catclient.py @@ -35,5 +35,5 @@ async def fetch_random_cat(self) -> str | None: if url.startswith("http"): return url return f"https://cataas.com{url}" - + return None diff --git a/src/europython_discord/cat/cog.py b/src/europython_discord/cat/cog.py index 84ec1c80..8b3353bd 100644 --- a/src/europython_discord/cat/cog.py +++ b/src/europython_discord/cat/cog.py @@ -8,8 +8,8 @@ import discord from discord.ext import commands -from europython_discord.cat.config import CatConfig from europython_discord.cat.catclient import CatClient +from europython_discord.cat.config import CatConfig _logger = logging.getLogger(__name__) diff --git a/tests/cat/test_cat.py b/tests/cat/test_cat.py index bbdaf4ab..659073df 100644 --- a/tests/cat/test_cat.py +++ b/tests/cat/test_cat.py @@ -3,9 +3,9 @@ import pytest from discord.ext import commands +from europython_discord.cat.catclient import CatClient from europython_discord.cat.cog import CatCog from europython_discord.cat.config import CatConfig -from europython_discord.cat.catclient import CatClient @pytest.fixture From a2399ef83b3c03a4f2e62ae0e48413c3c717d1e7 Mon Sep 17 00:00:00 2001 From: "sveeraraghavan@FRLPMC3060" Date: Mon, 29 Jun 2026 20:53:23 +0200 Subject: [PATCH 4/7] Standardise to one animal management --- prod-config.toml | 16 ++--- src/europython_discord/animals/__init__.py | 1 + src/europython_discord/animals/clients.py | 83 ++++++++++++++++++++++ src/europython_discord/animals/cog.py | 83 ++++++++++++++++++++++ src/europython_discord/animals/config.py | 16 +++++ src/europython_discord/bot.py | 20 ++---- src/europython_discord/cat/__init__.py | 1 - src/europython_discord/cat/catclient.py | 39 ---------- src/europython_discord/cat/cog.py | 63 ---------------- src/europython_discord/cat/config.py | 7 -- src/europython_discord/dog/__init__.py | 0 src/europython_discord/dog/cog.py | 63 ---------------- src/europython_discord/dog/config.py | 7 -- src/europython_discord/dog/dogclient.py | 25 ------- src/europython_discord/duck/__init__.py | 1 - src/europython_discord/duck/cog.py | 63 ---------------- src/europython_discord/duck/config.py | 7 -- src/europython_discord/duck/duckclient.py | 18 ----- src/europython_discord/fox/__init__.py | 1 - src/europython_discord/fox/cog.py | 63 ---------------- src/europython_discord/fox/config.py | 7 -- src/europython_discord/fox/foxclient.py | 25 ------- test-config.toml | 16 ++--- tests/cat/__init__.py | 0 tests/cat/test_cat.py | 38 ---------- tests/dog/__init__.py | 0 tests/dog/test_cog.py | 60 ---------------- tests/duck/__init__.py | 0 tests/duck/test_duck.py | 38 ---------- tests/fox/__init__.py | 0 tests/fox/test_fox.py | 38 ---------- 31 files changed, 199 insertions(+), 600 deletions(-) create mode 100644 src/europython_discord/animals/__init__.py create mode 100644 src/europython_discord/animals/clients.py create mode 100644 src/europython_discord/animals/cog.py create mode 100644 src/europython_discord/animals/config.py delete mode 100644 src/europython_discord/cat/__init__.py delete mode 100644 src/europython_discord/cat/catclient.py delete mode 100644 src/europython_discord/cat/cog.py delete mode 100644 src/europython_discord/cat/config.py delete mode 100644 src/europython_discord/dog/__init__.py delete mode 100644 src/europython_discord/dog/cog.py delete mode 100644 src/europython_discord/dog/config.py delete mode 100644 src/europython_discord/dog/dogclient.py delete mode 100644 src/europython_discord/duck/__init__.py delete mode 100644 src/europython_discord/duck/cog.py delete mode 100644 src/europython_discord/duck/config.py delete mode 100644 src/europython_discord/duck/duckclient.py delete mode 100644 src/europython_discord/fox/__init__.py delete mode 100644 src/europython_discord/fox/cog.py delete mode 100644 src/europython_discord/fox/config.py delete mode 100644 src/europython_discord/fox/foxclient.py delete mode 100644 tests/cat/__init__.py delete mode 100644 tests/cat/test_cat.py delete mode 100644 tests/dog/__init__.py delete mode 100644 tests/dog/test_cog.py delete mode 100644 tests/duck/__init__.py delete mode 100644 tests/duck/test_duck.py delete mode 100644 tests/fox/__init__.py delete mode 100644 tests/fox/test_fox.py diff --git a/prod-config.toml b/prod-config.toml index 11b2e853..e250c8f2 100644 --- a/prod-config.toml +++ b/prod-config.toml @@ -64,9 +64,11 @@ main_notification_channel_name = "programme-notifications" [guild_statistics] required_role = "Organizers" -[dog] +[animals] channel_name = "animal-appreciation" cooldown_seconds = 10 + +[animals.dog] error_messages = [ "The dogs are on strike today! Try again later. 🐾πŸͺ§", "A wild error appeared! The dog got away... πŸ•πŸ’¨", @@ -74,9 +76,7 @@ error_messages = [ "404: Dog not found. Have you checked under the couch? πŸ›‹οΈ", ] -[cat] -channel_name = "animal-appreciation" -cooldown_seconds = 10 +[animals.cat] error_messages = [ "The cats are on strike today! Try again later. 🐾πŸͺ§", "A wild error appeared! The cat got away... πŸ˜ΏπŸ’¨", @@ -84,9 +84,7 @@ error_messages = [ "404: Cat not found. Have you checked inside a cardboard box? πŸ“¦", ] -[duck] -channel_name = "animal-appreciation" -cooldown_seconds = 10 +[animals.duck] error_messages = [ "The ducks are on strike today! Try again later. 🐾πŸͺ§", "A wild error appeared! The duck got away... πŸ¦†πŸ’¨", @@ -94,9 +92,7 @@ error_messages = [ "Quack! The duck API seems to be having an off day.", ] -[fox] -channel_name = "animal-appreciation" -cooldown_seconds = 10 +[animals.fox] error_messages = [ "The foxes are on strike today! Try again later. 🐾πŸͺ§", "A wild error appeared! The fox got away... πŸ¦ŠπŸ’¨", diff --git a/src/europython_discord/animals/__init__.py b/src/europython_discord/animals/__init__.py new file mode 100644 index 00000000..9d48db4f --- /dev/null +++ b/src/europython_discord/animals/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/europython_discord/animals/clients.py b/src/europython_discord/animals/clients.py new file mode 100644 index 00000000..b5cdddce --- /dev/null +++ b/src/europython_discord/animals/clients.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import logging +import time + +import aiohttp + +_logger = logging.getLogger(__name__) + +# APIs to fetch animals +DOG_API_URL = "https://dog.ceo/api/breeds/image/random" +CAT_API_URL = "https://cataas.com/cat/says/I%20love%20EuroPython" +DUCK_API_URL = "https://random-d.uk/api/randomimg" +FOX_API_URL = "https://randomfox.ca/floof/" + + +class AnimalClient: + def __init__(self) -> None: + self._session = aiohttp.ClientSession() + + async def fetch_image(self, animal: str) -> str | None: + """Fetch a random image for the given animal.""" + if animal == "dog": + return await self._fetch_dog() + if animal == "cat": + return await self._fetch_cat() + if animal == "duck": + return await self._fetch_duck() + if animal == "fox": + return await self._fetch_fox() + _logger.warning(f"Sadly we don't have {animal} pics yet :(") + return None + + async def _fetch_dog(self) -> str | None: + try: + async with self._session.get(DOG_API_URL) as response: + response.raise_for_status() + data = await response.json() + except Exception: + _logger.exception("Failed to fetch dog image") + return None + + return data.get("message") + + async def _fetch_fox(self) -> str | None: + try: + async with self._session.get(FOX_API_URL) as response: + response.raise_for_status() + data = await response.json() + except Exception: + _logger.exception("Failed to fetch fox image") + return None + + return data.get("image") + + async def _fetch_duck(self) -> str | None: + timestamp = int(time.time() * 1000) + return f"{DUCK_API_URL}?t={timestamp}" + + async def _fetch_cat(self) -> str | None: + params = { + "position": "center", + "font": "Impact", + "fontSize": "50", + "fontColor": "#fff", + "fontBackground": "none", + "json": "true", + } + try: + async with self._session.get(CAT_API_URL, params=params) as response: + response.raise_for_status() + data = await response.json() + except Exception: + _logger.exception("Failed to fetch cat image") + return None + + url = data.get("url") + if url: + if url.startswith("http"): + return url + return f"https://cataas.com{url}" + + return None diff --git a/src/europython_discord/animals/cog.py b/src/europython_discord/animals/cog.py new file mode 100644 index 00000000..5a9105f0 --- /dev/null +++ b/src/europython_discord/animals/cog.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import logging +import random +import time +from collections import OrderedDict + +import discord +from discord.ext import commands + +from europython_discord.animals.clients import AnimalClient +from europython_discord.animals.config import AnimalsConfig + +_logger = logging.getLogger(__name__) + +_MAX_COOLDOWN_TRACKING = 100 + + +class AnimalsCog(commands.Cog): + def __init__( + self, + bot: commands.Bot, + config: AnimalsConfig, + client: AnimalClient | None = None, + ) -> None: + self.bot = bot + self.config = config + self._client = client or AnimalClient() + self._last_usage_timestamp_by_user_id: OrderedDict[int, float] = OrderedDict() + _logger.info("Cog 'Animals' has been initialized") + + async def _handle_animal_command( + self, ctx: commands.Context, animal: str, source_url: str + ) -> None: + if ctx.channel.name != self.config.channel_name: + return + + if self._is_rate_limited(ctx.author.id): + return + + image_url = await self._client.fetch_image(animal) + if image_url is None: + # Get the error messages for this specific animal + animal_config = getattr(self.config, animal) + message = random.choice(animal_config.error_messages) # noqa: S311 + await ctx.send(message) + return + + embed = discord.Embed() + embed.description = f"Behold! A friendly {animal} appeared from {source_url}" + embed.set_image(url=image_url) + + self._update_rate_limit_cache(ctx.author.id) + await ctx.send(embed=embed) + + @commands.hybrid_command(name="dog", description="Get a random dog picture") + async def dog_command(self, ctx: commands.Context) -> None: + await self._handle_animal_command(ctx, "dog", "https://dog.ceo") + + @commands.hybrid_command(name="cat", description="Get a random cat picture") + async def cat_command(self, ctx: commands.Context) -> None: + await self._handle_animal_command(ctx, "cat", "https://cataas.com") + + @commands.hybrid_command(name="duck", description="Get a random duck picture") + async def duck_command(self, ctx: commands.Context) -> None: + await self._handle_animal_command(ctx, "duck", "https://random-d.uk") + + @commands.hybrid_command(name="fox", description="Get a random fox picture") + async def fox_command(self, ctx: commands.Context) -> None: + await self._handle_animal_command(ctx, "fox", "https://randomfox.ca/floof/") + + def _is_rate_limited(self, user_id: int) -> bool: + last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0) + return last_usage_timestamp + self.config.cooldown_seconds > time.time() + + def _update_rate_limit_cache(self, user_id: int) -> None: + # update cache + self._last_usage_timestamp_by_user_id[user_id] = time.time() + + # trim cache + self._last_usage_timestamp_by_user_id.move_to_end(user_id) + if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING: + self._last_usage_timestamp_by_user_id.popitem(last=False) diff --git a/src/europython_discord/animals/config.py b/src/europython_discord/animals/config.py new file mode 100644 index 00000000..35ff3db0 --- /dev/null +++ b/src/europython_discord/animals/config.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class AnimalSpecificConfig(BaseModel): + error_messages: list[str] + + +class AnimalsConfig(BaseModel): + channel_name: str + cooldown_seconds: int + dog: AnimalSpecificConfig + cat: AnimalSpecificConfig + duck: AnimalSpecificConfig + fox: AnimalSpecificConfig diff --git a/src/europython_discord/bot.py b/src/europython_discord/bot.py index b9798790..55ca2af8 100644 --- a/src/europython_discord/bot.py +++ b/src/europython_discord/bot.py @@ -13,16 +13,10 @@ from discord.ext import commands from pydantic import BaseModel -from europython_discord.cat.cog import CatCog -from europython_discord.cat.config import CatConfig +from europython_discord.animals.cog import AnimalsCog +from europython_discord.animals.config import AnimalsConfig from europython_discord.cogs.guild_statistics import GuildStatisticsCog, GuildStatisticsConfig from europython_discord.cogs.ping import PingCog -from europython_discord.dog.cog import DogCog -from europython_discord.dog.config import DogConfig -from europython_discord.duck.cog import DuckCog -from europython_discord.duck.config import DuckConfig -from europython_discord.fox.cog import FoxCog -from europython_discord.fox.config import FoxConfig from europython_discord.program_notifications.cog import ProgramNotificationsCog from europython_discord.program_notifications.config import ProgramNotificationsConfig from europython_discord.registration.cog import RegistrationCog @@ -40,10 +34,7 @@ class Config(BaseModel): registration: RegistrationConfig program_notifications: ProgramNotificationsConfig guild_statistics: GuildStatisticsConfig - dog: DogConfig - cat: CatConfig - duck: DuckConfig - fox: FoxConfig + animals: AnimalsConfig async def run_bot(config: Config, auth_token: str) -> None: @@ -56,10 +47,7 @@ async def run_bot(config: Config, auth_token: str) -> None: async with commands.Bot(intents=intents, command_prefix="$") as bot: await bot.add_cog(PingCog(bot)) - await bot.add_cog(DogCog(bot, config.dog)) - await bot.add_cog(CatCog(bot, config.cat)) - await bot.add_cog(DuckCog(bot, config.duck)) - await bot.add_cog(FoxCog(bot, config.fox)) + await bot.add_cog(AnimalsCog(bot, config.animals)) await bot.add_cog(RegistrationCog(bot, config.registration)) await bot.add_cog(ProgramNotificationsCog(bot, config.program_notifications)) await bot.add_cog(GuildStatisticsCog(bot, config.guild_statistics)) diff --git a/src/europython_discord/cat/__init__.py b/src/europython_discord/cat/__init__.py deleted file mode 100644 index 5e7a42f9..00000000 --- a/src/europython_discord/cat/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Cat module diff --git a/src/europython_discord/cat/catclient.py b/src/europython_discord/cat/catclient.py deleted file mode 100644 index 3bf5b0db..00000000 --- a/src/europython_discord/cat/catclient.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -import logging - -import aiohttp - -_logger = logging.getLogger(__name__) - -CAT_API_URL = "https://cataas.com/cat/says/I%20love%20EuroPython" - - -class CatClient: - def __init__(self) -> None: - self._session = aiohttp.ClientSession() - - async def fetch_random_cat(self) -> str | None: - params = { - "position": "center", - "font": "Impact", - "fontSize": "50", - "fontColor": "#fff", - "fontBackground": "none", - "json": "true", - } - try: - async with self._session.get(CAT_API_URL, params=params) as response: - response.raise_for_status() - data = await response.json() - except Exception: - _logger.exception("Failed to fetch cat image") - return None - - url = data.get("url") - if url: - if url.startswith("http"): - return url - return f"https://cataas.com{url}" - - return None diff --git a/src/europython_discord/cat/cog.py b/src/europython_discord/cat/cog.py deleted file mode 100644 index 8b3353bd..00000000 --- a/src/europython_discord/cat/cog.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import logging -import random -import time -from collections import OrderedDict - -import discord -from discord.ext import commands - -from europython_discord.cat.catclient import CatClient -from europython_discord.cat.config import CatConfig - -_logger = logging.getLogger(__name__) - -_MAX_COOLDOWN_TRACKING = 100 - - -class CatCog(commands.Cog): - def __init__( - self, - bot: commands.Bot, - config: CatConfig, - client: CatClient | None = None, - ) -> None: - self.bot = bot - self.config = config - self._client = client or CatClient() - self._last_usage_timestamp_by_user_id: OrderedDict[int, float] = OrderedDict() - _logger.info("Cog 'Cat' has been initialized") - - @commands.hybrid_command(name="cat", description="Get a random cat picture") - async def cat_command(self, ctx: commands.Context) -> None: - if ctx.channel.name != self.config.channel_name: - return - - if self._is_rate_limited(ctx.author.id): - return - - if (image_url := await self._client.fetch_random_cat()) is None: - message = random.choice(self.config.error_messages) # noqa: S311 - await ctx.send(message) - return - - embed = discord.Embed() - embed.description = "A random cat image from https://cataas.com" - embed.set_image(url=image_url) - - self._update_rate_limit_cache(ctx.author.id) - await ctx.send(embed=embed) - - def _is_rate_limited(self, user_id: int) -> bool: - last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0) - return last_usage_timestamp + self.config.cooldown_seconds > time.time() - - def _update_rate_limit_cache(self, user_id: int) -> None: - # update cache - self._last_usage_timestamp_by_user_id[user_id] = time.time() - - # trim cache - self._last_usage_timestamp_by_user_id.move_to_end(user_id) - if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING: - self._last_usage_timestamp_by_user_id.popitem(last=False) diff --git a/src/europython_discord/cat/config.py b/src/europython_discord/cat/config.py deleted file mode 100644 index 69c2e7d4..00000000 --- a/src/europython_discord/cat/config.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel - - -class CatConfig(BaseModel): - channel_name: str - cooldown_seconds: int = 10 - error_messages: list[str] = ["404: Cat not found. Have you checked inside your closet?"] diff --git a/src/europython_discord/dog/__init__.py b/src/europython_discord/dog/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/europython_discord/dog/cog.py b/src/europython_discord/dog/cog.py deleted file mode 100644 index e52e63d0..00000000 --- a/src/europython_discord/dog/cog.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import logging -import random -import time -from collections import OrderedDict - -import discord -from discord.ext import commands - -from europython_discord.dog.config import DogConfig -from europython_discord.dog.dogclient import DogClient - -_logger = logging.getLogger(__name__) - -_MAX_COOLDOWN_TRACKING = 100 - - -class DogCog(commands.Cog): - def __init__( - self, - bot: commands.Bot, - config: DogConfig, - client: DogClient | None = None, - ) -> None: - self.bot = bot - self.config = config - self._client = client or DogClient() - self._last_usage_timestamp_by_user_id: OrderedDict[int, float] = OrderedDict() - _logger.info("Cog 'Dog' has been initialized") - - @commands.hybrid_command(name="dog", description="Get a random dog picture") - async def dog_command(self, ctx: commands.Context) -> None: - if ctx.channel.name != self.config.channel_name: - return - - if self._is_rate_limited(ctx.author.id): - return - - if (image_url := await self._client.fetch_random_dog()) is None: - message = random.choice(self.config.error_messages) # noqa: S311 - await ctx.send(message) - return - - embed = discord.Embed() - embed.description = "A random dog image from https://dog.ceo" - embed.set_image(url=image_url) - - self._update_rate_limit_cache(ctx.author.id) - await ctx.send(embed=embed) - - def _is_rate_limited(self, user_id: int) -> bool: - last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0) - return last_usage_timestamp + self.config.cooldown_seconds > time.time() - - def _update_rate_limit_cache(self, user_id: int) -> None: - # update cache - self._last_usage_timestamp_by_user_id[user_id] = time.time() - - # trim cache - self._last_usage_timestamp_by_user_id.move_to_end(user_id) - if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING: - self._last_usage_timestamp_by_user_id.popitem(last=False) diff --git a/src/europython_discord/dog/config.py b/src/europython_discord/dog/config.py deleted file mode 100644 index 6f327ebf..00000000 --- a/src/europython_discord/dog/config.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel - - -class DogConfig(BaseModel): - channel_name: str - cooldown_seconds: int = 10 - error_messages: list[str] = ["404: Dog not found. Have you checked under the couch? πŸ›‹οΈ"] diff --git a/src/europython_discord/dog/dogclient.py b/src/europython_discord/dog/dogclient.py deleted file mode 100644 index 80c7dcbb..00000000 --- a/src/europython_discord/dog/dogclient.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -import logging - -import aiohttp - -_logger = logging.getLogger(__name__) - -DOG_API_URL = "https://dog.ceo/api/breeds/image/random" - - -class DogClient: - def __init__(self) -> None: - self._session = aiohttp.ClientSession() - - async def fetch_random_dog(self) -> str | None: - try: - async with self._session.get(DOG_API_URL) as response: - response.raise_for_status() - data = await response.json() - except Exception: - _logger.exception("Failed to fetch dog image") - return None - - return data["message"] diff --git a/src/europython_discord/duck/__init__.py b/src/europython_discord/duck/__init__.py deleted file mode 100644 index 89a733ee..00000000 --- a/src/europython_discord/duck/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Duck module diff --git a/src/europython_discord/duck/cog.py b/src/europython_discord/duck/cog.py deleted file mode 100644 index 54c095dc..00000000 --- a/src/europython_discord/duck/cog.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import logging -import random -import time -from collections import OrderedDict - -import discord -from discord.ext import commands - -from europython_discord.duck.config import DuckConfig -from europython_discord.duck.duckclient import DuckClient - -_logger = logging.getLogger(__name__) - -_MAX_COOLDOWN_TRACKING = 100 - - -class DuckCog(commands.Cog): - def __init__( - self, - bot: commands.Bot, - config: DuckConfig, - client: DuckClient | None = None, - ) -> None: - self.bot = bot - self.config = config - self._client = client or DuckClient() - self._last_usage_timestamp_by_user_id: OrderedDict[int, float] = OrderedDict() - _logger.info("Cog 'Duck' has been initialized") - - @commands.hybrid_command(name="duck", description="Get a random duck picture") - async def duck_command(self, ctx: commands.Context) -> None: - if ctx.channel.name != self.config.channel_name: - return - - if self._is_rate_limited(ctx.author.id): - return - - if (image_url := await self._client.fetch_random_duck()) is None: - message = random.choice(self.config.error_messages) # noqa: S311 - await ctx.send(message) - return - - embed = discord.Embed() - embed.description = "A random duck image from https://random-d.uk" - embed.set_image(url=image_url) - - self._update_rate_limit_cache(ctx.author.id) - await ctx.send(embed=embed) - - def _is_rate_limited(self, user_id: int) -> bool: - last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0) - return last_usage_timestamp + self.config.cooldown_seconds > time.time() - - def _update_rate_limit_cache(self, user_id: int) -> None: - # update cache - self._last_usage_timestamp_by_user_id[user_id] = time.time() - - # trim cache - self._last_usage_timestamp_by_user_id.move_to_end(user_id) - if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING: - self._last_usage_timestamp_by_user_id.popitem(last=False) diff --git a/src/europython_discord/duck/config.py b/src/europython_discord/duck/config.py deleted file mode 100644 index 0f2ff631..00000000 --- a/src/europython_discord/duck/config.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel - - -class DuckConfig(BaseModel): - channel_name: str - cooldown_seconds: int = 10 - error_messages: list[str] = ["404: Duck not found. Have you checked the pond? πŸ¦†"] diff --git a/src/europython_discord/duck/duckclient.py b/src/europython_discord/duck/duckclient.py deleted file mode 100644 index 65ed637a..00000000 --- a/src/europython_discord/duck/duckclient.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -import logging -import time - -_logger = logging.getLogger(__name__) - -DUCK_API_URL = "https://random-d.uk/api/randomimg" - - -class DuckClient: - def __init__(self) -> None: - pass - - async def fetch_random_duck(self) -> str | None: - # Get a random duck image from random-d.uk - timestamp = int(time.time() * 1000) - return f"{DUCK_API_URL}?t={timestamp}" diff --git a/src/europython_discord/fox/__init__.py b/src/europython_discord/fox/__init__.py deleted file mode 100644 index db8b94eb..00000000 --- a/src/europython_discord/fox/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Fox module diff --git a/src/europython_discord/fox/cog.py b/src/europython_discord/fox/cog.py deleted file mode 100644 index ccedcad9..00000000 --- a/src/europython_discord/fox/cog.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import logging -import random -import time -from collections import OrderedDict - -import discord -from discord.ext import commands - -from europython_discord.fox.config import FoxConfig -from europython_discord.fox.foxclient import FoxClient - -_logger = logging.getLogger(__name__) - -_MAX_COOLDOWN_TRACKING = 100 - - -class FoxCog(commands.Cog): - def __init__( - self, - bot: commands.Bot, - config: FoxConfig, - client: FoxClient | None = None, - ) -> None: - self.bot = bot - self.config = config - self._client = client or FoxClient() - self._last_usage_timestamp_by_user_id: OrderedDict[int, float] = OrderedDict() - _logger.info("Cog 'Fox' has been initialized") - - @commands.hybrid_command(name="fox", description="Get a random fox picture") - async def fox_command(self, ctx: commands.Context) -> None: - if ctx.channel.name != self.config.channel_name: - return - - if self._is_rate_limited(ctx.author.id): - return - - if (image_url := await self._client.fetch_random_fox()) is None: - message = random.choice(self.config.error_messages) # noqa: S311 - await ctx.send(message) - return - - embed = discord.Embed() - embed.description = "A random fox image from https://randomfox.ca" - embed.set_image(url=image_url) - - self._update_rate_limit_cache(ctx.author.id) - await ctx.send(embed=embed) - - def _is_rate_limited(self, user_id: int) -> bool: - last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0) - return last_usage_timestamp + self.config.cooldown_seconds > time.time() - - def _update_rate_limit_cache(self, user_id: int) -> None: - # update cache - self._last_usage_timestamp_by_user_id[user_id] = time.time() - - # trim cache - self._last_usage_timestamp_by_user_id.move_to_end(user_id) - if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING: - self._last_usage_timestamp_by_user_id.popitem(last=False) diff --git a/src/europython_discord/fox/config.py b/src/europython_discord/fox/config.py deleted file mode 100644 index c065ac4c..00000000 --- a/src/europython_discord/fox/config.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel - - -class FoxConfig(BaseModel): - channel_name: str - cooldown_seconds: int = 10 - error_messages: list[str] = ["404: Fox not found. Have you checked the den? 🦊"] diff --git a/src/europython_discord/fox/foxclient.py b/src/europython_discord/fox/foxclient.py deleted file mode 100644 index 181be7ca..00000000 --- a/src/europython_discord/fox/foxclient.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -import logging - -import aiohttp - -_logger = logging.getLogger(__name__) - -FOX_API_URL = "https://randomfox.ca/floof/" - - -class FoxClient: - def __init__(self) -> None: - self._session = aiohttp.ClientSession() - - async def fetch_random_fox(self) -> str | None: - try: - async with self._session.get(FOX_API_URL) as response: - response.raise_for_status() - data = await response.json() - except Exception: - _logger.exception("Failed to fetch fox image") - return None - - return data.get("image") diff --git a/test-config.toml b/test-config.toml index cd901a00..48c7f16a 100644 --- a/test-config.toml +++ b/test-config.toml @@ -65,9 +65,11 @@ fast_mode = true [guild_statistics] required_role = "Organizers" -[dog] +[animals] channel_name = "animal-appreciation" cooldown_seconds = 10 + +[animals.dog] error_messages = [ "The dogs are on strike today! Try again later. 🐾πŸͺ§", "A wild error appeared! The dog got away... πŸ•πŸ’¨", @@ -75,9 +77,7 @@ error_messages = [ "404: Dog not found. Have you checked under the couch? πŸ›‹οΈ", ] -[cat] -channel_name = "animal-appreciation" -cooldown_seconds = 10 +[animals.cat] error_messages = [ "The cats are on strike today! Try again later. 🐾πŸͺ§", "A wild error appeared! The cat got away... πŸ˜ΏπŸ’¨", @@ -85,9 +85,7 @@ error_messages = [ "404: Cat not found. Have you checked inside a cardboard box? πŸ“¦", ] -[duck] -channel_name = "animal-appreciation" -cooldown_seconds = 10 +[animals.duck] error_messages = [ "The ducks are on strike today! Try again later. 🐾πŸͺ§", "A wild error appeared! The duck got away... πŸ¦†πŸ’¨", @@ -95,9 +93,7 @@ error_messages = [ "Quack! The duck API seems to be having an off day.", ] -[fox] -channel_name = "animal-appreciation" -cooldown_seconds = 10 +[animals.fox] error_messages = [ "The foxes are on strike today! Try again later. 🐾πŸͺ§", "A wild error appeared! The fox got away... πŸ¦ŠπŸ’¨", diff --git a/tests/cat/__init__.py b/tests/cat/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cat/test_cat.py b/tests/cat/test_cat.py deleted file mode 100644 index 659073df..00000000 --- a/tests/cat/test_cat.py +++ /dev/null @@ -1,38 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import pytest -from discord.ext import commands - -from europython_discord.cat.catclient import CatClient -from europython_discord.cat.cog import CatCog -from europython_discord.cat.config import CatConfig - - -@pytest.fixture -def mock_client() -> CatClient: - client = MagicMock(spec=CatClient) - client.fetch_random_cat.return_value = "https://cataas.com/cat/mockid" - return client - - -@pytest.fixture -def cog(mock_client: CatClient) -> CatCog: - bot = MagicMock(spec=commands.Bot) - config = CatConfig(channel_name="animal-appreciation") - return CatCog(bot, config, client=mock_client) - - -@pytest.fixture -def ctx() -> AsyncMock: - mock = AsyncMock(spec=commands.Context) - mock.channel.name = "animal-appreciation" - mock.send = AsyncMock() - return mock - - -async def test_cat_command_success(cog: CatCog, ctx: AsyncMock) -> None: - await cog.cat_command.callback(cog, ctx) - - ctx.send.assert_awaited_once() - embed = ctx.send.call_args.kwargs["embed"] - assert embed.image.url == "https://cataas.com/cat/mockid" diff --git a/tests/dog/__init__.py b/tests/dog/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/dog/test_cog.py b/tests/dog/test_cog.py deleted file mode 100644 index 599bc54d..00000000 --- a/tests/dog/test_cog.py +++ /dev/null @@ -1,60 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import pytest -from discord.ext import commands - -from europython_discord.dog.cog import DogCog -from europython_discord.dog.config import DogConfig -from europython_discord.dog.dogclient import DogClient - - -@pytest.fixture -def mock_client() -> DogClient: - client = MagicMock(spec=DogClient) - client.fetch_random_dog.return_value = "https://images.dog.ceo/dog.jpg" - return client - - -@pytest.fixture -def cog(mock_client: DogClient) -> DogCog: - bot = MagicMock(spec=commands.Bot) - config = DogConfig(channel_name="animal-appreciation") - return DogCog(bot, config, client=mock_client) - - -@pytest.fixture -def ctx() -> AsyncMock: - mock = AsyncMock(spec=commands.Context) - mock.channel.name = "animal-appreciation" - mock.send = AsyncMock() - return mock - - -async def test_dog_command_success(cog: DogCog, ctx: AsyncMock) -> None: - await cog.dog_command.callback(cog, ctx) - - ctx.send.assert_awaited_once() - embed = ctx.send.call_args.kwargs["embed"] - assert embed.image.url == "https://images.dog.ceo/dog.jpg" - - -async def test_dog_command_api_error(cog: DogCog, ctx: AsyncMock, mock_client: DogClient) -> None: - mock_client.fetch_random_dog.return_value = None - - await cog.dog_command.callback(cog, ctx) - - ctx.send.assert_awaited_once() - text = ctx.send.call_args.args[0] - - assert text in cog.config.error_messages - - -@pytest.mark.parametrize("channel_name", ["wrong-channel", "general", ""]) -async def test_dog_command_wrong_channel(cog: DogCog, channel_name: str) -> None: - ctx = AsyncMock(spec=commands.Context) - ctx.channel.name = channel_name - ctx.send = AsyncMock() - - await cog.dog_command.callback(cog, ctx) - - ctx.send.assert_not_awaited() diff --git a/tests/duck/__init__.py b/tests/duck/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/duck/test_duck.py b/tests/duck/test_duck.py deleted file mode 100644 index f1aac96b..00000000 --- a/tests/duck/test_duck.py +++ /dev/null @@ -1,38 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import pytest -from discord.ext import commands - -from europython_discord.duck.cog import DuckCog -from europython_discord.duck.config import DuckConfig -from europython_discord.duck.duckclient import DuckClient - - -@pytest.fixture -def mock_client() -> DuckClient: - client = MagicMock(spec=DuckClient) - client.fetch_random_duck.return_value = "https://random-d.uk/api/randomimg?t=123" - return client - - -@pytest.fixture -def cog(mock_client: DuckClient) -> DuckCog: - bot = MagicMock(spec=commands.Bot) - config = DuckConfig(channel_name="animal-appreciation") - return DuckCog(bot, config, client=mock_client) - - -@pytest.fixture -def ctx() -> AsyncMock: - mock = AsyncMock(spec=commands.Context) - mock.channel.name = "animal-appreciation" - mock.send = AsyncMock() - return mock - - -async def test_duck_command_success(cog: DuckCog, ctx: AsyncMock) -> None: - await cog.duck_command.callback(cog, ctx) - - ctx.send.assert_awaited_once() - embed = ctx.send.call_args.kwargs["embed"] - assert embed.image.url == "https://random-d.uk/api/randomimg?t=123" diff --git a/tests/fox/__init__.py b/tests/fox/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/fox/test_fox.py b/tests/fox/test_fox.py deleted file mode 100644 index 70c95471..00000000 --- a/tests/fox/test_fox.py +++ /dev/null @@ -1,38 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import pytest -from discord.ext import commands - -from europython_discord.fox.cog import FoxCog -from europython_discord.fox.config import FoxConfig -from europython_discord.fox.foxclient import FoxClient - - -@pytest.fixture -def mock_client() -> FoxClient: - client = MagicMock(spec=FoxClient) - client.fetch_random_fox.return_value = "https://randomfox.ca/images/123.jpg" - return client - - -@pytest.fixture -def cog(mock_client: FoxClient) -> FoxCog: - bot = MagicMock(spec=commands.Bot) - config = FoxConfig(channel_name="animal-appreciation") - return FoxCog(bot, config, client=mock_client) - - -@pytest.fixture -def ctx() -> AsyncMock: - mock = AsyncMock(spec=commands.Context) - mock.channel.name = "animal-appreciation" - mock.send = AsyncMock() - return mock - - -async def test_fox_command_success(cog: FoxCog, ctx: AsyncMock) -> None: - await cog.fox_command.callback(cog, ctx) - - ctx.send.assert_awaited_once() - embed = ctx.send.call_args.kwargs["embed"] - assert embed.image.url == "https://randomfox.ca/images/123.jpg" From 72ca12b8f2a1af8eb067ddadc1642f50fe4474a5 Mon Sep 17 00:00:00 2001 From: "sveeraraghavan@FRLPMC3060" Date: Mon, 29 Jun 2026 21:05:42 +0200 Subject: [PATCH 5/7] Animal tests --- tests/animals/__init__.py | 0 tests/animals/test_clients.py | 89 ++++++++++++++++++++++++++++++++ tests/animals/test_cog.py | 95 +++++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/animals/__init__.py create mode 100644 tests/animals/test_clients.py create mode 100644 tests/animals/test_cog.py diff --git a/tests/animals/__init__.py b/tests/animals/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/animals/test_clients.py b/tests/animals/test_clients.py new file mode 100644 index 00000000..de3058de --- /dev/null +++ b/tests/animals/test_clients.py @@ -0,0 +1,89 @@ +import json +from unittest.mock import AsyncMock, patch, MagicMock + +import pytest +from aiohttp import ClientSession + +from europython_discord.animals.clients import AnimalClient + + +@pytest.fixture +async def client() -> AnimalClient: + c = AnimalClient() + yield c + await c._session.close() + + +async def test_fetch_dog(client: AnimalClient) -> None: + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value={"message": "https://images.dog.ceo/dog.jpg", "status": "success"}) + mock_response.raise_for_status = MagicMock() + + mock_get = MagicMock() + mock_get.return_value.__aenter__.return_value = mock_response + + with patch.object(ClientSession, 'get', mock_get): + url = await client.fetch_image("dog") + + assert url == "https://images.dog.ceo/dog.jpg" + mock_get.assert_called_once() + + +async def test_fetch_cat(client: AnimalClient) -> None: + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value={"url": "/cat/mockid"}) + mock_response.raise_for_status = MagicMock() + + mock_get = MagicMock() + mock_get.return_value.__aenter__.return_value = mock_response + + with patch.object(ClientSession, 'get', mock_get): + url = await client.fetch_image("cat") + + assert url == "https://cataas.com/cat/mockid" + mock_get.assert_called_once() + + # Test absolute url + mock_response.json = AsyncMock(return_value={"url": "https://example.com/cat.jpg"}) + with patch.object(ClientSession, 'get', mock_get): + url = await client.fetch_image("cat") + + assert url == "https://example.com/cat.jpg" + + +async def test_fetch_duck(client: AnimalClient) -> None: + # Duck API just returns a formatted URL without HTTP requests in the client + with patch('time.time', return_value=12345.0): + url = await client.fetch_image("duck") + + assert url == "https://random-d.uk/api/randomimg?t=12345000" + + +async def test_fetch_fox(client: AnimalClient) -> None: + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value={"image": "https://randomfox.ca/images/123.jpg", "link": "..."}) + mock_response.raise_for_status = MagicMock() + + mock_get = MagicMock() + mock_get.return_value.__aenter__.return_value = mock_response + + with patch.object(ClientSession, 'get', mock_get): + url = await client.fetch_image("fox") + + assert url == "https://randomfox.ca/images/123.jpg" + mock_get.assert_called_once() + + +async def test_fetch_unknown(client: AnimalClient) -> None: + url = await client.fetch_image("unknown_animal") + assert url is None + + +async def test_fetch_error(client: AnimalClient) -> None: + mock_get = MagicMock() + mock_get.return_value.__aenter__.side_effect = Exception("API Error") + + with patch.object(ClientSession, 'get', mock_get): + url = await client.fetch_image("dog") + + assert url is None diff --git a/tests/animals/test_cog.py b/tests/animals/test_cog.py new file mode 100644 index 00000000..5c7bcf76 --- /dev/null +++ b/tests/animals/test_cog.py @@ -0,0 +1,95 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from discord.ext import commands + +from europython_discord.animals.clients import AnimalClient +from europython_discord.animals.cog import AnimalsCog +from europython_discord.animals.config import AnimalsConfig, AnimalSpecificConfig + + +@pytest.fixture +def mock_client() -> AnimalClient: + client = MagicMock(spec=AnimalClient) + client.fetch_image = AsyncMock(return_value="https://example.com/animal.jpg") + return client + + +@pytest.fixture +def config() -> AnimalsConfig: + return AnimalsConfig( + channel_name="animal-appreciation", + cooldown_seconds=10, + dog=AnimalSpecificConfig(error_messages=["dog error"]), + cat=AnimalSpecificConfig(error_messages=["cat error"]), + duck=AnimalSpecificConfig(error_messages=["duck error"]), + fox=AnimalSpecificConfig(error_messages=["fox error"]), + ) + + +@pytest.fixture +def cog(mock_client: AnimalClient, config: AnimalsConfig) -> AnimalsCog: + bot = MagicMock(spec=commands.Bot) + return AnimalsCog(bot, config, client=mock_client) + + +@pytest.fixture +def ctx() -> AsyncMock: + mock = AsyncMock(spec=commands.Context) + mock.channel.name = "animal-appreciation" + mock.author = MagicMock() + mock.author.id = 12345 + mock.send = AsyncMock() + return mock + + +@pytest.mark.parametrize("command_name", ["dog_command", "cat_command", "duck_command", "fox_command"]) +async def test_animal_commands_success(cog: AnimalsCog, ctx: AsyncMock, command_name: str) -> None: + command = getattr(cog, command_name) + await command.callback(cog, ctx) + + ctx.send.assert_awaited_once() + embed = ctx.send.call_args.kwargs["embed"] + assert embed.image.url == "https://example.com/animal.jpg" + assert "friendly" in embed.description + + +async def test_animal_command_api_error(cog: AnimalsCog, ctx: AsyncMock, mock_client: AnimalClient) -> None: + mock_client.fetch_image = AsyncMock(return_value=None) + + await cog.dog_command.callback(cog, ctx) + + ctx.send.assert_awaited_once() + text = ctx.send.call_args.args[0] + assert text in cog.config.dog.error_messages + + +@pytest.mark.parametrize("channel_name", ["wrong-channel", "general", ""]) +async def test_animal_command_wrong_channel(cog: AnimalsCog, channel_name: str) -> None: + ctx = AsyncMock(spec=commands.Context) + ctx.channel.name = channel_name + ctx.author = MagicMock() + ctx.author.id = 12345 + ctx.send = AsyncMock() + + await cog.dog_command.callback(cog, ctx) + + ctx.send.assert_not_awaited() + + +async def test_animal_command_rate_limit(cog: AnimalsCog, ctx: AsyncMock) -> None: + # First call succeeds + await cog.dog_command.callback(cog, ctx) + assert ctx.send.call_count == 1 + + # Second call right after fails due to rate limit + await cog.dog_command.callback(cog, ctx) + assert ctx.send.call_count == 1 # Still 1, didn't increase + + # Fast forward time to bypass cooldown + import time + cog._last_usage_timestamp_by_user_id[ctx.author.id] = time.time() - 20 + + # Third call succeeds + await cog.dog_command.callback(cog, ctx) + assert ctx.send.call_count == 2 From 06187e96b438d7f6ba97850f5e6a12b749cfb6ef Mon Sep 17 00:00:00 2001 From: "sveeraraghavan@FRLPMC3060" Date: Mon, 29 Jun 2026 21:06:57 +0200 Subject: [PATCH 6/7] lint well --- tests/animals/test_clients.py | 43 +++++++++++++++++++---------------- tests/animals/test_cog.py | 15 ++++++++---- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/tests/animals/test_clients.py b/tests/animals/test_clients.py index de3058de..ba8a8782 100644 --- a/tests/animals/test_clients.py +++ b/tests/animals/test_clients.py @@ -1,5 +1,4 @@ -import json -from unittest.mock import AsyncMock, patch, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiohttp import ClientSession @@ -16,15 +15,17 @@ async def client() -> AnimalClient: async def test_fetch_dog(client: AnimalClient) -> None: mock_response = AsyncMock() - mock_response.json = AsyncMock(return_value={"message": "https://images.dog.ceo/dog.jpg", "status": "success"}) + mock_response.json = AsyncMock( + return_value={"message": "https://images.dog.ceo/dog.jpg", "status": "success"} + ) mock_response.raise_for_status = MagicMock() - + mock_get = MagicMock() mock_get.return_value.__aenter__.return_value = mock_response - with patch.object(ClientSession, 'get', mock_get): + with patch.object(ClientSession, "get", mock_get): url = await client.fetch_image("dog") - + assert url == "https://images.dog.ceo/dog.jpg" mock_get.assert_called_once() @@ -33,43 +34,45 @@ async def test_fetch_cat(client: AnimalClient) -> None: mock_response = AsyncMock() mock_response.json = AsyncMock(return_value={"url": "/cat/mockid"}) mock_response.raise_for_status = MagicMock() - + mock_get = MagicMock() mock_get.return_value.__aenter__.return_value = mock_response - with patch.object(ClientSession, 'get', mock_get): + with patch.object(ClientSession, "get", mock_get): url = await client.fetch_image("cat") - + assert url == "https://cataas.com/cat/mockid" mock_get.assert_called_once() - + # Test absolute url mock_response.json = AsyncMock(return_value={"url": "https://example.com/cat.jpg"}) - with patch.object(ClientSession, 'get', mock_get): + with patch.object(ClientSession, "get", mock_get): url = await client.fetch_image("cat") - + assert url == "https://example.com/cat.jpg" async def test_fetch_duck(client: AnimalClient) -> None: # Duck API just returns a formatted URL without HTTP requests in the client - with patch('time.time', return_value=12345.0): + with patch("time.time", return_value=12345.0): url = await client.fetch_image("duck") - + assert url == "https://random-d.uk/api/randomimg?t=12345000" async def test_fetch_fox(client: AnimalClient) -> None: mock_response = AsyncMock() - mock_response.json = AsyncMock(return_value={"image": "https://randomfox.ca/images/123.jpg", "link": "..."}) + mock_response.json = AsyncMock( + return_value={"image": "https://randomfox.ca/images/123.jpg", "link": "..."} + ) mock_response.raise_for_status = MagicMock() - + mock_get = MagicMock() mock_get.return_value.__aenter__.return_value = mock_response - with patch.object(ClientSession, 'get', mock_get): + with patch.object(ClientSession, "get", mock_get): url = await client.fetch_image("fox") - + assert url == "https://randomfox.ca/images/123.jpg" mock_get.assert_called_once() @@ -83,7 +86,7 @@ async def test_fetch_error(client: AnimalClient) -> None: mock_get = MagicMock() mock_get.return_value.__aenter__.side_effect = Exception("API Error") - with patch.object(ClientSession, 'get', mock_get): + with patch.object(ClientSession, "get", mock_get): url = await client.fetch_image("dog") - + assert url is None diff --git a/tests/animals/test_cog.py b/tests/animals/test_cog.py index 5c7bcf76..86117985 100644 --- a/tests/animals/test_cog.py +++ b/tests/animals/test_cog.py @@ -43,7 +43,9 @@ def ctx() -> AsyncMock: return mock -@pytest.mark.parametrize("command_name", ["dog_command", "cat_command", "duck_command", "fox_command"]) +@pytest.mark.parametrize( + "command_name", ["dog_command", "cat_command", "duck_command", "fox_command"] +) async def test_animal_commands_success(cog: AnimalsCog, ctx: AsyncMock, command_name: str) -> None: command = getattr(cog, command_name) await command.callback(cog, ctx) @@ -54,7 +56,9 @@ async def test_animal_commands_success(cog: AnimalsCog, ctx: AsyncMock, command_ assert "friendly" in embed.description -async def test_animal_command_api_error(cog: AnimalsCog, ctx: AsyncMock, mock_client: AnimalClient) -> None: +async def test_animal_command_api_error( + cog: AnimalsCog, ctx: AsyncMock, mock_client: AnimalClient +) -> None: mock_client.fetch_image = AsyncMock(return_value=None) await cog.dog_command.callback(cog, ctx) @@ -81,15 +85,16 @@ async def test_animal_command_rate_limit(cog: AnimalsCog, ctx: AsyncMock) -> Non # First call succeeds await cog.dog_command.callback(cog, ctx) assert ctx.send.call_count == 1 - + # Second call right after fails due to rate limit await cog.dog_command.callback(cog, ctx) assert ctx.send.call_count == 1 # Still 1, didn't increase - + # Fast forward time to bypass cooldown import time + cog._last_usage_timestamp_by_user_id[ctx.author.id] = time.time() - 20 - + # Third call succeeds await cog.dog_command.callback(cog, ctx) assert ctx.send.call_count == 2 From 0199c27443953583f32e87e65a714cd58e43ce16 Mon Sep 17 00:00:00 2001 From: "sveeraraghavan@FRLPMC3060" Date: Mon, 29 Jun 2026 21:09:11 +0200 Subject: [PATCH 7/7] lint better --- tests/animals/test_cog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/animals/test_cog.py b/tests/animals/test_cog.py index 86117985..3cc6d849 100644 --- a/tests/animals/test_cog.py +++ b/tests/animals/test_cog.py @@ -1,3 +1,4 @@ +import time from unittest.mock import AsyncMock, MagicMock import pytest @@ -90,9 +91,7 @@ async def test_animal_command_rate_limit(cog: AnimalsCog, ctx: AsyncMock) -> Non await cog.dog_command.callback(cog, ctx) assert ctx.send.call_count == 1 # Still 1, didn't increase - # Fast forward time to bypass cooldown - import time - + # Time travel cog._last_usage_timestamp_by_user_id[ctx.author.id] = time.time() - 20 # Third call succeeds