From cf53ca71389a4cb00b371890687c1e1b2963ca2a Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Wed, 2 Apr 2025 23:47:30 +0200 Subject: [PATCH 01/17] Create `dev-requirements.txt` --- dev-requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 dev-requirements.txt diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..05a9259 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,6 @@ +-r requirements.txt +-r docs/requirements.txt +pre-commit +pytest +pytest-asyncio +pillow From 0226320cc472224aab3e04aac4976f8f4880bd13 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Thu, 3 Apr 2025 10:24:01 +0200 Subject: [PATCH 02/17] Test various generators --- dev-requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dev-requirements.txt b/dev-requirements.txt index 05a9259..9f92c18 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -4,3 +4,6 @@ pre-commit pytest pytest-asyncio pillow +openapi-generator-cli[jdk4py] +openapi-python-client +datamodel-code-generator From 5d5bdcb5f10adc44e5c52d905720fe74178cb605 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:17:02 +0200 Subject: [PATCH 03/17] Add GuildStats model --- cookie/api.py | 16 +++++++--------- tests/test_api.py | 12 ++++++------ 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/cookie/api.py b/cookie/api.py index 89094a8..c1a50f8 100644 --- a/cookie/api.py +++ b/cookie/api.py @@ -9,7 +9,7 @@ from dotenv import load_dotenv from .errors import CookieError, InvalidAPIKey, NoGuildAccess, NotFound, QuotaExceeded -from .models import GuildActivity, MemberActivity, MemberStats, UserStats +from .models import GuildActivity, MemberActivity, MemberStats, UserStats, GuildStats DEFAULT_DAYS = 14 BASE_URL = "https://api.cookieapp.me/v1/" @@ -101,7 +101,7 @@ async def _get(self, endpoint: str, stream: bool = False): return response.json() - async def get_member_count(self, guild_id: int, days: int = DEFAULT_DAYS) -> dict[date, int]: + async def get_guild_stats(self, guild_id: int, days: int = DEFAULT_DAYS) -> GuildStats: """Get the history of the guild member count for the provided number of days. Parameters @@ -116,9 +116,8 @@ async def get_member_count(self, guild_id: int, days: int = DEFAULT_DAYS) -> dic NoGuildAccess: You don't have access to that guild. """ - message_data = await self._get(f"member_count/{guild_id}?days={days}") - - return _stats_dict(message_data) + data = await self._get(f"stats/guild/{guild_id}?days={days}") + return GuildStats(**data) async def get_user_stats(self, user_id: int) -> UserStats: """Get the user's level stats. @@ -281,7 +280,7 @@ def _get(self, endpoint: str, stream: bool = False): return response.json() - def get_member_count(self, guild_id: int, days: int = DEFAULT_DAYS) -> dict[date, int]: + def get_guild_stats(self, guild_id: int, days: int = DEFAULT_DAYS) -> GuildStats: """Get the history of the guild member count for the provided number of days. Parameters @@ -296,9 +295,8 @@ def get_member_count(self, guild_id: int, days: int = DEFAULT_DAYS) -> dict[date NoGuildAccess: You don't have access to that guild. """ - message_data = self._get(f"member_count/{guild_id}?days={days}") - - return _stats_dict(message_data) + data = self._get(f"stats/guild/{guild_id}?days={days}") + return GuildStats(**data) def get_user_stats(self, user_id: int) -> UserStats: """Get the user's level stats. diff --git a/tests/test_api.py b/tests/test_api.py index dccd567..1947870 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,8 +18,8 @@ def test_sync_api(): api = cookie.CookieAPI() - member_count = api.get_member_count(GUILD_ID) - assert isinstance(member_count, dict) + member_count = api.get_guild_stats(GUILD_ID) + assert isinstance(member_count, cookie.GuildStats) user_stats = api.get_user_stats(USER_ID) assert isinstance(user_stats, cookie.UserStats) @@ -50,8 +50,8 @@ async def test_async_api(): async_api = cookie.AsyncCookieAPI() async with async_api as api: - member_count = await api.get_member_count(GUILD_ID) - assert isinstance(member_count, dict) + member_count = await api.get_guild_stats(GUILD_ID) + assert isinstance(member_count, cookie.GuildStats) user_stats = await api.get_user_stats(USER_ID) assert isinstance(user_stats, cookie.UserStats) @@ -83,9 +83,9 @@ async def test_invalid_keys(): with pytest.raises(cookie.InvalidAPIKey): api = cookie.CookieAPI(api_key=invalid_key) - api.get_member_count(GUILD_ID) + api.get_guild_stats(GUILD_ID) with pytest.raises(cookie.InvalidAPIKey): async_api = cookie.AsyncCookieAPI(api_key=invalid_key) async with async_api as api: - await api.get_member_count(GUILD_ID) + await api.get_guild_stats(GUILD_ID) From 6736804014aa6e0edcc85411c16efde038505ff8 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:18:20 +0200 Subject: [PATCH 04/17] Generate models using openapi spec --- README.md | 8 +++ cookie/models.py | 168 ++++++++++++++++++++++++++++--------------- dev-requirements.txt | 4 +- 3 files changed, 118 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 79ba9fa..ce6e4ab 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,11 @@ async def main(): ## ⚡ FastAPI Docs If you want to use the API without this wrapper, you can find the FastAPI docs [here](https://api.cookie-bot.xyz/docs). + + +## 🗿 Models +The models are automatically generated using the OpenAPI spec: +``` +datamodel-codegen --url http://localhost:8000/openapi.json --output cookie/models.py --input-file-type openapi +# TODO +``` diff --git a/cookie/models.py b/cookie/models.py index a8c81df..cb2b29c 100644 --- a/cookie/models.py +++ b/cookie/models.py @@ -1,61 +1,111 @@ +# generated by datamodel-codegen: +# filename: http://localhost:8000/openapi.json +# timestamp: 2025-04-03T20:08:21+00:00 + from __future__ import annotations -from dataclasses import dataclass -from datetime import date - - -@dataclass -class UserStats: - user_id: int - max_streak: int - streak: int - cookies: int - career: str - total_shifts: int - job: str - - -@dataclass -class MemberStats: - user_id: int - guild_id: int - level: int - xp: int - msg_count: int - voice_min: int - voice_xp: int - voice_level: int - current_level_progress: int - current_level_end: int - msg_rank: int - msg_total_members: int - voice_rank: int - voice_total_members: int - - -@dataclass -class MemberActivity: - days: int - user_id: int - guild_id: int - msg_activity: dict[date, int] - voice_activity: dict[date, int] - msg_count: int - voice_min: int - msg_rank: int - voice_rank: int - current_voice_min: int - - -@dataclass -class GuildActivity: - days: int - guild_id: int - msg_activity: dict[date, int] - voice_activity: dict[date, int] - msg_count: int - voice_min: int - top_channel: int - top_channel_messages: int - most_active_user_day: int | None - most_active_user_hour: int | None +from datetime import datetime +from typing import Dict, List, Optional, Union + +from pydantic import BaseModel, Field + + +class GuildActivity(BaseModel): + msg_activity: dict[str, int] = Field(..., title="Msg Activity") + voice_activity: dict[str, int] = Field(..., title="Voice Activity") + msg_count: int = Field(..., title="Msg Count") + voice_min: int = Field(..., title="Voice Min") + top_channel: int | None = Field(..., title="Top Channel") + top_channel_messages: int = Field(..., title="Top Channel Messages") + most_active_user_day: int | None = Field(..., title="Most Active User Day") + most_active_user_hour: int | None = Field(..., title="Most Active User Hour") + + +class GuildStats(BaseModel): + members: dict[str, int] = Field(..., title="Members") + boosts: dict[str, int] = Field(..., title="Boosts") + + +class MemberActivity(BaseModel): + msg_activity: dict[str, int] = Field(..., title="Msg Activity") + voice_activity: dict[str, int] = Field(..., title="Voice Activity") + msg_count: int = Field(..., title="Msg Count") + voice_min: int = Field(..., title="Voice Min") + msg_rank: int = Field(..., title="Msg Rank") + voice_rank: int = Field(..., title="Voice Rank") + current_voice_min: int = Field(..., title="Current Voice Min") + + +class Steals(BaseModel): + total: int = Field(..., title="Total") + users: int = Field(..., title="Users") + successful: int = Field(..., title="Successful") + cookies_gained: int = Field(..., title="Cookies Gained") + cookies_lost: int = Field(..., title="Cookies Lost") + + +class TextLevel(BaseModel): + msg: int = Field(..., title="Msg") + xp: int = Field(..., title="Xp") + level: int = Field(..., title="Level") + current_level_progress: int = Field(..., title="Current Level Progress") + current_level_end: int = Field(..., title="Current Level End") + rank: int = Field(..., title="Rank") + total_members: int = Field(..., title="Total Members") + + +class ValidationError(BaseModel): + loc: list[str | int] = Field(..., title="Location") + msg: str = Field(..., title="Message") + type: str = Field(..., title="Error Type") + + +class VoiceLevel(BaseModel): + minutes: int = Field(..., title="Minutes") + xp: int = Field(..., title="Xp") + level: int = Field(..., title="Level") + rank: int = Field(..., title="Rank") + total_members: int = Field(..., title="Total Members") + streak_days: int = Field(..., title="Streak Days") + cur_voice_min: int = Field(..., title="Cur Voice Min") + max_voice_min: int = Field(..., title="Max Voice Min") + + +class Work(BaseModel): + career: str = Field(..., title="Career") + total_shifts: int = Field(..., title="Total Shifts") + current_shifts: int = Field(..., title="Current Shifts") + job: str = Field(..., title="Job") + job_level: int = Field(..., title="Job Level") + job_ready: bool = Field(..., title="Job Ready") + + +class HTTPValidationError(BaseModel): + detail: list[ValidationError] | None = Field(None, title="Detail") + + +class MemberStats(BaseModel): + level: TextLevel + voice: VoiceLevel + greetings: int = Field(..., title="Greetings") + boost_days: int = Field(..., title="Boost Days") + profile_url: str = Field(..., title="Profile Url") + + +class UserStats(BaseModel): + max_streak: int = Field(..., title="Max Streak") + streak: int = Field(..., title="Streak") + cookies: int = Field(..., title="Cookies") + job: Work + oven_ready: bool = Field(..., title="Oven Ready") + oven_next: datetime = Field(..., title="Oven Next") + daily_ready: bool = Field(..., title="Daily Ready") + daily_next: datetime = Field(..., title="Daily Next") + steals: Steals + total_parties: int = Field(..., title="Total Parties") + cookie_jar_coins: int = Field(..., title="Cookie Jar Coins") + cookie_master_coins: int = Field(..., title="Cookie Master Coins") + fortune_cookies: int = Field(..., title="Fortune Cookies") + cookies_given: int = Field(..., title="Cookies Given") + profile_url: str = Field(..., title="Profile Url") + cookie_history: dict[str, int] = Field(..., title="Cookie History") diff --git a/dev-requirements.txt b/dev-requirements.txt index 9f92c18..fc52164 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -4,6 +4,4 @@ pre-commit pytest pytest-asyncio pillow -openapi-generator-cli[jdk4py] -openapi-python-client -datamodel-code-generator +datamodel-code-generator[http] From e2689b58f8e07dcdfd2bfbba50617376060748e6 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:25:27 +0200 Subject: [PATCH 05/17] Adapt routes to new models --- cookie/api.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/cookie/api.py b/cookie/api.py index c1a50f8..1b16698 100644 --- a/cookie/api.py +++ b/cookie/api.py @@ -9,7 +9,7 @@ from dotenv import load_dotenv from .errors import CookieError, InvalidAPIKey, NoGuildAccess, NotFound, QuotaExceeded -from .models import GuildActivity, MemberActivity, MemberStats, UserStats, GuildStats +from .models import GuildActivity, GuildStats, MemberActivity, MemberStats, UserStats DEFAULT_DAYS = 14 BASE_URL = "https://api.cookieapp.me/v1/" @@ -133,7 +133,7 @@ async def get_user_stats(self, user_id: int) -> UserStats: The user was not found. """ data = await self._get(f"stats/user/{user_id}") - return UserStats(user_id, **data) + return UserStats(**data) async def get_member_stats(self, user_id: int, guild_id: int) -> MemberStats: """Get the member's level stats. @@ -151,7 +151,7 @@ async def get_member_stats(self, user_id: int, guild_id: int) -> MemberStats: The user was not found. """ data = await self._get(f"stats/member/{user_id}/{guild_id}") - return MemberStats(user_id, guild_id, **data) + return MemberStats(**data) async def get_member_activity( self, user_id: int, guild_id: int, days: int = 14 @@ -173,9 +173,9 @@ async def get_member_activity( The user was not found. """ data = await self._get(f"activity/member/{user_id}/{guild_id}?days={days}") - msg_activity = _stats_dict(data.pop("msg_activity")) - voice_activity = _stats_dict(data.pop("voice_activity")) - return MemberActivity(days, user_id, guild_id, msg_activity, voice_activity, **data) + # msg_activity = _stats_dict(data.pop("msg_activity")) + # voice_activity = _stats_dict(data.pop("voice_activity")) + return MemberActivity(**data) async def get_guild_activity(self, guild_id: int, days: int = DEFAULT_DAYS) -> GuildActivity: """Get the guild's activity for the provided number of days. @@ -193,9 +193,9 @@ async def get_guild_activity(self, guild_id: int, days: int = DEFAULT_DAYS) -> G You don't have access to that guild. """ data = await self._get(f"activity/guild/{guild_id}?days={days}") - msg_activity = _stats_dict(data.pop("msg_activity")) - voice_activity = _stats_dict(data.pop("voice_activity")) - return GuildActivity(days, guild_id, msg_activity, voice_activity, **data) + # msg_activity = _stats_dict(data.pop("msg_activity")) + # voice_activity = _stats_dict(data.pop("voice_activity")) + return GuildActivity(**data) async def get_guild_image(self, guild_id: int, days: int = DEFAULT_DAYS) -> bytes: """Get the guild's activity image for the provided number of days. @@ -312,7 +312,7 @@ def get_user_stats(self, user_id: int) -> UserStats: The user was not found. """ data = self._get(f"stats/user/{user_id}") - return UserStats(user_id, **data) + return UserStats(**data) def get_member_stats(self, user_id: int, guild_id: int) -> MemberStats: """Get the member's level stats. @@ -330,7 +330,7 @@ def get_member_stats(self, user_id: int, guild_id: int) -> MemberStats: The user was not found. """ data = self._get(f"stats/member/{user_id}/{guild_id}") - return MemberStats(user_id, guild_id, **data) + return MemberStats(**data) def get_member_activity(self, user_id: int, guild_id: int, days: int = 14) -> MemberActivity: """Get the member's activity for the provided number of days. @@ -350,9 +350,9 @@ def get_member_activity(self, user_id: int, guild_id: int, days: int = 14) -> Me The user was not found. """ data = self._get(f"activity/member/{user_id}/{guild_id}?days={days}") - msg_activity = _stats_dict(data.pop("msg_activity")) - voice_activity = _stats_dict(data.pop("voice_activity")) - return MemberActivity(days, user_id, guild_id, msg_activity, voice_activity, **data) + # msg_activity = _stats_dict(data.pop("msg_activity")) + # voice_activity = _stats_dict(data.pop("voice_activity")) + return MemberActivity(**data) def get_guild_activity(self, guild_id: int, days: int = DEFAULT_DAYS) -> GuildActivity: """Get the guild's activity for the provided number of days. @@ -370,9 +370,9 @@ def get_guild_activity(self, guild_id: int, days: int = DEFAULT_DAYS) -> GuildAc You don't have access to that guild. """ data = self._get(f"activity/guild/{guild_id}?days={days}") - msg_activity = _stats_dict(data.pop("msg_activity")) - voice_activity = _stats_dict(data.pop("voice_activity")) - return GuildActivity(days, guild_id, msg_activity, voice_activity, **data) + # msg_activity = _stats_dict(data.pop("msg_activity")) + # voice_activity = _stats_dict(data.pop("voice_activity")) + return GuildActivity(**data) def get_guild_image(self, guild_id: int, days: int = DEFAULT_DAYS) -> bytes: """Get the guild's activity image for the provided number of days. From 34cfd020c3f96c345d2c0efc486f3505c05bb7b3 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:34:25 +0200 Subject: [PATCH 06/17] Add pydantic requirement --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index ccce48b..57f5cd5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ httpx +pydantic python-dotenv From e2611f5e1a96b73dbbd41ff5b0a4efb787e469c0 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Fri, 4 Apr 2025 19:20:08 +0200 Subject: [PATCH 07/17] Sort requirements --- .github/workflows/checks.yml | 5 +---- .readthedocs.yaml | 2 +- pyproject.toml | 2 +- dev-requirements.txt => requirements/dev.txt | 2 +- docs/requirements.txt => requirements/docs.txt | 3 +-- requirements.txt => requirements/requirements.txt | 0 6 files changed, 5 insertions(+), 9 deletions(-) rename dev-requirements.txt => requirements/dev.txt (78%) rename docs/requirements.txt => requirements/docs.txt (77%) rename requirements.txt => requirements/requirements.txt (100%) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 65cd23a..30d1357 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -19,10 +19,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pillow - pip install pytest - pip install pytest-asyncio + pip install -r requirements/dev.txt - name: Test API env: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 2ab7f91..92756d7 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -15,4 +15,4 @@ sphinx: python: install: - - requirements: docs/requirements.txt + - requirements: requirements/docs.txt diff --git a/pyproject.toml b/pyproject.toml index 7371cdf..798ba3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dynamic = ["dependencies", "version"] [tool.setuptools.dynamic] version = {attr = "cookie.__version__"} -dependencies = {file = "requirements.txt"} +dependencies = {file = "requirements/requirements.txt"} [project.urls] GitHub = "https://github.com/tibue99/cookie-api" diff --git a/dev-requirements.txt b/requirements/dev.txt similarity index 78% rename from dev-requirements.txt rename to requirements/dev.txt index fc52164..1ba1b57 100644 --- a/dev-requirements.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ -r requirements.txt --r docs/requirements.txt +-r docs.txt pre-commit pytest pytest-asyncio diff --git a/docs/requirements.txt b/requirements/docs.txt similarity index 77% rename from docs/requirements.txt rename to requirements/docs.txt index 9e0603f..c420f59 100644 --- a/docs/requirements.txt +++ b/requirements/docs.txt @@ -1,7 +1,6 @@ +-r requirements.txt sphinx furo sphinx-autodoc-typehints sphinx-copybutton myst-parser -httpx -python-dotenv diff --git a/requirements.txt b/requirements/requirements.txt similarity index 100% rename from requirements.txt rename to requirements/requirements.txt From f6d5ac606f71d6676500b797c673308d841b3a4d Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Fri, 4 Apr 2025 19:44:23 +0200 Subject: [PATCH 08/17] Rename member_count vars --- tests/test_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 1947870..5e875a8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,8 +18,8 @@ def test_sync_api(): api = cookie.CookieAPI() - member_count = api.get_guild_stats(GUILD_ID) - assert isinstance(member_count, cookie.GuildStats) + guild_stats = api.get_guild_stats(GUILD_ID) + assert isinstance(guild_stats, cookie.GuildStats) user_stats = api.get_user_stats(USER_ID) assert isinstance(user_stats, cookie.UserStats) @@ -50,8 +50,8 @@ async def test_async_api(): async_api = cookie.AsyncCookieAPI() async with async_api as api: - member_count = await api.get_guild_stats(GUILD_ID) - assert isinstance(member_count, cookie.GuildStats) + guild_stats = await api.get_guild_stats(GUILD_ID) + assert isinstance(guild_stats, cookie.GuildStats) user_stats = await api.get_user_stats(USER_ID) assert isinstance(user_stats, cookie.UserStats) From ed8518906d06236316c94c5e87fa21aca733183a Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Fri, 4 Apr 2025 20:06:50 +0200 Subject: [PATCH 09/17] Insert prod API URL --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index ce6e4ab..6b5c25d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,5 @@ If you want to use the API without this wrapper, you can find the FastAPI docs [ ## 🗿 Models The models are automatically generated using the OpenAPI spec: ``` -datamodel-codegen --url http://localhost:8000/openapi.json --output cookie/models.py --input-file-type openapi -# TODO +datamodel-codegen --url https://api.cookieapp.me/openapi.json --output cookie/models.py --input-file-type openapi ``` From f0f1797b2415d77fb382ae095104b6a878d7824b Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Fri, 4 Apr 2025 20:08:18 +0200 Subject: [PATCH 10/17] Fix default days --- cookie/api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cookie/api.py b/cookie/api.py index 1b16698..a0afb91 100644 --- a/cookie/api.py +++ b/cookie/api.py @@ -154,7 +154,7 @@ async def get_member_stats(self, user_id: int, guild_id: int) -> MemberStats: return MemberStats(**data) async def get_member_activity( - self, user_id: int, guild_id: int, days: int = 14 + self, user_id: int, guild_id: int, days: int = DEFAULT_DAYS ) -> MemberActivity: """Get the member's activity for the provided number of days. @@ -332,7 +332,9 @@ def get_member_stats(self, user_id: int, guild_id: int) -> MemberStats: data = self._get(f"stats/member/{user_id}/{guild_id}") return MemberStats(**data) - def get_member_activity(self, user_id: int, guild_id: int, days: int = 14) -> MemberActivity: + def get_member_activity( + self, user_id: int, guild_id: int, days: int = DEFAULT_DAYS + ) -> MemberActivity: """Get the member's activity for the provided number of days. Parameters From d6bf0d7c23010527c3f63920af823340974da2a4 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Fri, 4 Apr 2025 20:44:26 +0200 Subject: [PATCH 11/17] Regenerate models --- cookie/models.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/cookie/models.py b/cookie/models.py index cb2b29c..dbe7891 100644 --- a/cookie/models.py +++ b/cookie/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: -# filename: http://localhost:8000/openapi.json -# timestamp: 2025-04-03T20:08:21+00:00 +# filename: https://api.cookieapp.me/openapi.json +# timestamp: 2025-04-04T18:24:30+00:00 from __future__ import annotations @@ -10,6 +10,13 @@ from pydantic import BaseModel, Field +class Daily(BaseModel): + ready: bool = Field(..., title="Ready") + next: datetime = Field(..., title="Next") + streak: int = Field(..., title="Streak") + max_streak: int = Field(..., title="Max Streak") + + class GuildActivity(BaseModel): msg_activity: dict[str, int] = Field(..., title="Msg Activity") voice_activity: dict[str, int] = Field(..., title="Voice Activity") @@ -36,6 +43,11 @@ class MemberActivity(BaseModel): current_voice_min: int = Field(..., title="Current Voice Min") +class Oven(BaseModel): + ready: bool = Field(..., title="Ready") + next: datetime = Field(..., title="Next") + + class Steals(BaseModel): total: int = Field(..., title="Total") users: int = Field(..., title="Users") @@ -78,6 +90,7 @@ class Work(BaseModel): job: str = Field(..., title="Job") job_level: int = Field(..., title="Job Level") job_ready: bool = Field(..., title="Job Ready") + next_shift: datetime = Field(..., title="Next Shift") class HTTPValidationError(BaseModel): @@ -93,19 +106,10 @@ class MemberStats(BaseModel): class UserStats(BaseModel): - max_streak: int = Field(..., title="Max Streak") - streak: int = Field(..., title="Streak") cookies: int = Field(..., title="Cookies") + cookie_history: dict[str, int] = Field(..., title="Cookie History") job: Work - oven_ready: bool = Field(..., title="Oven Ready") - oven_next: datetime = Field(..., title="Oven Next") - daily_ready: bool = Field(..., title="Daily Ready") - daily_next: datetime = Field(..., title="Daily Next") steals: Steals - total_parties: int = Field(..., title="Total Parties") - cookie_jar_coins: int = Field(..., title="Cookie Jar Coins") - cookie_master_coins: int = Field(..., title="Cookie Master Coins") - fortune_cookies: int = Field(..., title="Fortune Cookies") - cookies_given: int = Field(..., title="Cookies Given") + oven: Oven + daily: Daily profile_url: str = Field(..., title="Profile Url") - cookie_history: dict[str, int] = Field(..., title="Cookie History") From d91b47ee82a27a5e81d3c0fcf58343d153e19dab Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Sat, 5 Apr 2025 09:02:58 +0200 Subject: [PATCH 12/17] Adjust model generation params --- README.md | 2 +- cookie/models.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6b5c25d..2c06bae 100644 --- a/README.md +++ b/README.md @@ -55,5 +55,5 @@ If you want to use the API without this wrapper, you can find the FastAPI docs [ ## 🗿 Models The models are automatically generated using the OpenAPI spec: ``` -datamodel-codegen --url https://api.cookieapp.me/openapi.json --output cookie/models.py --input-file-type openapi +datamodel-codegen --url https://api.cookieapp.me/openapi.json --output cookie/models.py --input-file-type openapi --use-union-operator --target-python-version 3.9 --use-double-quotes --use-standard-collections --output-model-type pydantic_v2.BaseModel ``` diff --git a/cookie/models.py b/cookie/models.py index dbe7891..3631b2d 100644 --- a/cookie/models.py +++ b/cookie/models.py @@ -1,11 +1,10 @@ # generated by datamodel-codegen: # filename: https://api.cookieapp.me/openapi.json -# timestamp: 2025-04-04T18:24:30+00:00 +# timestamp: 2025-04-05T07:00:13+00:00 from __future__ import annotations from datetime import datetime -from typing import Dict, List, Optional, Union from pydantic import BaseModel, Field From c18b4f9f8e2d70be762757d1ecaaf0fbb168e4e4 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Sat, 5 Apr 2025 10:17:41 +0200 Subject: [PATCH 13/17] Add chart model --- README.md | 2 +- cookie/models.py | 24 ++++++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2c06bae..8cd26b2 100644 --- a/README.md +++ b/README.md @@ -55,5 +55,5 @@ If you want to use the API without this wrapper, you can find the FastAPI docs [ ## 🗿 Models The models are automatically generated using the OpenAPI spec: ``` -datamodel-codegen --url https://api.cookieapp.me/openapi.json --output cookie/models.py --input-file-type openapi --use-union-operator --target-python-version 3.9 --use-double-quotes --use-standard-collections --output-model-type pydantic_v2.BaseModel +datamodel-codegen --url https://api.cookieapp.me/openapi.json --output cookie/models.py --input-file-type openapi --use-union-operator --target-python-version 3.9 --use-double-quotes --use-standard-collections --output-model-type pydantic_v2.BaseModel --disable-timestamp ``` diff --git a/cookie/models.py b/cookie/models.py index 3631b2d..ecda92e 100644 --- a/cookie/models.py +++ b/cookie/models.py @@ -1,14 +1,18 @@ # generated by datamodel-codegen: -# filename: https://api.cookieapp.me/openapi.json -# timestamp: 2025-04-05T07:00:13+00:00 +# filename: http://localhost:8000/openapi.json from __future__ import annotations -from datetime import datetime +from datetime import date, datetime from pydantic import BaseModel, Field +class Chart(BaseModel): + x: list[date] = Field(..., title="X") + y: list[int] = Field(..., title="Y") + + class Daily(BaseModel): ready: bool = Field(..., title="Ready") next: datetime = Field(..., title="Next") @@ -17,8 +21,8 @@ class Daily(BaseModel): class GuildActivity(BaseModel): - msg_activity: dict[str, int] = Field(..., title="Msg Activity") - voice_activity: dict[str, int] = Field(..., title="Voice Activity") + msg_activity: Chart + voice_activity: Chart msg_count: int = Field(..., title="Msg Count") voice_min: int = Field(..., title="Voice Min") top_channel: int | None = Field(..., title="Top Channel") @@ -28,13 +32,13 @@ class GuildActivity(BaseModel): class GuildStats(BaseModel): - members: dict[str, int] = Field(..., title="Members") - boosts: dict[str, int] = Field(..., title="Boosts") + members: Chart + boosts: Chart class MemberActivity(BaseModel): - msg_activity: dict[str, int] = Field(..., title="Msg Activity") - voice_activity: dict[str, int] = Field(..., title="Voice Activity") + msg_activity: Chart + voice_activity: Chart msg_count: int = Field(..., title="Msg Count") voice_min: int = Field(..., title="Voice Min") msg_rank: int = Field(..., title="Msg Rank") @@ -106,7 +110,7 @@ class MemberStats(BaseModel): class UserStats(BaseModel): cookies: int = Field(..., title="Cookies") - cookie_history: dict[str, int] = Field(..., title="Cookie History") + cookie_history: Chart job: Work steals: Steals oven: Oven From 7f6ef8d9d545dd94056029cc494f90f529ca3b08 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Sat, 5 Apr 2025 12:45:51 +0200 Subject: [PATCH 14/17] Add custom chart model and formatter --- README.md | 2 +- cookie/_internal/__init__.py | 1 + cookie/_internal/custom_models.py | 29 +++++++++++++++++++++++++++++ cookie/_internal/formatter.py | 12 ++++++++++++ cookie/_internal/model_generator.py | 24 ++++++++++++++++++++++++ cookie/api.py | 13 ------------- cookie/models.py | 4 +++- 7 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 cookie/_internal/__init__.py create mode 100644 cookie/_internal/custom_models.py create mode 100644 cookie/_internal/formatter.py create mode 100644 cookie/_internal/model_generator.py diff --git a/README.md b/README.md index 8cd26b2..3e90e52 100644 --- a/README.md +++ b/README.md @@ -55,5 +55,5 @@ If you want to use the API without this wrapper, you can find the FastAPI docs [ ## 🗿 Models The models are automatically generated using the OpenAPI spec: ``` -datamodel-codegen --url https://api.cookieapp.me/openapi.json --output cookie/models.py --input-file-type openapi --use-union-operator --target-python-version 3.9 --use-double-quotes --use-standard-collections --output-model-type pydantic_v2.BaseModel --disable-timestamp +python cookie/_internal/model_generator.py ``` diff --git a/cookie/_internal/__init__.py b/cookie/_internal/__init__.py new file mode 100644 index 0000000..d36056b --- /dev/null +++ b/cookie/_internal/__init__.py @@ -0,0 +1 @@ +from .custom_models import BaseChart diff --git a/cookie/_internal/custom_models.py b/cookie/_internal/custom_models.py new file mode 100644 index 0000000..9938e24 --- /dev/null +++ b/cookie/_internal/custom_models.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel + + +class BaseChart(BaseModel): + """Base class for all charts that allows dictionary usage.""" + + def to_dict(self): + try: + return {d: value for d, value in zip(getattr(self, "x"), getattr(self, "y"))} + except AttributeError: + return self.model_dump() + + def __getitem__(self, item): + return self.to_dict()[item] + + def values(self): + return self.to_dict().values() + + def keys(self): + return self.to_dict().keys() + + def items(self): + return self.to_dict().items() + + def __iter__(self): + return iter(self.to_dict()) + + def __len__(self): + return len(self.to_dict()) diff --git a/cookie/_internal/formatter.py b/cookie/_internal/formatter.py new file mode 100644 index 0000000..a74fa79 --- /dev/null +++ b/cookie/_internal/formatter.py @@ -0,0 +1,12 @@ +from datamodel_code_generator.format import CustomCodeFormatter + + +class CodeFormatter(CustomCodeFormatter): + def apply(self, code: str) -> str: + # Import BaseChart + code = code.replace("\nclass", "from ._internal import BaseChart\n\n\nclass", 1) + + # Let BaseChart inherit from BaseModel + code = code.replace("class Chart(BaseModel)", "class Chart(BaseChart)") + + return code diff --git a/cookie/_internal/model_generator.py b/cookie/_internal/model_generator.py new file mode 100644 index 0000000..0f34202 --- /dev/null +++ b/cookie/_internal/model_generator.py @@ -0,0 +1,24 @@ +"""Code for model generation.""" + +from pathlib import Path +from urllib.parse import urlparse + +from datamodel_code_generator import ( + DataModelType, + InputFileType, + PythonVersion, + generate, +) + +generate( + urlparse("http://localhost:8000/openapi.json"), + input_file_type=InputFileType.OpenAPI, + use_union_operator=True, + use_double_quotes=True, + use_standard_collections=True, + target_python_version=PythonVersion.PY_39, + custom_formatters=["formatter"], + output=Path("cookie/models.py"), + output_model_type=DataModelType.PydanticV2BaseModel, + disable_timestamp=True, +) diff --git a/cookie/api.py b/cookie/api.py index a0afb91..dbad1d7 100644 --- a/cookie/api.py +++ b/cookie/api.py @@ -2,7 +2,6 @@ import json import os -from datetime import date, datetime from typing import overload import httpx @@ -15,10 +14,6 @@ BASE_URL = "https://api.cookieapp.me/v1/" -def _stats_dict(data: dict[str, int]) -> dict[date, int]: - return {datetime.strptime(d, "%Y-%m-%d").date(): count for d, count in data.items()} - - def _handle_error(response: httpx.Response): try: data = response.json() @@ -173,8 +168,6 @@ async def get_member_activity( The user was not found. """ data = await self._get(f"activity/member/{user_id}/{guild_id}?days={days}") - # msg_activity = _stats_dict(data.pop("msg_activity")) - # voice_activity = _stats_dict(data.pop("voice_activity")) return MemberActivity(**data) async def get_guild_activity(self, guild_id: int, days: int = DEFAULT_DAYS) -> GuildActivity: @@ -193,8 +186,6 @@ async def get_guild_activity(self, guild_id: int, days: int = DEFAULT_DAYS) -> G You don't have access to that guild. """ data = await self._get(f"activity/guild/{guild_id}?days={days}") - # msg_activity = _stats_dict(data.pop("msg_activity")) - # voice_activity = _stats_dict(data.pop("voice_activity")) return GuildActivity(**data) async def get_guild_image(self, guild_id: int, days: int = DEFAULT_DAYS) -> bytes: @@ -352,8 +343,6 @@ def get_member_activity( The user was not found. """ data = self._get(f"activity/member/{user_id}/{guild_id}?days={days}") - # msg_activity = _stats_dict(data.pop("msg_activity")) - # voice_activity = _stats_dict(data.pop("voice_activity")) return MemberActivity(**data) def get_guild_activity(self, guild_id: int, days: int = DEFAULT_DAYS) -> GuildActivity: @@ -372,8 +361,6 @@ def get_guild_activity(self, guild_id: int, days: int = DEFAULT_DAYS) -> GuildAc You don't have access to that guild. """ data = self._get(f"activity/guild/{guild_id}?days={days}") - # msg_activity = _stats_dict(data.pop("msg_activity")) - # voice_activity = _stats_dict(data.pop("voice_activity")) return GuildActivity(**data) def get_guild_image(self, guild_id: int, days: int = DEFAULT_DAYS) -> bytes: diff --git a/cookie/models.py b/cookie/models.py index ecda92e..49b4d30 100644 --- a/cookie/models.py +++ b/cookie/models.py @@ -7,8 +7,10 @@ from pydantic import BaseModel, Field +from ._internal import BaseChart -class Chart(BaseModel): + +class Chart(BaseChart): x: list[date] = Field(..., title="X") y: list[int] = Field(..., title="Y") From 5afbdfdea5145f0f579a2e8a59fc75824252b7b5 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Sat, 5 Apr 2025 12:48:39 +0200 Subject: [PATCH 15/17] Use production URL --- cookie/_internal/model_generator.py | 2 +- cookie/models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cookie/_internal/model_generator.py b/cookie/_internal/model_generator.py index 0f34202..ee1d26e 100644 --- a/cookie/_internal/model_generator.py +++ b/cookie/_internal/model_generator.py @@ -11,7 +11,7 @@ ) generate( - urlparse("http://localhost:8000/openapi.json"), + urlparse("https://api.cookieapp.me/openapi.json"), input_file_type=InputFileType.OpenAPI, use_union_operator=True, use_double_quotes=True, diff --git a/cookie/models.py b/cookie/models.py index 49b4d30..c0bb6f7 100644 --- a/cookie/models.py +++ b/cookie/models.py @@ -1,5 +1,5 @@ # generated by datamodel-codegen: -# filename: http://localhost:8000/openapi.json +# filename: https://api.cookieapp.me/openapi.json from __future__ import annotations From b9cebce8fbf67182b70aa221af8198c6d1112732 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Sat, 5 Apr 2025 13:04:39 +0200 Subject: [PATCH 16/17] Add OpenAPI docs URL --- README.md | 4 ++-- pyproject.toml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3e90e52..0e96873 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ async def main(): user_stats = await con.get_user_stats(123456789) # Replace with user ID ``` -## ⚡ FastAPI Docs -If you want to use the API without this wrapper, you can find the FastAPI docs [here](https://api.cookie-bot.xyz/docs). +## ⚡ OpenAPI Docs +If you want to use the API without this wrapper, you can find the OpenAPI docs [here](https://api.cookieapp.me/docs). ## 🗿 Models diff --git a/pyproject.toml b/pyproject.toml index 798ba3b..ec10c25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = {file = "requirements/requirements.txt"} GitHub = "https://github.com/tibue99/cookie-api" Cookie = "https://cookieapp.me" Documentation = "https://cookie-api.readthedocs.io" +OpenAPI = "https://api.cookieapp.me/docs" [build-system] requires = ["setuptools>=61.0"] From 9b512aa8fd0a04f428266a915c239f180722ac52 Mon Sep 17 00:00:00 2001 From: Timo <35654063+tibue99@users.noreply.github.com> Date: Sat, 5 Apr 2025 13:08:19 +0200 Subject: [PATCH 17/17] Fix Python 3.9 --- requirements/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 57f5cd5..0ca428c 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,3 +1,4 @@ httpx pydantic python-dotenv +eval_type_backport; python_version<='3.9'