diff --git a/roblox/bases/basecatalogitem.py b/roblox/bases/basecatalogitem.py new file mode 100644 index 00000000..b0eb612b --- /dev/null +++ b/roblox/bases/basecatalogitem.py @@ -0,0 +1,47 @@ +""" + +This file contains the BaseCatalogItem object, which represents a Roblox catalog item ID. + +""" + +from __future__ import annotations +from typing import TYPE_CHECKING + +from .baseitem import BaseItem + +if TYPE_CHECKING: + from ..client import Client + + +class BaseCatalogItem(BaseItem): + """ + Represents a catalog item ID. + Instance IDs represent the ownership of a single Roblox item. + + Attributes: + id: The item ID. + item_type: The item's type, either 1 or 2. + """ + + def __init__(self, client: Client, catalog_item_id: int): + """ + Arguments: + client: The Client this object belongs to. + catalog_item_id: The ID of the catalog item. + """ + + self._client: Client = client + self.id: int = catalog_item_id + self.item_type: int = catalog_item_type + + # We need to redefine these special methods, as an asset and a bundle can have the same ID but not the same item_type + def __repr__(self): + return f"<{self.__class__.__name__} id={self.id} item_type={self.item_type}>" + + def __eq__(self, other): + return isinstance(other, self.__class__) and (other.id == self.id) and (other.item_type == self.item_type) + + def __ne__(self, other): + if isinstance(other, self.__class__): + return (other.id != self.id) and (other.item_type != self.item_type) + return True diff --git a/roblox/catalog.py b/roblox/catalog.py new file mode 100644 index 00000000..38ea3cd0 --- /dev/null +++ b/roblox/catalog.py @@ -0,0 +1,110 @@ +""" + +This module contains classes intended to parse and deal with data from Roblox catalog endpoint. + +""" +from __future__ import annotations +from datetime import datetime +from uuid import UUID +from dateutil.parser import parse + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .client import Client + from typing import Optional, Union +from .bases.basecatalogitem import BaseCatalogItem +from .bases.baseuser import BaseUser +from .assets import AssetType +from .partials.partialgroup import PartialGroup +from .partials.partialuser import CatalogCreatorPartialUser + +class CatalogItem(BaseCatalogItem): + """ + Represents a Catalog/Avatar Shop/Marketplace item. + + Attributes: + id: The item's ID. + name: The item's name. + item_type: Unknown. + asset_type: The asset's type as an instance of AssetType + description: The item's description. + is_offsale: If the item is offsale. + creator: A class representing the creator of the item. + price: The price of the item, in Robux. + purchase_count: The number of times the item has been purchased. + favorite_count: The number of times the item has been favorited. + sale_location_type: Unknown. + premium_pricing: A dictionary storing information about pricing for Roblox Premium members. + premium_pricing.in_robux: The pricing for Roblox Premium members, in Robux. + premium_pricing.discount_percentage: The percentage that Roblox Premium members get discounted. + """ + + def __init__(self, client: Client, data: dict): + self._client: Client = client + self.id: int = data["id"] + self.item_type = data["itemType"] + super().__init__(client=self._client, catalog_item_id=self.id, catalog_item_type=self.item_type) + + self.name: str = data["name"] + self.description: str = data["description"] + + self.asset_type: AssetType = AssetType(type_id=data["assetType"]) + + self.is_offsale: bool = data["isOffsale"] + + # Creator + self.creator: Union[CatalogCreatorPartialUser, CatalogCreatorPartialGroup] + if data["creatorType"] == "User": + self.creator = CatalogCreatorPartialUser(client=client, data=data) + elif data["creatorType"] == "Group": + self.creator = CatalogCreatorPartialGroup(client=client, group_id=data) + + self.price: int = data["price"] + self.purchase_count: int = data["purchaseCount"] + self.favorite_count: int = data["favoriteCount"] + self.sale_location_type: str = data["saleLocationType"] + + + + if data["premiumPricing"]: + self.premium_pricing = {} + self.premium_pricing.in_robux: int = data["premiumPricing"]["premiumPriceInRobux"] + self.premium_pricing.discount_percentage: int = data["premiumPricing"]["premiumDiscountPercentage"] + + + def __repr__(self): + return f"<{self.__class__.__name__} name={self.name!r}>" + + +class LimitedCatalogItem(CatalogItem): + """ + Represents a limited Catalog/Avatar Shop/Marketplace item. + + See also: + CatalogItem, which this class inherits. + + Attributes: + collectible_item_id: Unknown. + quantity_limit_per_user: The maximum number of this item that a user can own. + units_available_for_consumption: The amount of items that can be bought by all users. + total_quantity: The amount of items that are owned or can be purchased. + has_resellers: If the item has resellers. + offsale_deadline: The time that an item goes offsale (as an instance of a datetime.datetime object). + lowest_price: The lowest price, in Robux, offered to obtain this item. + lowest_resale_price: The lowest resale price, in Robux, offered to obtain this item. + price_status: Unknown. + """ + + def __init__(self, client=client, data=data): + super.__init__(client=client, data=data) + + self.collectible_item_id: UUID = UUID(data["collectibleItemId"]) + self.quantity_limit_per_user: int = data["quantityLimitPerUser"] + self.units_available_for_consumption: int = data["unitsAvailableForConsumption"] + self.total_quantity: int = data["totalQuantity"] + self.has_resellers: bool = data["hasResellers"] + self.offsale_deadline: Optional[datetime] = parse(data["offsaleDeadline"]) + self.lowest_price: int = data["lowestPrice"] + self.lowest_resale_price: int = data["lowestResalePrice"] + self.price_status: str = data["priceStatus"] diff --git a/roblox/client.py b/roblox/client.py index fbea3a43..3b47264d 100644 --- a/roblox/client.py +++ b/roblox/client.py @@ -4,7 +4,7 @@ """ -from typing import Union, List, Optional +from typing import Union, List, Optional, Literal, TypedDict from .account import AccountProvider from .assets import EconomyAsset @@ -550,3 +550,47 @@ def get_base_gamepass(self, gamepass_id: int) -> BaseGamePass: Returns: A BaseGamePass. """ return BaseGamePass(client=self, gamepass_id=gamepass_id) + + # Catalog + def get_catalog_items(self, catalog_item_array: List[TypedDict[catalog_id: int, catalog_item_type: Literal[1, 2]]]) -> List[CatalogItem]: + """ + Gets a catalog item with the passed ID. + + The catalog is also known as the Avatar Shop or the Marketplace. + + Arguments: + catalog_id: A Roblox catalog item ID. + catalog_item_type: The type of item. 1 for an asset, and 2 for a bundle. + + Returns: + A list of CatalogItem. + """ + try: + catalog_item_response = await self._requests.post( + url=self._url_generator.get_url( + "catalog", "v1/catalog/items/details" + ), + json={"data": catalog_item_array} + ) + except NotFound as exception: + raise CatalogItemNotFound( + message="Invalid catalog item.", + response=exception.response + ) from None + catalog_item_data = catalog_item_response.json() + catalog_list: Literal[CatalogItem] = [] + for catalog_item in catalog_item_data: + if data["collectibleItemId"]: # This is the only consistent indicator of an item's limited status + catalog_list.append(LimitedCatalogItem(client=self, data=catalog_item)) + else: + catalog_list.append(CatalogItem(client=self, data=catalog_item)) + + return catalog_list + + def get_base_catalog_items(self, catalog_item_array: List[TypedDict[catalog_id: int, catalog_item_type: Literal[1, 2]]]) -> List[CatalogItem]: + catalog_list: Literal[CatalogItem] = [] + + for catalog_item in catalog_item_array: + catalog_list.append(BaseCatalogItem(client=self, data=catalog_item)) + + return catalog_list diff --git a/roblox/partials/partialgroup.py b/roblox/partials/partialgroup.py index fa4574a7..6c58812b 100644 --- a/roblox/partials/partialgroup.py +++ b/roblox/partials/partialgroup.py @@ -72,3 +72,34 @@ def __init__(self, client: Client, data: dict): def __repr__(self): return f"<{self.__class__.__name__} id={self.id} name={self.name!r}>" + + +class CatalogCreatorPartialGroup(BaseGroup): + """ + Represents a partial group in the context of a catalog item. + + Attributes: + _data: The data we get back from the endpoint. + _client: The client object, which is passed to all objects this client generates. + id: Id of the group + name: Name of the group + has_verified_badge: If the group has a verified badge. + """ + + def __init__(self, client: Client, data: dict): + """ + Arguments: + client: The ClientSharedObject. + data: The data from the endpoint. + """ + self._client: Client = client + + super().__init__(client=client, data=data) + + self.has_verified_badge: bool = data["creatorHasVerifiedBadge"] + self.id: int = data["creatorTargetId"] + self.name: str = data["creatorName"] + + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.id} name={self.name!r}>" diff --git a/roblox/partials/partialuser.py b/roblox/partials/partialuser.py index 17eaa974..5dff0a27 100644 --- a/roblox/partials/partialuser.py +++ b/roblox/partials/partialuser.py @@ -79,3 +79,25 @@ def __init__(self, client: Client, data: dict): super().__init__(client=client, data=data) self.previous_usernames: List[str] = data["previousUsernames"] + + +class CatalogCreatorPartialUser(PartialUser): + """ + Represents a partial user in the context of a catalog item. + Attributes: + id: Id of the user. + name: Name of the user. + has_verified_badge: If the user has a verified badge. + """ + + def __init__(self, client: Client, data: dict): + """ + Arguments: + client: The Client. + data: The data from the endpoint. + """ + super().__init__(client=client, data=data) + + self.has_verified_badge: bool = data["creatorHasVerifiedBadge"] + self.id: int = data["creatorTargetId"] + self.name: str = data["creatorName"] diff --git a/roblox/utilities/exceptions.py b/roblox/utilities/exceptions.py index 2ee2f0d5..a3a288e0 100644 --- a/roblox/utilities/exceptions.py +++ b/roblox/utilities/exceptions.py @@ -205,6 +205,13 @@ class GroupNotFound(ItemNotFound): pass +class CatalogItemNotFound(ItemNotFound): + """ + Raised for invalid catalog item IDs. + """ + pass + + class PlaceNotFound(ItemNotFound): """ Raised for invalid place IDs. diff --git a/setup.py b/setup.py index 737095d8..7f1fc447 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ "Examples": "https://github.com/ro-py/ro.py/tree/main/examples", "Twitter": "https://twitter.com/jmkdev" }, - "python_requires": '>=3.7', + "python_requires": '>=3.8', "install_requires": [ "httpx>=0.21.0", "python-dateutil>=2.8.0"