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/README.md b/README.md index 79ba9fa..0e96873 100644 --- a/README.md +++ b/README.md @@ -48,5 +48,12 @@ 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 +The models are automatically generated using the OpenAPI spec: +``` +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..ee1d26e --- /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("https://api.cookieapp.me/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 89094a8..dbad1d7 100644 --- a/cookie/api.py +++ b/cookie/api.py @@ -2,23 +2,18 @@ import json import os -from datetime import date, datetime from typing import overload import httpx from dotenv import load_dotenv from .errors import CookieError, InvalidAPIKey, NoGuildAccess, NotFound, QuotaExceeded -from .models import GuildActivity, MemberActivity, MemberStats, UserStats +from .models import GuildActivity, GuildStats, MemberActivity, MemberStats, UserStats DEFAULT_DAYS = 14 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() @@ -101,7 +96,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 +111,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. @@ -134,7 +128,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. @@ -152,10 +146,10 @@ 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 + self, user_id: int, guild_id: int, days: int = DEFAULT_DAYS ) -> MemberActivity: """Get the member's activity for the provided number of days. @@ -174,9 +168,7 @@ 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) + 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. @@ -194,9 +186,7 @@ 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) + 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. @@ -281,7 +271,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 +286,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. @@ -314,7 +303,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. @@ -332,9 +321,11 @@ 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: + 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 @@ -352,9 +343,7 @@ 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) + 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. @@ -372,9 +361,7 @@ 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) + 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. diff --git a/cookie/models.py b/cookie/models.py index a8c81df..c0bb6f7 100644 --- a/cookie/models.py +++ b/cookie/models.py @@ -1,61 +1,120 @@ +# generated by datamodel-codegen: +# filename: https://api.cookieapp.me/openapi.json + 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 date, datetime + +from pydantic import BaseModel, Field + +from ._internal import BaseChart + + +class Chart(BaseChart): + 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") + streak: int = Field(..., title="Streak") + max_streak: int = Field(..., title="Max Streak") + + +class GuildActivity(BaseModel): + 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") + 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: Chart + boosts: Chart + + +class MemberActivity(BaseModel): + 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") + voice_rank: int = Field(..., title="Voice Rank") + 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") + 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") + next_shift: datetime = Field(..., title="Next Shift") + + +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): + cookies: int = Field(..., title="Cookies") + cookie_history: Chart + job: Work + steals: Steals + oven: Oven + daily: Daily + profile_url: str = Field(..., title="Profile Url") diff --git a/pyproject.toml b/pyproject.toml index 7371cdf..ec10c25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,12 +23,13 @@ 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" Cookie = "https://cookieapp.me" Documentation = "https://cookie-api.readthedocs.io" +OpenAPI = "https://api.cookieapp.me/docs" [build-system] requires = ["setuptools>=61.0"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index ccce48b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -httpx -python-dotenv diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..1ba1b57 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,7 @@ +-r requirements.txt +-r docs.txt +pre-commit +pytest +pytest-asyncio +pillow +datamodel-code-generator[http] 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/requirements.txt b/requirements/requirements.txt new file mode 100644 index 0000000..0ca428c --- /dev/null +++ b/requirements/requirements.txt @@ -0,0 +1,4 @@ +httpx +pydantic +python-dotenv +eval_type_backport; python_version<='3.9' diff --git a/tests/test_api.py b/tests/test_api.py index dccd567..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_member_count(GUILD_ID) - assert isinstance(member_count, dict) + 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_member_count(GUILD_ID) - assert isinstance(member_count, dict) + 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) @@ -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)