Add Twitch Live Notifications. Clean up code a bit.

This commit is contained in:
Funky Waddle 2025-06-23 07:56:09 -05:00
parent 4418ccebfc
commit 8abfc48ef5
20 changed files with 363 additions and 19 deletions

View file

@ -1,11 +1,11 @@
import os
import asyncio
import discord
from discord import Client
from libs.Db import Db
from libs.BotLog import BotLog
from libs.Channels import Channel
from libs.Guilds import Guilds
from libs.Channels import Channels
from discord.ext import commands
from dotenv import load_dotenv
@ -14,6 +14,7 @@ token = os.getenv("DISCORD_TOKEN")
prefix = os.getenv("COMMAND_PREFIX")
guild_id = os.getenv("GUILD_ID")
dev_mode = os.getenv("DEV_MODE")
db_conn_str = os.getenv("DB_CONN_STR")
bot_intents = discord.Intents.default()
bot_intents.message_content = True
@ -27,29 +28,36 @@ class FunkyBot(commands.Bot):
intents=bot_intents
)
self.log_handler = BotLog("bot.log")
self.db = Db('sqlite:///data/bot.db', True)
self.db = Db(db_conn_str, True)
self.guild = None
self.guild_id = guild_id
self.dev_mode = dev_mode == "1"
async def on_ready(self):
print(f"Logged in as {self.user.name}")
guild = discord.Object(id=guild_id)
synced = await self.tree.sync(guild=guild)
print(synced)
self.guild = await Guilds().get_guild(self)
await self.tree.sync(guild=self.guild)
async def async_cleanup(self):
guild = await Guilds().get_guild(self)
channel = await Channel().get_channel(guild, "add-roles")
await channel.purge()
await channel.send("FunkyBot is currently sleeping. Please try again once he has awakened.")
channels_to_purge = {
"add-roles": "FunkyBot is currently sleeping. Please try again once he has awakened.",
"live-penguins": "The FunkyBot Twitch Live Notification System is currently offline. Live Alerts will resume later."
}
for channel, message in channels_to_purge.items():
the_channel = await Channels().get_channel(self.guild, channel)
await the_channel.purge()
if message is not None:
await the_channel.send(message)
...
async def close(self):
await self.async_cleanup()
await super().close()
async def on_member_join(self, member):
channel = discord.utils.get(member.guild.channels, name="general")
role = discord.utils.get(await member.guild.fetch_roles(), name="Chinstrap Penguins")
channel = discord.utils.get(self.guild.channels, name="general")
role = discord.utils.get(await self.guild.fetch_roles(), name="Chinstrap Penguins")
if role is not None:
if role not in member.roles:
await member.add_roles(role)

View file

@ -1,7 +1,7 @@
import discord
from discord import Embed
from discord.ext import commands
from libs.Channels import Channel
from libs.Channels import Channels
from libs.Guilds import Guilds
from libs.Cog import Cog
from views.MatrixButtons import MatrixButtons
@ -17,7 +17,7 @@ class RolesCog(Cog):
@commands.Cog.listener()
async def on_ready(self):
guild = await Guilds().get_guild(self.bot)
channel = await Channel().get_channel(guild, "add-roles")
channel = await Channels().get_channel(guild, "add-roles")
matrix_embed = Embed(title="Matrix Roles", color=discord.Color.purple(), description="Please select your choice between these two roles. "
"\nClick to add. Click again to remove. "
"\nAlso, clicking on one of them will remove the other, if you have it. "
@ -27,8 +27,8 @@ class RolesCog(Cog):
if channel is not None:
await channel.purge()
await channel.send(embed=matrix_embed, view=MatrixButtons())
await channel.send(embed=language_embed, view=LanguageButtons())
# await channel.send(embed=matrix_embed, view=MatrixButtons())
# await channel.send(embed=language_embed, view=LanguageButtons())
async def setup(bot):

View file

@ -0,0 +1,83 @@
import discord
import json
from libs.Cog import Cog
from libs.Twitch import Twitch
from libs.Channels import Channels
from libs.Guilds import Guilds
from embeds.TwitchGameNotificationEmbed import TwitchGameNotificationEmbed
from discord.ext import commands, tasks
class TwitchNotificationsCog(Cog):
def __init__(self, bot):
super().__init__(bot)
self.bot = bot
self.guild = None
self.twitch = None
self.online_users = {}
self.config = {}
@commands.Cog.listener()
async def on_ready(self):
await self.load_config()
self.guild = await Guilds().get_guild(self.bot)
self.twitch = Twitch(self.config[self.guild.name]["twitch"]["client_id"], self.config[self.guild.name]["twitch"]["client_secret"])
await self.set_access_token_and_expires_date()
await self.set_notification_channel_and_purge_channel()
await self.write_config()
self.check_twitch_online_streamers.start()
self.check_twitch_access_token.start()
async def load_config(self):
with open("config.json") as config_file:
self.config = json.load(config_file)
async def write_config(self):
with open("config.json", "w") as config_file:
json.dump(self.config, config_file, indent=4)
async def set_notification_channel_and_purge_channel(self):
channel = await Channels().get_channel(self.guild, self.config[self.guild.name]['twitch']['channel_name'])
if self.config[self.guild.name]['twitch']['channel_id'] == 'xxx':
self.config[self.guild.name]["twitch"]["channel_id"] = channel.id
await channel.purge()
async def set_access_token_and_expires_date(self):
access_token, access_token_expires = await self.twitch.get_access_token()
self.config[self.guild.name]["twitch"]["access_token"] = access_token
self.config[self.guild.name]["twitch"]["expire_date"] = access_token_expires
@tasks.loop(seconds=60)
async def check_twitch_access_token(self):
print("Access token is checked")
access_token, access_token_expires = await self.twitch.check_access_token()
self.config[self.guild.name]["twitch"]["access_token"] = access_token
self.config[self.guild.name]["twitch"]["expire_date"] = access_token_expires
await self.write_config()
@tasks.loop(seconds=90)
async def check_twitch_online_streamers(self):
print("Online streamers are checked")
channel = await Channels().get_channel(self.guild, self.config[self.guild.name]["twitch"]["channel_name"])
print(channel)
if not channel:
return
online_notifications, offline_notifications = await self.twitch.get_notifications(self.config[self.guild.name]['twitch']['watchlist'])
print("online_notifications", online_notifications)
print("offline_notifications", offline_notifications)
for username in offline_notifications:
message = await channel.fetch_message(self.online_users[username])
await message.delete()
del self.online_users[username]
for notification in online_notifications:
embed = TwitchGameNotificationEmbed(notification)
message = await channel.send(embed=embed)
self.online_users[notification["user_login"]] = message.id
async def setup(bot):
await bot.add_cog(TwitchNotificationsCog(bot))

26
config.json Normal file
View file

@ -0,0 +1,26 @@
{
"servername": {
"twitch": {
"client_id": "",
"client_secret": "",
"access_token": "",
"channel_id": "xxx",
"channel_name": "xxx",
"expire_date": 1649873640,
"watchlist": []
}
},
"OgmaBotDev": {
"twitch": {
"client_id": "[redacted]",
"client_secret": "[redacted]",
"access_token": "[redacted]",
"channel_id": "[redacted]",
"channel_name": "live-penguins",
"expire_date": "[redacted]",
"watchlist": [
"funkywaddle"
]
}
}
}

View file

View file

@ -0,0 +1,27 @@
from discord import Embed
class TwitchGameNotificationEmbed(Embed):
def __init__(self, stream_data):
super().__init__()
game = stream_data["game_name"]
self.title = stream_data["title"]
self.url = f"https://twitch.tv/{stream_data["user_login"]}"
self.description = f"[Watch](https://twitch.tv/{stream_data["user_login"]})"
self.color = 0x001eff
self.set_author(name=f"{stream_data["user_name"]} Stream is Live",
url=f"https://twitch.tv/{stream_data["user_login"]}")
self.set_image(
url=f"https://static-cdn.jtvnw.net/previews-ttv/live_user_{stream_data["user_login"]}-1920x1080.jpg")
if game == "":
self.add_field(name="Game", value="404: Game not found", inline=True)
else:
self.add_field(name="Game", value=f"{stream_data["game_name"]}", inline=True)
self.add_field(name="Viewers", value=f"{stream_data["viewer_count"]}", inline=True)
tags = stream_data["tags"]
if len(tags) > 3:
tags = tags[:3]
tags.append('...')
self.add_field(name="Tags", value=f"{", ".join(tags)}")

View file

@ -1,8 +1,10 @@
class Channel:
import discord
class Channels:
def __init__(self):
pass
async def get_channel(self, guild, channel_name):
async def get_channel(self, guild: discord.Guild, channel_name):
channel = None
for c in guild.channels:
if c.name == channel_name:

View file

@ -1,8 +1,11 @@
import discord
from discord.ext import commands
class Guilds:
def __init__(self):
pass
async def get_guild(self, bot):
async def get_guild(self, bot: commands.Bot) -> discord.Guild:
guild = None
for g in bot.guilds:
if bot.dev_mode and g.name == 'OgmaBotDev':

84
libs/Twitch.py Normal file
View file

@ -0,0 +1,84 @@
import datetime
from datetime import timedelta
import json
import requests
class Twitch:
def __init__(self, client_id, client_secret):
self.access_token = None
self.access_token_expires = None
self.client_id = client_id
self.client_secret = client_secret
self.online_users = {}
self.config = {}
async def get_unix_time(self):
now = datetime.datetime.now()
future = now + timedelta(weeks=3)
unx_time = future.timestamp()
print("unix time:", unx_time)
return int(unx_time)
async def get_access_token(self):
params = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "client_credentials"
}
response = requests.post("https://id.twitch.tv/oauth2/token", params=params)
print("response:", response.json())
self.access_token = response.json()["access_token"]
self.access_token_expires = await self.get_unix_time()
return self.access_token, self.access_token_expires
async def get_users(self, login_names):
params = {"login": login_names}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Client-Id": self.client_id
}
response = requests.get("https://api.twitch.tv/helix/users", params=params, headers=headers)
return {entry["login"]: entry["id"] for entry in response.json()["data"]}
async def get_notifications(self, watchlist):
users = await self.get_users(watchlist)
streams = await self.get_streams(users)
online_notifications = []
offline_notifications = []
for user_name in watchlist:
if user_name not in self.online_users:
self.online_users[user_name] = None
if user_name not in streams:
if self.online_users[user_name] is not None:
offline_notifications.append(user_name)
self.online_users[user_name] = None
else:
if self.online_users[user_name] is None:
self.online_users[user_name] = datetime.datetime.now(datetime.UTC)
started_at = datetime.datetime.fromisoformat(streams[user_name]["started_at"])
if started_at < self.online_users[user_name]:
online_notifications.append(streams[user_name])
self.online_users[user_name] = started_at
return online_notifications, offline_notifications
async def get_streams(self, users):
params = {"user_id": users.values()}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Client-Id": self.client_id
}
response = requests.get("https://api.twitch.tv/helix/streams", params=params, headers=headers)
return {entry["user_login"]: entry for entry in response.json()["data"]}
async def check_access_token(self):
current_time = datetime.datetime.now().timestamp()
if int(current_time) >= self.access_token_expires:
await self.get_access_token()
return self.access_token, self.access_token_expires

Binary file not shown.

Binary file not shown.

View file

@ -4,7 +4,9 @@ version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.13"
dependencies = [
"datetime==5.5",
"discord-py==2.5.2",
"python-dotenv==1.1.0",
"requests==2.32.4",
"sqlalchemy==2.0.41",
]

View file

@ -1,3 +1,5 @@
datetime
requests
discord.py
python-dotenv~=1.1.0
SQLAlchemy~=2.0.41

107
uv.lock
View file

@ -106,6 +106,50 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/35/be73b6015511aa0173ec595fc579133b797ad532996f2998fd6b8d1bbe6b/audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0", size = 23918, upload-time = "2024-08-04T21:14:42.803Z" },
]
[[package]]
name = "certifi"
version = "2025.6.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
]
[[package]]
name = "datetime"
version = "5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytz" },
{ name = "zope-interface" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/66/e284b9978fede35185e5d18fb3ae855b8f573d8c90a56de5f6d03e8ef99e/DateTime-5.5.tar.gz", hash = "sha256:21ec6331f87a7fcb57bd7c59e8a68bfffe6fcbf5acdbbc7b356d6a9a020191d3", size = 63671, upload-time = "2024-03-21T07:26:50.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/78/8e382b8cb4346119e2e04270b6eb4a01c5ee70b47a8a0244ecdb157204f7/DateTime-5.5-py3-none-any.whl", hash = "sha256:0abf6c51cb4ba7cee775ca46ccc727f3afdde463be28dbbe8803631fefd4a120", size = 52649, upload-time = "2024-03-21T07:26:47.849Z" },
]
[[package]]
name = "discord-py"
version = "2.5.2"
@ -167,15 +211,19 @@ name = "funkybot"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "datetime" },
{ name = "discord-py" },
{ name = "python-dotenv" },
{ name = "requests" },
{ name = "sqlalchemy" },
]
[package.metadata]
requires-dist = [
{ name = "datetime", specifier = "==5.5" },
{ name = "discord-py", specifier = "==2.5.2" },
{ name = "python-dotenv", specifier = "==1.1.0" },
{ name = "requests", specifier = "==2.32.4" },
{ name = "sqlalchemy", specifier = "==2.0.41" },
]
@ -305,6 +353,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" },
]
[[package]]
name = "pytz"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
[[package]]
name = "requests"
version = "2.32.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
]
[[package]]
name = "setuptools"
version = "80.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.41"
@ -335,6 +416,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "yarl"
version = "1.20.1"
@ -382,3 +472,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" },
{ url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" },
]
[[package]]
name = "zope-interface"
version = "7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/93/9210e7606be57a2dfc6277ac97dcc864fd8d39f142ca194fdc186d596fda/zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe", size = 252960, upload-time = "2024-11-28T08:45:39.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/3b/e309d731712c1a1866d61b5356a069dd44e5b01e394b6cb49848fa2efbff/zope.interface-7.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:3e0350b51e88658d5ad126c6a57502b19d5f559f6cb0a628e3dc90442b53dd98", size = 208961, upload-time = "2024-11-28T08:48:29.865Z" },
{ url = "https://files.pythonhosted.org/packages/49/65/78e7cebca6be07c8fc4032bfbb123e500d60efdf7b86727bb8a071992108/zope.interface-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15398c000c094b8855d7d74f4fdc9e73aa02d4d0d5c775acdef98cdb1119768d", size = 209356, upload-time = "2024-11-28T08:48:33.297Z" },
{ url = "https://files.pythonhosted.org/packages/11/b1/627384b745310d082d29e3695db5f5a9188186676912c14b61a78bbc6afe/zope.interface-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:802176a9f99bd8cc276dcd3b8512808716492f6f557c11196d42e26c01a69a4c", size = 264196, upload-time = "2024-11-28T09:18:17.584Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f6/54548df6dc73e30ac6c8a7ff1da73ac9007ba38f866397091d5a82237bd3/zope.interface-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb23f58a446a7f09db85eda09521a498e109f137b85fb278edb2e34841055398", size = 259237, upload-time = "2024-11-28T08:48:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/b6/66/ac05b741c2129fdf668b85631d2268421c5cd1a9ff99be1674371139d665/zope.interface-7.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a71a5b541078d0ebe373a81a3b7e71432c61d12e660f1d67896ca62d9628045b", size = 264696, upload-time = "2024-11-28T08:48:41.161Z" },
{ url = "https://files.pythonhosted.org/packages/0a/2f/1bccc6f4cc882662162a1158cda1a7f616add2ffe322b28c99cb031b4ffc/zope.interface-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:4893395d5dd2ba655c38ceb13014fd65667740f09fa5bb01caa1e6284e48c0cd", size = 212472, upload-time = "2024-11-28T08:49:56.587Z" },
]