Skip to content

catalog.roblox.com endpoint support #111

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions roblox/bases/basecatalogitem.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

catalog items are either bundles or assets. This approach of one unified class makes it impossible to invoke methods that BaseAsset or a hypothetical BaseBundle would support even when applicable. Maybe forego this class entirely, introduce a BaseBundle (which we need anyway), and make "catalog items" just a BaseAsset | BaseBundle union? Or, instead of a union, both could derive from a new BaseCatalogItem class with a @property that indicates what "type" of catalog item it is (asset or bundle) determined by isinstance(self, BaseAsset): "asset" etc. not sure.

"""
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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make an enum for this: Asset or Bundle.

"""

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this coming from?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was in the process of converting the itemType stuff to a class, I committed this on accident before the rest was ready


# 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
110 changes: 110 additions & 0 deletions roblox/catalog.py
Original file line number Diff line number Diff line change
@@ -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"]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not how a dict works. also, why is this a dict and not a new class?

Copy link
Author

@tomodachi94 tomodachi94 Dec 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙈 Clearly I'm spending too much time in Lua-land /lh

Jokes aside: It was originally a class, but I forgot why I made it a dict.

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"]
46 changes: 45 additions & 1 deletion roblox/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my first comment, that would address this TypedDict mess by allowing you to just pass BaseAssets or a new BaseBundle

"""
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are still "classic Limiteds" that are not collectibles, which makes this inaccurate. Either way, I think just ditch the two classes and go with one CatalogItem class for both. Makes more sense to me even if the Python type system might not be able to imply that multiple properties must exist if one does. If we end up keeping this, call it a CollectibleCatalogItem instead for accuracy.

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]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditch the typeddict with the first comment's solutions, ditch the 1,2 for the enum

catalog_list: Literal[CatalogItem] = []

for catalog_item in catalog_item_array:
catalog_list.append(BaseCatalogItem(client=self, data=catalog_item))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for code quality and appearance, use a list comprehension here: return [BaseCatalogItem(client=self, data=catalog_item) for catalog_item in catalog_item_array]


return catalog_list
31 changes: 31 additions & 0 deletions roblox/partials/partialgroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}>"
22 changes: 22 additions & 0 deletions roblox/partials/partialuser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a new class is warranted here. I think, unless it has extra fields, all PartialUser-shaped objects should just be PartialUsers. Maybe PartialUser could optionally take fields directly , like PartialUser(client=..., id=..., name=..., has_verified_badge=...) so we don't have to make more classes.

"""
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"]
7 changes: 7 additions & 0 deletions roblox/utilities/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down