diff --git a/README.md b/README.md index c5e8195..d6a7333 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ - Guild Wars 2 API integration (accounts, WvW, sessions, wiki) - Server administration and moderation tools - Custom commands, profanity filtering, and text-to-speech +- Persistent embed pagination that survives bot restarts - PostgreSQL database with Alembic migrations - Docker deployment with automatic database migrations @@ -143,7 +144,7 @@ All configuration is done through environment variables in the `.env` file. | Variable | Default | Description | |:-------------------|:--------------|:--------------------| | `OPENAI_API_KEY` | | OpenAI API key | -| `BOT_OPENAI_MODEL` | `gpt-4o-mini` | OpenAI model to use | +| `BOT_OPENAI_MODEL` | `gpt-5.4` | OpenAI model to use | ## PostgreSQL Settings | Variable | Default | Description | diff --git a/docker-compose-localdb.yml b/docker-compose-localdb.yml index 9717da8..564620b 100644 --- a/docker-compose-localdb.yml +++ b/docker-compose-localdb.yml @@ -44,6 +44,9 @@ services: DOCKER_BUILDKIT: 1 networks: - postgres_network + depends_on: + discordbot_database: + condition: service_healthy command: ["sh", "-c", "uv run --frozen --no-sync alembic upgrade head && touch /tmp/alembic_done && sleep infinity"] deploy: restart_policy: diff --git a/pyproject.toml b/pyproject.toml index 980099d..41d2bda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "DiscordBot" -version = "3.0.11" +version = "3.0.12" description = "A simple Discord bot with OpenAI support and server administration tools" urls.Repository = "https://github.com/ddc/DiscordBot" urls.Homepage = "https://ddc.github.io/DiscordBot" diff --git a/src/bot/cogs/open_ai.py b/src/bot/cogs/open_ai.py index ab9959d..be4ab95 100644 --- a/src/bot/cogs/open_ai.py +++ b/src/bot/cogs/open_ai.py @@ -13,7 +13,8 @@ class OpenAi(commands.Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - self._openai_client: OpenAI | None = None + self._bot_settings = get_bot_settings() + self._openai_client: OpenAI = OpenAI(api_key=self._bot_settings.openai_api_key) @commands.command() @commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user) @@ -41,20 +42,11 @@ async def ai(self, ctx: commands.Context, *, msg_text: str) -> None: await bot_utils.send_embed(ctx, embeds[0], False) else: view = bot_utils.EmbedPaginatorView(embeds, ctx.author.id) - msg = await ctx.send(embed=embeds[0], view=view) - view.message = msg - - @property - def openai_client(self) -> OpenAI: - """Get or create OpenAI client instance.""" - if self._openai_client is None: - api_key = get_bot_settings().openai_api_key - self._openai_client = OpenAI(api_key=api_key) - return self._openai_client + await view.send_and_save(ctx) async def _get_ai_response(self, message: str) -> str: """Get response from OpenAI API.""" - model = get_bot_settings().openai_model + model = self._bot_settings.openai_model # Create properly typed messages for OpenAI API messages: list[ChatCompletionSystemMessageParam | ChatCompletionUserMessageParam] = [ @@ -66,18 +58,19 @@ async def _get_ai_response(self, message: str) -> str: ] # Use the correct OpenAI API endpoint - response = self.openai_client.chat.completions.create( + response = self._openai_client.chat.completions.create( model=model, messages=messages, max_completion_tokens=1000, temperature=0.7, ) - return response.choices[0].message.content.strip() + content = response.choices[0].message.content + return content.strip() if content else "" - @staticmethod - def _create_ai_embeds(ctx: commands.Context, description: str, color: discord.Color) -> list[discord.Embed]: + def _create_ai_embeds(self, ctx: commands.Context, description: str, color: discord.Color) -> list[discord.Embed]: """Create formatted embed(s) for AI response, paginating if needed.""" + model = self._bot_settings.openai_model max_length = 2000 chunks = [] @@ -97,13 +90,14 @@ def _create_ai_embeds(ctx: commands.Context, description: str, color: discord.Co for i, chunk in enumerate(chunks): embed = discord.Embed(color=color, description=chunk) embed.set_author( - name=ctx.author.display_name, icon_url=ctx.author.avatar.url if ctx.author.avatar else None + name=ctx.author.display_name, + icon_url=getattr(ctx.author.avatar, "url", None), ) - footer_text = bot_utils.get_current_date_time_str_long() + " UTC" + footer_text = f"{model} | {bot_utils.get_current_date_time_str_long()} UTC" if len(chunks) > 1: footer_text = f"Page {i + 1}/{len(chunks)} | {footer_text}" embed.set_footer( - icon_url=ctx.bot.user.avatar.url if ctx.bot.user.avatar else None, + icon_url=ctx.bot.user.avatar.url if ctx.bot.user and ctx.bot.user.avatar else None, text=footer_text, ) pages.append(embed) diff --git a/src/bot/discord_bot.py b/src/bot/discord_bot.py index 9c0a688..ce38c51 100644 --- a/src/bot/discord_bot.py +++ b/src/bot/discord_bot.py @@ -36,9 +36,10 @@ def __init__(self, *args, **kwargs): self._load_settings() async def setup_hook(self) -> None: - """Called after login - loads all cogs.""" + """Called after login - loads all cogs and registers persistent views.""" try: await bot_utils.load_cogs(self) + self.add_view(bot_utils.EmbedPaginatorView()) self.log.info(messages.BOT_LOADED_ALL_COGS_SUCCESS) except Exception as e: self.log.error(f"{messages.BOT_LOAD_COGS_FAILED}: {e}") diff --git a/src/bot/tools/bot_utils.py b/src/bot/tools/bot_utils.py index d7bb5a9..cae61cb 100644 --- a/src/bot/tools/bot_utils.py +++ b/src/bot/tools/bot_utils.py @@ -172,11 +172,14 @@ async def send_embed(ctx, embed, dm=False): class EmbedPaginatorView(discord.ui.View): - """Interactive pagination view for embed pages with Previous/Next buttons.""" + """Persistent pagination view for embed pages with Previous/Next buttons. - def __init__(self, pages: list[discord.Embed], author_id: int): + Pages are stored in the database so pagination survives bot restarts. + """ + + def __init__(self, pages: list[discord.Embed] | None = None, author_id: int = 0): super().__init__(timeout=None) - self.pages = pages + self.pages = pages or [] self.current_page = 0 self.author_id = author_id self.message: discord.Message | None = None @@ -184,11 +187,57 @@ def __init__(self, pages: list[discord.Embed], author_id: int): def _update_buttons(self): self.previous_button.disabled = self.current_page == 0 - self.page_indicator.label = f"{self.current_page + 1}/{len(self.pages)}" - self.next_button.disabled = self.current_page == len(self.pages) - 1 + self.page_indicator.label = f"{self.current_page + 1}/{len(self.pages)}" if self.pages else "0/0" + self.next_button.disabled = self.current_page >= len(self.pages) - 1 + + @staticmethod + def _embed_to_dict(embed: discord.Embed) -> dict: + return embed.to_dict() + + @staticmethod + def _dict_to_embed(data: dict) -> discord.Embed: + return discord.Embed.from_dict(data) + + async def send_and_save(self, ctx) -> None: + """Send the first page and save all pages to the database.""" + msg = await ctx.send(embed=self.pages[0], view=self) + self.message = msg + from src.database.dal.bot.embed_pages_dal import EmbedPagesDal + + dal = EmbedPagesDal(ctx.bot.db_session, ctx.bot.log) + pages_data = [self._embed_to_dict(p) for p in self.pages] + await dal.insert_embed_pages(msg.id, msg.channel.id, self.author_id, pages_data) + + async def _load_from_db(self, interaction: discord.Interaction) -> bool: + """Load pages from database if not in memory. Returns True if loaded.""" + if self.pages: + return True + from src.database.dal.bot.embed_pages_dal import EmbedPagesDal + + bot = interaction.client + dal = EmbedPagesDal(bot.db_session, bot.log) + record = await dal.get_embed_pages(interaction.message.id) + if not record: + await interaction.response.send_message("This pagination has expired.", ephemeral=True) + return False + self.pages = [self._dict_to_embed(p) for p in record["pages"]] + self.current_page = record["current_page"] + self.author_id = record["author_id"] + self._update_buttons() + return True + + async def _save_current_page(self, interaction: discord.Interaction) -> None: + """Update current page in database.""" + from src.database.dal.bot.embed_pages_dal import EmbedPagesDal + + bot = interaction.client + dal = EmbedPagesDal(bot.db_session, bot.log) + await dal.update_current_page(interaction.message.id, self.current_page) - @discord.ui.button(label="\u25c0", style=discord.ButtonStyle.secondary) + @discord.ui.button(label="\u25c0", style=discord.ButtonStyle.secondary, custom_id="paginator:prev") async def previous_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if not await self._load_from_db(interaction): + return if interaction.user.id != self.author_id: return await interaction.response.send_message( "Only the command invoker can use these buttons.", ephemeral=True @@ -196,13 +245,16 @@ async def previous_button(self, interaction: discord.Interaction, button: discor self.current_page -= 1 self._update_buttons() await interaction.response.edit_message(embed=self.pages[self.current_page], view=self) + await self._save_current_page(interaction) - @discord.ui.button(label="1/1", style=discord.ButtonStyle.secondary, disabled=True) + @discord.ui.button(label="1/1", style=discord.ButtonStyle.secondary, disabled=True, custom_id="paginator:page") async def page_indicator(self, interaction: discord.Interaction, button: discord.ui.Button): await interaction.response.defer() - @discord.ui.button(label="\u25b6", style=discord.ButtonStyle.secondary) + @discord.ui.button(label="\u25b6", style=discord.ButtonStyle.secondary, custom_id="paginator:next") async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if not await self._load_from_db(interaction): + return if interaction.user.id != self.author_id: return await interaction.response.send_message( "Only the command invoker can use these buttons.", ephemeral=True @@ -210,6 +262,7 @@ async def next_button(self, interaction: discord.Interaction, button: discord.ui self.current_page += 1 self._update_buttons() await interaction.response.edit_message(embed=self.pages[self.current_page], view=self) + await self._save_current_page(interaction) async def send_paginated_embed(ctx, embed: discord.Embed, max_fields: int = 25) -> None: @@ -246,8 +299,7 @@ async def send_paginated_embed(ctx, embed: discord.Embed, max_fields: int = 25) return view = EmbedPaginatorView(pages, ctx.message.author.id) - msg = await ctx.send(embed=pages[0], view=view) - view.message = msg + await view.send_and_save(ctx) async def delete_message(ctx, warning=False): diff --git a/src/database/dal/bot/embed_pages_dal.py b/src/database/dal/bot/embed_pages_dal.py new file mode 100644 index 0000000..a1bde17 --- /dev/null +++ b/src/database/dal/bot/embed_pages_dal.py @@ -0,0 +1,33 @@ +import sqlalchemy as sa +from ddcdatabases import DBUtilsAsync +from sqlalchemy.future import select +from src.database.models.bot_models import EmbedPages + + +class EmbedPagesDal: + def __init__(self, db_session, log): + self.columns = list(EmbedPages.__table__.columns.values()) + self.db_utils = DBUtilsAsync(db_session) + self.log = log + + async def insert_embed_pages(self, message_id: int, channel_id: int, author_id: int, pages: list[dict]): + stmt = EmbedPages( + message_id=message_id, + channel_id=channel_id, + author_id=author_id, + pages=pages, + ) + await self.db_utils.insert(stmt) + + async def get_embed_pages(self, message_id: int): + stmt = select(*self.columns).where(EmbedPages.message_id == message_id) + results = await self.db_utils.fetchall(stmt, True) + return results[0] if results else None + + async def update_current_page(self, message_id: int, current_page: int): + stmt = sa.update(EmbedPages).where(EmbedPages.message_id == message_id).values(current_page=current_page) + await self.db_utils.execute(stmt) + + async def delete_embed_pages(self, message_id: int): + stmt = sa.delete(EmbedPages).where(EmbedPages.message_id == message_id) + await self.db_utils.execute(stmt) diff --git a/src/database/migrations/versions/0011_embed_pages.py b/src/database/migrations/versions/0011_embed_pages.py new file mode 100644 index 0000000..bd77e2a --- /dev/null +++ b/src/database/migrations/versions/0011_embed_pages.py @@ -0,0 +1,48 @@ +"""embed_pages + +Revision ID: 0011 +Revises: 0010 +Create Date: 2026-03-28 18:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op +from collections.abc import Sequence +from sqlalchemy.dialects.postgresql import JSONB + +# revision identifiers, used by Alembic. +revision: str = "0011" +down_revision: str | None = "0010" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "embed_pages", + sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")), + sa.Column("message_id", sa.BigInteger(), nullable=False), + sa.Column("channel_id", sa.BigInteger(), nullable=False), + sa.Column("author_id", sa.BigInteger(), nullable=False), + sa.Column("current_page", sa.Integer(), server_default="0", nullable=False), + sa.Column("pages", JSONB(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(now() at time zone 'utc')"), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("(now() at time zone 'utc')"), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("id"), + sa.UniqueConstraint("message_id"), + ) + op.create_index(op.f("ix_embed_pages_message_id"), "embed_pages", ["message_id"], unique=True) + op.execute(""" + CREATE TRIGGER before_update_embed_pages_tr + BEFORE UPDATE ON embed_pages + FOR EACH ROW + EXECUTE PROCEDURE updated_at_column_func(); + """) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS before_update_embed_pages_tr ON embed_pages") + op.drop_index(op.f("ix_embed_pages_message_id"), table_name="embed_pages") + op.drop_table("embed_pages") diff --git a/src/database/models/bot_models.py b/src/database/models/bot_models.py index 3845323..005272d 100644 --- a/src/database/models/bot_models.py +++ b/src/database/models/bot_models.py @@ -1,7 +1,9 @@ -from sqlalchemy import CHAR, BigInteger, Boolean, ForeignKey, Uuid +from sqlalchemy import CHAR, BigInteger, Boolean, ForeignKey, Integer, Uuid +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from src.bot.constants import variables from src.database.models import BotBase +from typing import Any from uuid import UUID from uuid_utils import uuid7 @@ -67,3 +69,13 @@ class DiceRolls(BotBase): roll: Mapped[int] = mapped_column() dice_size: Mapped[int] = mapped_column() servers = relationship("Servers", back_populates="dice_rolls") + + +class EmbedPages(BotBase): + __tablename__ = "embed_pages" + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) + message_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) + channel_id: Mapped[int] = mapped_column(BigInteger) + author_id: Mapped[int] = mapped_column(BigInteger) + current_page: Mapped[int] = mapped_column(Integer, server_default="0") + pages: Mapped[list[dict[str, Any]]] = mapped_column(JSONB) diff --git a/src/gw2/cogs/worlds.py b/src/gw2/cogs/worlds.py index b5af315..9c0fa2b 100644 --- a/src/gw2/cogs/worlds.py +++ b/src/gw2/cogs/worlds.py @@ -147,8 +147,7 @@ async def _send_paginated_worlds_embed(ctx, embed): return view = EmbedPaginatorView(pages, ctx.author.id) - msg = await ctx.send(embed=pages[0], view=view) - view.message = msg + await view.send_and_save(ctx) async def setup(bot): diff --git a/tests/integration/test_alembic_migrations.py b/tests/integration/test_alembic_migrations.py index 45e793d..ee5da5a 100644 --- a/tests/integration/test_alembic_migrations.py +++ b/tests/integration/test_alembic_migrations.py @@ -13,6 +13,7 @@ "custom_commands", "profanity_filters", "dice_rolls", + "embed_pages", ] EXPECTED_GW2_TABLES = [ @@ -28,6 +29,7 @@ "before_update_custom_commands_tr", "before_update_profanity_filters_tr", "before_update_dice_rolls_tr", + "before_update_embed_pages_tr", ] EXPECTED_GW2_TRIGGERS = [ @@ -135,7 +137,7 @@ async def test_alembic_version_at_head(db_session): text("SELECT version_num FROM alembic_version"), ) assert len(rows) == 1 - assert rows[0]["version_num"] == "0010" + assert rows[0]["version_num"] == "0011" # ────────────────────────────────────────────────────────────────────── diff --git a/tests/unit/bot/cogs/test_open_ai.py b/tests/unit/bot/cogs/test_open_ai.py index 5f59012..fd30e53 100644 --- a/tests/unit/bot/cogs/test_open_ai.py +++ b/tests/unit/bot/cogs/test_open_ai.py @@ -28,7 +28,10 @@ def mock_bot(): @pytest.fixture def openai_cog(mock_bot): """Create an OpenAi cog instance.""" - return OpenAi(mock_bot) + with patch("src.bot.cogs.open_ai.get_bot_settings") as mock_settings, \ + patch("src.bot.cogs.open_ai.OpenAI"): + mock_settings.return_value = MagicMock(openai_api_key="test-key", openai_model="gpt-3.5-turbo") + return OpenAi(mock_bot) @pytest.fixture @@ -77,30 +80,13 @@ class TestOpenAi: def test_init(self, mock_bot): """Test OpenAi cog initialization.""" - cog = OpenAi(mock_bot) - assert cog.bot == mock_bot - assert cog._openai_client is None - - def test_openai_client_property_creates_client(self, openai_cog): - """Test that openai_client property creates client on first access.""" - with patch("src.bot.cogs.open_ai.OpenAI") as mock_openai_class: - mock_client = MagicMock() - mock_openai_class.return_value = mock_client - - client = openai_cog.openai_client - - assert client == mock_client - assert openai_cog._openai_client == mock_client - mock_openai_class.assert_called_once() - - def test_openai_client_property_returns_existing_client(self, openai_cog): - """Test that openai_client property returns existing client.""" - mock_client = MagicMock() - openai_cog._openai_client = mock_client - - client = openai_cog.openai_client - - assert client == mock_client + with patch("src.bot.cogs.open_ai.get_bot_settings") as mock_settings, \ + patch("src.bot.cogs.open_ai.OpenAI"): + mock_settings.return_value = MagicMock(openai_api_key="test-key", openai_model="gpt-3.5-turbo") + cog = OpenAi(mock_bot) + assert cog.bot == mock_bot + assert cog._openai_client is not None + assert hasattr(cog, '_bot_settings') @pytest.mark.asyncio @patch("src.bot.cogs.open_ai.get_bot_settings") @@ -258,16 +244,13 @@ def test_create_ai_embeds_no_bot_avatar(self, openai_cog, mock_ctx): assert "UTC" in embeds[0].footer.text @pytest.mark.asyncio - @patch("src.bot.cogs.open_ai.get_bot_settings") @patch("src.bot.cogs.open_ai.bot_utils.send_embed") async def test_ai_command_with_different_models( - self, mock_send_embed, mock_get_settings, openai_cog, mock_ctx, mock_openai_response + self, mock_send_embed, openai_cog, mock_ctx, mock_openai_response ): """Test AI command with different OpenAI models.""" - # Test with GPT-4 - mock_settings = MagicMock() - mock_settings.openai_model = "gpt-4" - mock_get_settings.return_value = mock_settings + # Test with GPT-4 - set model directly on the cog's stored settings + openai_cog._bot_settings.openai_model = "gpt-4" # Mock the client instance directly mock_client = MagicMock() @@ -356,12 +339,12 @@ async def test_get_ai_response_api_parameters( @patch("src.bot.cogs.open_ai.bot_utils.get_current_date_time_str_long") def test_create_ai_embeds_footer(self, mock_get_datetime, openai_cog, mock_ctx): - """Test that embed footer contains correct timestamp.""" + """Test that embed footer contains correct timestamp and model name.""" mock_get_datetime.return_value = "2023-01-01 12:00:00" embeds = openai_cog._create_ai_embeds(mock_ctx, "Test", discord.Color.blue()) - assert embeds[0].footer.text == "2023-01-01 12:00:00 UTC" + assert embeds[0].footer.text == "gpt-3.5-turbo | 2023-01-01 12:00:00 UTC" mock_get_datetime.assert_called_once() @pytest.mark.asyncio @@ -369,7 +352,10 @@ async def test_setup_function(self, mock_bot): """Test the setup function.""" from src.bot.cogs.open_ai import setup - await setup(mock_bot) + with patch("src.bot.cogs.open_ai.get_bot_settings") as mock_settings, \ + patch("src.bot.cogs.open_ai.OpenAI"): + mock_settings.return_value = MagicMock(openai_api_key="test-key", openai_model="gpt-3.5-turbo") + await setup(mock_bot) mock_bot.add_cog.assert_called_once() added_cog = mock_bot.add_cog.call_args[0][0] @@ -477,10 +463,14 @@ def test_create_ai_embeds_splits_on_newline(self, openai_cog, mock_ctx): assert embeds[1].description == second_part @pytest.mark.asyncio + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") @patch("src.bot.cogs.open_ai.get_bot_settings") - async def test_ai_command_pagination(self, mock_get_settings, openai_cog, mock_ctx, mock_bot_settings): + async def test_ai_command_pagination(self, mock_get_settings, mock_dal_class, openai_cog, mock_ctx, mock_bot_settings): """Test AI command uses pagination for long responses.""" mock_get_settings.return_value = mock_bot_settings + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal long_response = "a" * 3000 with patch.object(openai_cog, "_get_ai_response", return_value=long_response): diff --git a/tests/unit/bot/test_discord_bot.py b/tests/unit/bot/test_discord_bot.py index 94f0c5e..a354394 100644 --- a/tests/unit/bot/test_discord_bot.py +++ b/tests/unit/bot/test_discord_bot.py @@ -9,6 +9,7 @@ import pytest from src.bot.constants import messages from src.bot.discord_bot import Bot +from src.bot.tools.bot_utils import EmbedPaginatorView from unittest.mock import AsyncMock, MagicMock, patch @@ -311,6 +312,7 @@ async def test_setup_hook_loads_cogs(self, mock_bot_utils, mock_get_bot, mock_ge mock_bot_utils.get_current_date_time.return_value = MagicMock() mock_bot_utils.get_color_settings.return_value = discord.Color.green() mock_bot_utils.load_cogs = AsyncMock() + mock_bot_utils.EmbedPaginatorView.return_value = EmbedPaginatorView() mock_bot_settings = MagicMock() mock_get_bot.return_value = mock_bot_settings diff --git a/tests/unit/bot/tools/test_bot_utils_extra.py b/tests/unit/bot/tools/test_bot_utils_extra.py index 1b24979..06654c2 100644 --- a/tests/unit/bot/tools/test_bot_utils_extra.py +++ b/tests/unit/bot/tools/test_bot_utils_extra.py @@ -676,7 +676,8 @@ async def test_next_button_callback(self): interaction.user.id = 42 interaction.response = AsyncMock() - await view.next_button.callback(interaction) + with patch.object(view, '_save_current_page', new_callable=AsyncMock): + await view.next_button.callback(interaction) assert view.current_page == 1 interaction.response.edit_message.assert_called_once_with(embed=pages[1], view=view) @@ -692,7 +693,8 @@ async def test_previous_button_callback(self): interaction.user.id = 42 interaction.response = AsyncMock() - await view.previous_button.callback(interaction) + with patch.object(view, '_save_current_page', new_callable=AsyncMock): + await view.previous_button.callback(interaction) assert view.current_page == 1 interaction.response.edit_message.assert_called_once_with(embed=pages[1], view=view) @@ -768,8 +770,13 @@ async def test_no_pagination_under_limit(self, mock_ctx): mock_send.assert_called_once_with(mock_ctx, embed) @pytest.mark.asyncio - async def test_pagination_splits_fields(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_pagination_splits_fields(self, mock_dal_class, mock_ctx): """Test embed with >25 fields is split into pages (lines 221-246).""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = discord.Embed(color=discord.Color.green(), description="Test desc") embed.set_author(name="Author", icon_url="https://example.com/pic.png") embed.set_thumbnail(url="https://example.com/thumb.png") @@ -818,8 +825,13 @@ async def test_pagination_single_page_after_split(self, mock_ctx): mock_send.assert_called_once() @pytest.mark.asyncio - async def test_pagination_no_color_uses_settings(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_pagination_no_color_uses_settings(self, mock_dal_class, mock_ctx): """Test that embed without color gets it from settings.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = discord.Embed() for i in range(30): embed.add_field(name=f"Field {i}", value=f"Value {i}") @@ -831,8 +843,13 @@ async def test_pagination_no_color_uses_settings(self, mock_ctx): assert view.pages[0].color == discord.Color.blue() @pytest.mark.asyncio - async def test_pagination_stores_message(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_pagination_stores_message(self, mock_dal_class, mock_ctx): """Test that view.message is set after sending.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = discord.Embed(color=discord.Color.green()) for i in range(30): embed.add_field(name=f"Field {i}", value=f"Value {i}") @@ -845,3 +862,207 @@ async def test_pagination_stores_message(self, mock_ctx): call_kwargs = mock_ctx.send.call_args[1] view = call_kwargs["view"] assert view.message is sent_msg + + +class TestEmbedPaginatorViewPersistence: + """Test EmbedPaginatorView database persistence methods.""" + + def _make_pages(self, count=3): + return [discord.Embed(title=f"Page {i + 1}", description=f"Content {i + 1}") for i in range(count)] + + def test_embed_to_dict(self): + """Test _embed_to_dict converts embed to dict.""" + embed = discord.Embed(title="Test", description="Desc", color=discord.Color.green()) + result = EmbedPaginatorView._embed_to_dict(embed) + assert isinstance(result, dict) + assert result["title"] == "Test" + assert result["description"] == "Desc" + + def test_dict_to_embed(self): + """Test _dict_to_embed converts dict back to embed.""" + data = {"title": "Test", "description": "Desc", "color": 0x00FF00} + result = EmbedPaginatorView._dict_to_embed(data) + assert isinstance(result, discord.Embed) + assert result.title == "Test" + assert result.description == "Desc" + + def test_embed_roundtrip(self): + """Test embed -> dict -> embed preserves data.""" + original = discord.Embed(title="Round", description="Trip", color=discord.Color.red()) + original.set_footer(text="Footer") + data = EmbedPaginatorView._embed_to_dict(original) + restored = EmbedPaginatorView._dict_to_embed(data) + assert restored.title == original.title + assert restored.description == original.description + assert restored.footer.text == original.footer.text + + @pytest.mark.asyncio + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_send_and_save(self, mock_dal_class): + """Test send_and_save sends message and saves to DB.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + + pages = self._make_pages(2) + view = EmbedPaginatorView(pages, author_id=42) + + ctx = MagicMock() + mock_msg = MagicMock() + mock_msg.id = 111 + mock_msg.channel.id = 222 + ctx.send = AsyncMock(return_value=mock_msg) + ctx.bot.db_session = MagicMock() + ctx.bot.log = MagicMock() + + await view.send_and_save(ctx) + + assert view.message is mock_msg + ctx.send.assert_called_once() + mock_dal.insert_embed_pages.assert_awaited_once() + call_args = mock_dal.insert_embed_pages.call_args + assert call_args[0][0] == 111 # message_id + assert call_args[0][1] == 222 # channel_id + assert call_args[0][2] == 42 # author_id + assert len(call_args[0][3]) == 2 # pages data + + @pytest.mark.asyncio + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_load_from_db_pages_already_set(self, mock_dal_class): + """Test _load_from_db returns True immediately when pages exist.""" + pages = self._make_pages(2) + view = EmbedPaginatorView(pages, author_id=42) + interaction = MagicMock() + + result = await view._load_from_db(interaction) + + assert result is True + mock_dal_class.assert_not_called() + + @pytest.mark.asyncio + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_load_from_db_loads_from_database(self, mock_dal_class): + """Test _load_from_db loads pages from DB when not in memory.""" + mock_dal = MagicMock() + page_data = [{"title": "P1", "description": "D1"}, {"title": "P2", "description": "D2"}] + mock_dal.get_embed_pages = AsyncMock(return_value={ + "pages": page_data, + "current_page": 1, + "author_id": 42, + }) + mock_dal_class.return_value = mock_dal + + view = EmbedPaginatorView() # No pages + interaction = MagicMock() + interaction.message.id = 111 + interaction.client.db_session = MagicMock() + interaction.client.log = MagicMock() + + result = await view._load_from_db(interaction) + + assert result is True + assert len(view.pages) == 2 + assert view.current_page == 1 + assert view.author_id == 42 + + @pytest.mark.asyncio + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_load_from_db_record_not_found(self, mock_dal_class): + """Test _load_from_db returns False when record not in DB.""" + mock_dal = MagicMock() + mock_dal.get_embed_pages = AsyncMock(return_value=None) + mock_dal_class.return_value = mock_dal + + view = EmbedPaginatorView() # No pages + interaction = MagicMock() + interaction.message.id = 999 + interaction.client.db_session = MagicMock() + interaction.client.log = MagicMock() + interaction.response = AsyncMock() + + result = await view._load_from_db(interaction) + + assert result is False + interaction.response.send_message.assert_called_once_with( + "This pagination has expired.", ephemeral=True + ) + + @pytest.mark.asyncio + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_save_current_page(self, mock_dal_class): + """Test _save_current_page updates DB.""" + mock_dal = MagicMock() + mock_dal.update_current_page = AsyncMock() + mock_dal_class.return_value = mock_dal + + pages = self._make_pages(3) + view = EmbedPaginatorView(pages, author_id=42) + view.current_page = 2 + + interaction = MagicMock() + interaction.message.id = 111 + interaction.client.db_session = MagicMock() + interaction.client.log = MagicMock() + + await view._save_current_page(interaction) + + mock_dal.update_current_page.assert_awaited_once_with(111, 2) + + def test_init_no_pages(self): + """Test EmbedPaginatorView with no pages defaults.""" + view = EmbedPaginatorView() + assert view.pages == [] + assert view.author_id == 0 + assert view.page_indicator.label == "0/0" + + +class TestEmbedPagesDal: + """Test EmbedPagesDal class.""" + + @pytest.fixture + def mock_dal(self): + db_session = MagicMock() + log = MagicMock() + with patch("src.database.dal.bot.embed_pages_dal.DBUtilsAsync") as mock_db_utils_class: + mock_db_utils = MagicMock() + mock_db_utils.insert = AsyncMock() + mock_db_utils.execute = AsyncMock() + mock_db_utils.fetchall = AsyncMock(return_value=[]) + mock_db_utils_class.return_value = mock_db_utils + from src.database.dal.bot.embed_pages_dal import EmbedPagesDal + dal = EmbedPagesDal(db_session, log) + dal._mock_db_utils = mock_db_utils + return dal + + @pytest.mark.asyncio + async def test_insert_embed_pages(self, mock_dal): + """Test insert_embed_pages calls db_utils.insert.""" + pages = [{"title": "P1"}] + await mock_dal.insert_embed_pages(111, 222, 42, pages) + mock_dal._mock_db_utils.insert.assert_awaited_once() + + @pytest.mark.asyncio + async def test_get_embed_pages_found(self, mock_dal): + """Test get_embed_pages returns record when found.""" + mock_dal._mock_db_utils.fetchall = AsyncMock(return_value=[{"message_id": 111, "pages": []}]) + result = await mock_dal.get_embed_pages(111) + assert result == {"message_id": 111, "pages": []} + + @pytest.mark.asyncio + async def test_get_embed_pages_not_found(self, mock_dal): + """Test get_embed_pages returns None when not found.""" + mock_dal._mock_db_utils.fetchall = AsyncMock(return_value=[]) + result = await mock_dal.get_embed_pages(999) + assert result is None + + @pytest.mark.asyncio + async def test_update_current_page(self, mock_dal): + """Test update_current_page calls db_utils.execute.""" + await mock_dal.update_current_page(111, 2) + mock_dal._mock_db_utils.execute.assert_awaited_once() + + @pytest.mark.asyncio + async def test_delete_embed_pages(self, mock_dal): + """Test delete_embed_pages calls db_utils.execute.""" + await mock_dal.delete_embed_pages(111) + mock_dal._mock_db_utils.execute.assert_awaited_once() diff --git a/tests/unit/gw2/cogs/test_worlds.py b/tests/unit/gw2/cogs/test_worlds.py index b42d28d..d786aed 100644 --- a/tests/unit/gw2/cogs/test_worlds.py +++ b/tests/unit/gw2/cogs/test_worlds.py @@ -489,7 +489,8 @@ async def test_next_button_advances_page(self): interaction.user.id = 42 interaction.response = AsyncMock() - await view.next_button.callback(interaction) + with patch.object(view, '_save_current_page', new_callable=AsyncMock): + await view.next_button.callback(interaction) assert view.current_page == 1 interaction.response.edit_message.assert_called_once() @@ -507,7 +508,8 @@ async def test_previous_button_goes_back(self): interaction.user.id = 42 interaction.response = AsyncMock() - await view.previous_button.callback(interaction) + with patch.object(view, '_save_current_page', new_callable=AsyncMock): + await view.previous_button.callback(interaction) assert view.current_page == 1 interaction.response.edit_message.assert_called_once() @@ -636,8 +638,13 @@ async def test_sends_single_embed_exactly_25_fields(self, mock_ctx): mock_ctx.send.assert_called_once() @pytest.mark.asyncio - async def test_paginates_when_more_than_25_fields(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_paginates_when_more_than_25_fields(self, mock_dal_class, mock_ctx): """Test that embed with >25 fields sends first page with view.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = self._make_embed_with_fields(30) await _send_paginated_worlds_embed(mock_ctx, embed) mock_ctx.send.assert_called_once() @@ -647,8 +654,13 @@ async def test_paginates_when_more_than_25_fields(self, mock_ctx): assert len(sent_embed.fields) == 25 @pytest.mark.asyncio - async def test_paginated_footer_shows_page_numbers(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_paginated_footer_shows_page_numbers(self, mock_dal_class, mock_ctx): """Test that paginated embeds have correct page footer.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = self._make_embed_with_fields(30) await _send_paginated_worlds_embed(mock_ctx, embed) call_kwargs = mock_ctx.send.call_args[1] @@ -656,8 +668,13 @@ async def test_paginated_footer_shows_page_numbers(self, mock_ctx): assert "Page 1/2" in sent_embed.footer.text @pytest.mark.asyncio - async def test_paginated_view_has_second_page(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_paginated_view_has_second_page(self, mock_dal_class, mock_ctx): """Test that the view contains both pages with correct fields.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = self._make_embed_with_fields(30) await _send_paginated_worlds_embed(mock_ctx, embed) call_kwargs = mock_ctx.send.call_args[1] @@ -677,8 +694,13 @@ async def test_single_page_after_split_sends_without_view(self, mock_ctx): assert "view" not in call_kwargs @pytest.mark.asyncio - async def test_embed_description_preserved_in_pages(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_embed_description_preserved_in_pages(self, mock_dal_class, mock_ctx): """Test that embed description is preserved in paginated pages.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = self._make_embed_with_fields(30, description=gw2_messages.NA_SERVERS_TITLE) await _send_paginated_worlds_embed(mock_ctx, embed) call_kwargs = mock_ctx.send.call_args[1] @@ -686,8 +708,13 @@ async def test_embed_description_preserved_in_pages(self, mock_ctx): assert sent_embed.description == gw2_messages.NA_SERVERS_TITLE @pytest.mark.asyncio - async def test_color_applied_to_all_pages(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_color_applied_to_all_pages(self, mock_dal_class, mock_ctx): """Test that color is applied to paginated pages.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = self._make_embed_with_fields(30) await _send_paginated_worlds_embed(mock_ctx, embed) call_kwargs = mock_ctx.send.call_args[1] @@ -696,8 +723,13 @@ async def test_color_applied_to_all_pages(self, mock_ctx): assert page.color.value == 0x00FF00 @pytest.mark.asyncio - async def test_fields_split_correctly_across_pages(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_fields_split_correctly_across_pages(self, mock_dal_class, mock_ctx): """Test that fields are correctly distributed across pages.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = self._make_embed_with_fields(55) await _send_paginated_worlds_embed(mock_ctx, embed) call_kwargs = mock_ctx.send.call_args[1] @@ -709,8 +741,13 @@ async def test_fields_split_correctly_across_pages(self, mock_ctx): assert "Page 1/3" in view.pages[0].footer.text @pytest.mark.asyncio - async def test_view_message_reference_is_set(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_view_message_reference_is_set(self, mock_dal_class, mock_ctx): """Test that view.message is set to the sent message.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = self._make_embed_with_fields(30) mock_msg = AsyncMock() mock_ctx.send.return_value = mock_msg @@ -720,8 +757,13 @@ async def test_view_message_reference_is_set(self, mock_ctx): assert view.message is mock_msg @pytest.mark.asyncio - async def test_view_author_id_matches_ctx_author(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_view_author_id_matches_ctx_author(self, mock_dal_class, mock_ctx): """Test that view.author_id matches ctx.author.id.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = self._make_embed_with_fields(30) await _send_paginated_worlds_embed(mock_ctx, embed) call_kwargs = mock_ctx.send.call_args[1] @@ -772,8 +814,13 @@ async def test_single_page_after_split_sends_directly(self, mock_ctx): assert len(sent_embed.fields) == 25 @pytest.mark.asyncio - async def test_paginated_26_fields_creates_two_pages(self, mock_ctx): + @patch("src.database.dal.bot.embed_pages_dal.EmbedPagesDal") + async def test_paginated_26_fields_creates_two_pages(self, mock_dal_class, mock_ctx): """Test pagination with 26 fields creates exactly 2 pages.""" + mock_dal = MagicMock() + mock_dal.insert_embed_pages = AsyncMock() + mock_dal_class.return_value = mock_dal + embed = self._make_embed_with_fields(26) await _send_paginated_worlds_embed(mock_ctx, embed) call_kwargs = mock_ctx.send.call_args[1] diff --git a/uv.lock b/uv.lock index eff1a44..201e81a 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -24,42 +24,42 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, ] [[package]] @@ -381,7 +381,7 @@ wheels = [ [[package]] name = "discordbot" -version = "3.0.11" +version = "3.0.12" source = { virtual = "." } dependencies = [ { name = "alembic" },