diff --git a/conftest.py b/conftest.py index 459e417..a981554 100644 --- a/conftest.py +++ b/conftest.py @@ -2,6 +2,7 @@ from pathlib import Path import pytest +from aiohttp import ClientSession from dotenv import load_dotenv from src.tf2_utils.utils import read_json_file @@ -81,3 +82,9 @@ def painted_hat() -> dict: @pytest.fixture def ellis_cap() -> dict: return ELLIS_CAP + + +@pytest.fixture +async def aiohttp_session(): + async with ClientSession() as session: + yield session diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c848d88 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto \ No newline at end of file diff --git a/src/tf2_utils/__init__.py b/src/tf2_utils/__init__.py index 5589a30..3516104 100644 --- a/src/tf2_utils/__init__.py +++ b/src/tf2_utils/__init__.py @@ -1,6 +1,7 @@ +# flake8: noqa: F401, F403 __title__ = "tf2-utils" __author__ = "offish" -__version__ = "2.3.5" +__version__ = "2.4.0" __license__ = "MIT" from .currency import CurrencyExchange @@ -8,24 +9,8 @@ from .inventory import Inventory, map_inventory from .item import Item from .item_name import * -from .marketplace_tf import ( - MarketplaceTF, - MarketplaceTFException, - NoAPIKey, - SKUDoesNotMatch, -) +from .marketplace_tf import MarketplaceTF from .offer import Offer -from .prices_tf import ( - EmptyResponse, - InternalServerError, - PricesTF, - PricesTFError, - RateLimited, - UnauthorizedError, -) -from .prices_tf_websocket import PricesTFWebsocket from .schema import SchemaItemsUtils from .sku import * from .utils import * - -# flake8: noqa: F401, F403 diff --git a/src/tf2_utils/instances.py b/src/tf2_utils/instances.py new file mode 100644 index 0000000..9cbb7d5 --- /dev/null +++ b/src/tf2_utils/instances.py @@ -0,0 +1,3 @@ +from .schema import SchemaItemsUtils + +schema = SchemaItemsUtils() diff --git a/src/tf2_utils/marketplace_tf.py b/src/tf2_utils/marketplace_tf.py index a352157..ee9400a 100644 --- a/src/tf2_utils/marketplace_tf.py +++ b/src/tf2_utils/marketplace_tf.py @@ -1,162 +1,46 @@ -import time +from aiohttp import ClientSession -import requests - -from .exceptions import TF2UtilsError -from .schema import SchemaItemsUtils +from .instances import schema from .sku import sku_is_craftable, sku_to_quality_name -class MarketplaceTFException(TF2UtilsError): - pass - - -class SKUDoesNotMatch(MarketplaceTFException): - pass - - -class NoAPIKey(MarketplaceTFException): - pass - - -def api_key_required(func): - def wrapper(self, *args, **kwargs): - if self._api_key is None: - raise NoAPIKey("No API key provided") - - return func(self, *args, **kwargs) - - return wrapper - - class MarketplaceTF: - def __init__(self, api_key: str = None): - self._api_key = api_key - self._schema = SchemaItemsUtils() - self._data = {} - - def _get_request(self, endpoint: str, params: dict = {}) -> dict: - url = "https://marketplace.tf/api" + endpoint - - if self._api_key is not None: - params["key"] = self._api_key - - response = requests.get(url, params=params) - response.raise_for_status() - - return response.json() - - def get_endpoints(self) -> dict: - return self._get_request("/Meta/GetEndpoints/v1") - - @api_key_required - def get_bots(self) -> dict: - return self._get_request("/Bots/GetBots/v2") - - @api_key_required - def get_bans(self, steam_id: str) -> dict: - return self._get_request("/Bans/GetUserBan/v2", {"steamid": steam_id}) - - def get_is_banned(self, steam_id: str) -> bool: - return self.get_bans(steam_id)["result"][0]["banned"] - - def get_name(self, steam_id: str) -> str: - return self.get_bans(steam_id)["result"][0]["name"] - - def get_is_seller(self, steam_id: str) -> bool: - return self.get_bans(steam_id)["result"][0]["seller"] - - def get_seller_id(self, steam_id: str) -> int: - return self.get_bans(steam_id)["result"][0]["id"] - - @api_key_required - def get_dashboard_items(self) -> dict: - return self._get_request("/Seller/GetDashboardItems/v2") - - @api_key_required - def get_sales(self, number: int = 100, start_before: int = 0) -> dict: - if start_before == 0: - start_before = int(time.time()) - - return self._get_request( - "/Seller/GetSales/v2", {"num": number, "start_before": start_before} - ) + def __init__(self, session: ClientSession) -> None: + self.session = session @staticmethod - def _format_url(item_name: str, quality: str, craftable: bool) -> str: + def format_url(item_name: str, quality: str, craftable: bool) -> str: url = "https://api.backpack.tf/item/get_third_party_prices" craftable = "Craftable" if craftable else "Non-Craftable" - return f"{url}/{quality}/{item_name}/Tradable/{craftable}" - def _format_url_sku(self, sku: str) -> str: - item_name = self._schema.sku_to_base_name(sku) + def format_url_sku(self, sku: str) -> str: + item_name = schema.sku_to_base_name(sku) quality = sku_to_quality_name(sku) - - return self._format_url(item_name, quality, sku_is_craftable(sku)) + return self.format_url(item_name, quality, sku_is_craftable(sku)) @staticmethod - def _format_price_to_float(price: str | float) -> float: + def format_price_to_float(price: str | float) -> float: if isinstance(price, float): return price return float(price.replace("$", "")) - def _set_data(self, data: dict) -> None: - self._data = data["prices"]["mp"] - - def fetch_item_raw(self, item_name: str, quality: str, craftable: bool) -> dict: - url = self._format_url(item_name, quality, craftable) - self._set_data(requests.get(url).json()) - - return self._data - - def fetch_item(self, sku: str) -> dict: - url = self._format_url_sku(sku) - self._set_data(requests.get(url).json()) - mptf_sku = self.get_sku() - - if mptf_sku != sku: - raise SKUDoesNotMatch(f"SKU {sku} does not match {mptf_sku}") - - return self._data - - def get_item_data(self) -> dict: - return self._data - - def get_lowest_price(self) -> float: - price = self._data.get("lowest_price", 0.0) - return self._format_price_to_float(price) - - def get_price(self) -> float: - return self.get_lowest_price() - - def get_highest_buy_order(self) -> float: - price = self._data.get("highest_buy_order", 0.0) - return self._format_price_to_float(price) - - def get_buy_order(self) -> float: - return self.get_highest_buy_order() - - def get_stock(self) -> int: - return self._data.get("num_for_sale", 0) - - def get_sku(self) -> str: - return self._data.get("sku", "") - - def fetch_lowest_price(self, sku: str) -> float: - price = self.fetch_item(sku).get("lowest_price", 0.0) - return self._format_price_to_float(price) - - def fetch_price(self, sku: str) -> float: - return self.fetch_lowest_price(sku) + async def fetch_item(self, sku: str) -> dict: + url = self.format_url_sku(sku) - def fetch_highest_buy_order(self, sku: str) -> float: - price = self.fetch_item(sku).get("highest_buy_order") - return self._format_price_to_float(price) + async with self.session.get(url) as resp: + resp.raise_for_status() + data = await resp.json() - def fetch_buy_order(self, sku: str) -> float: - return self.fetch_highest_buy_order(sku) + prices = data["prices"]["mp"] + highest_buy_order = prices["highest_buy_order"] + lowest_price = prices["lowest_price"] + stock = int(prices["num_for_sale"]) - def fetch_stock(self, sku: str) -> int: - return self.fetch_item(sku).get("num_for_sale", 0) + return { + "sku": prices["sku"], + "highest_buy_order": self.format_price_to_float(highest_buy_order), + "lowest_price": self.format_price_to_float(lowest_price), + "stock": stock, + } diff --git a/src/tf2_utils/prices_tf.py b/src/tf2_utils/prices_tf.py deleted file mode 100644 index 8447f69..0000000 --- a/src/tf2_utils/prices_tf.py +++ /dev/null @@ -1,163 +0,0 @@ -import time -from typing import Any - -import requests - -from .exceptions import TF2UtilsError -from .utils import refinedify - - -class PricesTFError(TF2UtilsError): - pass - - -class UnauthorizedError(PricesTFError): - pass - - -class InternalServerError(PricesTFError): - pass - - -class RateLimited(PricesTFError): - pass - - -class EmptyResponse(PricesTFError): - pass - - -class PricesTF: - def __init__(self) -> None: - self.url = "https://api2.prices.tf" - self._access_token = "" - self._headers = {} - - @staticmethod - def format_price(data: dict) -> dict: - buy_keys = data.get("buyKeys", 0) - buy_metal = refinedify(data.get("buyHalfScrap", 0) / 18) - sell_keys = data.get("sellKeys", 0) - sell_metal = refinedify(data.get("sellHalfScrap", 0) / 18) - - return { - "buy": {"keys": buy_keys, "metal": buy_metal}, - "sell": {"keys": sell_keys, "metal": sell_metal}, - } - - @staticmethod - def _validate_response(response: dict[str, Any]) -> None: - if not response: - raise EmptyResponse("response from server was empty") - - status_code = response.get("statusCode") - - if status_code == 401: - raise UnauthorizedError("unauthorized, please request a new access token") - - if status_code == 500: - raise InternalServerError("there was an interal server error") - - if status_code == 429: - raise RateLimited("currently ratelimited") - - def _set_header(self, header: dict) -> None: - self._headers = header - - def _get(self, endpoint: str, params: dict = {}) -> dict: - url = self.url + endpoint - response = requests.get(url, headers=self._headers, params=params) - res = response.json() - self._validate_response(res) - - return res - - def _post(self, endpoint: str) -> tuple[dict, int]: - url = self.url + endpoint - response = requests.post(url, headers=self._headers) - res = response.json() - self._validate_response(res) - - return (res, response.status_code) - - def get_prices_till_page( - self, page_limit: int, print_rate_limit: bool = False - ) -> dict: - prices = {} - current_page = 1 - # set higher than current page first time - max_page = page_limit if page_limit != -1 else 2 - - while current_page < max_page: - try: - response = self.get_prices(current_page) - except RateLimited: - timeout = 60 - - if print_rate_limit: - print(f"We are rate limited, waiting {timeout} seconds...") - - time.sleep(timeout) - continue - except UnauthorizedError: - if print_rate_limit: - print("We are unauthorized, requesting new access token...") - - self.request_access_token() - continue - - if "items" not in response: - raise PricesTFError("could not find any items in response") - - for item in response["items"]: - prices[item["sku"]] = self.format_price(item) - - current_page = response["meta"]["currentPage"] + 1 - total_pages = response["meta"]["totalPages"] - - if page_limit == -1: - max_page = total_pages - - return prices - - def get_history( - self, sku: str, page: int = 1, limit: int = 100, order: str = "ASC" - ) -> dict: - return self._get( - f"/history/{sku}", {"page": page, "limit": limit, "order": order} - ) - - def get_price(self, sku: str) -> dict: - return self._get(f"/prices/{sku}") - - def get_prices(self, page: int, limit: int = 100, order: str = "DESC") -> dict: - return self._get("/prices", {"page": page, "limit": limit, "order": order}) - - def get_all_prices(self, print_rate_limit: bool = False) -> dict: - return self.get_prices_till_page(-1, print_rate_limit) - - def update_price(self, sku: str) -> tuple[dict, int]: - return self._post(f"/prices/{sku}/refresh") - - def request_access_token(self) -> None: - res, _ = self._post("/auth/access") - self._validate_response(res) - self._access_token = res["accessToken"] - - self._set_header( - { - "accept": "application/json", - "Authorization": f"Bearer {self._access_token}", - } - ) - - @property - def access_token(self) -> str: - if not self._access_token: - raise PricesTFError("Access token was never set!") - - return self._access_token - - @property - def headers(self) -> dict: - return self._headers diff --git a/src/tf2_utils/prices_tf_websocket.py b/src/tf2_utils/prices_tf_websocket.py deleted file mode 100644 index e4234b0..0000000 --- a/src/tf2_utils/prices_tf_websocket.py +++ /dev/null @@ -1,54 +0,0 @@ -import json -from typing import Callable - -from websockets.sync.client import ClientConnection, connect - -from .prices_tf import PricesTF - - -class PricesTFWebsocket: - def __init__( - self, - callback: Callable[[dict], None], - settings: dict = {}, - ) -> None: - """ - Args: - callback: Function pointer where you want the data to end up - settings: Additional websocket settings as a dict to be unpacked - """ - self._callback = callback - self._prices_tf = PricesTF() - self._settings = settings - - def _process_message(self, ws: ClientConnection, message: str) -> None: - data = json.loads(message) - - if data.get("type") != "AUTH_REQUIRED": - self._callback(data) - return - - # our auths are only valid for 10 minutes at a time - # pricestf requests us to authenticate again - self._prices_tf.request_access_token() - - payload = { - "type": "AUTH", - "data": {"accessToken": self._prices_tf.access_token}, - } - - ws.send(json.dumps(payload)) - - def listen(self) -> None: - """Listen for messages from PricesTF.""" - self._prices_tf.request_access_token() - headers = self._prices_tf.headers - - with connect( - "wss://ws.prices.tf", - additional_headers=headers, - **self._settings, - ) as websocket: - while True: - message = websocket.recv() - self._process_message(websocket, message) diff --git a/tests/test_marketplace_tf.py b/tests/test_marketplace_tf.py index 0becb3a..83cfd80 100644 --- a/tests/test_marketplace_tf.py +++ b/tests/test_marketplace_tf.py @@ -1,69 +1,18 @@ -from src.tf2_utils import MarketplaceTF +from aiohttp import ClientSession -mplc = None +from src.tf2_utils.marketplace_tf import MarketplaceTF -def test_init() -> None: - global mplc - mplc = MarketplaceTF() +async def test_fetch_data(aiohttp_session: ClientSession) -> None: + sku = "5021;6" + mplc = MarketplaceTF(aiohttp_session) + data = await mplc.fetch_item(sku) - -def test_key_data() -> None: - price = mplc.fetch_item("5021;6") - - assert mplc.get_sku() == "5021;6" - assert mplc.get_stock() > 1 - assert mplc.get_price() > 1.4 - assert mplc.get_highest_buy_order() < 2.5 - assert mplc.get_item_data() == price - assert mplc.get_lowest_price() == mplc.get_price() - - -# def test_no_api_key(marketplace_tf_api_key: str) -> None: -# global mplc -# mplc = MarketplaceTF() -# endpoints = mplc.get_endpoints() - -# assert "endpoints" in endpoints - -# with pytest.raises(NoAPIKey): -# mplc.get_bots() - -# with pytest.raises(NoAPIKey): -# mplc.get_is_banned("76561198253325712") - -# mplc._api_key = marketplace_tf_api_key - - -# def test_get_bots() -> None: -# assert mplc.get_bots()["success"] - - -# def test_get_bans() -> None: -# assert "confern" == mplc.get_name("76561198253325712") -# assert mplc.get_is_seller("76561198253325712") -# assert not mplc.get_is_banned("76561198253325712") -# assert mplc.get_seller_id("76561198253325712") == 195002 - -# assert mplc.get_is_banned("76561198115857578") -# assert not mplc.get_is_seller("76561198115857578") - - -# def test_get_dashboard_items() -> None: -# dashboard = mplc.get_dashboard_items() - -# assert "items" in dashboard -# assert dashboard["success"] - - -# def test_get_sales() -> None: -# sales = mplc.get_sales() - -# assert "sales" in sales -# assert sales["success"] - -# sales = mplc.get_sales(number=1) -# assert len(sales["sales"]) == 1 - -# sales = mplc.get_sales(start_before=0) -# assert sales == {} + assert "sku" in data + assert "highest_buy_order" in data + assert "lowest_price" in data + assert "stock" in data + assert data["sku"] == sku + assert isinstance(data["highest_buy_order"], float) + assert isinstance(data["lowest_price"], float) + assert isinstance(data["stock"], int) diff --git a/tests/test_prices_tf.py b/tests/test_prices_tf.py deleted file mode 100644 index 328a200..0000000 --- a/tests/test_prices_tf.py +++ /dev/null @@ -1,72 +0,0 @@ -import pytest - -from src.tf2_utils import PricesTF, UnauthorizedError - -prices_tf = PricesTF() - - -def test_inital() -> None: - assert prices_tf._access_token == "" - assert prices_tf._headers == {} - - with pytest.raises(UnauthorizedError): - prices_tf.get_price("5021;6") - - prices_tf.request_access_token() - - assert prices_tf._access_token != "" - assert prices_tf._headers != {} - - -def test_get_price() -> None: - price = prices_tf.get_price("5021;6") - - assert isinstance(price, dict) - assert "sku" in price - assert "buyHalfScrap" in price - assert "buyKeys" in price - assert "buyKeyHalfScrap" in price - assert "sellHalfScrap" in price - assert "sellKeys" in price - assert "sellKeyHalfScrap" in price - assert "createdAt" in price - assert "updatedAt" in price - - -def test_formatting_price() -> None: - item = { - "sku": "5021;6", - "buyHalfScrap": 1210, - "buyKeys": 0, - "buyKeyHalfScrap": None, - "sellHalfScrap": 1212, - "sellKeys": 0, - "sellKeyHalfScrap": None, - "createdAt": "2021-10-11T23:05:32.696Z", - "updatedAt": "2025-04-03T16:36:22.624Z", - } - formatted_price = prices_tf.format_price(item) - - assert formatted_price["buy"]["keys"] == 0 - assert formatted_price["buy"]["metal"] == 67.22 - assert formatted_price["sell"]["keys"] == 0 - assert formatted_price["sell"]["metal"] == 67.33 - - -def test_get_prices_till_page() -> None: - pages = 2 - prices = prices_tf.get_prices_till_page(pages) - - assert len(prices) == pages * 50 - - for sku in prices: - price = prices[sku] - - assert isinstance(price, dict) - assert "buy" in price - assert "sell" in price - assert "keys" in price["buy"] - assert "metal" in price["buy"] - assert "keys" in price["sell"] - assert "metal" in price["sell"] - break diff --git a/tests/test_providers.py b/tests/test_providers.py index 4e6d24b..8d21475 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -10,7 +10,7 @@ def test_steam_community_inventory(steam_id: str) -> None: assert url == f"https://steamcommunity.com/inventory/{steam_id}/440/2" assert params == { "l": "english", - "count": 5000, + "count": 2500, } assert provider.headers == {}