diff --git a/docs/source/datastore.rst b/docs/source/datastore.rst index bc8b787..a3f530a 100644 --- a/docs/source/datastore.rst +++ b/docs/source/datastore.rst @@ -57,7 +57,6 @@ Data Store :raises rblx-open-cloud.RateLimited: You're being rate limited by Roblox. Try again in a minute. :raises rblx-open-cloud.ServiceUnavailable: Roblox's services as currently experiencing downtime. :raises rblx-open-cloud.rblx_opencloudException: Roblox's response was unexpected. - .. method:: get(key) @@ -189,6 +188,132 @@ Data Store :raises rblx-open-cloud.ServiceUnavailable: Roblox's services as currently experiencing downtime. :raises rblx-open-cloud.rblx_opencloudException: Roblox's response was unexpected. +.. class:: OrderedDataStore + + Class for interacting with the Ordered DataStore API for a specific Ordered DataStore. + + .. versionadded:: 1.2 + + .. warning:: + + This class is not designed to be created by users. It is returned by :meth:`Experince.get_ordered_data_store`. + + .. attribute:: name + + :type: str + + .. attribute:: scope + + :type: Optional[str] + + .. attribute:: experince + + :type: :meth:`rblx-open-cloud.Experince` + + .. method:: sort_keys(descending=True, limit=None, min=None, max=None) + + Returns a list of keys and their values. + + The example below would list all keys, along with their value. + + .. code:: py + + for key in datastore.sort_keys(): + print(key.name, key.value) + + You can simply convert it to a list by putting it in the list function: + + .. code:: py + + list(datastore.sort_keys()) + + Lua equivalent: `OrderedDataStore:GetSortedAsync() `__ + + :param bool descending: Wether the largest number should be first, or the smallest. + :param bool limit: Max number of entries to loop through. + :param int min: Minimum entry value to retrieve + :param int max: Maximum entry value to retrieve. + + :returns: Iterable[:class:`SortedEntry`] + :raises ValueError: The :class:`OrderedDataStore` doesn't have a scope. + :raises rblx-open-cloud.InvalidToken: The token is invalid or doesn't have sufficent permissions to list data store keys. + :raises rblx-open-cloud.NotFound: The datastore or key does not exist + :raises rblx-open-cloud.RateLimited: You're being rate limited by Roblox. Try again in a minute. + :raises rblx-open-cloud.ServiceUnavailable: Roblox's services as currently experiencing downtime. + :raises rblx-open-cloud.rblx_opencloudException: Roblox's response was unexpected. + + .. note:: + + Unlike :meth:`DataStore.list_keys`, this function is unable to work without a scope, this is an Open Cloud limitation. You can still use other functions with the normal ``scope/key`` syntax when scope is ``None``. + + .. method:: get(key) + + Gets the value of a key. + + Lua equivalent: `OrderedDataStore:GetAsync() `__ + + :param str key: The key to find. + + :returns: int + :raises ValueError: The :class:`OrderedDataStore` doesn't have a scope and the key must be formatted as ``scope/key`` + :raises rblx-open-cloud.InvalidToken: The token is invalid or doesn't have sufficent permissions to read data store keys. + :raises rblx-open-cloud.NotFound: The datastore or key does not exist + :raises rblx-open-cloud.RateLimited: You're being rate limited by Roblox. Try again in a minute. + :raises rblx-open-cloud.ServiceUnavailable: Roblox's services as currently experiencing downtime. + :raises rblx-open-cloud.rblx_opencloudException: Roblox's response was unexpected. + + .. method:: set(key, value, exclusive_create=False, exclusive_update=False) + + Sets the value of a key. + + Lua equivalent: `OrderedDataStore:SetAsync() `__ + + :param str key: The key to create/update. + :param int value: The new integer value. Must be positive. + :param bool exclusive_create: Wether to fail if the key already has a value. + :param bool exclusive_update: Wether to fail if the key does not have a value. + + :returns: int + :raises ValueError: The :class:`OrderedDataStore` doesn't have a scope and the key must be formatted as ``scope/key`` or both ``exclusive_create`` and ``exclusive_update`` are ``True``. + :raises rblx-open-cloud.InvalidToken: The token is invalid or doesn't have sufficent permissions to write data store keys. + :raises rblx-open-cloud.NotFound: The datastore or key does not exist + :raises rblx-open-cloud.RateLimited: You're being rate limited by Roblox. Try again in a minute. + :raises rblx-open-cloud.ServiceUnavailable: Roblox's services as currently experiencing downtime. + :raises rblx-open-cloud.rblx_opencloudException: Roblox's response was unexpected. + :raises rblx-open-cloud.PreconditionFailed: ``exclusive_create`` is ``True`` and the key already has a value, or ``exclusive_update`` is ``True`` and there is no pre-existing value. + + .. method:: increment(key, increment) + + Increments the value of a key. + + Lua equivalent: `OrderedDataStore:IncrementAsync() `__ + + :param str key: The key to increment. + :param int increment: The amount to increment the key by. You can use negative numbers to decrease the value. + + :returns: int + :raises ValueError: The :class:`OrderedDataStore` doesn't have a scope and the key must be formatted as ``scope/key`` + :raises rblx-open-cloud.InvalidToken: The token is invalid or doesn't have sufficent permissions to write data store keys. + :raises rblx-open-cloud.NotFound: The datastore or key does not exist + :raises rblx-open-cloud.RateLimited: You're being rate limited by Roblox. Try again in a minute. + :raises rblx-open-cloud.ServiceUnavailable: Roblox's services as currently experiencing downtime. + :raises rblx-open-cloud.rblx_opencloudException: Roblox's response was unexpected. + + .. method:: remove(key) + + Removes a key. + + Lua equivalent: `OrderedDataStore:RemoveAsync() `__ + + :param str key: The key to remove. + + :raises ValueError: The :class:`OrderedDataStore` doesn't have a scope and the key must be formatted as ``scope/key`` + :raises rblx-open-cloud.InvalidToken: The token is invalid or doesn't have sufficent permissions to write data store keys. + :raises rblx-open-cloud.NotFound: The datastore or key does not exist + :raises rblx-open-cloud.RateLimited: You're being rate limited by Roblox. Try again in a minute. + :raises rblx-open-cloud.ServiceUnavailable: Roblox's services as currently experiencing downtime. + :raises rblx-open-cloud.rblx_opencloudException: Roblox's response was unexpected. + .. class:: EntryInfo Contains data about an entry such as version ID, timestamps, users and metadata. @@ -272,7 +397,7 @@ Data Store .. class:: ListedEntry - Object which contains a entry's key and scope. + Object which contains an entry's key and scope. .. warning:: @@ -288,4 +413,32 @@ Data Store The Entry's scope - :type: str \ No newline at end of file + :type: str + +.. class:: SortedEntry + + Object which contains a sorted entry's key, scope, and value. + + .. versionadded:: 1.2 + + .. warning:: + + This class is not designed to be created by users. It is returned by :meth:`OrderedDataStore.sort_keys`. + + .. attribute:: key + + The Entry's key + + :type: str + + .. attribute:: scope + + The Entry's scope + + :type: str + + .. attribute:: value + + The Entry's value + + :type: int \ No newline at end of file diff --git a/docs/source/experience.rst b/docs/source/experience.rst index 8ee575f..40c8993 100644 --- a/docs/source/experience.rst +++ b/docs/source/experience.rst @@ -27,9 +27,6 @@ Experience :param str name: The name of the data store :param Union[str, None] scope: A string specifying the scope, can also be None. :returns: :class:`rblx-open-cloud.DataStore` - - .. note:: - Ordered DataStores are still in alpha, to use them you must `sign up for the beta `__ and then `install the beta library __` .. method:: list_data_stores(prefix="", scope="global") @@ -60,6 +57,18 @@ Experience :raises rblx-open-cloud.RateLimited: You're being rate limited by Roblox. Try again in a minute. :raises rblx-open-cloud.ServiceUnavailable: Roblox's services as currently experiencing downtime. :raises rblx-open-cloud.rblx_opencloudException: Roblox's response was unexpected. + + .. method:: get_ordered_data_store(name, scope="global") + + Creates a :class:`rblx-open-cloud.OrderedDataStore` with the provided name and scope. + + If ``scope`` is ``None`` then keys require to be formatted like ``scope/key`` and :meth:`OrderedDataStore.sort_keys` will not work. + + Lua equivalent: `DataStoreService:GetDataStore() `__ + + :param str name: The name of the data store + :param Union[str, None] scope: A string specifying the scope, can also be None. + :returns: :class:`rblx-open-cloud.DataStore` .. method:: publish_message(topic, data) diff --git a/rblxopencloud/__init__.py b/rblxopencloud/__init__.py index 0616a50..815a104 100644 --- a/rblxopencloud/__init__.py +++ b/rblxopencloud/__init__.py @@ -7,7 +7,7 @@ from typing import Literal -VERSION: str = "1.1.0" -VERSION_INFO: Literal['alpha', 'beta', 'final'] = "final" +VERSION: str = "1.2.0" +VERSION_INFO: Literal['alpha', 'beta', 'final'] = "beta" -del Literal +del Literal \ No newline at end of file diff --git a/rblxopencloud/datastore.py b/rblxopencloud/datastore.py index eb6b3c9..d1fa9aa 100644 --- a/rblxopencloud/datastore.py +++ b/rblxopencloud/datastore.py @@ -1,7 +1,10 @@ from .exceptions import rblx_opencloudException, InvalidKey, NotFound, RateLimited, ServiceUnavailable, PreconditionFailed import requests, json, datetime +import base64, hashlib, urllib.parse from typing import Union, Optional, Iterable, TYPE_CHECKING -import base64, hashlib + +if TYPE_CHECKING: + from .experience import Experience if TYPE_CHECKING: from .experience import Experience @@ -10,7 +13,9 @@ "EntryInfo", "EntryVersion", "ListedEntry", - "DataStore" + "DataStore", + "SortedEntry", + "OrderedDataStore" ) class EntryInfo(): @@ -312,4 +317,152 @@ def get_version(self, key: str, version: str) -> tuple[Union[str, dict, list, in elif response.status_code == 404: raise NotFound(f"The key {key} does not exist.") elif response.status_code == 429: raise RateLimited("You're being rate limited.") elif response.status_code >= 500: raise ServiceUnavailable("The service is unavailable or has encountered an error.") + else: raise rblx_opencloudException(f"Unexpected HTTP {response.status_code}") + +class SortedEntry(): + def __init__(self, key: str, value: int, scope: str="global") -> None: + self.key: str = key + self.scope: str = scope + self.value: int = value + + def __eq__(self, object) -> bool: + if not isinstance(object, SortedEntry): + return NotImplemented + return self.key == object.key and self.scope == object.scope and self.value == object.value + + def __repr__(self) -> str: + return f"rblxopencloud.SortedEntry(\"{self.key}\", value={self.value})" + +class OrderedDataStore(): + def __init__(self, name, experince, api_key, scope): + self.name = name + self.__api_key = api_key + self.scope = scope + self.experince = experince + + def __repr__(self) -> str: + return f"rblxopencloud.OrderedDataStore(\"{self.name}\", scope=\"{self.scope}\", experince={repr(self.experince)})" + + def __str__(self) -> str: + return self.name + + def sort_keys(self, descending: bool=True, limit: Union[None, int]=None, min=None, max=None) -> Iterable[SortedEntry]: + if not self.scope: raise ValueError("A scope is required to list keys on OrderedDataStore.") + + filter = None + if min and max: + if min > max: raise ValueError("min must not be greater than max.") + filter = f"entry >= {min} && entry <= {max}" + + if min: filter = f"entry >= {min}" + if max: filter = f"entry <= {max}" + + nextcursor = "" + yields = 0 + while limit == None or yields < limit: + response = requests.get(f"https://apis.roblox.com/ordered-data-stores/v1/universes/{self.experince.id}/orderedDataStores/{urllib.parse.quote(self.name)}/scopes/{urllib.parse.quote(self.scope)}/entries", + headers={"x-api-key": self.__api_key}, params={ + "max_page_size": limit if limit and limit < 100 else 100, + "order_by": "desc" if descending else None, + "page_token": nextcursor if nextcursor else None, + "filter": filter + }) + if response.status_code == 401 or response.status_code == 403: raise InvalidKey("Your key may have expired, or may not have permission to access this resource.") + elif response.status_code == 404: raise NotFound("The datastore you're trying to access does not exist.") + elif response.status_code == 429: raise RateLimited("You're being rate limited.") + elif response.status_code >= 500: raise ServiceUnavailable("The service is unavailable or has encountered an error.") + elif not response.ok: raise rblx_opencloudException(f"Unexpected HTTP {response.status_code}") + + data = response.json() + for key in data["entries"]: + yields += 1 + yield SortedEntry(key["id"], key["value"], self.scope) + if limit != None and yields >= limit: break + nextcursor = data.get("nextPageToken") + if not nextcursor: break + + def get(self, key: str) -> int: + try: + if not self.scope: scope, key = key.split("/", maxsplit=1) + else: scope = self.scope + except(ValueError): raise ValueError("a scope and key seperated by a forward slash is required for OrderedDataStore without a scope.") + + response = requests.get(f"https://apis.roblox.com/ordered-data-stores/v1/universes/{self.experince.id}/orderedDataStores/{urllib.parse.quote(self.name)}/scopes/{urllib.parse.quote(scope)}/entries/{urllib.parse.quote(key)}", + headers={"x-api-key": self.__api_key}) + + if response.status_code == 200: return int(response.json()["value"]) + elif response.status_code == 401: raise InvalidKey("Your key may have expired, or may not have permission to access this resource.") + elif response.status_code == 404: raise NotFound(f"The key {key} does not exist.") + elif response.status_code == 429: raise RateLimited("You're being rate limited.") + elif response.status_code >= 500: raise ServiceUnavailable("The service is unavailable or has encountered an error.") + else: raise rblx_opencloudException(f"Unexpected HTTP {response.status_code}") + + def set(self, key: str, value: int, exclusive_create: bool=False, exclusive_update: bool=False) -> int: + try: + if not self.scope: scope, key = key.split("/", maxsplit=1) + else: scope = self.scope + except(ValueError): raise ValueError("a scope and key seperated by a forward slash is required for OrderedDataStore without a scope.") + if exclusive_create and exclusive_update: raise ValueError("exclusive_create and exclusive_updated can not both be True") + + if not exclusive_create: + response = requests.patch(f"https://apis.roblox.com/ordered-data-stores/v1/universes/{self.experince.id}/orderedDatastores/{urllib.parse.quote(self.name)}/scopes/{urllib.parse.quote(scope)}/entries/{urllib.parse.quote(key)}", + headers={"x-api-key": self.__api_key}, params={"allow_missing": not exclusive_update}, json={ + "value": value + }) + else: + response = requests.post(f"https://apis.roblox.com/ordered-data-stores/v1/universes/{self.experince.id}/orderedDatastores/{urllib.parse.quote(self.name)}/scopes/{urllib.parse.quote(scope)}/entries", + headers={"x-api-key": self.__api_key}, params={"id": key}, json={ + "value": value + }) + + if exclusive_create and response.status_code == 400 and response.json()["code"] == "INVALID_ARGUMENT": + raise PreconditionFailed(None, None, f"An entry already exists with the provided key and scope") + elif response.status_code == 200: return int(response.json()["value"]) + elif response.status_code == 401: raise InvalidKey("Your key may have expired, or may not have permission to access this resource.") + elif response.status_code in [404, 409]: + if exclusive_create: + error = "An entry already exists with the provided key and scope" + elif exclusive_update: + error = "There is no pre-existing entry with the provided key" + else: + error = "A Precondition Failed" + + raise PreconditionFailed(None, None, error) + if response.status_code == 429: raise RateLimited("You're being rate limited.") + elif response.status_code >= 500: raise ServiceUnavailable("The service is unavailable or has encountered an error.") + else: raise rblx_opencloudException(f"Unexpected HTTP {response.status_code}") + + def increment(self, key: str, increment: int) -> None: + try: + if not self.scope: scope, key = key.split("/", maxsplit=1) + else: scope = self.scope + except(ValueError): raise ValueError("a scope and key seperated by a forward slash is required for OrderedDataStore without a scope.") + + response = requests.post(f"https://apis.roblox.com/ordered-data-stores/v1/universes/{self.experince.id}/orderedDatastores/{urllib.parse.quote(self.name)}/scopes/{urllib.parse.quote(scope)}/entries/{urllib.parse.quote(key)}:increment", + headers={"x-api-key": self.__api_key}, json={ + "amount": increment + }) + + if response.status_code == 200: return int(response.json()["value"]) + elif response.status_code == 401: raise InvalidKey("Your key may have expired, or may not have permission to access this resource.") + elif response.status_code == 404: raise NotFound(f"The key {key} does not exist.") + elif response.status_code == 409 and response.json()["code"] == 10: raise ValueError("New value can't be less than 0.") + elif response.status_code == 429: raise RateLimited("You're being rate limited.") + elif response.status_code >= 500: raise ServiceUnavailable("The service is unavailable or has encountered an error.") + else: raise rblx_opencloudException(f"Unexpected HTTP {response.status_code}") + + def remove(self, key: str) -> None: + try: + if not self.scope: scope, key = key.split("/", maxsplit=1) + else: scope = self.scope + except(ValueError): raise ValueError("a scope and key seperated by a forward slash is required for OrderedDataStore without a scope.") + + response = requests.delete(f"https://apis.roblox.com/ordered-data-stores/v1/universes/{self.experince.id}/orderedDatastores/{urllib.parse.quote(self.name)}/scopes/{urllib.parse.quote(scope)}/entries/{urllib.parse.quote(key)}", + headers={"x-api-key": self.__api_key}) + + if response.status_code == 200: return None + elif response.status_code == 401: raise InvalidKey("Your key may have expired, or may not have permission to access this resource.") + elif response.status_code == 404: raise NotFound(f"The key {key} does not exist.") + elif response.status_code == 429: raise RateLimited("You're being rate limited.") + elif response.status_code >= 500: raise ServiceUnavailable("The service is unavailable or has encountered an error.") else: raise rblx_opencloudException(f"Unexpected HTTP {response.status_code}") \ No newline at end of file diff --git a/rblxopencloud/experience.py b/rblxopencloud/experience.py index b5f2d42..2e5ab5f 100644 --- a/rblxopencloud/experience.py +++ b/rblxopencloud/experience.py @@ -1,7 +1,8 @@ from .exceptions import rblx_opencloudException, InvalidKey, NotFound, RateLimited, ServiceUnavailable import requests, io + from typing import Optional, Iterable -from .datastore import DataStore +from .datastore import DataStore, OrderedDataStore __all__ = ( "Experience", @@ -18,6 +19,9 @@ def __repr__(self) -> str: def get_data_store(self, name: str, scope: Optional[str]="global") -> DataStore: """Creates a `rblx-open-cloud.DataStore` without `DataStore.created` with the provided name and scope. If `scope` is `None` then keys require to be formatted like `scope/key` and `DataStore.list_keys` will return keys from all scopes.""" return DataStore(name, self, self.__api_key, None, scope) + + def get_ordered_data_store(self, name: str, scope: Optional[str]="global") -> OrderedDataStore: + return OrderedDataStore(name, self, self.__api_key, scope) def list_data_stores(self, prefix: str="", limit: Optional[int]=None, scope: str="global") -> Iterable[DataStore]: """Returns an `Iterable` of all `rblx-open-cloud.DataStore` in the Experience which includes `DataStore.created`, optionally matching a prefix. The example below would list all versions, along with their value.