From b9b1fdc109cecdcffa45ce795feb988f98e88454 Mon Sep 17 00:00:00 2001 From: extreme4all <40169115+extreme4all@users.noreply.github.com> Date: Sun, 27 Oct 2024 12:10:47 +0100 Subject: [PATCH] added itemdb endpoint --- .github/workflows/python-package.yml | 37 +++++- README.md | 31 ++++- osrs/async_api/osrs/itemdb.py | 185 +++++++++++++++++++++++++++ tests/test_async_hiscore.py | 6 +- tests/test_async_itemdb.py | 104 +++++++++++++++ 5 files changed, 352 insertions(+), 11 deletions(-) create mode 100644 osrs/async_api/osrs/itemdb.py create mode 100644 tests/test_async_itemdb.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 34fb0f4..986fd9a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -1,6 +1,4 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - +# This workflow will install Python dependencies, run tests with pytest, and publish package on successful builds. name: Python package on: @@ -9,25 +7,50 @@ on: - main jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7','3.8', '3.9', '3.10', '3.11'] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-test.txt + + - name: Run tests + run: | + pytest --maxfail=5 --disable-warnings + deploy: + needs: test runs-on: ubuntu-latest - # https://docs.pypi.org/trusted-publishers/using-a-publisher/ - # Specifying a GitHub environment is optional, but strongly encouraged environment: release permissions: - # IMPORTANT: this permission is mandatory for trusted publishing id-token: write steps: - uses: actions/checkout@v4 + - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.x' + - name: Install dependencies run: | python -m pip install --upgrade pip pip install build + - name: Build package run: python -m build + - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/README.md b/README.md index fbe94c7..ef83156 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,33 @@ async def main(): loop = asyncio.get_running_loop() await loop.create_task(main()) -``` \ No newline at end of file +``` +```py +import asyncio +from aiohttp import ClientSession +from osrs.catalogue import Catalogue, Mode # Assuming this is the file/module name for your code + +async def main(): + # Initialize the Catalogue with optional proxy and rate limiter + catalogue = Catalogue(proxy="") + + async with ClientSession() as session: + # Example 1: Fetching items by alphabetical filter + alpha = "A" # Items starting with "A" + page = 1 # First page of results + category = 1 # Category identifier, for OSRS there is only 1 category + items = await catalogue.get_items(session, alpha=alpha, page=page, mode=Mode.OLDSCHOOL, category=category) + print("Fetched Items:", items) + + # Example 2: Fetching detailed information for a specific item + item_id = 4151 # Example item ID (Abyssal whip in OSRS) + item_detail = await catalogue.get_detail(session, item_id=item_id, mode=Mode.OLDSCHOOL) + print("Item Detail:", item_detail) + + # Example 3: Fetching historical trade data (price graph) for a specific item + trade_history = await catalogue.get_graph(session, item_id=item_id, mode=Mode.OLDSCHOOL) + print("Trade History:", trade_history) + +# Run the asynchronous main function +asyncio.run(main()) +``` \ No newline at end of file diff --git a/osrs/async_api/osrs/itemdb.py b/osrs/async_api/osrs/itemdb.py new file mode 100644 index 0000000..b8ad2ba --- /dev/null +++ b/osrs/async_api/osrs/itemdb.py @@ -0,0 +1,185 @@ +import json +import logging +from datetime import datetime +from enum import Enum +from typing import Union + +from aiohttp import ClientSession +from pydantic import BaseModel, HttpUrl + +from osrs.utils import RateLimiter + +logger = logging.getLogger(__name__) + + +class Mode(str, Enum): + OLDSCHOOL: str = "itemdb_oldschool" + RS3: str = "itemdb_rs" + + +class CurrentPrice(BaseModel): + trend: str + price: int | str # Price can be an int or a formatted string (e.g., "15.5k") + + +class TodayPrice(BaseModel): + trend: str + price: int | str # Price can also be an int or string (e.g., "+2") + + +class Item(BaseModel): + icon: HttpUrl + icon_large: HttpUrl + id: int + type: str + typeIcon: HttpUrl + name: str + description: str + current: CurrentPrice + today: TodayPrice + members: str | bool + + +class Items(BaseModel): + total: int + items: list[Item] + + +class PriceDetail(BaseModel): + trend: str + price: Union[int, str] # Price can be int or formatted string (e.g., "+9") + + +class ChangeDetail(BaseModel): + trend: str + change: str # Change is a percentage string (e.g., "+8.0%") + + +class ItemDetail(BaseModel): + icon: HttpUrl + icon_large: HttpUrl + id: int + type: str + typeIcon: HttpUrl + name: str + description: str + current: PriceDetail + today: PriceDetail + members: str # Boolean-like strings ("true"/"false") + day30: ChangeDetail + day90: ChangeDetail + day180: ChangeDetail + + +class Detail(BaseModel): + item: ItemDetail + + +class TradeHistory(BaseModel): + daily: dict[datetime, int] + average: dict[datetime, int] + + +class Catalogue: + BASE_URL = "https://secure.runescape.com" + + def __init__( + self, proxy: str = "", rate_limiter: RateLimiter = RateLimiter() + ) -> None: + """Initialize the Catalogue with an optional proxy and rate limiter. + + Args: + proxy (str): Proxy URL to use for API requests. Defaults to "". + rate_limiter (RateLimiter): Rate limiter to manage request throttling. + Defaults to a new RateLimiter instance. + """ + self.proxy = proxy + self.rate_limiter = rate_limiter + + async def get_items( + self, + session: ClientSession, + alpha: str, + page: int | None = 1, + mode: Mode = Mode.OLDSCHOOL, + category: int = 1, + ) -> Items: + """Fetch items from the RuneScape item catalog based on alphabetical filter. + + Args: + session (ClientSession): An active aiohttp session for making requests. + alpha (str): Alphabetical character to filter item names. + page (int, optional): Page number to retrieve. Defaults to 1. + mode (Mode, optional): Game mode (RS3 or OLDSCHOOL). Defaults to Mode.OLDSCHOOL. + category (int, optional): Category identifier for items. Defaults to 1. + + Returns: + Items: List of items matching the filter. + """ + await self.rate_limiter.check() + + url = f"{self.BASE_URL}/m={mode.value}/api/catalogue/items.json" + params = {"category": category, "alpha": alpha, "page": page} + params = {k: v for k, v in params.items()} + + logger.info(f"[GET]: {url=}, {params=}") + + async with session.get(url, proxy=self.proxy, params=params) as response: + response.raise_for_status() + data = await response.text() + return Items(**json.loads(data)) + + async def get_detail( + self, + session: ClientSession, + item_id: int, + mode: Mode = Mode.OLDSCHOOL, + ) -> Detail: + """Fetch detailed information about a specific item. + + Args: + session (ClientSession): An active aiohttp session for making requests. + item_id (int): Unique identifier for the item. + mode (Mode, optional): Game mode (RS3 or OLDSCHOOL). Defaults to Mode.OLDSCHOOL. + + Returns: + Detail: Detailed information about the item. + """ + await self.rate_limiter.check() + + url = f"{self.BASE_URL}/m={mode.value}/api/catalogue/detail.json" + params = {"item": item_id} + + logger.info(f"[GET]: {url=}, {params=}") + + async with session.get(url, proxy=self.proxy, params=params) as response: + response.raise_for_status() + data = await response.text() + return Detail(**json.loads(data)) + + async def get_graph( + self, + session: ClientSession, + item_id: int, + mode: Mode = Mode.OLDSCHOOL, + ) -> TradeHistory: + """Fetch trade history graph data for a specific item. + + Args: + session (ClientSession): An active aiohttp session for making requests. + item_id (int): Unique identifier for the item. + mode (Mode, optional): Game mode (RS3 or OLDSCHOOL). Defaults to Mode.OLDSCHOOL. + + Returns: + TradeHistory: Historical trading data for the item. + """ + await self.rate_limiter.check() + + url = f"{self.BASE_URL}/m={mode.value}/api/graph/{item_id}.json" + + logger.info(f"[GET]: {url=}") + + async with session.get(url, proxy=self.proxy) as response: + response.raise_for_status() + data = await response.text() + return TradeHistory(**json.loads(data)) diff --git a/tests/test_async_hiscore.py b/tests/test_async_hiscore.py index 71d6cf2..e2e991f 100644 --- a/tests/test_async_hiscore.py +++ b/tests/test_async_hiscore.py @@ -1,13 +1,13 @@ import pytest from aiohttp import ClientSession -from osrs.async_api.osrs.hiscores import Mode, PlayerStats, hiscore +from osrs.async_api.osrs.hiscores import Hiscore, Mode, PlayerStats from osrs.exceptions import PlayerDoesNotExist @pytest.mark.asyncio async def test_get_valid(): - hiscore_instance = hiscore() + hiscore_instance = Hiscore() async with ClientSession() as session: player_stats = await hiscore_instance.get( mode=Mode.OLDSCHOOL, @@ -25,7 +25,7 @@ async def test_get_valid(): @pytest.mark.asyncio async def test_get_invalid(): - hiscore_instance = hiscore() + hiscore_instance = Hiscore() async with ClientSession() as session: with pytest.raises(PlayerDoesNotExist): _ = await hiscore_instance.get( diff --git a/tests/test_async_itemdb.py b/tests/test_async_itemdb.py new file mode 100644 index 0000000..b684d34 --- /dev/null +++ b/tests/test_async_itemdb.py @@ -0,0 +1,104 @@ +import pytest +from aiohttp import ClientSession + +from osrs.async_api.osrs.itemdb import Catalogue, Detail, Items, Mode, TradeHistory + + +@pytest.mark.asyncio +async def test_get_items_valid(): + """Test fetching items with valid parameters""" + catalogue_instance = Catalogue() + async with ClientSession() as session: + items = await catalogue_instance.get_items( + session=session, + alpha="a", # Assume items starting with "A" exist + page=1, + mode=Mode.OLDSCHOOL, + category=1, + ) + + # Assertions to confirm the response is correct + assert isinstance(items, Items), "The returned object is not of type Items" + assert items.items, "Items list should not be empty" + assert items.total > 0, "Total count should be greater than zero" + + +@pytest.mark.asyncio +async def test_get_items_invalid_page(): + """Test fetching items with an invalid page number""" + catalogue_instance = Catalogue() + async with ClientSession() as session: + items = await catalogue_instance.get_items( + session=session, + alpha="A", + page=9999, # Assume this page does not exist + mode=Mode.OLDSCHOOL, + category=1, + ) + + # Assertions for an empty result or similar handling + assert isinstance(items, Items), "The returned object is not of type Items" + assert not items.items, "Items list should be empty for a non-existing page" + + +@pytest.mark.asyncio +async def test_get_detail_valid(): + """Test fetching details for a valid item ID""" + catalogue_instance = Catalogue() + async with ClientSession() as session: + item_id = 4151 # Assume this is a valid item ID + item_detail = await catalogue_instance.get_detail( + session=session, item_id=item_id, mode=Mode.OLDSCHOOL + ) + + # Assertions to confirm the response is correct + assert isinstance( + item_detail, Detail + ), "The returned object is not of type Detail" + assert item_detail.item.name == "Abyssal whip", "Unexpected item name returned" + + +@pytest.mark.asyncio +async def test_get_detail_invalid(): + """Test fetching details for an invalid item ID""" + catalogue_instance = Catalogue() + async with ClientSession() as session: + invalid_item_id = 9999999 # Assume this item ID does not exist + with pytest.raises( + Exception + ): # Replace Exception with a specific exception if defined + await catalogue_instance.get_detail( + session=session, item_id=invalid_item_id, mode=Mode.OLDSCHOOL + ) + + +@pytest.mark.asyncio +async def test_get_graph_valid(): + """Test fetching trade history for a valid item ID""" + catalogue_instance = Catalogue() + async with ClientSession() as session: + item_id = 4151 # Assume this is a valid item ID + trade_history = await catalogue_instance.get_graph( + session=session, item_id=item_id, mode=Mode.OLDSCHOOL + ) + + # Assertions to confirm the response is correct + assert isinstance( + trade_history, TradeHistory + ), "The returned object is not of type TradeHistory" + assert trade_history.daily, "Daily trade history should not be empty" + assert trade_history.average, "Average trade history should not be empty" + + +@pytest.mark.asyncio +async def test_get_graph_invalid(): + """Test fetching trade history for an invalid item ID""" + catalogue_instance = Catalogue() + async with ClientSession() as session: + invalid_item_id = 9999999 # Assume this item ID does not exist + with pytest.raises( + Exception + ): # Replace Exception with a specific exception if defined + await catalogue_instance.get_graph( + session=session, item_id=invalid_item_id, mode=Mode.OLDSCHOOL + )