From 9893d36be3ba1382717f908c804f085b1cf6f675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Wed, 18 Dec 2024 18:27:46 +0100 Subject: [PATCH 01/29] tests: add base tests --- .pre-commit-config.yaml | 5 +- Makefile | 6 +- pyproject.toml | 17 +- src/huesoporro/actions/store_quote.py | 2 +- src/huesoporro/api/errors.py | 2 +- src/huesoporro/api/main.py | 3 + src/huesoporro/api/routes/api.py | 12 +- src/huesoporro/bot.py | 25 ++- src/huesoporro/infra/authenticator.py | 2 +- src/huesoporro/infra/db.py | 25 ++- src/huesoporro/libs/db.py | 2 +- src/huesoporro/main.py | 4 +- src/huesoporro/models.py | 6 +- src/huesoporro/settings.py | 2 +- src/huesoporro/svc/generate.py | 23 +- src/huesoporro/svc/hello.py | 12 +- src/huesoporro/svc/is_mod.py | 10 +- src/huesoporro/svc/store.py | 1 + src/huesoporro/svc/store_settings.py | 4 +- tests/__init__.py | 0 tests/conftest.py | 56 +++++ tests/test_svc.py | 35 +++ uv.lock | 311 ++++++++++++++------------ 23 files changed, 356 insertions(+), 209 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_svc.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8b115c..6cea421 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,6 @@ repos: - id: mixed-line-ending args: [ --fix=lf ] - - repo: local hooks: @@ -34,7 +33,7 @@ repos: - html - id: ruff-format - name: ruff format + name: uv run ruff format language: system entry: ruff format . exclude: LICENSE|charts @@ -47,7 +46,7 @@ repos: - id: ruff-check name: ruff check language: system - entry: ruff check . --fix --exit-non-zero-on-fix + entry: uv run ruff check . --fix --exit-non-zero-on-fix exclude: LICENSE|charts exclude_types: - markdown diff --git a/Makefile b/Makefile index 9b87ccf..1443b85 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,13 @@ PROJECT_TARGET := "serve" fmt: uvx pre-commit run --all-files --color always +fmt--mypy: + uvx pre-commit run --all-files --color always mypy + + .PHONY: tests tests: - uv run pytest --cov=halig -vv tests --report-log reportlog.json + uv run pytest --cov=src -vv tests uv run coverage html uv run coverage xml diff --git a/pyproject.toml b/pyproject.toml index 543c278..30cd414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,26 +12,24 @@ dependencies = [ "platformdirs>=4.3.6", "pydantic>=2.9.2", "pydantic-settings>=2.6.0", - "pyinstaller>=6.11.0", - "twitchwebsocket>=1.2.1", "loguru>=0.7.2", - "ffmpeg>=1.4", - "ffmpeg-python>=0.2.0", "gtts>=2.5.4", "litestar[standard]>=2.13.0", "httpx>=0.28.0", "caribou>=0.4.1", "aiosqlite>=0.20.0", "pyjwt>=2.10.1", - "huey>=2.5.2", "twitchio>=2.10.0", "redis>=5.2.1", - "pytest>=8.3.4", ] [tool.uv] dev-dependencies = [ "mypy>=1.13.0", + "pytest>=8.3.4", + "pytest-asyncio>=0.25.0", + "ruff>=0.8.3", + "pytest-coverage>=0.0", ] [[tool.mypy.overrides]] @@ -42,6 +40,9 @@ module = [ "nltk.tokenize.destructive", "TwitchWebsocket", "tokenizer", + "caribou.migrate", + "twitchio", + "twitchio.ext", "gtts" ] ignore_missing_imports = true @@ -52,3 +53,7 @@ extend-select = [ "SIM", "PTH", "ERA", "PGH", "PL", "RUF", "FURB", "PERF" ] extend-ignore = ["S101", "ISC002", "COM812", "ISC001"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/src/huesoporro/actions/store_quote.py b/src/huesoporro/actions/store_quote.py index 719c776..2d569a6 100644 --- a/src/huesoporro/actions/store_quote.py +++ b/src/huesoporro/actions/store_quote.py @@ -12,7 +12,7 @@ class StoreQuoteAction(BaseModel): async def run( self, user: User, channel: str, quote: str, author: str, username: str ) -> str: - if not await self.is_mod_svc.run(user=user, username=username): + if not await self.is_mod_svc.run(user=user, username=username, channel=channel): return f"{username} is not a mod and cannot add quotes. Only moderators can add quotes. Sorry!" await self.quote_storer_svc.run(channel, quote, author) return f"«{quote}» added by {author}." diff --git a/src/huesoporro/api/errors.py b/src/huesoporro/api/errors.py index f530ae3..e88a6f0 100644 --- a/src/huesoporro/api/errors.py +++ b/src/huesoporro/api/errors.py @@ -30,7 +30,7 @@ def httpx_status_error_handler(_: Request, exc: httpx.HTTPStatusError): ) -async def after_exception_handler(exc: Exception, scope: "Scope") -> None: +async def after_exception_handler(exc: Exception, scope: "Scope") -> None: # noqa: F821 """Hook function that will be invoked after each exception.""" state = scope["app"].state if not hasattr(state, "error_count"): diff --git a/src/huesoporro/api/main.py b/src/huesoporro/api/main.py index 07dba33..dd0489a 100644 --- a/src/huesoporro/api/main.py +++ b/src/huesoporro/api/main.py @@ -83,3 +83,6 @@ def create_app(): "sbs": Provide(store_chatbot_settings_svc), }, ) + + +app = create_app() diff --git a/src/huesoporro/api/routes/api.py b/src/huesoporro/api/routes/api.py index 9161f28..dff54c2 100644 --- a/src/huesoporro/api/routes/api.py +++ b/src/huesoporro/api/routes/api.py @@ -44,12 +44,15 @@ async def get_tts_permalink(access_token: str) -> Template: ) async def get_index(user: User, gbs: ChatbotSettingsGetterSvc) -> Template: chatbot_settings = await gbs.run(user=user) - return Template(template_name="index.html", context=chatbot_settings.model_dump() if chatbot_settings else {}) + return Template( + template_name="index.html", + context=chatbot_settings.model_dump() if chatbot_settings else {}, + ) @put("/api/v1/bot") async def manage_bot( - user: User, data: ManageBotDTO, gbs: ChatbotSettingsGetterSvc, bm: BotsManager + user: User, data: ManageBotDTO, gbs: ChatbotSettingsGetterSvc, bm: BotsManager ) -> Response: chatbot_settings = await gbs.run(user=user) if data.command == "start": @@ -62,6 +65,7 @@ async def manage_bot( if data.command == "stop" and user.user in bm.bots: await bm.stop_user_bot(user) return Response({"message": "Bot stopped"}) + return Response({"message": "Invalid command"}, status_code=400) @get("/api/v1/bot") @@ -73,14 +77,14 @@ async def get_bot_status(user: User, bm: BotsManager) -> dict: @get("/api/v1/bot/settings") async def get_bot_settings( - user: User, gbs: ChatbotSettingsGetterSvc + user: User, gbs: ChatbotSettingsGetterSvc ) -> ChatbotSettings: return await gbs.run(user=user) @put("/api/v1/bot/settings") async def save_bot_settings( - user: User, data: ChatbotSettings, sbs: ChatbotSettingsStorerSvc + user: User, data: ChatbotSettings, sbs: ChatbotSettingsStorerSvc ) -> dict: await sbs.run(user=user, bot_settings=data) return {"status": "ok"} diff --git a/src/huesoporro/bot.py b/src/huesoporro/bot.py index a842586..82151c0 100644 --- a/src/huesoporro/bot.py +++ b/src/huesoporro/bot.py @@ -31,7 +31,7 @@ class Bot(commands.Bot): ) self.get_random_quote_svc = RandomQuoteGetterSvc(db=db) - + self.cbs = chatbot_settings self.quote_routine = routines.routine( seconds=chatbot_settings.automatic_quote_timer, wait_first=True )(self.send_quote) @@ -43,16 +43,20 @@ class Bot(commands.Bot): logger.info(f"Logged in as {self.nick}") logger.info(f"User id is {self.user_id}") - @commands.command() - async def hello(self, ctx: commands.Context, user: User | None = None): - username = user.name if user else ctx.author.name + @commands.command(aliases=["h"]) + async def hello(self, ctx: commands.Context, username: str | None = None): + username = username or ctx.author.name await ctx.send(self.hello_svc.run(username)) @commands.command(aliases=["g"]) async def generate(self, ctx: commands.Context, *, words: str | None = None): sentence = await self.generate_svc.run(words) - if sentence: - await ctx.send(sentence) + if not sentence: + logger.warning( + f"Could not generate sentence for {words or 'no words provided'}" + ) + return + await ctx.send(sentence) @commands.command(aliases=["qadd"]) async def add_quote(self, ctx: commands.Context, *, quote: str): @@ -91,9 +95,12 @@ class Bot(commands.Bot): await channel.send(sentence) def start_routines(self): - logger.info("Starting routines") - self.quote_routine.start(stop_on_error=False) - self.generation_routine.start(stop_on_error=False) + if self.cbs.automatic_quote_timer > 0: + logger.info("Starting quote routine") + self.quote_routine.start(stop_on_error=False) + if self.cbs.automatic_generation_timer > 0: + logger.info("Starting generation routine") + self.generation_routine.start(stop_on_error=False) def stop_routines(self): logger.info("Stopping routines") diff --git a/src/huesoporro/infra/authenticator.py b/src/huesoporro/infra/authenticator.py index 9bad27c..d46922c 100644 --- a/src/huesoporro/infra/authenticator.py +++ b/src/huesoporro/infra/authenticator.py @@ -26,7 +26,7 @@ class TwitchAuthenticator(BaseModel): headers={"Accept": "application/json"}, ) - if auto_refresh and response.status_code == 401: + if auto_refresh and response.status_code == 401: # noqa: PLR2004 return await self.refresh_token(response.json()["refresh_token"]) response.raise_for_status() diff --git a/src/huesoporro/infra/db.py b/src/huesoporro/infra/db.py index 3bb481c..b2f6f8f 100644 --- a/src/huesoporro/infra/db.py +++ b/src/huesoporro/infra/db.py @@ -2,11 +2,11 @@ import datetime from contextlib import asynccontextmanager import aiosqlite +from loguru import logger from pydantic import BaseModel, Field from src.huesoporro.models import ChatbotSettings, User from src.huesoporro.settings import Settings -from loguru import logger class Database(BaseModel): @@ -27,7 +27,7 @@ class Database(BaseModel): async def save_user(self, user: User, auto_commit=True): async with self.get_client(auto_commit=auto_commit) as db: async with db.execute( - "SELECT * FROM users WHERE user = ?", (user.user,) + "SELECT * FROM users WHERE user = ?", (user.user,) ) as cursor: result = await cursor.fetchone() if result: @@ -62,7 +62,7 @@ class Database(BaseModel): ) async def save_chatbot_settings( - self, user: User, chatbot_settings: ChatbotSettings, auto_commit: bool = True + self, user: User, chatbot_settings: ChatbotSettings, auto_commit: bool = True ): async with self.get_client(auto_commit=auto_commit) as db: current_settings = await self.get_chatbot_settings(user) @@ -109,7 +109,7 @@ class Database(BaseModel): async with self.get_client() as db: db.row_factory = aiosqlite.Row async with db.execute( - "SELECT * FROM settings WHERE user_id = ?", (user.user,) + "SELECT * FROM settings WHERE user_id = ?", (user.user,) ) as cursor: result = await cursor.fetchone() if not result: @@ -124,11 +124,12 @@ class Database(BaseModel): ) await db.commit() - async def get_random_quote(self, channel_name: str): - async with self.get_client() as db: - async with db.execute( - "SELECT quote, author FROM quotes WHERE channel = ? ORDER BY RANDOM() LIMIT 1", - (channel_name,), - ) as cursor: - result = await cursor.fetchone() - return result + async def get_random_quote(self, channel_name: str) -> tuple[str, str] | None: + async with ( + self.get_client() as db, + db.execute( + "SELECT quote, author FROM quotes WHERE channel = ? ORDER BY RANDOM() LIMIT 1", + (channel_name,), + ) as cursor, + ): + return await cursor.fetchone() diff --git a/src/huesoporro/libs/db.py b/src/huesoporro/libs/db.py index a9faba2..ef523ca 100644 --- a/src/huesoporro/libs/db.py +++ b/src/huesoporro/libs/db.py @@ -266,7 +266,7 @@ class Database: ); """) self.add_execute_queue( - f'INSERT INTO MarkovGrammar{first_char}{second_char} SELECT * FROM MarkovGrammar{first_char} WHERE word2 LIKE "{second_char}%";', + f'INSERT INTO MarkovGrammar{first_char}{second_char} SELECT * FROM MarkovGrammar{first_char} WHERE word2 LIKE "{second_char}%";', # noqa: S608 ) self.add_execute_queue( f'DELETE FROM MarkovGrammar{first_char} WHERE word2 LIKE "{second_char}%";', # noqa: S608 diff --git a/src/huesoporro/main.py b/src/huesoporro/main.py index e2616b8..318c67d 100644 --- a/src/huesoporro/main.py +++ b/src/huesoporro/main.py @@ -1,9 +1,7 @@ import uvicorn -from src.huesoporro.api.main import create_app from src.huesoporro.settings import Settings if __name__ == "__main__": settings = Settings.get() - app = create_app() - uvicorn.run(app, host=settings.host, port=settings.port) + uvicorn.run("src.huesoporro.api.main:app", host=settings.host, port=settings.port) diff --git a/src/huesoporro/models.py b/src/huesoporro/models.py index 4680f92..b4df9e9 100644 --- a/src/huesoporro/models.py +++ b/src/huesoporro/models.py @@ -1,7 +1,7 @@ from typing import Self import jwt -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator from src.huesoporro.settings import Settings @@ -36,10 +36,12 @@ class User(BaseModel): class ChatbotSettings(BaseModel): automatic_generation_timer: int = 300 automatic_quote_timer: int = 500 - mods: list[str] | None = None + mods: list[str] = Field(default_factory=list) @property def mods_as_string(self): + if not self.mods: + return "" return ",".join(self.mods) @field_validator("mods", mode="before") diff --git a/src/huesoporro/settings.py b/src/huesoporro/settings.py index 1bc19f9..358abc0 100644 --- a/src/huesoporro/settings.py +++ b/src/huesoporro/settings.py @@ -27,7 +27,7 @@ class Settings(BaseSettings): default_factory=lambda: ["channel:bot", "chat:edit", "chat:read"] ) allowed_users: list[str] | str = Field(default_factory=lambda: ["huesoporro"]) - server_hostname: HttpUrl = "http://localhost:8000" + server_hostname: HttpUrl = "http://localhost:8000" # type: ignore[assignment] @staticmethod @lru_cache(maxsize=1) diff --git a/src/huesoporro/svc/generate.py b/src/huesoporro/svc/generate.py index f6f91f6..0f5058d 100644 --- a/src/huesoporro/svc/generate.py +++ b/src/huesoporro/svc/generate.py @@ -15,18 +15,6 @@ class SentenceGeneratorSvc(BaseModel): sentence_separator: str = " " model_config = ConfigDict(arbitrary_types_allowed=True) - def is_mod(self, username: str, channel: str) -> bool: - """True if the user is a moderator. - - Args: - username (str): The name of the user to check - channel (str): The name of the channel - - Returns: - bool: True if the user is a moderator. - """ - return username in self.s.mods or username == channel - @staticmethod def get_sentence_length(sentences: list[list[str]]) -> int: """Given a list of tokens representing a sentence, return the number of words in there. @@ -63,10 +51,6 @@ class SentenceGeneratorSvc(BaseModel): # e.g. when the first sentence has fewer words than self.min_sentence_length. sentences: list[list | list[str]] = [[]] - # Check for commands or recursion, eg: !generate !generate - if len(params) > 0: - return "You can't make me do commands, you madman!", False - # Get the starting key and starting sentence. # If there is more than 1 param, get the last 2 as the key. # Note that self.key_length is fixed to 2 in this implementation @@ -148,11 +132,12 @@ class SentenceGeneratorSvc(BaseModel): async def run( self, sentence: str | None = None, - ) -> str|None: + ) -> str | None: if sentence: sentence = tokenize(sentence) logger.info(f"Generating sentence from {sentence}") sentence, success = self.generate(sentence) logger.info(f"Generated sentence: {sentence}") - if success: - return sentence + if not success: + return None + return sentence diff --git a/src/huesoporro/svc/hello.py b/src/huesoporro/svc/hello.py index 119095b..19dd38f 100644 --- a/src/huesoporro/svc/hello.py +++ b/src/huesoporro/svc/hello.py @@ -4,7 +4,15 @@ from pydantic import BaseModel, Field class HelloGeneratorSvc(BaseModel): - hellos: list[str] = Field(default_factory=lambda: ["Hola", "Ayo", "Hi", "Bon día"]) + hellos: list[str] = Field( + default_factory=lambda: [ + "Hola", + "Ayo", + "Hi", + "Bon día", + "Hola mi tremendo elemento", + ] + ) def run(self, username: str): - return f"{random.choice(self.hellos)} {username}" + return f"{random.choice(self.hellos)} {username}" # noqa: S311 diff --git a/src/huesoporro/svc/is_mod.py b/src/huesoporro/svc/is_mod.py index 8450448..d15e988 100644 --- a/src/huesoporro/svc/is_mod.py +++ b/src/huesoporro/svc/is_mod.py @@ -7,6 +7,14 @@ from src.huesoporro.models import User class IsModSvc(BaseModel): db: Database - async def run(self, user: User, username: str) -> bool: + async def run(self, user: User, username: str, channel: str) -> bool: + """A user given username is a mod if they're the same as the current channel or if they're in the modlist + available in a user's settings""" + + if channel == username: + return True + chatbot_settings = await self.db.get_chatbot_settings(user=user) + if not chatbot_settings: + return False return username in chatbot_settings.mods diff --git a/src/huesoporro/svc/store.py b/src/huesoporro/svc/store.py index fc45d46..48e3115 100644 --- a/src/huesoporro/svc/store.py +++ b/src/huesoporro/svc/store.py @@ -23,6 +23,7 @@ class SentenceStorerSvc(BaseModel): import nltk nltk.download("punkt") + nltk.download("punkt_tab") logger.debug("Downloaded required punkt resource.") sentences = sent_tokenize(stripped_message) diff --git a/src/huesoporro/svc/store_settings.py b/src/huesoporro/svc/store_settings.py index e912d2d..96ef827 100644 --- a/src/huesoporro/svc/store_settings.py +++ b/src/huesoporro/svc/store_settings.py @@ -7,9 +7,7 @@ from src.huesoporro.models import ChatbotSettings, User class ChatbotSettingsStorerSvc(BaseModel): db: Database - async def run( - self, user: User, bot_settings: ChatbotSettings - ) -> dict[str, str | int | None] | None: + async def run(self, user: User, bot_settings: ChatbotSettings): return await self.db.save_chatbot_settings( user=user, chatbot_settings=bot_settings ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b01710c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,56 @@ +from pathlib import Path + +import pytest +from caribou.migrate import Database as CaribouDatabase +from caribou.migrate import load_migrations + +from src.huesoporro.infra.db import Database +from src.huesoporro.models import ChatbotSettings, TwitchAuth, User +from src.huesoporro.settings import Settings +from src.huesoporro.svc.is_mod import IsModSvc + + +@pytest.fixture +def user() -> User: + return User( + user="huesoporro", + expires_at=1671234567.0, + twitch_auth=TwitchAuth( + access_token="test_access_token", refresh_token="test_refresh_token" + ), + ) + + +@pytest.fixture +def s(tmp_path: Path, user: User) -> Settings: + return Settings( + static_files_path=tmp_path / "static_files", + db_filepath=tmp_path / "huesoporro.db", + twitch_client_id="test_client_id", + twitch_client_secret="test_client_secret", # type: ignore[arg-type] + jwt_secret="test_jwt_secret", # type: ignore[arg-type] + allowed_users=[user.user], + ) + + +@pytest.fixture +def db(s) -> Database: + cdb = CaribouDatabase( + db_url=s.db_filepath, + ) + cdb.initialize_version_control() + migrations = load_migrations(Path(__file__).parents[1] / "migrations") + cdb.upgrade(migrations) + return Database(s=s) + + +@pytest.fixture +def is_mod_svc(db) -> IsModSvc: + return IsModSvc(db=db) + + +@pytest.fixture +async def chatbot_settings(db: Database, user) -> ChatbotSettings: + cbs = ChatbotSettings(mods=[user.user, "allowed_user"]) + await db.save_chatbot_settings(user=user, chatbot_settings=cbs) + return cbs diff --git a/tests/test_svc.py b/tests/test_svc.py new file mode 100644 index 0000000..5a5aacd --- /dev/null +++ b/tests/test_svc.py @@ -0,0 +1,35 @@ +import pytest + +from src.huesoporro.models import ChatbotSettings, User +from src.huesoporro.svc.is_mod import IsModSvc + + +async def test_is_mod_svc_returns_true_for_channel(is_mod_svc: IsModSvc, user: User): + is_mod = await is_mod_svc.run(user=user, username="TestUser", channel="TestUser") + assert is_mod + + +async def test_is_mod_svc_returns_true_for_user_in_modlist( + is_mod_svc: IsModSvc, + user: User, + chatbot_settings: ChatbotSettings, +): + is_mod = await is_mod_svc.run( + user=user, username=chatbot_settings.mods[1], channel=user.user + ) + assert is_mod + + +async def test_is_mod_svc_returns_false_for_settingless_user( + is_mod_svc: IsModSvc, user: User +): + is_mod = await is_mod_svc.run(user=user, username="TestUser", channel="TestUser2") + assert not is_mod + + +@pytest.mark.usefixtures("chatbot_settings") +async def test_is_mod_svc_returns_false_for_user_not_in_modlist( + is_mod_svc: IsModSvc, user: User +): + is_mod = await is_mod_svc.run(user=user, username="TestUser2", channel=user.user) + assert not is_mod diff --git a/uv.lock b/uv.lock index 82f6e01..618cfee 100644 --- a/uv.lock +++ b/uv.lock @@ -96,15 +96,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c4/c93eb22025a2de6b83263dfe3d7df2e19138e345bca6f18dba7394120930/aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6", size = 15564 }, ] -[[package]] -name = "altgraph" -version = "0.17.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/a8/7145824cf0b9e3c28046520480f207df47e927df83aa9555fb47f8505922/altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406", size = 48418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212 }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -239,6 +230,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "coverage" +version = "7.6.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/d2/c25011f4d036cf7e8acbbee07a8e09e9018390aee25ba085596c4b83d510/coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d", size = 801710 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/91/b3dc2f7f38b5cca1236ab6bbb03e84046dd887707b4ec1db2baa47493b3b/coverage-7.6.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:932fc826442132dde42ee52cf66d941f581c685a6313feebed358411238f60f9", size = 207133 }, + { url = "https://files.pythonhosted.org/packages/0d/2b/53fd6cb34d443429a92b3ec737f4953627e38b3bee2a67a3c03425ba8573/coverage-7.6.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:085161be5f3b30fd9b3e7b9a8c301f935c8313dcf928a07b116324abea2c1c2c", size = 207577 }, + { url = "https://files.pythonhosted.org/packages/74/f2/68edb1e6826f980a124f21ea5be0d324180bf11de6fd1defcf9604f76df0/coverage-7.6.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc660a77e1c2bf24ddbce969af9447a9474790160cfb23de6be4fa88e3951c7", size = 239524 }, + { url = "https://files.pythonhosted.org/packages/d3/83/8fec0ee68c2c4a5ab5f0f8527277f84ed6f2bd1310ae8a19d0c5532253ab/coverage-7.6.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c69e42c892c018cd3c8d90da61d845f50a8243062b19d228189b0224150018a9", size = 236925 }, + { url = "https://files.pythonhosted.org/packages/8b/20/8f50e7c7ad271144afbc2c1c6ec5541a8c81773f59352f8db544cad1a0ec/coverage-7.6.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0824a28ec542a0be22f60c6ac36d679e0e262e5353203bea81d44ee81fe9c6d4", size = 238792 }, + { url = "https://files.pythonhosted.org/packages/6f/62/4ac2e5ad9e7a5c9ec351f38947528e11541f1f00e8a0cdce56f1ba7ae301/coverage-7.6.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4401ae5fc52ad8d26d2a5d8a7428b0f0c72431683f8e63e42e70606374c311a1", size = 237682 }, + { url = "https://files.pythonhosted.org/packages/58/2f/9d2203f012f3b0533c73336c74134b608742be1ce475a5c72012573cfbb4/coverage-7.6.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98caba4476a6c8d59ec1eb00c7dd862ba9beca34085642d46ed503cc2d440d4b", size = 236310 }, + { url = "https://files.pythonhosted.org/packages/33/6d/31f6ab0b4f0f781636075f757eb02141ea1b34466d9d1526dbc586ed7078/coverage-7.6.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee5defd1733fd6ec08b168bd4f5387d5b322f45ca9e0e6c817ea6c4cd36313e3", size = 237096 }, + { url = "https://files.pythonhosted.org/packages/7d/fb/e14c38adebbda9ed8b5f7f8e03340ac05d68d27b24397f8d47478927a333/coverage-7.6.9-cp311-cp311-win32.whl", hash = "sha256:f2d1ec60d6d256bdf298cb86b78dd715980828f50c46701abc3b0a2b3f8a0dc0", size = 209682 }, + { url = "https://files.pythonhosted.org/packages/a4/11/a782af39b019066af83fdc0e8825faaccbe9d7b19a803ddb753114b429cc/coverage-7.6.9-cp311-cp311-win_amd64.whl", hash = "sha256:0d59fd927b1f04de57a2ba0137166d31c1a6dd9e764ad4af552912d70428c92b", size = 210542 }, + { url = "https://files.pythonhosted.org/packages/60/52/b16af8989a2daf0f80a88522bd8e8eed90b5fcbdecf02a6888f3e80f6ba7/coverage-7.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8", size = 207325 }, + { url = "https://files.pythonhosted.org/packages/0f/79/6b7826fca8846c1216a113227b9f114ac3e6eacf168b4adcad0cb974aaca/coverage-7.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a", size = 207563 }, + { url = "https://files.pythonhosted.org/packages/a7/07/0bc73da0ccaf45d0d64ef86d33b7d7fdeef84b4c44bf6b85fb12c215c5a6/coverage-7.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015", size = 240580 }, + { url = "https://files.pythonhosted.org/packages/71/8a/9761f409910961647d892454687cedbaccb99aae828f49486734a82ede6e/coverage-7.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3", size = 237613 }, + { url = "https://files.pythonhosted.org/packages/8b/10/ee7d696a17ac94f32f2dbda1e17e730bf798ae9931aec1fc01c1944cd4de/coverage-7.6.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae", size = 239684 }, + { url = "https://files.pythonhosted.org/packages/16/60/aa1066040d3c52fff051243c2d6ccda264da72dc6d199d047624d395b2b2/coverage-7.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4", size = 239112 }, + { url = "https://files.pythonhosted.org/packages/4e/e5/69f35344c6f932ba9028bf168d14a79fedb0dd4849b796d43c81ce75a3c9/coverage-7.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6", size = 237428 }, + { url = "https://files.pythonhosted.org/packages/32/20/adc895523c4a28f63441b8ac645abd74f9bdd499d2d175bef5b41fc7f92d/coverage-7.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f", size = 239098 }, + { url = "https://files.pythonhosted.org/packages/a9/a6/e0e74230c9bb3549ec8ffc137cfd16ea5d56e993d6bffed2218bff6187e3/coverage-7.6.9-cp312-cp312-win32.whl", hash = "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692", size = 209940 }, + { url = "https://files.pythonhosted.org/packages/3e/18/cb5b88349d4aa2f41ec78d65f92ea32572b30b3f55bc2b70e87578b8f434/coverage-7.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97", size = 210726 }, + { url = "https://files.pythonhosted.org/packages/35/26/9abab6539d2191dbda2ce8c97b67d74cbfc966cc5b25abb880ffc7c459bc/coverage-7.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664", size = 207356 }, + { url = "https://files.pythonhosted.org/packages/44/da/d49f19402240c93453f606e660a6676a2a1fbbaa6870cc23207790aa9697/coverage-7.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c", size = 207614 }, + { url = "https://files.pythonhosted.org/packages/da/e6/93bb9bf85497816082ec8da6124c25efa2052bd4c887dd3b317b91990c9e/coverage-7.6.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014", size = 240129 }, + { url = "https://files.pythonhosted.org/packages/df/65/6a824b9406fe066835c1274a9949e06f084d3e605eb1a602727a27ec2fe3/coverage-7.6.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00", size = 237276 }, + { url = "https://files.pythonhosted.org/packages/9f/79/6c7a800913a9dd23ac8c8da133ebb556771a5a3d4df36b46767b1baffd35/coverage-7.6.9-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d", size = 239267 }, + { url = "https://files.pythonhosted.org/packages/57/e7/834d530293fdc8a63ba8ff70033d5182022e569eceb9aec7fc716b678a39/coverage-7.6.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a", size = 238887 }, + { url = "https://files.pythonhosted.org/packages/15/05/ec9d6080852984f7163c96984444e7cd98b338fd045b191064f943ee1c08/coverage-7.6.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077", size = 236970 }, + { url = "https://files.pythonhosted.org/packages/0a/d8/775937670b93156aec29f694ce37f56214ed7597e1a75b4083ee4c32121c/coverage-7.6.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb", size = 238831 }, + { url = "https://files.pythonhosted.org/packages/f4/58/88551cb7fdd5ec98cb6044e8814e38583436b14040a5ece15349c44c8f7c/coverage-7.6.9-cp313-cp313-win32.whl", hash = "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba", size = 210000 }, + { url = "https://files.pythonhosted.org/packages/b7/12/cfbf49b95120872785ff8d56ab1c7fe3970a65e35010c311d7dd35c5fd00/coverage-7.6.9-cp313-cp313-win_amd64.whl", hash = "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1", size = 210753 }, + { url = "https://files.pythonhosted.org/packages/7c/68/c1cb31445599b04bde21cbbaa6d21b47c5823cdfef99eae470dfce49c35a/coverage-7.6.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419", size = 208091 }, + { url = "https://files.pythonhosted.org/packages/11/73/84b02c6b19c4a11eb2d5b5eabe926fb26c21c080e0852f5e5a4f01165f9e/coverage-7.6.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a", size = 208369 }, + { url = "https://files.pythonhosted.org/packages/de/e0/ae5d878b72ff26df2e994a5c5b1c1f6a7507d976b23beecb1ed4c85411ef/coverage-7.6.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4", size = 251089 }, + { url = "https://files.pythonhosted.org/packages/ab/9c/0aaac011aef95a93ef3cb2fba3fde30bc7e68a6635199ed469b1f5ea355a/coverage-7.6.9-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae", size = 246806 }, + { url = "https://files.pythonhosted.org/packages/f8/19/4d5d3ae66938a7dcb2f58cef3fa5386f838f469575b0bb568c8cc9e3a33d/coverage-7.6.9-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030", size = 249164 }, + { url = "https://files.pythonhosted.org/packages/b3/0b/4ee8a7821f682af9ad440ae3c1e379da89a998883271f088102d7ca2473d/coverage-7.6.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be", size = 248642 }, + { url = "https://files.pythonhosted.org/packages/8a/12/36ff1d52be18a16b4700f561852e7afd8df56363a5edcfb04cf26a0e19e0/coverage-7.6.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e", size = 246516 }, + { url = "https://files.pythonhosted.org/packages/43/d0/8e258f6c3a527c1655602f4f576215e055ac704de2d101710a71a2affac2/coverage-7.6.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9", size = 247783 }, + { url = "https://files.pythonhosted.org/packages/a9/0d/1e4a48d289429d38aae3babdfcadbf35ca36bdcf3efc8f09b550a845bdb5/coverage-7.6.9-cp313-cp313t-win32.whl", hash = "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b", size = 210646 }, + { url = "https://files.pythonhosted.org/packages/26/74/b0729f196f328ac55e42b1e22ec2f16d8bcafe4b8158a26ec9f1cdd1d93e/coverage-7.6.9-cp313-cp313t-win_amd64.whl", hash = "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611", size = 211815 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "editorconfig" version = "0.12.4" @@ -281,24 +325,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/4b/07fe4d7b5c458bdde9b0bfd8e8cb5762341af6c9727b43c2331c0cb0dbc3/fast_query_parsers-1.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:21ae5f3a209aee7d3b84bdcdb33dd79f39fc8cb608b3ae8cfcb78123758c1a16", size = 689717 }, ] -[[package]] -name = "ffmpeg" -version = "1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/cc/3b7408b8ecf7c1d20ad480c3eaed7619857bf1054b690226e906fdf14258/ffmpeg-1.4.tar.gz", hash = "sha256:6931692c890ff21d39938433c2189747815dca0c60ddc7f9bb97f199dba0b5b9", size = 5055 } - -[[package]] -name = "ffmpeg-python" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "future" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dd/5e/d5f9105d59c1325759d838af4e973695081fbbc97182baf73afc78dec266/ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127", size = 21543 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/0c/56be52741f75bad4dc6555991fabd2e07b432d333da82c11ad701123888a/ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5", size = 25024 }, -] - [[package]] name = "frozenlist" version = "1.5.0" @@ -353,15 +379,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, ] -[[package]] -name = "future" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326 }, -] - [[package]] name = "gtts" version = "2.5.4" @@ -448,61 +465,53 @@ source = { virtual = "." } dependencies = [ { name = "aiosqlite" }, { name = "caribou" }, - { name = "ffmpeg" }, - { name = "ffmpeg-python" }, { name = "gtts" }, { name = "httpx" }, - { name = "huey" }, { name = "litestar", extra = ["standard"] }, { name = "loguru" }, { name = "nltk" }, { name = "platformdirs" }, { name = "pydantic" }, { name = "pydantic-settings" }, - { name = "pyinstaller" }, { name = "pyjwt" }, - { name = "pytest" }, { name = "redis" }, { name = "twitchio" }, - { name = "twitchwebsocket" }, ] [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-coverage" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "caribou", specifier = ">=0.4.1" }, - { name = "ffmpeg", specifier = ">=1.4" }, - { name = "ffmpeg-python", specifier = ">=0.2.0" }, { name = "gtts", specifier = ">=2.5.4" }, { name = "httpx", specifier = ">=0.28.0" }, - { name = "huey", specifier = ">=2.5.2" }, { name = "litestar", extras = ["standard"], specifier = ">=2.13.0" }, { name = "loguru", specifier = ">=0.7.2" }, { name = "nltk", specifier = ">=3.9.1" }, { name = "platformdirs", specifier = ">=4.3.6" }, { name = "pydantic", specifier = ">=2.9.2" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, - { name = "pyinstaller", specifier = ">=6.11.0" }, { name = "pyjwt", specifier = ">=2.10.1" }, - { name = "pytest", specifier = ">=8.3.4" }, { name = "redis", specifier = ">=5.2.1" }, { name = "twitchio", specifier = ">=2.10.0" }, - { name = "twitchwebsocket", specifier = ">=1.2.1" }, ] [package.metadata.requires-dev] -dev = [{ name = "mypy", specifier = ">=1.13.0" }] - -[[package]] -name = "huey" -version = "2.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/fe/2e063984cdd512aa71e9c9c2a9200b58a830c532d25ca2c6cbc8e44bf7b7/huey-2.5.2.tar.gz", hash = "sha256:df33db474c05414ed40ee2110e9df692369871734da22d74ffb035a4bd74047f", size = 889357 } +dev = [ + { name = "mypy", specifier = ">=1.13.0" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", specifier = ">=0.25.0" }, + { name = "pytest-coverage", specifier = ">=0.0" }, + { name = "ruff", specifier = ">=0.8.3" }, +] [[package]] name = "idna" @@ -615,18 +624,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 }, ] -[[package]] -name = "macholib" -version = "1.16.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altgraph" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/ee/af1a3842bdd5902ce133bd246eb7ffd4375c38642aeb5dc0ae3a0329dfa2/macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30", size = 59309 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094 }, -] - [[package]] name = "markdown-it-py" version = "3.0.0" @@ -833,15 +830,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] -[[package]] -name = "pefile" -version = "2023.2.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791 }, -] - [[package]] name = "platformdirs" version = "4.3.6" @@ -1019,47 +1007,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, ] -[[package]] -name = "pyinstaller" -version = "6.11.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altgraph" }, - { name = "macholib", marker = "sys_platform == 'darwin'" }, - { name = "packaging" }, - { name = "pefile", marker = "sys_platform == 'win32'" }, - { name = "pyinstaller-hooks-contrib" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/d4/54f5f5c73b803e6256ea97ffc6ba8a305d9a5f57f85f9b00b282512bf18a/pyinstaller-6.11.1.tar.gz", hash = "sha256:491dfb4d9d5d1d9650d9507daec1ff6829527a254d8e396badd60a0affcb72ef", size = 4249772 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/15/b0f1c0985ee32fcd2f6ad9a486ef94e4db3fef9af025a3655e76cb708009/pyinstaller-6.11.1-py3-none-macosx_10_13_universal2.whl", hash = "sha256:44e36172de326af6d4e7663b12f71dbd34e2e3e02233e181e457394423daaf03", size = 991780 }, - { url = "https://files.pythonhosted.org/packages/fd/0f/9f54cb18abe2b1d89051bc9214c0cb40d7b5f4049c151c315dacc067f4a2/pyinstaller-6.11.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:6d12c45a29add78039066a53fb05967afaa09a672426072b13816fe7676abfc4", size = 711739 }, - { url = "https://files.pythonhosted.org/packages/32/f7/79d10830780eff8339bfa793eece1df4b2459e35a712fc81983e8536cc29/pyinstaller-6.11.1-py3-none-manylinux2014_i686.whl", hash = "sha256:ddc0fddd75f07f7e423da1f0822e389a42af011f9589e0269b87e0d89aa48c1f", size = 714053 }, - { url = "https://files.pythonhosted.org/packages/25/f7/9961ef02cdbd2dbb1b1a215292656bd0ea72a83aafd8fb6373513849711e/pyinstaller-6.11.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:0d6475559c4939f0735122989611d7f739ed3bf02f666ce31022928f7a7e4fda", size = 719133 }, - { url = "https://files.pythonhosted.org/packages/6f/4d/7f854842a1ce798de762a0b0bc5d5a4fc26ad06164a98575dc3c54abed1f/pyinstaller-6.11.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:e21c7806e34f40181e7606926a14579f848bfb1dc52cbca7eea66eccccbfe977", size = 709591 }, - { url = "https://files.pythonhosted.org/packages/7f/e0/00d29fc90c3ba50620c61554e26ebb4d764569507be7cd1c8794aa696f9a/pyinstaller-6.11.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:32c742a24fe65d0702958fadf4040f76de85859c26bec0008766e5dbabc5b68f", size = 710068 }, - { url = "https://files.pythonhosted.org/packages/3e/57/d14b44a69f068d2caaee49d15e45f9fa0f37c6a2d2ad778c953c1722a1ca/pyinstaller-6.11.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:208c0ef6dab0837a0a273ea32d1a3619a208e3d1fe3fec3785eea71a77fd00ce", size = 714439 }, - { url = "https://files.pythonhosted.org/packages/88/01/256824bb57ca208099c86c2fb289f888ca7732580e91ced48fa14e5903b2/pyinstaller-6.11.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ad84abf465bcda363c1d54eafa76745d77b6a8a713778348377dc98d12a452f7", size = 710457 }, - { url = "https://files.pythonhosted.org/packages/7c/f0/98c9138f5f0ff17462f1ad6d712dcfa643b9a283d6238d464d8145bc139d/pyinstaller-6.11.1-py3-none-win32.whl", hash = "sha256:2e8365276c5131c9bef98e358fbc305e4022db8bedc9df479629d6414021956a", size = 1280261 }, - { url = "https://files.pythonhosted.org/packages/7d/08/f43080614b3e8bce481d4dfd580e579497c7dcdaf87656d9d2ad912e5796/pyinstaller-6.11.1-py3-none-win_amd64.whl", hash = "sha256:7ac83c0dc0e04357dab98c487e74ad2adb30e7eb186b58157a8faf46f1fa796f", size = 1340482 }, - { url = "https://files.pythonhosted.org/packages/ed/56/953c6594cb66e249563854c9cc04ac5a055c6c99d1614298feeaeaa9b87e/pyinstaller-6.11.1-py3-none-win_arm64.whl", hash = "sha256:35e6b8077d240600bb309ed68bb0b1453fd2b7ab740b66d000db7abae6244423", size = 1267519 }, -] - -[[package]] -name = "pyinstaller-hooks-contrib" -version = "2024.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/73/6a/9d0057e312b85fbd17a79e1c1955d115fd9bbc78b85bab757777c8ef2307/pyinstaller_hooks_contrib-2024.10.tar.gz", hash = "sha256:8a46655e5c5b0186b5e527399118a9b342f10513eb1425c483fa4f6d02e8800c", size = 140592 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/64/445861ee7a5fd32874c0f6cfe8222aacc8feda22539332e0d8ff50dadec6/pyinstaller_hooks_contrib-2024.10-py3-none-any.whl", hash = "sha256:ad47db0e153683b4151e10d231cb91f2d93c85079e78d76d9e0f57ac6c8a5e10", size = 338417 }, -] - [[package]] name = "pyjwt" version = "2.10.1" @@ -1084,6 +1031,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, ] +[[package]] +name = "pytest-asyncio" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/18/82fcb4ee47d66d99f6cd1efc0b11b2a25029f303c599a5afda7c1bca4254/pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609", size = 53298 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/56/2ee0cab25c11d4e38738a2a98c645a8f002e2ecf7b5ed774c70d53b92bb1/pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3", size = 19245 }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, +] + +[[package]] +name = "pytest-cover" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest-cov" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/27/20964101a7cdb260f8d6c4e854659026968321d10c90552b1fe7f6c5f913/pytest-cover-3.0.0.tar.gz", hash = "sha256:5bdb6c1cc3dd75583bb7bc2c57f5e1034a1bfcb79d27c71aceb0b16af981dbf4", size = 3211 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/9b/7b4700c462628e169bd859c6368d596a6aedc87936bde733bead9f875fce/pytest_cover-3.0.0-py2.py3-none-any.whl", hash = "sha256:578249955eb3b5f3991209df6e532bb770b647743b7392d3d97698dc02f39ebb", size = 3769 }, +] + +[[package]] +name = "pytest-coverage" +version = "0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest-cover" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/81/1d954849aed17b254d1c397eb4447a05eedce612a56b627c071df2ce00c1/pytest-coverage-0.0.tar.gz", hash = "sha256:db6af2cbd7e458c7c9fd2b4207cee75258243c8a81cad31a7ee8cfad5be93c05", size = 873 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/4b/d95b052f87db89a2383233c0754c45f6d3b427b7a4bcb771ac9316a6fae1/pytest_coverage-0.0-py2.py3-none-any.whl", hash = "sha256:dedd084c5e74d8e669355325916dc011539b190355021b037242514dee546368", size = 2013 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1105,15 +1101,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, ] -[[package]] -name = "pywin32-ctypes" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -1257,12 +1244,28 @@ wheels = [ ] [[package]] -name = "setuptools" -version = "75.6.0" +name = "ruff" +version = "0.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/54/292f26c208734e9a7f067aea4a7e282c080750c4546559b58e2e45413ca0/setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6", size = 1337429 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/5e/683c7ef7a696923223e7d95ca06755d6e2acbc5fd8382b2912a28008137c/ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3", size = 3378522 } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/21/47d163f615df1d30c094f6c8bbb353619274edccf0327b185cc2493c2c33/setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d", size = 1224032 }, + { url = "https://files.pythonhosted.org/packages/f8/c4/bfdbb8b9c419ff3b52479af8581026eeaac3764946fdb463dec043441b7d/ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6", size = 10535860 }, + { url = "https://files.pythonhosted.org/packages/ef/c5/0aabdc9314b4b6f051168ac45227e2aa8e1c6d82718a547455e40c9c9faa/ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939", size = 10346327 }, + { url = "https://files.pythonhosted.org/packages/1a/78/4843a59e7e7b398d6019cf91ab06502fd95397b99b2b858798fbab9151f5/ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d", size = 9942585 }, + { url = "https://files.pythonhosted.org/packages/91/5a/642ed8f1ba23ffc2dd347697e01eef3c42fad6ac76603be4a8c3a9d6311e/ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13", size = 10797597 }, + { url = "https://files.pythonhosted.org/packages/30/25/2e654bc7226da09a49730a1a2ea6e89f843b362db80b4b2a7a4f948ac986/ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18", size = 10307244 }, + { url = "https://files.pythonhosted.org/packages/c0/2d/a224d56bcd4383583db53c2b8f410ebf1200866984aa6eb9b5a70f04e71f/ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502", size = 11362439 }, + { url = "https://files.pythonhosted.org/packages/82/01/03e2857f9c371b8767d3e909f06a33bbdac880df17f17f93d6f6951c3381/ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d", size = 12078538 }, + { url = "https://files.pythonhosted.org/packages/af/ae/ff7f97b355da16d748ceec50e1604a8215d3659b36b38025a922e0612e9b/ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82", size = 11616172 }, + { url = "https://files.pythonhosted.org/packages/6a/d0/6156d4d1e53ebd17747049afe801c5d7e3014d9b2f398b9236fe36ba4320/ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452", size = 12919886 }, + { url = "https://files.pythonhosted.org/packages/4e/84/affcb30bacb94f6036a128ad5de0e29f543d3f67ee42b490b17d68e44b8a/ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd", size = 11212599 }, + { url = "https://files.pythonhosted.org/packages/60/b9/5694716bdefd8f73df7c0104334156c38fb0f77673d2966a5a1345bab94d/ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20", size = 10784637 }, + { url = "https://files.pythonhosted.org/packages/24/7e/0e8f835103ac7da81c3663eedf79dec8359e9ae9a3b0d704bae50be59176/ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc", size = 10390591 }, + { url = "https://files.pythonhosted.org/packages/27/da/180ec771fc01c004045962ce017ca419a0281f4bfaf867ed0020f555b56e/ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060", size = 10894298 }, + { url = "https://files.pythonhosted.org/packages/6d/f8/29f241742ed3954eb2222314b02db29f531a15cab3238d1295e8657c5f18/ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea", size = 11275965 }, + { url = "https://files.pythonhosted.org/packages/79/e9/5b81dc9afc8a80884405b230b9429efeef76d04caead904bd213f453b973/ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964", size = 8807651 }, + { url = "https://files.pythonhosted.org/packages/ea/67/7291461066007617b59a707887b90e319b6a043c79b4d19979f86b7a20e7/ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9", size = 9625289 }, + { url = "https://files.pythonhosted.org/packages/03/8f/e4fa95288b81233356d9a9dcaed057e5b0adc6399aa8fd0f6d784041c9c3/ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936", size = 9078754 }, ] [[package]] @@ -1283,6 +1286,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1309,15 +1351,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/be/49d93b0e13dad69a636e550a7b96a5208af9a91100f9b142a363882e0c4c/twitchio-2.10.0-py3-none-any.whl", hash = "sha256:7aa0b6950dad90feeb04b03fd10d3e4292fa8a7c2e7aea6b2fd6686bc5425fb2", size = 143761 }, ] -[[package]] -name = "twitchwebsocket" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/28/06/89e4ff964a7c9ca9e098d9cf9dd4fb09b3e32c5578d51f0ab27f0164bd80/TwitchWebsocket-1.2.1.tar.gz", hash = "sha256:b43d6981a691468ee49eff261d9120a75f2a4d895fabeb9813910e0b32742cef", size = 15029 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/d8/4dcd312dd333f1e0664afb9a91672a684d188eb2dc18c1e6deb4901364d7/TwitchWebsocket-1.2.1-py2.py3-none-any.whl", hash = "sha256:f24a12b7bf68d9e348abeb317b63710813b44e8aadbebacdfd1077a8e5bcdfbd", size = 11897 }, -] - [[package]] name = "typing-extensions" version = "4.12.2" From 2cad170eb3f8ad07b488539ec71fef5dd97ef461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Wed, 18 Dec 2024 18:37:55 +0100 Subject: [PATCH 02/29] chore: update to v0.2.3 --- charts/huesoporro/Chart.yaml | 4 ++-- charts/huesoporro/values.yaml | 4 ++-- pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/charts/huesoporro/Chart.yaml b/charts/huesoporro/Chart.yaml index ac75742..1c6576a 100644 --- a/charts/huesoporro/Chart.yaml +++ b/charts/huesoporro/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.2.2 +version: 0.2.3 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.2.2" +appVersion: "0.2.3" diff --git a/charts/huesoporro/values.yaml b/charts/huesoporro/values.yaml index ffa36f2..ae50d59 100644 --- a/charts/huesoporro/values.yaml +++ b/charts/huesoporro/values.yaml @@ -11,7 +11,7 @@ image: # This sets the pull policy for images. pullPolicy: Always # Overrides the image tag whose default is the chart appVersion. - tag: "0.2.2" + tag: "0.2.3" # This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ imagePullSecrets: [] @@ -135,4 +135,4 @@ persistence: annotations: {} secret: - existingSecretName: huesoporro-secrets \ No newline at end of file + existingSecretName: huesoporro-secrets diff --git a/pyproject.toml b/pyproject.toml index 30cd414..9d19c3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "huesoporro" -version = "0.2.2" +version = "0.2.3" description = "Misc Twitch bots" readme = "README.md" authors = [ From 3bc4e19de1cb5b30377f0da2c799645093dfc317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Thu, 19 Dec 2024 18:13:38 +0100 Subject: [PATCH 03/29] feat: add backoff service and some message reactions --- pyproject.toml | 2 +- src/huesoporro/api/errors.py | 2 +- src/huesoporro/api/routes/api.py | 18 ++- src/huesoporro/bot.py | 172 ++++++++++++++++++++++++- src/huesoporro/svc/backoff_service.py | 111 ++++++++++++++++ src/huesoporro/svc/generate.py | 12 +- src/huesoporro/svc/get_random_quote.py | 2 +- src/huesoporro/svc/hello.py | 4 +- tests/conftest.py | 32 ++++- tests/test_svc.py | 61 +++++++++ uv.lock | 2 +- 11 files changed, 394 insertions(+), 24 deletions(-) create mode 100644 src/huesoporro/svc/backoff_service.py diff --git a/pyproject.toml b/pyproject.toml index 9d19c3b..9333e7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ extend-select = [ "W", "C90", "I", "N", "UP", "S", "BLE", "B", "A", "COM", "C4", "DTZ", "T10", "EM", "ISC", "T20", "PT", "RSE", "RET", "SIM", "PTH", "ERA", "PGH", "PL", "RUF", "FURB", "PERF" ] -extend-ignore = ["S101", "ISC002", "COM812", "ISC001"] +extend-ignore = ["S101", "ISC002", "COM812", "ISC001", "EM101", "EM102"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/src/huesoporro/api/errors.py b/src/huesoporro/api/errors.py index e88a6f0..19426df 100644 --- a/src/huesoporro/api/errors.py +++ b/src/huesoporro/api/errors.py @@ -30,7 +30,7 @@ def httpx_status_error_handler(_: Request, exc: httpx.HTTPStatusError): ) -async def after_exception_handler(exc: Exception, scope: "Scope") -> None: # noqa: F821 +async def after_exception_handler(exc: Exception, scope: "Scope") -> None: # type: ignore[name-defined] # noqa: F821 """Hook function that will be invoked after each exception.""" state = scope["app"].state if not hasattr(state, "error_count"): diff --git a/src/huesoporro/api/routes/api.py b/src/huesoporro/api/routes/api.py index dff54c2..d427cde 100644 --- a/src/huesoporro/api/routes/api.py +++ b/src/huesoporro/api/routes/api.py @@ -52,13 +52,20 @@ async def get_index(user: User, gbs: ChatbotSettingsGetterSvc) -> Template: @put("/api/v1/bot") async def manage_bot( - user: User, data: ManageBotDTO, gbs: ChatbotSettingsGetterSvc, bm: BotsManager + user: User, + data: ManageBotDTO, + gbs: ChatbotSettingsGetterSvc, + sbs: ChatbotSettingsStorerSvc, + bm: BotsManager, ) -> Response: chatbot_settings = await gbs.run(user=user) + if not chatbot_settings: + await sbs.run(user=user, bot_settings=ChatbotSettings()) + chatbot_settings = await gbs.run(user=user) if data.command == "start": if not data.channel_name: return Response({"message": "Channel name is required"}, status_code=400) - bm.add_bot(user, data.channel_name, chatbot_settings=chatbot_settings) + bm.add_bot(user, data.channel_name, chatbot_settings=chatbot_settings) # type: ignore[arg-type] if user.user in bm.bots: await bm.run_user_bot(user) return Response({"message": "Bot started"}) @@ -78,8 +85,11 @@ async def get_bot_status(user: User, bm: BotsManager) -> dict: @get("/api/v1/bot/settings") async def get_bot_settings( user: User, gbs: ChatbotSettingsGetterSvc -) -> ChatbotSettings: - return await gbs.run(user=user) +) -> ChatbotSettings | dict: + cbs = await gbs.run(user=user) + if not cbs: + return {"status": "Not found"} + return cbs @put("/api/v1/bot/settings") diff --git a/src/huesoporro/bot.py b/src/huesoporro/bot.py index 82151c0..fae650e 100644 --- a/src/huesoporro/bot.py +++ b/src/huesoporro/bot.py @@ -1,4 +1,7 @@ import asyncio +import random +from collections.abc import Callable +from enum import StrEnum from loguru import logger from twitchio import Channel @@ -8,6 +11,7 @@ from src.huesoporro.actions.store_quote import StoreQuoteAction from src.huesoporro.infra.db import Database from src.huesoporro.libs.db import Database as MarkovDB from src.huesoporro.models import ChatbotSettings, User +from src.huesoporro.svc.backoff_service import BackoffService from src.huesoporro.svc.generate import SentenceGeneratorSvc from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc from src.huesoporro.svc.hello import HelloGeneratorSvc @@ -75,16 +79,18 @@ class Bot(commands.Bot): @commands.command(aliases=["q", "quote"]) async def get_random_quote(self, ctx: commands.Context): quote = await self.get_random_quote_svc.run(channel_name=self.channel) - await ctx.send(f"«{quote[0]}» - {quote[1]}") + if quote: + await ctx.send(f"«{quote[0]}» - {quote[1]}") def get_channel_conn(self) -> Channel: return Channel(name=self.channel, websocket=self._connection) async def send_quote(self): quote = await self.get_random_quote_svc.run(channel_name=self.channel) - channel = self.get_channel_conn() - logger.info(f"Sending random quote {quote[0]}") - await channel.send(f"«{quote[0]}» - {quote[1]}") + if quote: + channel = self.get_channel_conn() + logger.info(f"Sending random quote {quote[0]}") + await channel.send(f"«{quote[0]}» - {quote[1]}") async def send_generation(self): sentence = await self.generate_svc.run() @@ -108,14 +114,15 @@ class Bot(commands.Bot): self.generation_routine.cancel() -class SaveMessagesCog(commands.Cog): +class SaveMessagesCog2(commands.Cog): def __init__(self, bot): self.bot = bot self.store_svc = SentenceStorerSvc(db=MarkovDB(channel=bot.channel)) + self.hello_svc = HelloGeneratorSvc() + self.backoff_svc = BackoffService() @commands.Cog.event() async def event_message(self, message): - # An event inside a cog! content = message.content if content.startswith("!"): return @@ -125,6 +132,159 @@ class SaveMessagesCog(commands.Cog): await self.store_svc.run(content) + if message.content in ["hola", "HOLA", "hiii", "ayo"]: + hello_message = self.hello_svc.run(message.author.name) + await message.channel.send(hello_message) + return + + if message.content == "Yes": + await message.channel.send("Indeed") + return + + if message.content.startswith("WHAT"): + await message.channel.send("WHAT Ramon") + return + + laughs_messages = [ + "om", + "KEK", + "LuL", + "LUL", + "OMEGALUL", + "kek", + "keking", + "KEKW", + "OMEGADANCEBUTFAST", + ] + + if message.content in laughs_messages: + await message.channel.send(random.choice(laughs_messages)) # noqa: S311 + return + + +class MessageType(StrEnum): + COMMAND = "COMMAND" + HELLO = "HELLO" + YES = "YES" + WHAT = "WHAT" + LAUGH = "LAUGH" + OTHER = "OTHER" + + +class MessageHandler: + """Handles different types of messages with their corresponding responses""" + + def __init__(self, channel_send_func: Callable): + self.hello_patterns = ["hola", "HOLA", "hiii", "ayo"] + self.laugh_patterns = [ + "om", + "KEK", + "LuL", + "LUL", + "OMEGALUL", + "kek", + "keking", + "KEKW", + "OMEGADANCEBUTFAST", + ] + self.send = channel_send_func + + def get_message_type(self, content: str) -> MessageType: + """Determines the type of message based on its content""" + if content.startswith("!"): + return MessageType.COMMAND + if content in self.hello_patterns: + return MessageType.HELLO + if content == "Yes": + return MessageType.YES + if content.startswith("WHAT"): + return MessageType.WHAT + if content in self.laugh_patterns: + return MessageType.LAUGH + return MessageType.OTHER + + async def handle_hello(self, author_name: str, hello_svc) -> str: + """Handles hello messages""" + return hello_svc.run(author_name) + + async def handle_laugh(self) -> str: + """Handles laugh messages""" + return random.choice(self.laugh_patterns) # noqa: S311 + + +class SaveMessagesCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.store_svc = SentenceStorerSvc(db=MarkovDB(channel=bot.channel)) + self.hello_svc = HelloGeneratorSvc() + self.backoff_svc = BackoffService() + self.message_handler = MessageHandler(self._send_message) + + # Register a separate send function for each message type + self.send_functions = { + MessageType.HELLO: self._create_typed_send("hello"), + MessageType.YES: self._create_typed_send("yes"), + MessageType.WHAT: self._create_typed_send("what"), + MessageType.LAUGH: self._create_typed_send("laugh"), + } + + # Register each send function with its own backoff + for func in self.send_functions.values(): + self.backoff_svc.add_callable(func, backoff_seconds=10) + + def _create_typed_send(self, type_name: str): + """Creates a send function for a specific message type""" + + async def typed_send(content: str): + if hasattr(self, "current_message"): + await self.current_message.channel.send(content) + + # Set a unique name for the function to ensure it's treated as distinct + typed_send.__name__ = f"send_{type_name}" + return typed_send + + async def _send_message(self, content: str): + """Generic send message function (for non-backoff uses)""" + if hasattr(self, "current_message"): + await self.current_message.channel.send(content) + + @commands.Cog.event() + async def event_message(self, message): + """Main message event handler""" + if not message.author: + return + + # Store reference to current message for send functions + self.current_message = message + + # Store the message content + await self.store_svc.run(message.content) + + # Determine message type and handle accordingly + msg_type = self.message_handler.get_message_type(message.content) + + response = None + + match msg_type: + case MessageType.COMMAND: + return + case MessageType.HELLO: + response = await self.message_handler.handle_hello( + message.author.name, self.hello_svc + ) + case MessageType.YES: + response = "Indeed" + case MessageType.WHAT: + response = "WHAT Ramon" + case MessageType.LAUGH: + response = await self.message_handler.handle_laugh() + case MessageType.OTHER: + return + + if response and msg_type in self.send_functions: + # Use the type-specific send function + await self.backoff_svc.call_async(self.send_functions[msg_type], response) + class BotsManager: def __init__(self): diff --git a/src/huesoporro/svc/backoff_service.py b/src/huesoporro/svc/backoff_service.py new file mode 100644 index 0000000..08123d1 --- /dev/null +++ b/src/huesoporro/svc/backoff_service.py @@ -0,0 +1,111 @@ +import asyncio +import time +from collections.abc import Callable + +from pydantic import BaseModel + + +class CallableInfo(BaseModel): + backoff_seconds: int + last_call: float | None = None + is_async: bool + + +class BackoffService(BaseModel): + """Use this service to implement a backoff strategy on random callables. + The callable will be called the first time without delay but every subsequent + call may be hold off for a given time + + Examples: + >>> def callable(x): print(f"foo {x}") + >>> backoff_service = BackoffService() + >>> backoff_service.add_callable(callable, backoff_time=3) + >>> backoff_service.call(callable, "bar") # prints "foo bar" + >>> backoff_service.call(callable, "baz") # prints nothing + >>> # wait 3 seconds before calling callable again + >>> backoff_service.call(callable, "qux") # prints "foo qux" + """ + + callables: dict[Callable, CallableInfo] = {} + + def add_callable(self, func: Callable, backoff_seconds: int): + """Adds a callable to the local mapper with its backoff configuration. + + Args: + func: The function to be registered + backoff_seconds: The number of seconds to wait between successive calls + """ + self.callables[func] = CallableInfo( + backoff_seconds=backoff_seconds, is_async=self._is_async(func) + ) + + @staticmethod + def _is_async(func: Callable) -> bool: + """Checks if the callable is async""" + return asyncio.iscoroutinefunction(func) + + def _can_call(self, func: Callable) -> bool: + """Determines if enough time has passed since the last call""" + if func not in self.callables: + raise ValueError(f"Function {func} not registered with backoff service") + + func_info = self.callables[func] + last_call = func_info.last_call + + if last_call is None: + return True + + elapsed = time.time() - last_call + return elapsed >= func_info.backoff_seconds + + def call(self, func: Callable, *args, **kwargs): + """Calls the callable with arguments and returns its result if it isn't held off + + Args: + func: The function to call + *args: Positional arguments for the function + **kwargs: Keyword arguments for the function + + Returns: + Optional[Any]: The result of the function call if executed, None if held off + """ + if func not in self.callables: + raise ValueError(f"Function {func} not registered with backoff service") + + if self.callables[func].is_async: + raise ValueError( + "Cannot call async function with .call(), use .call_async() instead" + ) + + if not self._can_call(func): + return None + + result = func(*args, **kwargs) + self.callables[func].last_call = time.time() + return result + + async def call_async(self, func: Callable, *args, **kwargs): + """Same as .call(...) but for async functions + + Args: + func: The async function to call + *args: Positional arguments for the function + **kwargs: Keyword arguments for the function + + Returns: + Optional[Any]: The result of the async function call if executed, None if held off + """ + if func not in self.callables: + raise ValueError(f"Function {func} not registered with backoff service") + + if not self.callables[func].is_async: + raise ValueError( + "Cannot call sync function with .call_async(), use .call() instead" + ) + + if not self._can_call(func): + return None + + result = await func(*args, **kwargs) + self.callables[func].last_call = time.time() + return result diff --git a/src/huesoporro/svc/generate.py b/src/huesoporro/svc/generate.py index 0f5058d..5bef7fe 100644 --- a/src/huesoporro/svc/generate.py +++ b/src/huesoporro/svc/generate.py @@ -133,11 +133,11 @@ class SentenceGeneratorSvc(BaseModel): self, sentence: str | None = None, ) -> str | None: - if sentence: - sentence = tokenize(sentence) - logger.info(f"Generating sentence from {sentence}") - sentence, success = self.generate(sentence) - logger.info(f"Generated sentence: {sentence}") + split_sentence = tokenize(sentence) if sentence else None + + logger.info(f"Generating sentence from {split_sentence}") + generated_sentence, success = self.generate(split_sentence) + logger.info(f"Generated sentence: {generated_sentence}") if not success: return None - return sentence + return generated_sentence diff --git a/src/huesoporro/svc/get_random_quote.py b/src/huesoporro/svc/get_random_quote.py index 4371d4e..f6b4f6c 100644 --- a/src/huesoporro/svc/get_random_quote.py +++ b/src/huesoporro/svc/get_random_quote.py @@ -6,5 +6,5 @@ from src.huesoporro.infra.db import Database class RandomQuoteGetterSvc(BaseModel): db: Database - async def run(self, channel_name: str) -> tuple[str, str]: + async def run(self, channel_name: str) -> tuple[str, str] | None: return await self.db.get_random_quote(channel_name=channel_name) diff --git a/src/huesoporro/svc/hello.py b/src/huesoporro/svc/hello.py index 19dd38f..5be2180 100644 --- a/src/huesoporro/svc/hello.py +++ b/src/huesoporro/svc/hello.py @@ -11,8 +11,10 @@ class HelloGeneratorSvc(BaseModel): "Hi", "Bon día", "Hola mi tremendo elemento", + "HOLA", + "hiii", ] ) def run(self, username: str): - return f"{random.choice(self.hellos)} {username}" # noqa: S311 + return f"{random.choice(self.hellos)} @{username}" # noqa: S311 diff --git a/tests/conftest.py b/tests/conftest.py index b01710c..19218d9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from caribou.migrate import load_migrations from src.huesoporro.infra.db import Database from src.huesoporro.models import ChatbotSettings, TwitchAuth, User from src.huesoporro.settings import Settings +from src.huesoporro.svc.backoff_service import BackoffService from src.huesoporro.svc.is_mod import IsModSvc @@ -16,7 +17,8 @@ def user() -> User: user="huesoporro", expires_at=1671234567.0, twitch_auth=TwitchAuth( - access_token="test_access_token", refresh_token="test_refresh_token" + access_token="test_access_token", # noqa: S106 + refresh_token="test_refresh_token", # noqa: S106 ), ) @@ -27,8 +29,8 @@ def s(tmp_path: Path, user: User) -> Settings: static_files_path=tmp_path / "static_files", db_filepath=tmp_path / "huesoporro.db", twitch_client_id="test_client_id", - twitch_client_secret="test_client_secret", # type: ignore[arg-type] - jwt_secret="test_jwt_secret", # type: ignore[arg-type] + twitch_client_secret="test_client_secret", # type: ignore[arg-type] # noqa: S106 + jwt_secret="test_jwt_secret", # type: ignore[arg-type] # noqa: S106 allowed_users=[user.user], ) @@ -54,3 +56,27 @@ async def chatbot_settings(db: Database, user) -> ChatbotSettings: cbs = ChatbotSettings(mods=[user.user, "allowed_user"]) await db.save_chatbot_settings(user=user, chatbot_settings=cbs) return cbs + + +@pytest.fixture +def backoff_callable(): + def foo(): + return "foo" + + return foo + + +@pytest.fixture +def async_backoff_callable(): + async def async_foo(): + return "async foo" + + return async_foo + + +@pytest.fixture +async def backoff_svc(backoff_callable, async_backoff_callable): + backoff_svc = BackoffService() + backoff_svc.add_callable(backoff_callable, 3) + backoff_svc.add_callable(async_backoff_callable, 3) + return backoff_svc diff --git a/tests/test_svc.py b/tests/test_svc.py index 5a5aacd..c88e1c9 100644 --- a/tests/test_svc.py +++ b/tests/test_svc.py @@ -1,3 +1,6 @@ +import asyncio +import time + import pytest from src.huesoporro.models import ChatbotSettings, User @@ -33,3 +36,61 @@ async def test_is_mod_svc_returns_false_for_user_not_in_modlist( ): is_mod = await is_mod_svc.run(user=user, username="TestUser2", channel=user.user) assert not is_mod + + +async def test_backoff_svc_returns_for_first_attempt( + backoff_svc, backoff_callable, async_backoff_callable +): + assert backoff_svc.call(backoff_callable) == "foo" + + assert await backoff_svc.call_async(async_backoff_callable) == "async foo" + + +async def test_backoff_svc_returns_none_for_second_attempt( + backoff_svc, backoff_callable, async_backoff_callable +): + assert backoff_svc.call(backoff_callable) == "foo" + assert backoff_svc.call(backoff_callable) is None + + assert await backoff_svc.call_async(async_backoff_callable) == "async foo" + assert await backoff_svc.call_async(async_backoff_callable) is None + + +async def test_backoff_svc_returns_for_second_attempt_after_delay( + backoff_svc, backoff_callable, async_backoff_callable +): + assert backoff_svc.call(backoff_callable) == "foo" + assert backoff_svc.call(backoff_callable) is None + time.sleep(3) + assert backoff_svc.call(backoff_callable) == "foo" + + assert await backoff_svc.call_async(async_backoff_callable) == "async foo" + assert await backoff_svc.call_async(async_backoff_callable) is None + await asyncio.sleep(3) + assert await backoff_svc.call_async(async_backoff_callable) == "async foo" + + +async def test_backoff_svc_raises_value_error_for_unknown_callable(backoff_svc): + with pytest.raises(ValueError, match="not registered with backoff service"): + backoff_svc.call(lambda: "foo") + + +async def test_backoff_svc_raises_value_error_for_unknown_async_callable(backoff_svc): + with pytest.raises(ValueError, match="not registered with backoff service"): + await backoff_svc.call_async(lambda: "foo") + + +async def test_backoff_svc_raises_value_error_for_async_called_from_sync( + backoff_svc, backoff_callable +): + with pytest.raises( + ValueError, match="Cannot call sync function with .call_async()" + ): + await backoff_svc.call_async(backoff_callable) + + +async def test_backoff_svc_raises_value_error_for_sync_called_from_async( + backoff_svc, async_backoff_callable +): + with pytest.raises(ValueError, match="Cannot call async function with .call()"): + backoff_svc.call(async_backoff_callable) diff --git a/uv.lock b/uv.lock index 618cfee..458a1a4 100644 --- a/uv.lock +++ b/uv.lock @@ -460,7 +460,7 @@ wheels = [ [[package]] name = "huesoporro" -version = "0.2.2" +version = "0.2.3" source = { virtual = "." } dependencies = [ { name = "aiosqlite" }, From efac1cc33ccf807f3c2b1a176286db24af891c23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Thu, 19 Dec 2024 18:18:37 +0100 Subject: [PATCH 04/29] chore: update to v0.2.4 and remove useless code --- charts/huesoporro/Chart.yaml | 4 +-- charts/huesoporro/values.yaml | 2 +- pyproject.toml | 2 +- src/huesoporro/bot.py | 48 ----------------------------------- uv.lock | 2 +- 5 files changed, 5 insertions(+), 53 deletions(-) diff --git a/charts/huesoporro/Chart.yaml b/charts/huesoporro/Chart.yaml index 1c6576a..22ca75f 100644 --- a/charts/huesoporro/Chart.yaml +++ b/charts/huesoporro/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.2.3 +version: 0.2.4 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.2.3" +appVersion: "0.2.4" diff --git a/charts/huesoporro/values.yaml b/charts/huesoporro/values.yaml index ae50d59..48b749a 100644 --- a/charts/huesoporro/values.yaml +++ b/charts/huesoporro/values.yaml @@ -11,7 +11,7 @@ image: # This sets the pull policy for images. pullPolicy: Always # Overrides the image tag whose default is the chart appVersion. - tag: "0.2.3" + tag: "0.2.4" # This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ imagePullSecrets: [] diff --git a/pyproject.toml b/pyproject.toml index 9333e7f..5b913bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "huesoporro" -version = "0.2.3" +version = "0.2.4" description = "Misc Twitch bots" readme = "README.md" authors = [ diff --git a/src/huesoporro/bot.py b/src/huesoporro/bot.py index fae650e..e6b3c36 100644 --- a/src/huesoporro/bot.py +++ b/src/huesoporro/bot.py @@ -114,54 +114,6 @@ class Bot(commands.Bot): self.generation_routine.cancel() -class SaveMessagesCog2(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.store_svc = SentenceStorerSvc(db=MarkovDB(channel=bot.channel)) - self.hello_svc = HelloGeneratorSvc() - self.backoff_svc = BackoffService() - - @commands.Cog.event() - async def event_message(self, message): - content = message.content - if content.startswith("!"): - return - - if not message.author: - return - - await self.store_svc.run(content) - - if message.content in ["hola", "HOLA", "hiii", "ayo"]: - hello_message = self.hello_svc.run(message.author.name) - await message.channel.send(hello_message) - return - - if message.content == "Yes": - await message.channel.send("Indeed") - return - - if message.content.startswith("WHAT"): - await message.channel.send("WHAT Ramon") - return - - laughs_messages = [ - "om", - "KEK", - "LuL", - "LUL", - "OMEGALUL", - "kek", - "keking", - "KEKW", - "OMEGADANCEBUTFAST", - ] - - if message.content in laughs_messages: - await message.channel.send(random.choice(laughs_messages)) # noqa: S311 - return - - class MessageType(StrEnum): COMMAND = "COMMAND" HELLO = "HELLO" diff --git a/uv.lock b/uv.lock index 458a1a4..c6caf63 100644 --- a/uv.lock +++ b/uv.lock @@ -460,7 +460,7 @@ wheels = [ [[package]] name = "huesoporro" -version = "0.2.3" +version = "0.2.4" source = { virtual = "." } dependencies = [ { name = "aiosqlite" }, From 3186afe96defbd040a0d640bdab1455e7f170e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Thu, 19 Dec 2024 18:50:05 +0100 Subject: [PATCH 05/29] fix: fix logout flow which wasn't being triggered, remove useless html code --- charts/huesoporro/Chart.yaml | 2 +- charts/huesoporro/values.yaml | 2 +- pyproject.toml | 2 +- src/huesoporro/api/routes/api.py | 2 +- src/huesoporro/static/js/utils.js | 33 ------------- src/huesoporro/templates/index.html | 12 ++--- src/huesoporro/templates/login.html | 62 +------------------------ src/huesoporro/templates/logout.html | 13 ++++++ src/huesoporro/templates/sentences.html | 15 +++--- src/huesoporro/templates/tts.html | 7 +-- uv.lock | 2 +- 11 files changed, 34 insertions(+), 118 deletions(-) create mode 100644 src/huesoporro/templates/logout.html diff --git a/charts/huesoporro/Chart.yaml b/charts/huesoporro/Chart.yaml index 22ca75f..3d392ad 100644 --- a/charts/huesoporro/Chart.yaml +++ b/charts/huesoporro/Chart.yaml @@ -21,4 +21,4 @@ version: 0.2.4 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.2.4" +appVersion: "0.2.5" diff --git a/charts/huesoporro/values.yaml b/charts/huesoporro/values.yaml index 48b749a..ea27e5a 100644 --- a/charts/huesoporro/values.yaml +++ b/charts/huesoporro/values.yaml @@ -11,7 +11,7 @@ image: # This sets the pull policy for images. pullPolicy: Always # Overrides the image tag whose default is the chart appVersion. - tag: "0.2.4" + tag: "0.2.5" # This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ imagePullSecrets: [] diff --git a/pyproject.toml b/pyproject.toml index 5b913bf..1da3c58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "huesoporro" -version = "0.2.4" +version = "0.2.5" description = "Misc Twitch bots" readme = "README.md" authors = [ diff --git a/src/huesoporro/api/routes/api.py b/src/huesoporro/api/routes/api.py index d427cde..9412598 100644 --- a/src/huesoporro/api/routes/api.py +++ b/src/huesoporro/api/routes/api.py @@ -19,7 +19,7 @@ class ManageBotDTO(BaseModel): "/tts", media_type=MediaType.HTML, ) -async def get_tts_overlay() -> Template: +async def get_tts_overlay(user: User) -> Template: return Template(template_name="tts.html") diff --git a/src/huesoporro/static/js/utils.js b/src/huesoporro/static/js/utils.js index e014ab2..9bb1116 100644 --- a/src/huesoporro/static/js/utils.js +++ b/src/huesoporro/static/js/utils.js @@ -7,36 +7,3 @@ function getWebsocketProtocol() { return "wss://"; } } - -function addLogoutEvent() { - const logoutButton = document.getElementById("logoutButton"); - logoutButton.addEventListener("click", () => { - document.cookie = "twitchLoginData=; expires=Thu, 01 Jan 1970 00:00:00 UTC"; - window.location.href = "/"; - }); - -} - -function setCookie(name, value, days) { - const date = new Date(); - date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); - const expires = `expires=${date.toUTCString()}`; - document.cookie = `${name}=${value};${expires};path=/;SameSite=Strict`; -} - -function getCookie(name) { - const cookieName = `${name}=`; - const decodedCookie = decodeURIComponent(document.cookie); - const cookieArray = decodedCookie.split(';'); - - for (let i = 0; i < cookieArray.length; i++) { - let cookie = cookieArray[i]; - while (cookie.charAt(0) === ' ') { - cookie = cookie.substring(1); - } - if (cookie.indexOf(cookieName) === 0) { - return cookie.substring(cookieName.length, cookie.length); - } - } - return null; -} diff --git a/src/huesoporro/templates/index.html b/src/huesoporro/templates/index.html index bdf08ee..b17419f 100644 --- a/src/huesoporro/templates/index.html +++ b/src/huesoporro/templates/index.html @@ -5,12 +5,10 @@
@@ -178,7 +176,7 @@ }) const data = await response.json() console.log(data); - if (response.ok){ + if (response.ok) { alert("Settings saved successfully") } } @@ -186,8 +184,6 @@ const chatbotManager = new ChatbotManager(); chatbotManager.setEvents(); - - addLogoutEvent() }); diff --git a/src/huesoporro/templates/login.html b/src/huesoporro/templates/login.html index 00e0a38..3a3181a 100644 --- a/src/huesoporro/templates/login.html +++ b/src/huesoporro/templates/login.html @@ -6,7 +6,8 @@
- Login + Login with Twitch @@ -14,66 +15,7 @@
diff --git a/src/huesoporro/templates/logout.html b/src/huesoporro/templates/logout.html new file mode 100644 index 0000000..61f189c --- /dev/null +++ b/src/huesoporro/templates/logout.html @@ -0,0 +1,13 @@ + + diff --git a/src/huesoporro/templates/sentences.html b/src/huesoporro/templates/sentences.html index 7a0b611..e390f79 100644 --- a/src/huesoporro/templates/sentences.html +++ b/src/huesoporro/templates/sentences.html @@ -1,17 +1,14 @@ - {% include 'header.html' %}

Huesoporro🦴🍃

@@ -20,14 +17,18 @@ - + {% for sentence in sentences %} - + {% endfor %}
SentenceSentence Action
{{ sentence.sentence }} + +
diff --git a/src/huesoporro/templates/tts.html b/src/huesoporro/templates/tts.html index a0f3266..5a7843e 100644 --- a/src/huesoporro/templates/tts.html +++ b/src/huesoporro/templates/tts.html @@ -8,10 +8,7 @@
  • TTS
  • Le Funny
  • - - + {% include 'logout.html' %}

    Huesoporro🦴🍃

    @@ -186,7 +183,7 @@ // generate /tts/permalink?access_token= // the access token is available in the twitchLoginData cookie - const cookie = JSON.parse(getCookie("twitchLoginData")) + const cookie = JSON.parse(getCookie("huesoporroAuth")) const permalinkUrl = `${window.location.origin}/tts/permalink?access_token=${cookie.access_token}`; navigator.clipboard.writeText(permalinkUrl); alert('OBS link copied to clipboard ' + permalinkUrl); diff --git a/uv.lock b/uv.lock index c6caf63..d861103 100644 --- a/uv.lock +++ b/uv.lock @@ -460,7 +460,7 @@ wheels = [ [[package]] name = "huesoporro" -version = "0.2.4" +version = "0.2.5" source = { virtual = "." } dependencies = [ { name = "aiosqlite" }, From 50900986fa5cb8b2d0065259ea95364d9bbe9beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Fri, 17 Jan 2025 18:15:58 +0100 Subject: [PATCH 06/29] feat: revamp authentication -- remove twitch's tokens from our own wrapper token --- devenv.lock | 50 +++++++- devenv.nix | 7 + devenv.yaml | 17 +-- migrations/20241219191711_sentences.py | 35 +++++ .../20250112153541_user_external_auth.py | 53 ++++++++ .../20250113142241_external_auth_json.py | 35 +++++ pyproject.toml | 2 + src/huesoporro/actions/authenticate.py | 27 ++++ src/huesoporro/actions/get_user_by_jwt.py | 38 ++++++ src/huesoporro/actions/refresh.py | 27 ++++ src/huesoporro/api/dependencies.py | 39 ++++-- src/huesoporro/api/main.py | 15 ++- src/huesoporro/api/routes/api.py | 17 ++- src/huesoporro/api/routes/auth.py | 8 +- src/huesoporro/bot.py | 2 +- src/huesoporro/infra/authenticator.py | 16 ++- src/huesoporro/infra/db.py | 43 ++----- src/huesoporro/infra/repos.py | 114 +++++++++++++++++ src/huesoporro/models.py | 35 +++-- src/huesoporro/svc/authenticate.py | 26 ---- src/huesoporro/svc/get_sentences_svc.py | 11 ++ src/huesoporro/svc/refresh.py | 27 ---- src/huesoporro/templates/header.html | 11 +- src/huesoporro/templates/index.html | 10 +- .../templates/le_funny_dropdown.html | 10 ++ src/huesoporro/templates/login.html | 4 +- src/huesoporro/templates/logout.html | 2 +- src/huesoporro/templates/sentences.html | 121 ++++++++++++++++-- tests/conftest.py | 11 +- tests/test_repos.py | 53 ++++++++ uv.lock | 25 ++++ 31 files changed, 736 insertions(+), 155 deletions(-) create mode 100644 migrations/20241219191711_sentences.py create mode 100644 migrations/20250112153541_user_external_auth.py create mode 100644 migrations/20250113142241_external_auth_json.py create mode 100644 src/huesoporro/actions/authenticate.py create mode 100644 src/huesoporro/actions/get_user_by_jwt.py create mode 100644 src/huesoporro/actions/refresh.py create mode 100644 src/huesoporro/infra/repos.py delete mode 100644 src/huesoporro/svc/authenticate.py create mode 100644 src/huesoporro/svc/get_sentences_svc.py delete mode 100644 src/huesoporro/svc/refresh.py create mode 100644 src/huesoporro/templates/le_funny_dropdown.html create mode 100644 tests/test_repos.py diff --git a/devenv.lock b/devenv.lock index 8ab1706..97f804a 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,10 +3,10 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1733788855, + "lastModified": 1735530587, "owner": "cachix", "repo": "devenv", - "rev": "d59fee8696cd48f69cf79f65992269df9891ba86", + "rev": "69645885c1052cc1ca398ac30ba7dfc63386c0e3", "type": "github" }, "original": { @@ -31,6 +31,21 @@ "type": "github" } }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1733328505, + "owner": "edolstra", + "repo": "flake-compat", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, "gitignore": { "inputs": { "nixpkgs": [ @@ -66,12 +81,32 @@ "type": "github" } }, + "nixpkgs-python": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1733319315, + "owner": "cachix", + "repo": "nixpkgs-python", + "rev": "01263eeb28c09f143d59cd6b0b7c4cc8478efd48", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "nixpkgs-python", + "type": "github" + } + }, "nixpkgs-stable": { "locked": { - "lastModified": 1733730953, + "lastModified": 1735286948, "owner": "NixOS", "repo": "nixpkgs", - "rev": "7109b680d161993918b0a126f38bc39763e5a709", + "rev": "31ac92f9628682b294026f0860e14587a09ffb4b", "type": "github" }, "original": { @@ -83,7 +118,7 @@ }, "pre-commit-hooks": { "inputs": { - "flake-compat": "flake-compat", + "flake-compat": "flake-compat_2", "gitignore": "gitignore", "nixpkgs": [ "nixpkgs" @@ -91,10 +126,10 @@ "nixpkgs-stable": "nixpkgs-stable" }, "locked": { - "lastModified": 1733665616, + "lastModified": 1734797603, "owner": "cachix", "repo": "pre-commit-hooks.nix", - "rev": "d8c02f0ffef0ef39f6063731fc539d8c71eb463a", + "rev": "f0f0dc4920a903c3e08f5bdb9246bb572fcae498", "type": "github" }, "original": { @@ -107,6 +142,7 @@ "inputs": { "devenv": "devenv", "nixpkgs": "nixpkgs", + "nixpkgs-python": "nixpkgs-python", "pre-commit-hooks": "pre-commit-hooks" } } diff --git a/devenv.nix b/devenv.nix index 771f4d7..eea6c6c 100644 --- a/devenv.nix +++ b/devenv.nix @@ -5,8 +5,15 @@ packages = [ pkgs.git ]; + certificates = [ + "id.twitch.tv" + "twitch.tv" + "discord.com" + ]; + languages.python.enable = true; languages.python.uv.enable = true; + languages.python.version = "3.12.8"; scripts.hello.exec = '' echo hello from $GREET diff --git a/devenv.yaml b/devenv.yaml index 116a2ad..184b866 100644 --- a/devenv.yaml +++ b/devenv.yaml @@ -1,15 +1,8 @@ -# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json inputs: nixpkgs: url: github:cachix/devenv-nixpkgs/rolling - -# If you're using non-OSS software, you can set allowUnfree to true. -# allowUnfree: true - -# If you're willing to use a package that's vulnerable -# permittedInsecurePackages: -# - "openssl-1.1.1w" - -# If you have more than one devenv you can merge them -#imports: -# - ./backend + nixpkgs-python: + url: github:cachix/nixpkgs-python + inputs: + nixpkgs: + follows: nixpkgs diff --git a/migrations/20241219191711_sentences.py b/migrations/20241219191711_sentences.py new file mode 100644 index 0000000..06830e0 --- /dev/null +++ b/migrations/20241219191711_sentences.py @@ -0,0 +1,35 @@ +""" +This module contains a Caribou migration. + +Migration Name: sentences +Migration Version: 20241219191711 +""" + + +def upgrade(connection): + # update table `sentences` to have a user_id row + # which references users.id + # and a channel VARCHAR(255) row + + sql = """ + DROP TABLE IF EXISTS sentences; + """ + connection.execute(sql) + connection.commit() + sql = """ + CREATE TABLE sentences( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + sentence VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + user_id VARCHAR(255) NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + """ + connection.execute(sql) + connection.commit() + + +def downgrade(connection): + # add your downgrade step here + pass diff --git a/migrations/20250112153541_user_external_auth.py b/migrations/20250112153541_user_external_auth.py new file mode 100644 index 0000000..d873a70 --- /dev/null +++ b/migrations/20250112153541_user_external_auth.py @@ -0,0 +1,53 @@ +""" +This module contains a Caribou migration. + +Migration Name: user_external_auth +Migration Version: 20250112153541 +""" + + +def upgrade(connection): + """ + - delete access_token, refresh_token, and expires_at from users + - add external_auth table which will store the external auths: + - type: twitch or discord + - credentials: JSON + """ + + sql = """ + ALTER TABLE users DROP COLUMN access_token; + """ + connection.execute(sql) + sql = """ + ALTER TABLE users DROP COLUMN refresh_token; + """ + connection.execute(sql) + sql = """ + ALTER TABLE users DROP COLUMN expires_at; + """ + connection.execute(sql) + + sql = """ + CREATE TABLE external_auth( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + type VARCHAR(255) NOT NULL, + credentials JSON NOT NULL + ); + """ + connection.execute(sql) + + sql = """ + CREATE TABLE user_external_auth( + user_id VARCHAR(255) NOT NULL, + external_auth_id INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (external_auth_id) REFERENCES external_auth(id) + ); + """ + connection.execute(sql) + connection.commit() + + +def downgrade(connection): + # add your downgrade step here + pass diff --git a/migrations/20250113142241_external_auth_json.py b/migrations/20250113142241_external_auth_json.py new file mode 100644 index 0000000..e92e847 --- /dev/null +++ b/migrations/20250113142241_external_auth_json.py @@ -0,0 +1,35 @@ +""" +This module contains a Caribou migration. + +Migration Name: external_auth_json +Migration Version: 20250113142241 +""" + + +def upgrade(connection): + """remove tables: + - external_auth + - user_external_auth + add column to users table: + - external_auth JSON + """ + sql = """ + DROP TABLE IF EXISTS external_auth; + """ + connection.execute(sql) + + sql = """ + DROP TABLE IF EXISTS user_external_auth; + """ + connection.execute(sql) + + sql = """ + ALTER TABLE users ADD COLUMN external_auth JSON; + """ + connection.execute(sql) + connection.commit() + + +def downgrade(connection): + # add your downgrade step here + pass diff --git a/pyproject.toml b/pyproject.toml index 1da3c58..d2f948a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "pyjwt>=2.10.1", "twitchio>=2.10.0", "redis>=5.2.1", + "pytz>=2024.2", + "discord-py>=2.4.0", ] [tool.uv] diff --git a/src/huesoporro/actions/authenticate.py b/src/huesoporro/actions/authenticate.py new file mode 100644 index 0000000..9bf7b6a --- /dev/null +++ b/src/huesoporro/actions/authenticate.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel + +from src.huesoporro.infra.authenticator import TwitchAuthenticator +from src.huesoporro.infra.repos import UserRepo +from src.huesoporro.models import User +from src.huesoporro.settings import Settings + + +class AuthenticateAction(BaseModel): + user_repo: UserRepo + authenticator: TwitchAuthenticator + s: Settings + + async def run( + self, + auth_code: str, + ): + tokens = await self.authenticator.get_token(auth_code) + username = tokens.userinfo["preferred_username"] + if username not in self.s.allowed_users: + raise ValueError(f"User {username} is not allowed to use this bot") + user = User(user=username, external_auth={"twitch": tokens.model_dump()}) + if await self.user_repo.get_by_user(user.user): + await self.user_repo.update(user) + else: + await self.user_repo.create(user) + return user.encode() diff --git a/src/huesoporro/actions/get_user_by_jwt.py b/src/huesoporro/actions/get_user_by_jwt.py new file mode 100644 index 0000000..b5311ab --- /dev/null +++ b/src/huesoporro/actions/get_user_by_jwt.py @@ -0,0 +1,38 @@ +from loguru import logger +from pydantic import BaseModel + +from src.huesoporro.infra.authenticator import TwitchAuthenticator +from src.huesoporro.infra.repos import UserRepo +from src.huesoporro.models import User +from src.huesoporro.settings import Settings + + +class GetUserByJWTAction(BaseModel): + user_repo: UserRepo + authenticator: TwitchAuthenticator + s: Settings + + async def run( + self, + jwt_token: str, + ) -> User: + user_data = User.decode(jwt_token) + username = user_data["user"] + user = await self.user_repo.get_by_user(username) + if not user: + raise ValueError(f"User {username} not found") + is_valid = await self.authenticator.token_is_valid( + user.external_auth["twitch"]["access_token"] + ) + logger.info(f"Token {user} is valid: {is_valid}") + if not is_valid: + logger.info(f"Refreshing token for user {user}") + new_tokens = await self.authenticator.refresh_token( + user.external_auth["twitch"]["refresh_token"] + ) + user.external_auth["twitch"]["access_token"] = new_tokens["access_token"] # type: ignore[index] + user.external_auth["twitch"]["refresh_token"] = new_tokens["refresh_token"] # type: ignore[index] + await self.user_repo.update(user) + return user + + return user diff --git a/src/huesoporro/actions/refresh.py b/src/huesoporro/actions/refresh.py new file mode 100644 index 0000000..4ba8543 --- /dev/null +++ b/src/huesoporro/actions/refresh.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel + +from src.huesoporro.infra.authenticator import TwitchAuthenticator +from src.huesoporro.infra.repos import UserRepo +from src.huesoporro.models import User +from src.huesoporro.settings import Settings + + +class RefreshAction(BaseModel): + user_repo: UserRepo + authenticator: TwitchAuthenticator + s: Settings + + async def run(self, user: User): + is_valid = await self.authenticator.token_is_valid( + user.external_auth["twitch"]["access_token"] + ) + + if not is_valid: + new_tokens = await self.authenticator.refresh_token( + user.external_auth["twitch"]["refresh_token"] + ) + user.external_auth["twitch"]["access_token"] = new_tokens["access_token"] # type: ignore[index] + user.external_auth["twitch"]["refresh_token"] = new_tokens["refresh_token"] # type: ignore[index] + await self.user_repo.update(user) + return user.encode() + return None diff --git a/src/huesoporro/api/dependencies.py b/src/huesoporro/api/dependencies.py index 5db8371..d1d9812 100644 --- a/src/huesoporro/api/dependencies.py +++ b/src/huesoporro/api/dependencies.py @@ -1,12 +1,15 @@ from litestar import Request from litestar.exceptions import HTTPException +from src.huesoporro.actions.authenticate import AuthenticateAction +from src.huesoporro.actions.get_user_by_jwt import GetUserByJWTAction from src.huesoporro.infra.authenticator import TwitchAuthenticator from src.huesoporro.infra.db import Database +from src.huesoporro.infra.repos import UserRepo from src.huesoporro.models import User from src.huesoporro.settings import Settings -from src.huesoporro.svc.authenticate import CodeAuthenticatorSvc from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc +from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc @@ -22,27 +25,43 @@ def get_db(s: Settings): return Database(s=s) -async def authenticate(request: Request) -> User: +async def get_get_user_by_jwt_action( + user_repo: UserRepo, authenticator: TwitchAuthenticator, s: Settings +): + return GetUserByJWTAction(user_repo=user_repo, authenticator=authenticator, s=s) + + +async def authenticate( + request: Request, get_user_by_jwt_action: GetUserByJWTAction +) -> User: token = request.query_params.get("huesoporro_token") if token: - return User.decode(token) + return await get_user_by_jwt_action.run(token) cookies = request.cookies.get("huesoporroAuth") if cookies: - return User.decode(cookies) + return await get_user_by_jwt_action.run(cookies) raise HTTPException(status_code=401, detail="Unauthorized") -async def get_code_authenticator_svc( - a: TwitchAuthenticator, db: Database -) -> CodeAuthenticatorSvc: - return CodeAuthenticatorSvc(authenticator=a, db=db) - - async def get_chatbot_settings_svc(db: Database): return ChatbotSettingsGetterSvc(db=db) async def store_chatbot_settings_svc(db: Database): return ChatbotSettingsStorerSvc(db=db) + + +async def get_sentences_svc(db: Database): + return SentencesGetterSvc(db=db) + + +async def get_user_repo(s: Settings): + return UserRepo(s=s) + + +async def get_authenticate_action( + user_repo: UserRepo, authenticator: TwitchAuthenticator, s: Settings +): + return AuthenticateAction(user_repo=user_repo, authenticator=authenticator, s=s) diff --git a/src/huesoporro/api/main.py b/src/huesoporro/api/main.py index dd0489a..8df704e 100644 --- a/src/huesoporro/api/main.py +++ b/src/huesoporro/api/main.py @@ -8,11 +8,14 @@ from litestar.template import TemplateConfig from src.huesoporro.api.dependencies import ( authenticate, + get_authenticate_action, get_authenticator, get_chatbot_settings_svc, - get_code_authenticator_svc, get_db, + get_get_user_by_jwt_action, + get_sentences_svc, get_settings, + get_user_repo, store_chatbot_settings_svc, ) from src.huesoporro.api.errors import ( @@ -24,10 +27,12 @@ from src.huesoporro.api.routes.api import ( get_bot_settings, get_bot_status, get_index, + get_sentences, get_tts_overlay, get_tts_permalink, manage_bot, save_bot_settings, + save_new_sentence, ) from src.huesoporro.api.routes.auth import get_code, login from src.huesoporro.bot import BotsManager @@ -52,6 +57,8 @@ def create_app(): get_bot_status, save_bot_settings, get_bot_settings, + get_sentences, + save_new_sentence, ], static_files_config=( StaticFilesConfig( @@ -77,10 +84,14 @@ def create_app(): "a": Provide(get_authenticator, use_cache=True), "user": Provide(authenticate), "db": Provide(get_db, use_cache=True), - "code_authenticator_svc": Provide(get_code_authenticator_svc), "bm": Provide(BotsManager, use_cache=True), "gbs": Provide(get_chatbot_settings_svc), "sbs": Provide(store_chatbot_settings_svc), + "sgs": Provide(get_sentences_svc), + "authenticator": Provide(get_authenticator), + "authenticate_action": Provide(get_authenticate_action), + "user_repo": Provide(get_user_repo), + "get_user_by_jwt_action": Provide(get_get_user_by_jwt_action), }, ) diff --git a/src/huesoporro/api/routes/api.py b/src/huesoporro/api/routes/api.py index 9412598..55e6557 100644 --- a/src/huesoporro/api/routes/api.py +++ b/src/huesoporro/api/routes/api.py @@ -1,12 +1,13 @@ from typing import Literal -from litestar import MediaType, Response, get, put +from litestar import MediaType, Response, get, post, put from litestar.response import Template from pydantic import BaseModel from src.huesoporro.bot import BotsManager from src.huesoporro.models import ChatbotSettings, User from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc +from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc @@ -98,3 +99,17 @@ async def save_bot_settings( ) -> dict: await sbs.run(user=user, bot_settings=data) return {"status": "ok"} + + +@get("/sentences") +async def get_sentences(user: User, sgs: SentencesGetterSvc) -> Template: + sentences = await sgs.run(user=user) + return Template( + template_name="sentences.html", + context={"sentences": [sentence.model_dump() for sentence in sentences]}, + ) + + +@post("/api/v1/sentences") +async def save_new_sentence(user: User, data: dict) -> dict: + return {"id": 54, "sentence": data["sentence"]} diff --git a/src/huesoporro/api/routes/auth.py b/src/huesoporro/api/routes/auth.py index 2f1c287..df95441 100644 --- a/src/huesoporro/api/routes/auth.py +++ b/src/huesoporro/api/routes/auth.py @@ -3,14 +3,14 @@ import secrets from litestar import MediaType, get from litestar.response import Redirect, Template +from src.huesoporro.actions.authenticate import AuthenticateAction from src.huesoporro.settings import Settings -from src.huesoporro.svc.authenticate import CodeAuthenticatorSvc @get(path="/o/code") -async def get_code(code: str, code_authenticator_svc: CodeAuthenticatorSvc) -> Redirect: - user = await code_authenticator_svc.run(code) - return Redirect("/", cookies={"huesoporroAuth": user.encode()}) +async def get_code(code: str, authenticate_action: AuthenticateAction) -> Redirect: + token = await authenticate_action.run(code) + return Redirect("/", cookies={"huesoporroAuth": token}) @get( diff --git a/src/huesoporro/bot.py b/src/huesoporro/bot.py index e6b3c36..025a2dd 100644 --- a/src/huesoporro/bot.py +++ b/src/huesoporro/bot.py @@ -23,7 +23,7 @@ from src.huesoporro.svc.store_quote import QuoteStorerSvc class Bot(commands.Bot): def __init__(self, user: User, chatbot_settings: ChatbotSettings, channel: str): super().__init__( - token=user.twitch_auth.access_token, prefix="!", initial_channels=[channel] + token=user.twitch_access_token, prefix="!", initial_channels=[channel] ) self.channel = channel self.user = user diff --git a/src/huesoporro/infra/authenticator.py b/src/huesoporro/infra/authenticator.py index d46922c..9c372d8 100644 --- a/src/huesoporro/infra/authenticator.py +++ b/src/huesoporro/infra/authenticator.py @@ -30,7 +30,15 @@ class TwitchAuthenticator(BaseModel): return await self.refresh_token(response.json()["refresh_token"]) response.raise_for_status() - return TwitchAuth(**response.json()) + profile = await self.get_userinfo(response.json()["access_token"]) + return TwitchAuth(**response.json(), userinfo=profile) + + async def get_userinfo(self, access_token): + response = await self.client.get( + "/oauth2/userinfo", headers={"Authorization": f"Bearer {access_token}"} + ) + response.raise_for_status() + return response.json() async def refresh_token(self, refresh_token: str) -> TwitchAuth: response = await self.client.post( @@ -60,3 +68,9 @@ class TwitchAuthenticator(BaseModel): raise HTTPException(status_code=403, detail="Forbidden") return user + + async def token_is_valid(self, access_token: str) -> bool: + response = await self.client.get( + "/oauth2/validate", headers={"Authorization": f"OAuth {access_token}"} + ) + return response.status_code == 200 # noqa: PLR2004 diff --git a/src/huesoporro/infra/db.py b/src/huesoporro/infra/db.py index b2f6f8f..86d8260 100644 --- a/src/huesoporro/infra/db.py +++ b/src/huesoporro/infra/db.py @@ -5,7 +5,7 @@ import aiosqlite from loguru import logger from pydantic import BaseModel, Field -from src.huesoporro.models import ChatbotSettings, User +from src.huesoporro.models import ChatbotSettings, Sentence, User from src.huesoporro.settings import Settings @@ -24,36 +24,6 @@ class Database(BaseModel): def get_now() -> float: return datetime.datetime.now(datetime.UTC).timestamp() - async def save_user(self, user: User, auto_commit=True): - async with self.get_client(auto_commit=auto_commit) as db: - async with db.execute( - "SELECT * FROM users WHERE user = ?", (user.user,) - ) as cursor: - result = await cursor.fetchone() - if result: - await db.execute( - "UPDATE users SET access_token = ?, refresh_token = ?, expires_at = ?, last_updated_at = ? WHERE user = ?", - ( - user.twitch_auth.access_token, - user.twitch_auth.refresh_token, - user.expires_at, - self.get_now(), - user.user, - ), - ) - return - - await db.execute( - "INSERT INTO users (user, access_token, refresh_token, expires_at, last_updated_at) VALUES (?,?,?,?,?)", - ( - user.user, - user.twitch_auth.access_token, - user.twitch_auth.refresh_token, - user.expires_at, - self.get_now(), - ), - ) - async def save_quote(self, channel: str, quote: str, author: str, auto_commit=True): async with self.get_client(auto_commit=auto_commit) as db: await db.execute( @@ -133,3 +103,14 @@ class Database(BaseModel): ) as cursor, ): return await cursor.fetchone() + + async def get_sentences(self, user: User) -> list[Sentence]: + async with self.get_client() as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM sentences WHERE user_id = ?", (user.user,) + ) as cursor: + result = await cursor.fetchall() + if not result: + return [] + return [Sentence(user=user, **dict(value)) for value in result] diff --git a/src/huesoporro/infra/repos.py b/src/huesoporro/infra/repos.py new file mode 100644 index 0000000..7e4e939 --- /dev/null +++ b/src/huesoporro/infra/repos.py @@ -0,0 +1,114 @@ +import json +from abc import ABC, abstractmethod +from contextlib import asynccontextmanager +from typing import Generic, TypeVar + +import aiosqlite +from pydantic import BaseModel, Field + +from src.huesoporro.models import User +from src.huesoporro.settings import Settings + +T = TypeVar("T", bound=BaseModel) + + +class IRepo(BaseModel, ABC, Generic[T]): + s: Settings = Field(default_factory=Settings.get) + + @asynccontextmanager + async def get_client(self, auto_commit=True): + async with aiosqlite.connect(self.s.db_filepath) as db: + db.row_factory = aiosqlite.Row + yield db + if auto_commit: + await db.commit() + + @abstractmethod + async def create(self, obj: T, auto_commit=True) -> T: + pass + + @abstractmethod + async def update(self, obj: T, auto_commit=True) -> T: + pass + + @abstractmethod + async def delete(self, obj: T, auto_commit=True): + pass + + @abstractmethod + async def get_by_id(self, obj_id: int | str, auto_commit=True) -> T | None: + pass + + @abstractmethod + async def list( + self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True + ) -> list[T]: + pass + + +class UserRepo(IRepo[User]): + async def get_by_id(self, obj_id: int | str, auto_commit=True) -> User | None: + raise NotImplementedError("Not implemented since it's not needed") + + async def create(self, obj: User, auto_commit=True) -> User: + async with self.get_client(auto_commit=auto_commit) as db: + await db.execute( + "INSERT INTO users (user, external_auth) VALUES (?, ?)", + (obj.user, json.dumps(obj.external_auth)), + ) + return obj + + async def update(self, obj: User, auto_commit=True) -> User: + if not await self.get_by_user(obj.user): + raise ValueError(f"User {obj.user} does not exist") + + async with ( + self.get_client(auto_commit=auto_commit) as db, + db.execute( + """ + UPDATE users + SET external_auth = ? + WHERE user = ? + RETURNING * + """, + (json.dumps(obj.external_auth), obj.user), + ) as cursor, + ): + data = await cursor.fetchone() + return User( + user=data["user"], external_auth=json.loads(data["external_auth"]) + ) + + async def delete(self, obj: User, auto_commit=True): + async with self.get_client(auto_commit=auto_commit) as db: + await db.execute( + """ + DELETE FROM users WHERE user = ? + """, + (obj.user,), + ) + + async def get_by_user(self, user: str, auto_commit=True) -> User | None: + async with ( + self.get_client(auto_commit=auto_commit) as db, + db.execute( + """ + SELECT * FROM users WHERE user = ? + """, + (user,), + ) as cursor, + ): + data = await cursor.fetchone() + if not data: + return None + return User( + user=data["user"], external_auth=json.loads(data["external_auth"]) + ) + + async def list( + self, obj: User, offset: int = 0, limit: int = 10, auto_commit=True + ) -> list[User]: + raise NotImplementedError("Not implemented since it's not needed") + + async def count(self, obj: User, auto_commit=True): + raise NotImplementedError("Not implemented since it's not needed") diff --git a/src/huesoporro/models.py b/src/huesoporro/models.py index b4df9e9..f791cf6 100644 --- a/src/huesoporro/models.py +++ b/src/huesoporro/models.py @@ -1,4 +1,4 @@ -from typing import Self +from typing import Literal import jwt from pydantic import BaseModel, Field, field_validator @@ -9,28 +9,39 @@ from src.huesoporro.settings import Settings class TwitchAuth(BaseModel): access_token: str refresh_token: str + userinfo: dict + + +class ExternalAuth(BaseModel): + credentials: dict + type: Literal["twitch"] = "twitch" class User(BaseModel): user: str - expires_at: float - twitch_auth: TwitchAuth + external_auth: dict[Literal["twitch", "discord"], dict] - def encode(self, settings: Settings | None = None) -> str: + def encode( + self, settings: Settings | None = None, exclude_fields: set[str] | None = None + ) -> str: s = settings or Settings.get() + exclude_fields = exclude_fields or {"external_auth"} return jwt.encode( - self.model_dump(), + self.model_dump(exclude=exclude_fields), key=s.jwt_secret.get_secret_value(), algorithm="HS256", ) @classmethod - def decode(cls, token: str, settings: Settings | None = None) -> Self: + def decode(cls, token: str, settings: Settings | None = None) -> dict: s = settings or Settings.get() - decoded = jwt.decode( + return jwt.decode( token, key=s.jwt_secret.get_secret_value(), algorithms=["HS256"] ) - return cls(**decoded) + + @property + def twitch_access_token(self): + return self.external_auth["twitch"]["access_token"] class ChatbotSettings(BaseModel): @@ -50,3 +61,11 @@ class ChatbotSettings(BaseModel): if isinstance(v, str): return v.split(",") return v + + +class Sentence(BaseModel): + id: int + sentence: str + created_at: float + last_updated_at: float + user: User diff --git a/src/huesoporro/svc/authenticate.py b/src/huesoporro/svc/authenticate.py deleted file mode 100644 index 346a407..0000000 --- a/src/huesoporro/svc/authenticate.py +++ /dev/null @@ -1,26 +0,0 @@ -import datetime - -from pydantic import BaseModel - -from src.huesoporro.infra.authenticator import TwitchAuthenticator -from src.huesoporro.infra.db import Database -from src.huesoporro.models import User - - -class CodeAuthenticatorSvc(BaseModel): - db: Database - authenticator: TwitchAuthenticator - - @staticmethod - def get_four_hours_from_now() -> float: - now = datetime.datetime.now(datetime.UTC) - four_hours_later = now + datetime.timedelta(hours=4) - return four_hours_later.timestamp() - - async def run(self, code: str) -> User: - auth = await self.authenticator.get_token(code) - username = await self.authenticator.validate_token(auth.access_token) - expires_at = self.get_four_hours_from_now() - user = User(user=username, expires_at=expires_at, twitch_auth=auth) - await self.db.save_user(user) - return user diff --git a/src/huesoporro/svc/get_sentences_svc.py b/src/huesoporro/svc/get_sentences_svc.py new file mode 100644 index 0000000..1cb0a1c --- /dev/null +++ b/src/huesoporro/svc/get_sentences_svc.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from src.huesoporro.infra.db import Database +from src.huesoporro.models import Sentence, User + + +class SentencesGetterSvc(BaseModel): + db: Database + + async def run(self, user: User) -> list[Sentence]: + return await self.db.get_sentences(user=user) diff --git a/src/huesoporro/svc/refresh.py b/src/huesoporro/svc/refresh.py deleted file mode 100644 index 9209743..0000000 --- a/src/huesoporro/svc/refresh.py +++ /dev/null @@ -1,27 +0,0 @@ -import datetime - -from pydantic import BaseModel - -from src.huesoporro.infra.authenticator import TwitchAuthenticator -from src.huesoporro.infra.db import Database -from src.huesoporro.models import User - - -class RefreshTokenAuthenticator(BaseModel): - db: Database - authenticator: TwitchAuthenticator - - @staticmethod - def get_four_hours_from_now() -> float: - now = datetime.datetime.now(datetime.UTC) - four_hours_later = now + datetime.timedelta(hours=4) - return four_hours_later.timestamp() - - async def run(self, refresh_token: str) -> User: - auth = await self.authenticator.refresh_token(refresh_token) - username = await self.authenticator.validate_token(auth.access_token) - expires_at = self.get_four_hours_from_now() - - user = User(user=username, expires_at=expires_at, twitch_auth=auth) - await self.db.save_user(user) - return user diff --git a/src/huesoporro/templates/header.html b/src/huesoporro/templates/header.html index 7c1cfd9..e6fe60a 100644 --- a/src/huesoporro/templates/header.html +++ b/src/huesoporro/templates/header.html @@ -2,15 +2,24 @@ - + + + + + Huesoporro diff --git a/src/huesoporro/templates/index.html b/src/huesoporro/templates/index.html index b17419f..e7ffa8c 100644 --- a/src/huesoporro/templates/index.html +++ b/src/huesoporro/templates/index.html @@ -2,16 +2,16 @@
    -
    -
    +
    @@ -102,7 +102,7 @@ .catch((error) => { console.error('Failed to retrieve chatbot status', error); }); - }, 2000); + }, 5000); } async startBot() { @@ -184,6 +184,8 @@ const chatbotManager = new ChatbotManager(); chatbotManager.setEvents(); + + }); diff --git a/src/huesoporro/templates/le_funny_dropdown.html b/src/huesoporro/templates/le_funny_dropdown.html new file mode 100644 index 0000000..a7f63a6 --- /dev/null +++ b/src/huesoporro/templates/le_funny_dropdown.html @@ -0,0 +1,10 @@ +
  • + +
  • diff --git a/src/huesoporro/templates/login.html b/src/huesoporro/templates/login.html index 3a3181a..b40ac0c 100644 --- a/src/huesoporro/templates/login.html +++ b/src/huesoporro/templates/login.html @@ -1,9 +1,9 @@ {% include 'header.html' %} -
    +

    Huesoporro🦴🚬

    -
    +
    -
  • Logout
  • +
  • Logout
  • -
    +
    - + + + + + +
    - - + + + + {% for sentence in sentences %} - + + {% endfor %} +
    SentenceActionSentenceLast modifiedAction
    {{ sentence.sentence }}{{ sentence.last_updated_at }} - +
    + + +
    diff --git a/tests/conftest.py b/tests/conftest.py index 19218d9..c8c6c23 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ from caribou.migrate import Database as CaribouDatabase from caribou.migrate import load_migrations from src.huesoporro.infra.db import Database -from src.huesoporro.models import ChatbotSettings, TwitchAuth, User +from src.huesoporro.models import ChatbotSettings, User from src.huesoporro.settings import Settings from src.huesoporro.svc.backoff_service import BackoffService from src.huesoporro.svc.is_mod import IsModSvc @@ -15,11 +15,10 @@ from src.huesoporro.svc.is_mod import IsModSvc def user() -> User: return User( user="huesoporro", - expires_at=1671234567.0, - twitch_auth=TwitchAuth( - access_token="test_access_token", # noqa: S106 - refresh_token="test_refresh_token", # noqa: S106 - ), + external_auth={ + "twitch": {"token": "twitch_token"}, + "discord": {"token": "discord_token"}, + }, ) diff --git a/tests/test_repos.py b/tests/test_repos.py new file mode 100644 index 0000000..423e195 --- /dev/null +++ b/tests/test_repos.py @@ -0,0 +1,53 @@ +import json + +import pytest + +from src.huesoporro.infra.repos import UserRepo +from src.huesoporro.models import User + + +@pytest.fixture +async def user_repo(s, db, user: User): + async with db.get_client() as client: + await client.execute( + "INSERT INTO users (user, external_auth) VALUES (?, ?)", + (user.user, json.dumps(user.external_auth)), + ) + + return UserRepo(s=s) + + +async def test_get_user(user_repo: UserRepo, user: User): + db_user = await user_repo.get_by_user(user.user) + assert db_user == user + + +async def test_get_user_returns_none(user_repo: UserRepo): + assert await user_repo.get_by_user("unknown_user") is None + + +async def test_create_user(user_repo: UserRepo): + new_user = User( + user="new_user", external_auth={"twitch": {"token": "twitch_token"}} + ) + assert await user_repo.create(new_user) == new_user + + +async def test_update_users_tokens(user_repo: UserRepo, user: User): + new_tokens = {"twitch": {"token": "new_tokens"}} + user.external_auth = new_tokens # type: ignore[assignment] + assert await user_repo.update(user) == user + + +async def test_update_non_existing_user_raises_value_error(user_repo: UserRepo): + with pytest.raises(ValueError, match="User unknown_user does not exist"): + await user_repo.update( + User( + user="unknown_user", external_auth={"twitch": {"token": "twitch_token"}} + ) + ) + + +async def test_delete_user(user_repo: UserRepo, user: User): + assert await user_repo.delete(user) is None + assert await user_repo.get_by_user(user.user) is None diff --git a/uv.lock b/uv.lock index d861103..ccca98c 100644 --- a/uv.lock +++ b/uv.lock @@ -283,6 +283,18 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "discord-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/af/80cab4015722d3bee175509b7249a11d5adf77b5ff4c27f268558079d149/discord_py-2.4.0.tar.gz", hash = "sha256:d07cb2a223a185873a1d0ee78b9faa9597e45b3f6186df21a95cec1e9bcdc9a5", size = 1027707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/10/3c44e9331a5ec3bae8b2919d51f611a5b94e179563b1b89eb6423a8f43eb/discord.py-2.4.0-py3-none-any.whl", hash = "sha256:b8af6711c70f7e62160bfbecb55be699b5cb69d007426759ab8ab06b1bd77d1d", size = 1125988 }, +] + [[package]] name = "editorconfig" version = "0.12.4" @@ -465,6 +477,7 @@ source = { virtual = "." } dependencies = [ { name = "aiosqlite" }, { name = "caribou" }, + { name = "discord-py" }, { name = "gtts" }, { name = "httpx" }, { name = "litestar", extra = ["standard"] }, @@ -474,6 +487,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "pytz" }, { name = "redis" }, { name = "twitchio" }, ] @@ -491,6 +505,7 @@ dev = [ requires-dist = [ { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "caribou", specifier = ">=0.4.1" }, + { name = "discord-py", specifier = ">=2.4.0" }, { name = "gtts", specifier = ">=2.5.4" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "litestar", extras = ["standard"], specifier = ">=2.13.0" }, @@ -500,6 +515,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.9.2" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "pyjwt", specifier = ">=2.10.1" }, + { name = "pytz", specifier = ">=2024.2" }, { name = "redis", specifier = ">=5.2.1" }, { name = "twitchio", specifier = ">=2.10.0" }, ] @@ -1101,6 +1117,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, ] +[[package]] +name = "pytz" +version = "2024.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, +] + [[package]] name = "pyyaml" version = "6.0.2" From 75df191253987cd7d4b06c3b837b42430c7edd33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Thu, 13 Feb 2025 09:52:15 +0100 Subject: [PATCH 07/29] feat: add GetRandomQuoteAction --- devenv.lock | 26 +-- devenv.nix | 13 -- src/huesoporro/actions/get_random_quote.py | 11 ++ src/huesoporro/api/routes/api.py | 2 - src/huesoporro/api/routes/auth.py | 12 +- src/huesoporro/bot.py | 20 ++- src/huesoporro/infra/gtts.py | 48 ++++++ src/huesoporro/infra/repos.py | 45 ++++- src/huesoporro/models.py | 15 ++ src/huesoporro/svc/get_random_quote.py | 9 +- src/huesoporro/templates/index.html | 2 +- src/huesoporro/templates/tts.html | 181 ++------------------- tests/test_repos.py | 19 ++- 13 files changed, 185 insertions(+), 218 deletions(-) create mode 100644 src/huesoporro/actions/get_random_quote.py create mode 100644 src/huesoporro/infra/gtts.py diff --git a/devenv.lock b/devenv.lock index 97f804a..d776fa9 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,10 +3,10 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1735530587, + "lastModified": 1739362938, "owner": "cachix", "repo": "devenv", - "rev": "69645885c1052cc1ca398ac30ba7dfc63386c0e3", + "rev": "27276816caa1718f8b8e8d53d64cc18da059e101", "type": "github" }, "original": { @@ -101,35 +101,19 @@ "type": "github" } }, - "nixpkgs-stable": { - "locked": { - "lastModified": 1735286948, - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "31ac92f9628682b294026f0860e14587a09ffb4b", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-24.05", - "repo": "nixpkgs", - "type": "github" - } - }, "pre-commit-hooks": { "inputs": { "flake-compat": "flake-compat_2", "gitignore": "gitignore", "nixpkgs": [ "nixpkgs" - ], - "nixpkgs-stable": "nixpkgs-stable" + ] }, "locked": { - "lastModified": 1734797603, + "lastModified": 1737465171, "owner": "cachix", "repo": "pre-commit-hooks.nix", - "rev": "f0f0dc4920a903c3e08f5bdb9246bb572fcae498", + "rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17", "type": "github" }, "original": { diff --git a/devenv.nix b/devenv.nix index eea6c6c..06edec4 100644 --- a/devenv.nix +++ b/devenv.nix @@ -5,24 +5,11 @@ packages = [ pkgs.git ]; - certificates = [ - "id.twitch.tv" - "twitch.tv" - "discord.com" - ]; - languages.python.enable = true; languages.python.uv.enable = true; languages.python.version = "3.12.8"; - scripts.hello.exec = '' - echo hello from $GREET - ''; - enterShell = '' - hello - git --version - fish ''; dotenv.enable = true; diff --git a/src/huesoporro/actions/get_random_quote.py b/src/huesoporro/actions/get_random_quote.py new file mode 100644 index 0000000..47c7b5b --- /dev/null +++ b/src/huesoporro/actions/get_random_quote.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from src.huesoporro.models import Quote +from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc + + +class GetRandomQuoteAction(BaseModel): + quote_getter_svc: RandomQuoteGetterSvc + + async def run(self, channel_name: str) -> Quote | None: + return await self.quote_getter_svc.run(channel_name=channel_name) diff --git a/src/huesoporro/api/routes/api.py b/src/huesoporro/api/routes/api.py index 55e6557..607789f 100644 --- a/src/huesoporro/api/routes/api.py +++ b/src/huesoporro/api/routes/api.py @@ -32,8 +32,6 @@ async def get_tts_permalink(access_token: str) -> Template: """Handler for the /tts permalink endpoint to be used by apps that can only give the authentication as a query param and not as a cookie, i.e. OBS""" - # authenticate the user using the provided access token - return Template( template_name="tts.html", ) diff --git a/src/huesoporro/api/routes/auth.py b/src/huesoporro/api/routes/auth.py index df95441..7d930b2 100644 --- a/src/huesoporro/api/routes/auth.py +++ b/src/huesoporro/api/routes/auth.py @@ -1,6 +1,7 @@ import secrets from litestar import MediaType, get +from litestar.datastructures.cookie import Cookie from litestar.response import Redirect, Template from src.huesoporro.actions.authenticate import AuthenticateAction @@ -10,7 +11,16 @@ from src.huesoporro.settings import Settings @get(path="/o/code") async def get_code(code: str, authenticate_action: AuthenticateAction) -> Redirect: token = await authenticate_action.run(code) - return Redirect("/", cookies={"huesoporroAuth": token}) + return Redirect( + "/", + cookies=[ + Cookie( + key="huesoporroAuth", + value=token, + expires=604800, # 1 week + ) + ], + ) @get( diff --git a/src/huesoporro/bot.py b/src/huesoporro/bot.py index 025a2dd..f682260 100644 --- a/src/huesoporro/bot.py +++ b/src/huesoporro/bot.py @@ -7,8 +7,11 @@ from loguru import logger from twitchio import Channel from twitchio.ext import commands, routines +from src.huesoporro.actions.get_random_quote import GetRandomQuoteAction from src.huesoporro.actions.store_quote import StoreQuoteAction +from src.huesoporro.api.dependencies import get_settings from src.huesoporro.infra.db import Database +from src.huesoporro.infra.repos import QuoteRepo from src.huesoporro.libs.db import Database as MarkovDB from src.huesoporro.models import ChatbotSettings, User from src.huesoporro.svc.backoff_service import BackoffService @@ -33,8 +36,11 @@ class Bot(commands.Bot): self.store_quote_action = StoreQuoteAction( quote_storer_svc=QuoteStorerSvc(db=db), is_mod_svc=IsModSvc(db=db) ) - - self.get_random_quote_svc = RandomQuoteGetterSvc(db=db) + self.quote_repo = QuoteRepo(s=get_settings()) + self.get_random_quote_svc = RandomQuoteGetterSvc(quote_repo=self.quote_repo) + self.get_random_quote_action = GetRandomQuoteAction( + quote_getter_svc=self.get_random_quote_svc + ) self.cbs = chatbot_settings self.quote_routine = routines.routine( seconds=chatbot_settings.automatic_quote_timer, wait_first=True @@ -78,19 +84,19 @@ class Bot(commands.Bot): @commands.command(aliases=["q", "quote"]) async def get_random_quote(self, ctx: commands.Context): - quote = await self.get_random_quote_svc.run(channel_name=self.channel) + quote = await self.get_random_quote_action.run(channel_name=self.channel) if quote: - await ctx.send(f"«{quote[0]}» - {quote[1]}") + await ctx.send(quote.as_pretty()) def get_channel_conn(self) -> Channel: return Channel(name=self.channel, websocket=self._connection) async def send_quote(self): - quote = await self.get_random_quote_svc.run(channel_name=self.channel) + quote = await self.get_random_quote_action.run(channel_name=self.channel) if quote: channel = self.get_channel_conn() - logger.info(f"Sending random quote {quote[0]}") - await channel.send(f"«{quote[0]}» - {quote[1]}") + logger.info(f"Sending random quote {quote.quote}") + await channel.send(quote.quote) async def send_generation(self): sentence = await self.generate_svc.run() diff --git a/src/huesoporro/infra/gtts.py b/src/huesoporro/infra/gtts.py new file mode 100644 index 0000000..10814ec --- /dev/null +++ b/src/huesoporro/infra/gtts.py @@ -0,0 +1,48 @@ +from collections import deque +from hashlib import sha512 +from pathlib import Path + +from gtts import gTTS +from loguru import logger +from pydantic import BaseModel + +from src.huesoporro.settings import Settings + + +class GTTS(BaseModel): + s: Settings + chunk_size: int = 128 + text_max_length: int = 100 + queue: deque = deque() + + async def generate(self, text: str, lang: str = "pt", tld="com.br") -> Path: + text = text[: self.text_max_length] + raw_filename = f"{text.lower()}_{lang}_{tld}" + logger.info(f"Generating TTS for {raw_filename}") + filepath = ( + self.s.tts_cache_path / f"{sha512(raw_filename.encode()).hexdigest()}.mp3" + ) + tts = gTTS(text=text, lang=lang, tld=tld) + logger.info(f"Saving TTS to {filepath}") + tts.save(str(filepath)) + self.queue.append(filepath) + return filepath + + async def consume(self): + """If there are items in the queue, return a generator + that reads the file's bytes by chunks of self.chunk_size""" + while self.queue: + filepath = self.queue.popleft() + if not filepath.exists(): + logger.warning(f"File {filepath} does not exist, skipping") + continue + + logger.info(f"Reading file {filepath}") + try: + with filepath.open("rb") as f: + while chunk := f.read(self.chunk_size): + yield chunk + logger.info(f"Finished reading {filepath}") + except Exception as e: # noqa: BLE001 + logger.error(f"Error reading file {filepath}: {e}") + continue diff --git a/src/huesoporro/infra/repos.py b/src/huesoporro/infra/repos.py index 7e4e939..a4aab5b 100644 --- a/src/huesoporro/infra/repos.py +++ b/src/huesoporro/infra/repos.py @@ -6,7 +6,7 @@ from typing import Generic, TypeVar import aiosqlite from pydantic import BaseModel, Field -from src.huesoporro.models import User +from src.huesoporro.models import Quote, User from src.huesoporro.settings import Settings T = TypeVar("T", bound=BaseModel) @@ -112,3 +112,46 @@ class UserRepo(IRepo[User]): async def count(self, obj: User, auto_commit=True): raise NotImplementedError("Not implemented since it's not needed") + + +class QuoteRepo(IRepo[Quote]): + async def create(self, obj: Quote, auto_commit=True) -> Quote: + raise NotImplementedError("Not implemented since it's not needed") + + async def update(self, obj: Quote, auto_commit=True) -> Quote: + raise NotImplementedError("Not implemented since it's not needed") + + async def delete(self, obj: Quote, auto_commit=True): + raise NotImplementedError("Not implemented since it's not needed") + + async def get_by_id(self, obj_id: int | str, auto_commit=True) -> Quote | None: + raise NotImplementedError("Not implemented since it's not needed") + + async def list( + self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True + ) -> list[T]: + raise NotImplementedError("Not implemented since it's not needed") + + async def get_random(self, channel_name: str, auto_commit=True) -> Quote | None: + async with ( + self.get_client(auto_commit=auto_commit) as db, + db.execute( + """ + SELECT * FROM quotes + WHERE channel = ? + ORDER BY RANDOM() + LIMIT 1 + """, + (channel_name,), + ) as cursor, + ): + data = await cursor.fetchone() + if not data: + return None + return Quote( + quote=data["quote"], + author=User(user=data["author"], external_auth={}), + channel=User(user=data["channel"], external_auth={}), + created_at=data["created_at"], + last_updated_at=data["last_updated_at"], + ) diff --git a/src/huesoporro/models.py b/src/huesoporro/models.py index f791cf6..7222887 100644 --- a/src/huesoporro/models.py +++ b/src/huesoporro/models.py @@ -1,3 +1,4 @@ +import datetime from typing import Literal import jwt @@ -69,3 +70,17 @@ class Sentence(BaseModel): created_at: float last_updated_at: float user: User + + +class Quote(BaseModel): + quote: str + author: User + channel: User + created_at: datetime.datetime + last_updated_at: datetime.datetime + + def as_pretty(self) -> str: + return f"«{self.quote}» - {self.author}" + + def as_pretty_saved(self): + return f"He añadido la cita «{self.quote}» de {self.author}" diff --git a/src/huesoporro/svc/get_random_quote.py b/src/huesoporro/svc/get_random_quote.py index f6b4f6c..055b633 100644 --- a/src/huesoporro/svc/get_random_quote.py +++ b/src/huesoporro/svc/get_random_quote.py @@ -1,10 +1,11 @@ from pydantic import BaseModel -from src.huesoporro.infra.db import Database +from src.huesoporro.infra.repos import QuoteRepo +from src.huesoporro.models import Quote class RandomQuoteGetterSvc(BaseModel): - db: Database + quote_repo: QuoteRepo - async def run(self, channel_name: str) -> tuple[str, str] | None: - return await self.db.get_random_quote(channel_name=channel_name) + async def run(self, channel_name: str) -> Quote | None: + return await self.quote_repo.get_random(channel_name=channel_name) diff --git a/src/huesoporro/templates/index.html b/src/huesoporro/templates/index.html index e7ffa8c..082c17e 100644 --- a/src/huesoporro/templates/index.html +++ b/src/huesoporro/templates/index.html @@ -5,7 +5,7 @@