yotpy API documentation

# yotpy

Yotpy: An easy-to-use Python wrapper for the Yotpo web API.

View Source

      1"""
      2Yotpy: An easy-to-use Python wrapper for the Yotpo web API.
      3"""
      4
      5__version__ = "0.0.5"
      6
      7import asyncio
      8import aiohttp
      9from json import loads as json_loads
     10from csv import DictWriter
     11from io import StringIO
     12from typing import AsyncGenerator, Iterator, Union, Optional, Callable, Union
     13from html import unescape
     14from urllib.parse import urlencode
     15from math import ceil
     16from itertools import chain
     17
     18
     19class SessionNotCreatedError(Exception):
     20    """Raised when the aiohttp.ClientSession has not been created before making a request."""
     21    def __init__(self, message="Session not created. Please use `async with` context manager to make requests."):
     22        super().__init__(message)
     23
     24
     25class PreflightException(Exception):
     26    pass
     27
     28class UploadException(Exception):
     29    pass
     30
     31class SendException(Exception):
     32    pass
     33
     34class CustomException(Exception):
     35    pass
     36
     37# TODO: Refactor naming for the below two classes
     38class UserIdNotFound(Exception):
     39    """Raised when the user ID cannot be retrieved."""
     40    pass
     41
     42class AppDataNotFound(Exception):
     43    """Raised when the app data cannot be retrieved."""
     44    pass
     45
     46# TODO: More insightful sentiment analysis
     47# TODO: Give better error messages for bad requests
     48
     49class JSONTransformer:
     50    """
     51    A utility class for transforming JSON data into various formats.
     52    """
     53
     54    @staticmethod
     55    def _stream_json_data(json: dict, keys: tuple) -> Iterator[dict[str, Union[tuple, str]]]:
     56        """
     57        Stream the full key directory and its final value of a JSON object of arbitrary depth, including lists.
     58
     59        This method recursively traverses a nested JSON object and yields dictionaries containing the full key
     60        directory (as tuples) and their associated values. It handles dictionaries and lists within the JSON object.
     61
     62        Example:
     63            Input JSON:
     64            ```json
     65            { "a":
     66                { "b":
     67                    [ { "c": "d" }, { "e": [1,2,3] } ]
     68                },
     69            "x": { "y": "z" }
     70            }
     71            ```
     72            Streaming key directories and values:
     73            ```python
     74            >>> print(list(JSONTransformer._stream_json_data(json, tuple())))
     75            >>> [{"keys": ("a", "b", "0", "c"), "value": "d"},
     76            >>>  {"keys": ("a", "b", "1", "e", "0"), "value": 1},
     77            >>>  {"keys": ("a", "b", "1", "e", "1"), "value": 2},
     78            >>>  {"keys": ("a", "b", "1", "e", "2"), "value": 3},
     79            >>>  {"keys": ("x", "y"), "value": "z"}]
     80
     81            ```
     82
     83        Args:
     84            json (dict): The JSON object to stream.
     85            keys (tuple): The current list of keys being traversed.
     86
     87        Yields:
     88            dict[str, Union[tuple | str]]: A dictionary containing the current key "directory" (as a tuple) and its associated value.
     89
     90        Returns:
     91            Iterator[dict[str, Union[tuple | str]]]: An iterator that yields dictionaries with the current key "directory" and its associated value.
     92
     93        Note:
     94            Tuples have been used instead of lists for the keys argument, for performance at the cost of readability of this function.
     95        """
     96
     97        for key, value in json.items():
     98            if isinstance(value, dict):
     99                # If the value is another dictionary, recursively call this method
    100                # with the updated keys "list".
    101                for result in JSONTransformer._stream_json_data(value, keys + (key,)):
    102                    yield result
    103            elif isinstance(value, list):
    104                # If the value is a list, iterate through the list and recursively
    105                # call this method with the updated keys "list" and index as an additional key.
    106                for index, item in enumerate(value):
    107                    if not isinstance(item, (dict, list)):
    108                        yield {'keys': keys + (key, str(index)), 'value': item}
    109                        continue
    110
    111                    for result in JSONTransformer._stream_json_data(item, keys + (key, str(index))):
    112                        yield result
    113            else:
    114                # If the value is not a dictionary or list, yield a dictionary containing
    115                # the current key "list" (as a tuple) and value.
    116                yield {'keys': keys + (key,), 'value': value}
    117
    118
    119    @staticmethod
    120    def merge_on_key(key: str, list_1: list[dict], list_2: list[dict]) -> list[dict]:
    121        """
    122        Merges two lists of dictionaries into a single list, based on matching dictionary keys.
    123
    124        Args:
    125            key (str): The key to match the dictionaries on.
    126            list_1 (list[dict]): The first list of dictionaries.
    127            list_2 (list[dict]): The second list of dictionaries.
    128
    129        Returns:
    130            list[dict]: A merged list of dictionaries containing merged disctionaries based on matching keys from both input lists.
    131        """
    132        if not list_1 or not list_2:
    133            raise Exception(
    134                f"Cannot merge empty list(s).\nlist_1: length={len(list_1)}\nlist_2: length={len(list_2)}")
    135
    136        # Use list comprehension to merge dictionaries that have matching keys
    137        return [
    138            dict(item_1, **item_2)
    139            for item_1 in list_1
    140            for item_2 in list_2
    141            if item_1.get(key, 0) == item_2.get(key, 1)
    142        ]
    143
    144    @staticmethod
    145    def flatten(json: dict[str, any], sep: str, exclude: list[str] = [], include: list[str] = []) -> dict[str, any]:
    146        """
    147        Flatten a nested JSON object into a dictionary with flattened keys.
    148
    149        This method takes a nested JSON object and converts it into a dictionary with
    150        keys that represent the nested structure using a specified separator. Optionally,
    151        you can provide a list of keys to exclude or include in the resulting dictionary.
    152
    153        Example:
    154            Input JSON:
    155            ```json
    156            { "a":
    157                { "b":
    158                    [ { "c": "d" }, { "e": [1,2,3] } ]
    159                },
    160                "x": { "y": "z" }
    161            }
    162            ```
    163            Flattening with a "." separator:
    164            ```python
    165            >>> print(JSONTransformer.flatten(json, "."))
    166            >>> {"a.b.0.c": "d",
    167            >>>  "a.b.1.e.0": 1,
    168            >>>  "a.b.1.e.1": 2,
    169            >>>  "a.b.1.e.2": 3,
    170            >>>  "x.y": "z"}
    171            ```
    172            Include and Exclude should be formatted with the complete nested key output in mind.
    173
    174            e.g  `include=["a.b.0.c"]`, returns `{"a.b.0.c": "d"}` only.
    175
    176        Args:
    177            json (dict): The JSON object to flatten.
    178            sep (str): The separator to use for joining nested keys.
    179            exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
    180            include (list[str]): If specified, only include keys that match the specified keys.
    181
    182        Returns:
    183            dict[str, str]: A flattened dictionary with keys representing the nested structure.
    184        """
    185
    186        # Stream the key-value pairs of the JSON object using the _stream_json_data method.
    187        data = {}
    188        for item in JSONTransformer._stream_json_data(json, tuple()):
    189            keys = item['keys']
    190            key_str = sep.join(keys)
    191            if (exclude and key_str in exclude) or (include and key_str not in include):
    192                continue
    193            # Check if the key string matches the specified key strings for content and title.
    194            # If so, html.unescape the value, this is from Yotpo's side of things.
    195            # This is done so NLTK is able to properly process the reviews.
    196            if ("content", "title").count(key_str):
    197                data[key_str] = unescape(item['value'])
    198            else:
    199                data[key_str] = item['value']
    200        return data
    201
    202    @staticmethod
    203    def flatten_list(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> list[dict[str, any]]:
    204        """
    205        Flattens a list of JSON objects into a list of dictionaries with flattened keys.
    206
    207        This method takes a list of nested JSON objects and converts each JSON object into a
    208        dictionary with keys representing the nested structure using a specified separator.
    209        Optionally, you can provide a list of keys to exclude or include in the resulting dictionaries.
    210
    211        Example:
    212            Input JSON list:
    213            ```json
    214            [
    215                {
    216                    "a": {"b": {"c": "d"}},
    217                    "x": {"y": "z"}
    218                },
    219                {
    220                    "p": {"q": {"r": "s"}},
    221                    "u": {"v": "w"}
    222                }
    223            ]
    224            ```
    225            Flattening with a "." separator:
    226            ```python
    227            >>> print(JSONTransformer.flatten_list(json_list, "."))
    228            >>> [
    229            >>>     {"a.b.c": "d", "x.y": "z"},
    230            >>>     {"p.q.r": "s", "u.v": "w"}
    231            >>> ]
    232            ```
    233
    234        Args:
    235            json_list (list[dict]): A list of JSON objects to flatten.
    236            sep (str): The separator to use for joining nested keys.
    237            exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
    238            include (list[str]): If specified, only include keys that match the specified keys.
    239
    240        Returns:
    241            list[dict[str, any]]: A list of flattened dictionaries with keys representing the nested structure.
    242        """
    243        return [JSONTransformer.flatten(point, sep, exclude, include) for point in json_list]
    244
    245    @staticmethod
    246    def get_headers(json_list: list[dict]) -> set[str]:
    247        """
    248        Get a complete headers list from a list of JSON objects.
    249
    250        Args:
    251            json_list (list[dict]): A list of JSON objects to iterate over.
    252
    253        Returns:
    254            set[str]: A set of headers.
    255        """
    256        return {key for item in json_list for key in item.keys()}
    257
    258    @staticmethod
    259    def from_bigquery_iterator(iterator: Iterator, exclude: set[str] = {}, include: set[str] = {}) -> tuple[set, list]:
    260        """
    261        Convert a BigQuery RowIterator into a set of headers and a list of rows.
    262
    263        Args:
    264            iterator (Iterator): A BigQuery RowIterator object.
    265            exclude (set[str], optional): A set of field names to exclude from the output. Defaults to an empty set.
    266            include (set[str], optional): A set of field names to include in the output. If provided, only these fields
    267                                          will be included. Defaults to an empty set.
    268
    269        Returns:
    270            tuple[set, list]: A tuple containing a set of headers and a list of rows as dictionaries.
    271                              Headers are the field names included in the output, and rows are dictionaries
    272                              containing field values for each row in the RowIterator.
    273
    274        Note:
    275            If both `exclude` and `include` are provided, the `include` set takes precedence.
    276        """
    277        schema = list(iterator.schema)
    278        headers = {field.name for field in schema if (include and field.name in include) or (exclude and field.name not in exclude)}
    279
    280        rows = []
    281        for row in iterator:
    282            [row.pop(field.name, None) for field in schema if field.name not in headers]
    283            rows.append(row)
    284
    285        return headers, rows
    286
    287    @staticmethod
    288    def to_rows(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> tuple[list, set]:
    289        """
    290        Flatten a list of JSON objects into a list of rows and a set of headers.
    291
    292        This method takes a list of nested JSON objects and converts each JSON object into a
    293        dictionary with keys representing the nested structure using a specified separator.
    294        It then creates a list of rows, where each row contains the values from the flattened
    295        dictionaries, and a set of headers representing the unique keys of the flattened dictionaries.
    296
    297        Args:
    298            json_list (list[dict]): The list of JSON objects to flatten.
    299            sep (str): The separator to use for joining nested keys.
    300            exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
    301            include (list[str]): If specified, only include keys that match the specified keys.
    302
    303        Note:
    304            If both exclude and include are specified, exclude takes precedence.
    305            This is done by using the following logic:
    306                ```
    307                if key_str in exclude or (include and key_str not in include):
    308                    continue
    309                ```
    310
    311        Returns:
    312            tuple[list, set]: A tuple containing the list of rows and the set of headers.
    313        """
    314        rows, headers = [], set()
    315        # Flatten the JSON object into a list of dictionaries using the flatten method.
    316        for item in JSONTransformer.flatten_list(json_list, sep, exclude, include):
    317            rows.append(item)
    318            headers.update(item.keys())
    319        return rows, headers
    320
    321
    322    @staticmethod
    323    def to_csv_stringio(rows: list[dict], headers: set) -> StringIO:
    324        """
    325        Convert a list of rows into a CSV formatted StringIO object.
    326
    327        This method takes a list of rows (dictionaries) and a set of headers, and writes them into
    328        a CSV formatted StringIO object. It can be used to create a CSV file-like object without
    329        creating an actual file on the filesystem.
    330
    331        Args:
    332            rows (list[dict]): A list of rows to convert into a CSV formatted StringIO object.
    333            headers (set): A set of headers to use for the CSV data.
    334
    335        Returns:
    336            StringIO: A CSV formatted StringIO object.
    337        """
    338        # Create a StringIO object to write the CSV data to.
    339        csv_stringio = StringIO()
    340        # Create a csv writer and write the rows to the StringIO object.
    341        writer = DictWriter(csv_stringio, fieldnames=headers)
    342        writer.writeheader()
    343        writer.writerows(rows)
    344
    345        # Reset the StringIO object's position to the beginning
    346        csv_stringio.seek(0)
    347
    348        return csv_stringio
    349
    350
    351class YotpoAPIWrapper:
    352    #NOTE: Update docstring if more methods are added to account for any added functionality outside of the defined scope.
    353    """
    354    A class for interacting with the Yotpo API to fetch app and account information, review data, and send manual review requests.
    355
    356    The YotpoAPIWrapper uses the provided app_key and secret for authentication and constructs the necessary API endpoints for making requests.
    357
    358    Args:
    359        app_key (str): The Yotpo app key for API authentication.
    360        secret (str): The client secret to authenticate requests.
    361        preferred_uid (Optional[int], optional): The user ID to use for fetching data relating to the Yotpo APP. Defaults to None.
    362
    363    Raises:
    364        Exception: If either app_key or secret is not provided.
    365    """
    366
    367    # TODO: Update the explanation of the preferred_uid argument to be more accurate/helpful.
    368
    369    def __init__(self, app_key: str, secret: str, preferred_uid: Optional[int] = None) -> None:
    370        if not app_key or not secret:
    371            raise Exception(f"app_key(exists={bool(app_key)}) and secret(exists={bool(secret)}) are required.")
    372
    373        self._app_key = app_key
    374        self._secret = secret
    375        self.user_id = preferred_uid
    376
    377    async def __aenter__(self):
    378        self.aiohttp_session = aiohttp.ClientSession()
    379        self._utoken = await self._get_user_token()
    380
    381        self.app_endpoint = f"https://api.yotpo.com/v1/apps/{self._app_key}/reviews?utoken={self._utoken}&"
    382        self.widget_endpoint = f"https://api.yotpo.com/v1/widget/{self._app_key}/reviews?utoken={self._utoken}&"
    383        self.write_user_endpoint = f"https://api-write.yotpo.com/users/me?utoken={self._utoken}"
    384        self.write_app_endpoint = f"https://api-write.yotpo.com/apps"
    385
    386        if self.user_id is None:
    387            self.user_id = (await self.get_user())['id']
    388
    389        return self
    390
    391    async def __aexit__(self, exc_type, exc, tb):
    392        await self.aiohttp_session.close()
    393
    394    async def _get_user_token(self) -> str:
    395        """
    396        Get the user access token using the provided secret.
    397
    398        Args:
    399            app_key (str): The target app you want authenticated.
    400            secret (str): The client secret to authenticate the request.
    401
    402        Returns:
    403            str: The user access token.
    404
    405        Raises:
    406            Exception: If the response status is not OK (200).
    407        """
    408        url = "https://api.yotpo.com/oauth/token"
    409        data = {
    410            "grant_type": "client_credentials",
    411            "client_id": self._app_key,
    412            "client_secret": self._secret,
    413        }
    414
    415        return (await self._post_request(url, data=data, parser=lambda x: x["access_token"]))
    416
    417    async def _options_request(self, url: str, method: str) -> bool:
    418        """
    419        Asynchronously sends a preflight OPTIONS request to check if the given method is allowed for the given endpoint.
    420
    421        Args:
    422            url (str): The API endpoint URL.
    423
    424        Returns:
    425            bool: True if the method is allowed, False otherwise.
    426        """
    427        if not hasattr(self, 'aiohttp_session'):
    428            raise SessionNotCreatedError()
    429
    430        # Cheeky way to make sure `method` is at least syntactically correct.
    431        if (method := method.upper()) not in ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE']:
    432            raise Exception("Invalid HTTP method: ", method)
    433
    434        async with self.aiohttp_session.options(url) as response:
    435            if response.ok:
    436                allowed_methods = response.headers.get("Allow", "")
    437                return method in allowed_methods.split(", ")
    438            raise Exception(f"Error: {response.status} {response.reason} - {url}")
    439
    440    async def _get_request(self, url: str, parser: Callable[[dict], dict] = None, exception_type: Exception = CustomException, **kwargs) -> dict:
    441        """
    442        Asynchronously sends a GET request to the specified URL and parses the response using the provided parser.
    443
    444        Args:
    445            session (aiohttp.ClientSession): An aiohttp client session for making requests.
    446            url (str): The URL to send the request to.
    447            parser (Callable[[dict], dict]): A function to parse the response JSON. Defaults to json.loads.
    448            exception_type (Union[CustomException, Exception]): The type of exception to raise when an error occurs. Defaults to CustomException.
    449            **kwargs: Additional keyword arguments to be passed to the request object.
    450
    451        Returns:
    452            dict: The parsed JSON response.
    453
    454        Raises:
    455            exception_type: If the response status is not 200, an instance of the specified exception_type is raised with an error message.
    456        """
    457        if not hasattr(self, 'aiohttp_session'):
    458            raise SessionNotCreatedError()
    459
    460        async with self.aiohttp_session.get(url, **kwargs) as response:
    461            if response.status == 200:
    462                raw_data = json_loads(await response.read())
    463                if parser is None:
    464                    return raw_data
    465                return parser(raw_data)
    466            raise exception_type(f"Error: {response.status} | {response.reason} - {url}")
    467
    468    async def _post_request(self, url: str, data: dict, parser: Callable[[dict], dict] = None, exception_type: Exception = CustomException, **kwargs) -> dict:
    469        """
    470        Asynchronously sends a POST request to the specified URL and parses the response using the provided parser.
    471
    472        Args:
    473            session (aiohttp.ClientSession): An aiohttp client session for making requests.
    474            url (str): The URL to send the request to.
    475            data (dict): The data to send in the request body.
    476            parser (Callable[[dict], dict]): A function to parse the response JSON. Defaults to json.loads.
    477            exception_type (Union[CustomException, Exception]): The type of exception to raise when an error occurs. Defaults to CustomException.
    478            **kwargs: Additional keyword arguments to be passed to the request object.
    479
    480        Returns:
    481            dict: The parsed JSON response.
    482
    483        Raises:
    484            exception_type: If the response status is not 200, an instance of the specified exception_type is raised with an error message.
    485        """
    486        if not hasattr(self, 'aiohttp_session'):
    487            raise SessionNotCreatedError()
    488
    489        async with self.aiohttp_session.post(url, data=data, **kwargs) as response:
    490            if response.status == 200:
    491                raw_data = json_loads(await response.read())
    492                if parser is None:
    493                    return raw_data
    494                return parser(raw_data)
    495            raise exception_type(f"Error: {response.status} | {response.reason} - {url}")
    496
    497    async def _pages(self, endpoint: str, start_page: int = 1) -> AsyncGenerator[tuple[str, int], None]:
    498        """
    499        Asynchronously generate URLs for each page of results.
    500
    501        Args:
    502            endpoint (str): The API endpoint to query.
    503            start_page (int): The first page to generate.
    504
    505        Yields:
    506            tuple[str, int] ->
    507                str: The URL for the next page of results.
    508                int: The current page number.
    509        """
    510        last_page = ceil((await self.get_total_reviews()) / 100)
    511        is_widget = "widget" in endpoint
    512        for num in range(start_page, last_page + 1):
    513            yield endpoint + urlencode(({("per_page" if is_widget else "count"): 100, "page": num})), num
    514
    515    async def get_user(self) -> dict:
    516        """
    517        Asynchronously fetches user data from the user endpoint.
    518
    519        This function returns various user-related data, such as name, email, display_name, company, position, and other
    520        metadata like sign_in_count and package details. This data can be useful for understanding user profiles,
    521        managing access, or customizing the user experience based on the retrieved information.
    522
    523        Returns:
    524            dict: A dictionary containing user information, including personal details, activity statistics, and package details.
    525
    526
    527        Example:
    528            ```python
    529            >>> yotpo = YotpoAPIWrapper(app_key, secret)
    530            >>> async with yotpo as yp:
    531            >>>     user = await yp.get_user()
    532            ```
    533
    534        Raises:
    535            Exception: If the request to the user endpoint returns a non-OK status.
    536        """
    537        url = self.write_user_endpoint
    538
    539        return await self._get_request(url, parser=lambda data: data['response']['user'])
    540
    541    async def get_app(self) -> dict:
    542        """
    543        Asynchronously fetches app data for the app associated with the preferred user ID.
    544
    545        Returns:
    546            dict: A dictionary containing app information derived from the specified user. The app information includes
    547            details such as app_key, domain, name, url, account type, users, reminders, custom design, created
    548            and updated timestamps, tracking code status, account emails, package details, enabled product
    549            categories, features usage summary, data for events, category, installed status, organization,
    550            and associated apps.
    551
    552        Raises:
    553            AppDataNotFound: If the request to the app endpoint returns a non-OK status.
    554        """
    555        # NOTE: The user_id does not appear to actually matter at all, as it still returns data even if it is not a valid user ID.
    556        # I suspect `user_id` is just used to track which user is making the request.
    557
    558        # TODO: If the above is true, we can probably abstract away the `user_id` parameter and just use the user ID from the user endpoint.
    559        # And if need be we can add some configuration to allow the user to specify a user ID if they want to.
    560
    561        url = f"https://api-write.yotpo.com/apps/{self._app_key}?user_id={self.user_id}&utoken={self._utoken}"
    562
    563        return await self._get_request(url, parser=lambda data: data['response']['app'], exception_type=AppDataNotFound)
    564
    565    async def get_total_reviews(self) -> int:
    566        """
    567        Asynchronously fetches the total number of reviews for an app.
    568
    569        This method first retrieves the user and their user ID, then fetches the app data
    570        associated with that user. Finally, it returns the total number of reviews for the app.
    571
    572        Returns:
    573            int: The total number of reviews for the app.
    574
    575        Raises:
    576            AppDataNotFound: If unable to get the app data.
    577        """
    578        app = await self.get_app()
    579
    580        return app['data_for_events']['total_reviews']
    581
    582    async def get_templates(self) -> dict:
    583        """
    584        Asynchronously fetch the email templates associated with the Yotpo account.
    585
    586        This method retrieves the app data, extracts the email templates, and returns them as a dictionary.
    587        The primary use case for this function is to obtain the template IDs required for the 'send_review_request' method.
    588
    589        The returned dictionary contains template objects with properties such as 'id' (template_id),
    590        'email_type', 'data', and others. For example:
    591        ```python
    592            {
    593                'id': 10291872,
    594                'account_id': 9092811,
    595                'email_type_id': 31,
    596                'email_type': {
    597                    'id': 31,
    598                    'name': 'mail_post_service',
    599                    'template': 'testimonials_request'
    600                },
    601                'data': {
    602                    'subject': 'Tell us what you think - {company_name}',
    603                    'header': 'Hello {user},\\n We love having you as a customer and would really appreciate it if you filled out the form below.',
    604                    'bottom': 'Thanks so much! <br> - {company_name}'
    605                },
    606                'formless_call_to_action': '',
    607                'days_delay': 3,
    608                'email_submission_type_id': 9
    609            }
    610        ```
    611        Returns:
    612            dict: A dictionary containing the email templates, keyed by their names.
    613        """
    614        app = await self.get_app()
    615        templates = app['account_emails']
    616        return templates
    617
    618
    619    async def fetch_review_page(self, url: str) -> list[dict]:
    620        """
    621        Asynchronously fetch a single review page from the specified URL.
    622
    623        This function fetches review data from the provided URL and parses the response based on whether
    624        the URL is for the widget endpoint or not.
    625
    626        Args:
    627            url (str): The URL from which to fetch review data.
    628
    629        Returns:
    630            list[dict]: A list of review dictionaries.
    631
    632        """
    633        is_widget = "widget" in url
    634        return await self._get_request(url, parser=lambda data: data['response']['reviews'] if is_widget else data['reviews'])
    635
    636
    637    async def fetch_all_reviews(self, published: bool = True) -> list[dict]:
    638        """
    639        Asynchronously fetch all reviews from the specified endpoints.
    640
    641        This function fetches review data from the app and widget endpoints if `published` is set to True,
    642        and only from the app endpoint if `published` is set to False. The fetched reviews are then merged
    643        based on their "id" attribute.
    644
    645        Args:
    646            published (bool, optional): Determines if the function should fetch reviews from both
    647                                         the app and widget endpoints or just the app endpoint.
    648                                         Defaults to True.
    649
    650        Returns:
    651            list[dict]: A list of merged review dictionaries.
    652
    653        Raises:
    654            Exception: If one or more requests fail.
    655        """
    656        reviews = []
    657        for endpoint in ([self.app_endpoint, self.widget_endpoint] if published else [self.app_endpoint]):
    658            review_requests = []
    659            async for url, _ in self._pages(endpoint):
    660                task = asyncio.create_task(self.fetch_review_page(url))
    661                review_requests.append(task)
    662
    663            print(f"Gathering {len(review_requests)} review requests from {endpoint}...")
    664            results = await asyncio.gather(*review_requests, return_exceptions=True)
    665
    666            if any([isinstance(result, Exception) for result in results]):
    667                raise Exception("One or more requests failed.")
    668            else:
    669                # Flatten the list of lists into one big list using itertools.chain
    670                results = list(chain.from_iterable(results))
    671                reviews.append(results)
    672
    673        return JSONTransformer.merge_on_key("id", *reviews)
    674
    675
    676    async def send_review_request(self, template_id: int | str, csv_stringio: StringIO, spam_check: bool = False):
    677        """
    678        Asynchronously send a "manual" review request to Yotpo using a specific email template.
    679
    680        This method takes a template_id, a CSV formatted StringIO object containing the review
    681        request data, and an optional spam_check flag. It uploads the review data to Yotpo and
    682        sends the review request using the specified email template.
    683
    684        Args:
    685            template_id (int | str): The ID of the email template to use for the review request.
    686            csv_stringio (StringIO): A StringIO object containing the review request data in CSV format.
    687                                     (Use the `JSONTransformer` class to generate this.)
    688            spam_check (bool, optional): Whether or not to enable the built-in Yotpo spam check. Defaults to False.
    689
    690        Returns:
    691            dict: A dictionary containing the response data.
    692
    693        Raises:
    694            Exception: If any response status is not 200.
    695            UploadException: If the uploaded file is not valid.
    696        """
    697        upload_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/upload_mailing_list"
    698        send_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/send_burst_email"
    699
    700        upload_response = await self._post_request(upload_url, {"file": csv_stringio, "utoken": self._utoken})
    701        upload_data = upload_response['response']['response']
    702
    703        if upload_data['is_valid_file']:
    704            send_response = await self._post_request(send_url, {"file_path": upload_data['file_path'], "utoken": self._utoken, "activate_spam_limitations": spam_check})
    705        else:
    706            raise UploadException("Error: Uploaded file is not valid")
    707
    708        return {"upload": upload_response, "send": send_response}

class SessionNotCreatedError(builtins.Exception): View Source

    20class SessionNotCreatedError(Exception):
    21    """Raised when the aiohttp.ClientSession has not been created before making a request."""
    22    def __init__(self, message="Session not created. Please use `async with` context manager to make requests."):
    23        super().__init__(message)

Raised when the aiohttp.ClientSession has not been created before making a request.

SessionNotCreatedError( message='Session not created. Please use `async with` context manager to make requests.') View Source

    22    def __init__(self, message="Session not created. Please use `async with` context manager to make requests."):
    23        super().__init__(message)

##### Inherited Members

builtins.BaseException
  with_traceback
  add_note

class PreflightException(builtins.Exception): View Source

    26class PreflightException(Exception):
    27    pass

Common base class for all non-exit exceptions.

##### Inherited Members

builtins.Exception
  Exception
builtins.BaseException
  with_traceback
  add_note

class UploadException(builtins.Exception): View Source

    29class UploadException(Exception):
    30    pass

Common base class for all non-exit exceptions.

##### Inherited Members

builtins.Exception
  Exception
builtins.BaseException
  with_traceback
  add_note

class SendException(builtins.Exception): View Source

    32class SendException(Exception):
    33    pass

Common base class for all non-exit exceptions.

##### Inherited Members

builtins.Exception
  Exception
builtins.BaseException
  with_traceback
  add_note

class CustomException(builtins.Exception): View Source

    35class CustomException(Exception):
    36    pass

Common base class for all non-exit exceptions.

##### Inherited Members

builtins.Exception
  Exception
builtins.BaseException
  with_traceback
  add_note

class UserIdNotFound(builtins.Exception): View Source

    39class UserIdNotFound(Exception):
    40    """Raised when the user ID cannot be retrieved."""
    41    pass

Raised when the user ID cannot be retrieved.

##### Inherited Members

builtins.Exception
  Exception
builtins.BaseException
  with_traceback
  add_note

class AppDataNotFound(builtins.Exception): View Source

    43class AppDataNotFound(Exception):
    44    """Raised when the app data cannot be retrieved."""
    45    pass

Raised when the app data cannot be retrieved.

##### Inherited Members

builtins.Exception
  Exception
builtins.BaseException
  with_traceback
  add_note

class JSONTransformer: View Source

     50class JSONTransformer:
     51    """
     52    A utility class for transforming JSON data into various formats.
     53    """
     54
     55    @staticmethod
     56    def _stream_json_data(json: dict, keys: tuple) -> Iterator[dict[str, Union[tuple, str]]]:
     57        """
     58        Stream the full key directory and its final value of a JSON object of arbitrary depth, including lists.
     59
     60        This method recursively traverses a nested JSON object and yields dictionaries containing the full key
     61        directory (as tuples) and their associated values. It handles dictionaries and lists within the JSON object.
     62
     63        Example:
     64            Input JSON:
     65            ```json
     66            { "a":
     67                { "b":
     68                    [ { "c": "d" }, { "e": [1,2,3] } ]
     69                },
     70            "x": { "y": "z" }
     71            }
     72            ```
     73            Streaming key directories and values:
     74            ```python
     75            >>> print(list(JSONTransformer._stream_json_data(json, tuple())))
     76            >>> [{"keys": ("a", "b", "0", "c"), "value": "d"},
     77            >>>  {"keys": ("a", "b", "1", "e", "0"), "value": 1},
     78            >>>  {"keys": ("a", "b", "1", "e", "1"), "value": 2},
     79            >>>  {"keys": ("a", "b", "1", "e", "2"), "value": 3},
     80            >>>  {"keys": ("x", "y"), "value": "z"}]
     81
     82            ```
     83
     84        Args:
     85            json (dict): The JSON object to stream.
     86            keys (tuple): The current list of keys being traversed.
     87
     88        Yields:
     89            dict[str, Union[tuple | str]]: A dictionary containing the current key "directory" (as a tuple) and its associated value.
     90
     91        Returns:
     92            Iterator[dict[str, Union[tuple | str]]]: An iterator that yields dictionaries with the current key "directory" and its associated value.
     93
     94        Note:
     95            Tuples have been used instead of lists for the keys argument, for performance at the cost of readability of this function.
     96        """
     97
     98        for key, value in json.items():
     99            if isinstance(value, dict):
    100                # If the value is another dictionary, recursively call this method
    101                # with the updated keys "list".
    102                for result in JSONTransformer._stream_json_data(value, keys + (key,)):
    103                    yield result
    104            elif isinstance(value, list):
    105                # If the value is a list, iterate through the list and recursively
    106                # call this method with the updated keys "list" and index as an additional key.
    107                for index, item in enumerate(value):
    108                    if not isinstance(item, (dict, list)):
    109                        yield {'keys': keys + (key, str(index)), 'value': item}
    110                        continue
    111
    112                    for result in JSONTransformer._stream_json_data(item, keys + (key, str(index))):
    113                        yield result
    114            else:
    115                # If the value is not a dictionary or list, yield a dictionary containing
    116                # the current key "list" (as a tuple) and value.
    117                yield {'keys': keys + (key,), 'value': value}
    118
    119
    120    @staticmethod
    121    def merge_on_key(key: str, list_1: list[dict], list_2: list[dict]) -> list[dict]:
    122        """
    123        Merges two lists of dictionaries into a single list, based on matching dictionary keys.
    124
    125        Args:
    126            key (str): The key to match the dictionaries on.
    127            list_1 (list[dict]): The first list of dictionaries.
    128            list_2 (list[dict]): The second list of dictionaries.
    129
    130        Returns:
    131            list[dict]: A merged list of dictionaries containing merged disctionaries based on matching keys from both input lists.
    132        """
    133        if not list_1 or not list_2:
    134            raise Exception(
    135                f"Cannot merge empty list(s).\nlist_1: length={len(list_1)}\nlist_2: length={len(list_2)}")
    136
    137        # Use list comprehension to merge dictionaries that have matching keys
    138        return [
    139            dict(item_1, **item_2)
    140            for item_1 in list_1
    141            for item_2 in list_2
    142            if item_1.get(key, 0) == item_2.get(key, 1)
    143        ]
    144
    145    @staticmethod
    146    def flatten(json: dict[str, any], sep: str, exclude: list[str] = [], include: list[str] = []) -> dict[str, any]:
    147        """
    148        Flatten a nested JSON object into a dictionary with flattened keys.
    149
    150        This method takes a nested JSON object and converts it into a dictionary with
    151        keys that represent the nested structure using a specified separator. Optionally,
    152        you can provide a list of keys to exclude or include in the resulting dictionary.
    153
    154        Example:
    155            Input JSON:
    156            ```json
    157            { "a":
    158                { "b":
    159                    [ { "c": "d" }, { "e": [1,2,3] } ]
    160                },
    161                "x": { "y": "z" }
    162            }
    163            ```
    164            Flattening with a "." separator:
    165            ```python
    166            >>> print(JSONTransformer.flatten(json, "."))
    167            >>> {"a.b.0.c": "d",
    168            >>>  "a.b.1.e.0": 1,
    169            >>>  "a.b.1.e.1": 2,
    170            >>>  "a.b.1.e.2": 3,
    171            >>>  "x.y": "z"}
    172            ```
    173            Include and Exclude should be formatted with the complete nested key output in mind.
    174
    175            e.g  `include=["a.b.0.c"]`, returns `{"a.b.0.c": "d"}` only.
    176
    177        Args:
    178            json (dict): The JSON object to flatten.
    179            sep (str): The separator to use for joining nested keys.
    180            exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
    181            include (list[str]): If specified, only include keys that match the specified keys.
    182
    183        Returns:
    184            dict[str, str]: A flattened dictionary with keys representing the nested structure.
    185        """
    186
    187        # Stream the key-value pairs of the JSON object using the _stream_json_data method.
    188        data = {}
    189        for item in JSONTransformer._stream_json_data(json, tuple()):
    190            keys = item['keys']
    191            key_str = sep.join(keys)
    192            if (exclude and key_str in exclude) or (include and key_str not in include):
    193                continue
    194            # Check if the key string matches the specified key strings for content and title.
    195            # If so, html.unescape the value, this is from Yotpo's side of things.
    196            # This is done so NLTK is able to properly process the reviews.
    197            if ("content", "title").count(key_str):
    198                data[key_str] = unescape(item['value'])
    199            else:
    200                data[key_str] = item['value']
    201        return data
    202
    203    @staticmethod
    204    def flatten_list(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> list[dict[str, any]]:
    205        """
    206        Flattens a list of JSON objects into a list of dictionaries with flattened keys.
    207
    208        This method takes a list of nested JSON objects and converts each JSON object into a
    209        dictionary with keys representing the nested structure using a specified separator.
    210        Optionally, you can provide a list of keys to exclude or include in the resulting dictionaries.
    211
    212        Example:
    213            Input JSON list:
    214            ```json
    215            [
    216                {
    217                    "a": {"b": {"c": "d"}},
    218                    "x": {"y": "z"}
    219                },
    220                {
    221                    "p": {"q": {"r": "s"}},
    222                    "u": {"v": "w"}
    223                }
    224            ]
    225            ```
    226            Flattening with a "." separator:
    227            ```python
    228            >>> print(JSONTransformer.flatten_list(json_list, "."))
    229            >>> [
    230            >>>     {"a.b.c": "d", "x.y": "z"},
    231            >>>     {"p.q.r": "s", "u.v": "w"}
    232            >>> ]
    233            ```
    234
    235        Args:
    236            json_list (list[dict]): A list of JSON objects to flatten.
    237            sep (str): The separator to use for joining nested keys.
    238            exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
    239            include (list[str]): If specified, only include keys that match the specified keys.
    240
    241        Returns:
    242            list[dict[str, any]]: A list of flattened dictionaries with keys representing the nested structure.
    243        """
    244        return [JSONTransformer.flatten(point, sep, exclude, include) for point in json_list]
    245
    246    @staticmethod
    247    def get_headers(json_list: list[dict]) -> set[str]:
    248        """
    249        Get a complete headers list from a list of JSON objects.
    250
    251        Args:
    252            json_list (list[dict]): A list of JSON objects to iterate over.
    253
    254        Returns:
    255            set[str]: A set of headers.
    256        """
    257        return {key for item in json_list for key in item.keys()}
    258
    259    @staticmethod
    260    def from_bigquery_iterator(iterator: Iterator, exclude: set[str] = {}, include: set[str] = {}) -> tuple[set, list]:
    261        """
    262        Convert a BigQuery RowIterator into a set of headers and a list of rows.
    263
    264        Args:
    265            iterator (Iterator): A BigQuery RowIterator object.
    266            exclude (set[str], optional): A set of field names to exclude from the output. Defaults to an empty set.
    267            include (set[str], optional): A set of field names to include in the output. If provided, only these fields
    268                                          will be included. Defaults to an empty set.
    269
    270        Returns:
    271            tuple[set, list]: A tuple containing a set of headers and a list of rows as dictionaries.
    272                              Headers are the field names included in the output, and rows are dictionaries
    273                              containing field values for each row in the RowIterator.
    274
    275        Note:
    276            If both `exclude` and `include` are provided, the `include` set takes precedence.
    277        """
    278        schema = list(iterator.schema)
    279        headers = {field.name for field in schema if (include and field.name in include) or (exclude and field.name not in exclude)}
    280
    281        rows = []
    282        for row in iterator:
    283            [row.pop(field.name, None) for field in schema if field.name not in headers]
    284            rows.append(row)
    285
    286        return headers, rows
    287
    288    @staticmethod
    289    def to_rows(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> tuple[list, set]:
    290        """
    291        Flatten a list of JSON objects into a list of rows and a set of headers.
    292
    293        This method takes a list of nested JSON objects and converts each JSON object into a
    294        dictionary with keys representing the nested structure using a specified separator.
    295        It then creates a list of rows, where each row contains the values from the flattened
    296        dictionaries, and a set of headers representing the unique keys of the flattened dictionaries.
    297
    298        Args:
    299            json_list (list[dict]): The list of JSON objects to flatten.
    300            sep (str): The separator to use for joining nested keys.
    301            exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
    302            include (list[str]): If specified, only include keys that match the specified keys.
    303
    304        Note:
    305            If both exclude and include are specified, exclude takes precedence.
    306            This is done by using the following logic:
    307                ```
    308                if key_str in exclude or (include and key_str not in include):
    309                    continue
    310                ```
    311
    312        Returns:
    313            tuple[list, set]: A tuple containing the list of rows and the set of headers.
    314        """
    315        rows, headers = [], set()
    316        # Flatten the JSON object into a list of dictionaries using the flatten method.
    317        for item in JSONTransformer.flatten_list(json_list, sep, exclude, include):
    318            rows.append(item)
    319            headers.update(item.keys())
    320        return rows, headers
    321
    322
    323    @staticmethod
    324    def to_csv_stringio(rows: list[dict], headers: set) -> StringIO:
    325        """
    326        Convert a list of rows into a CSV formatted StringIO object.
    327
    328        This method takes a list of rows (dictionaries) and a set of headers, and writes them into
    329        a CSV formatted StringIO object. It can be used to create a CSV file-like object without
    330        creating an actual file on the filesystem.
    331
    332        Args:
    333            rows (list[dict]): A list of rows to convert into a CSV formatted StringIO object.
    334            headers (set): A set of headers to use for the CSV data.
    335
    336        Returns:
    337            StringIO: A CSV formatted StringIO object.
    338        """
    339        # Create a StringIO object to write the CSV data to.
    340        csv_stringio = StringIO()
    341        # Create a csv writer and write the rows to the StringIO object.
    342        writer = DictWriter(csv_stringio, fieldnames=headers)
    343        writer.writeheader()
    344        writer.writerows(rows)
    345
    346        # Reset the StringIO object's position to the beginning
    347        csv_stringio.seek(0)
    348
    349        return csv_stringio

A utility class for transforming JSON data into various formats.

@staticmethod
def merge_on_key(key: str, list_1: list[dict], list_2: list[dict]) -> list[dict]: View Source

    120    @staticmethod
    121    def merge_on_key(key: str, list_1: list[dict], list_2: list[dict]) -> list[dict]:
    122        """
    123        Merges two lists of dictionaries into a single list, based on matching dictionary keys.
    124
    125        Args:
    126            key (str): The key to match the dictionaries on.
    127            list_1 (list[dict]): The first list of dictionaries.
    128            list_2 (list[dict]): The second list of dictionaries.
    129
    130        Returns:
    131            list[dict]: A merged list of dictionaries containing merged disctionaries based on matching keys from both input lists.
    132        """
    133        if not list_1 or not list_2:
    134            raise Exception(
    135                f"Cannot merge empty list(s).\nlist_1: length={len(list_1)}\nlist_2: length={len(list_2)}")
    136
    137        # Use list comprehension to merge dictionaries that have matching keys
    138        return [
    139            dict(item_1, **item_2)
    140            for item_1 in list_1
    141            for item_2 in list_2
    142            if item_1.get(key, 0) == item_2.get(key, 1)
    143        ]

Merges two lists of dictionaries into a single list, based on matching dictionary keys.

###### Arguments:

- key (str): The key to match the dictionaries on.
- list_1 (list[dict]): The first list of dictionaries.
- list_2 (list[dict]): The second list of dictionaries.

###### Returns:

> list[dict]: A merged list of dictionaries containing merged disctionaries based on matching keys from both input lists.

@staticmethod
def flatten( json: dict[str, any], sep: str, exclude: list[str] = [], include: list[str] = []) -> dict[str, any]: View Source

    145    @staticmethod
    146    def flatten(json: dict[str, any], sep: str, exclude: list[str] = [], include: list[str] = []) -> dict[str, any]:
    147        """
    148        Flatten a nested JSON object into a dictionary with flattened keys.
    149
    150        This method takes a nested JSON object and converts it into a dictionary with
    151        keys that represent the nested structure using a specified separator. Optionally,
    152        you can provide a list of keys to exclude or include in the resulting dictionary.
    153
    154        Example:
    155            Input JSON:
    156            ```json
    157            { "a":
    158                { "b":
    159                    [ { "c": "d" }, { "e": [1,2,3] } ]
    160                },
    161                "x": { "y": "z" }
    162            }
    163            ```
    164            Flattening with a "." separator:
    165            ```python
    166            >>> print(JSONTransformer.flatten(json, "."))
    167            >>> {"a.b.0.c": "d",
    168            >>>  "a.b.1.e.0": 1,
    169            >>>  "a.b.1.e.1": 2,
    170            >>>  "a.b.1.e.2": 3,
    171            >>>  "x.y": "z"}
    172            ```
    173            Include and Exclude should be formatted with the complete nested key output in mind.
    174
    175            e.g  `include=["a.b.0.c"]`, returns `{"a.b.0.c": "d"}` only.
    176
    177        Args:
    178            json (dict): The JSON object to flatten.
    179            sep (str): The separator to use for joining nested keys.
    180            exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
    181            include (list[str]): If specified, only include keys that match the specified keys.
    182
    183        Returns:
    184            dict[str, str]: A flattened dictionary with keys representing the nested structure.
    185        """
    186
    187        # Stream the key-value pairs of the JSON object using the _stream_json_data method.
    188        data = {}
    189        for item in JSONTransformer._stream_json_data(json, tuple()):
    190            keys = item['keys']
    191            key_str = sep.join(keys)
    192            if (exclude and key_str in exclude) or (include and key_str not in include):
    193                continue
    194            # Check if the key string matches the specified key strings for content and title.
    195            # If so, html.unescape the value, this is from Yotpo's side of things.
    196            # This is done so NLTK is able to properly process the reviews.
    197            if ("content", "title").count(key_str):
    198                data[key_str] = unescape(item['value'])
    199            else:
    200                data[key_str] = item['value']
    201        return data

Flatten a nested JSON object into a dictionary with flattened keys.

This method takes a nested JSON object and converts it into a dictionary with keys that represent the nested structure using a specified separator. Optionally, you can provide a list of keys to exclude or include in the resulting dictionary.

###### Example:

> Input JSON:

>     { "a":
>         { "b":
>             [ { "c": "d" }, { "e": [1,2,3] } ]
>         },
>         "x": { "y": "z" }
>     }

> Flattening with a "." separator:

>     >>> print(JSONTransformer.flatten(json, "."))
>     >>> {"a.b.0.c": "d",
>     >>>  "a.b.1.e.0": 1,
>     >>>  "a.b.1.e.1": 2,
>     >>>  "a.b.1.e.2": 3,
>     >>>  "x.y": "z"}

> Include and Exclude should be formatted with the complete nested key output in mind.

> e.g `include=["a.b.0.c"]`, returns `{"a.b.0.c": "d"}` only.

###### Arguments:

- json (dict): The JSON object to flatten.
- sep (str): The separator to use for joining nested keys.
- exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
- include (list[str]): If specified, only include keys that match the specified keys.

###### Returns:

> dict[str, str]: A flattened dictionary with keys representing the nested structure.

@staticmethod
def flatten_list( json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> list[dict[str, any]]: View Source

    203    @staticmethod
    204    def flatten_list(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> list[dict[str, any]]:
    205        """
    206        Flattens a list of JSON objects into a list of dictionaries with flattened keys.
    207
    208        This method takes a list of nested JSON objects and converts each JSON object into a
    209        dictionary with keys representing the nested structure using a specified separator.
    210        Optionally, you can provide a list of keys to exclude or include in the resulting dictionaries.
    211
    212        Example:
    213            Input JSON list:
    214            ```json
    215            [
    216                {
    217                    "a": {"b": {"c": "d"}},
    218                    "x": {"y": "z"}
    219                },
    220                {
    221                    "p": {"q": {"r": "s"}},
    222                    "u": {"v": "w"}
    223                }
    224            ]
    225            ```
    226            Flattening with a "." separator:
    227            ```python
    228            >>> print(JSONTransformer.flatten_list(json_list, "."))
    229            >>> [
    230            >>>     {"a.b.c": "d", "x.y": "z"},
    231            >>>     {"p.q.r": "s", "u.v": "w"}
    232            >>> ]
    233            ```
    234
    235        Args:
    236            json_list (list[dict]): A list of JSON objects to flatten.
    237            sep (str): The separator to use for joining nested keys.
    238            exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
    239            include (list[str]): If specified, only include keys that match the specified keys.
    240
    241        Returns:
    242            list[dict[str, any]]: A list of flattened dictionaries with keys representing the nested structure.
    243        """
    244        return [JSONTransformer.flatten(point, sep, exclude, include) for point in json_list]

Flattens a list of JSON objects into a list of dictionaries with flattened keys.

This method takes a list of nested JSON objects and converts each JSON object into a dictionary with keys representing the nested structure using a specified separator. Optionally, you can provide a list of keys to exclude or include in the resulting dictionaries.

###### Example:

> Input JSON list:

>     [
>         {
>             "a": {"b": {"c": "d"}},
>             "x": {"y": "z"}
>         },
>         {
>             "p": {"q": {"r": "s"}},
>             "u": {"v": "w"}
>         }
>     ]

> Flattening with a "." separator:

>     >>> print(JSONTransformer.flatten_list(json_list, "."))
>     >>> [
>     >>>     {"a.b.c": "d", "x.y": "z"},
>     >>>     {"p.q.r": "s", "u.v": "w"}
>     >>> ]

###### Arguments:

- json_list (list[dict]): A list of JSON objects to flatten.
- sep (str): The separator to use for joining nested keys.
- exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
- include (list[str]): If specified, only include keys that match the specified keys.

###### Returns:

> list[dict[str, any]]: A list of flattened dictionaries with keys representing the nested structure.

@staticmethod
def get_headers(json_list: list[dict]) -> set[str]: View Source

    246    @staticmethod
    247    def get_headers(json_list: list[dict]) -> set[str]:
    248        """
    249        Get a complete headers list from a list of JSON objects.
    250
    251        Args:
    252            json_list (list[dict]): A list of JSON objects to iterate over.
    253
    254        Returns:
    255            set[str]: A set of headers.
    256        """
    257        return {key for item in json_list for key in item.keys()}

Get a complete headers list from a list of JSON objects.

###### Arguments:

- json_list (list[dict]): A list of JSON objects to iterate over.

###### Returns:

> set[str]: A set of headers.

@staticmethod
def from_bigquery_iterator( iterator: Iterator, exclude: set[str] = {}, include: set[str] = {}) -> tuple[set, list]: View Source

    259    @staticmethod
    260    def from_bigquery_iterator(iterator: Iterator, exclude: set[str] = {}, include: set[str] = {}) -> tuple[set, list]:
    261        """
    262        Convert a BigQuery RowIterator into a set of headers and a list of rows.
    263
    264        Args:
    265            iterator (Iterator): A BigQuery RowIterator object.
    266            exclude (set[str], optional): A set of field names to exclude from the output. Defaults to an empty set.
    267            include (set[str], optional): A set of field names to include in the output. If provided, only these fields
    268                                          will be included. Defaults to an empty set.
    269
    270        Returns:
    271            tuple[set, list]: A tuple containing a set of headers and a list of rows as dictionaries.
    272                              Headers are the field names included in the output, and rows are dictionaries
    273                              containing field values for each row in the RowIterator.
    274
    275        Note:
    276            If both `exclude` and `include` are provided, the `include` set takes precedence.
    277        """
    278        schema = list(iterator.schema)
    279        headers = {field.name for field in schema if (include and field.name in include) or (exclude and field.name not in exclude)}
    280
    281        rows = []
    282        for row in iterator:
    283            [row.pop(field.name, None) for field in schema if field.name not in headers]
    284            rows.append(row)
    285
    286        return headers, rows

Convert a BigQuery RowIterator into a set of headers and a list of rows.

###### Arguments:

- iterator (Iterator): A BigQuery RowIterator object.
- exclude (set[str], optional): A set of field names to exclude from the output. Defaults to an empty set.
- include (set[str], optional): A set of field names to include in the output. If provided, only these fields will be included. Defaults to an empty set.

###### Returns:

> tuple[set, list]: A tuple containing a set of headers and a list of rows as dictionaries. Headers are the field names included in the output, and rows are dictionaries containing field values for each row in the RowIterator.

###### Note:

> If both `exclude` and `include` are provided, the `include` set takes precedence.

@staticmethod
def to_rows( json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> tuple[list, set]: View Source

    288    @staticmethod
    289    def to_rows(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> tuple[list, set]:
    290        """
    291        Flatten a list of JSON objects into a list of rows and a set of headers.
    292
    293        This method takes a list of nested JSON objects and converts each JSON object into a
    294        dictionary with keys representing the nested structure using a specified separator.
    295        It then creates a list of rows, where each row contains the values from the flattened
    296        dictionaries, and a set of headers representing the unique keys of the flattened dictionaries.
    297
    298        Args:
    299            json_list (list[dict]): The list of JSON objects to flatten.
    300            sep (str): The separator to use for joining nested keys.
    301            exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
    302            include (list[str]): If specified, only include keys that match the specified keys.
    303
    304        Note:
    305            If both exclude and include are specified, exclude takes precedence.
    306            This is done by using the following logic:
    307                ```
    308                if key_str in exclude or (include and key_str not in include):
    309                    continue
    310                ```
    311
    312        Returns:
    313            tuple[list, set]: A tuple containing the list of rows and the set of headers.
    314        """
    315        rows, headers = [], set()
    316        # Flatten the JSON object into a list of dictionaries using the flatten method.
    317        for item in JSONTransformer.flatten_list(json_list, sep, exclude, include):
    318            rows.append(item)
    319            headers.update(item.keys())
    320        return rows, headers

Flatten a list of JSON objects into a list of rows and a set of headers.

This method takes a list of nested JSON objects and converts each JSON object into a dictionary with keys representing the nested structure using a specified separator. It then creates a list of rows, where each row contains the values from the flattened dictionaries, and a set of headers representing the unique keys of the flattened dictionaries.

###### Arguments:

- json_list (list[dict]): The list of JSON objects to flatten.
- sep (str): The separator to use for joining nested keys.
- exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
- include (list[str]): If specified, only include keys that match the specified keys.

###### Note:

> If both exclude and include are specified, exclude takes precedence. This is done by using the following logic:

>     if key_str in exclude or (include and key_str not in include):
>         continue

###### Returns:

> tuple[list, set]: A tuple containing the list of rows and the set of headers.

@staticmethod
def to_csv_stringio(rows: list[dict], headers: set) -> _io.StringIO: View Source

    323    @staticmethod
    324    def to_csv_stringio(rows: list[dict], headers: set) -> StringIO:
    325        """
    326        Convert a list of rows into a CSV formatted StringIO object.
    327
    328        This method takes a list of rows (dictionaries) and a set of headers, and writes them into
    329        a CSV formatted StringIO object. It can be used to create a CSV file-like object without
    330        creating an actual file on the filesystem.
    331
    332        Args:
    333            rows (list[dict]): A list of rows to convert into a CSV formatted StringIO object.
    334            headers (set): A set of headers to use for the CSV data.
    335
    336        Returns:
    337            StringIO: A CSV formatted StringIO object.
    338        """
    339        # Create a StringIO object to write the CSV data to.
    340        csv_stringio = StringIO()
    341        # Create a csv writer and write the rows to the StringIO object.
    342        writer = DictWriter(csv_stringio, fieldnames=headers)
    343        writer.writeheader()
    344        writer.writerows(rows)
    345
    346        # Reset the StringIO object's position to the beginning
    347        csv_stringio.seek(0)
    348
    349        return csv_stringio

Convert a list of rows into a CSV formatted StringIO object.

This method takes a list of rows (dictionaries) and a set of headers, and writes them into a CSV formatted StringIO object. It can be used to create a CSV file-like object without creating an actual file on the filesystem.

###### Arguments:

- rows (list[dict]): A list of rows to convert into a CSV formatted StringIO object.
- headers (set): A set of headers to use for the CSV data.

###### Returns:

> StringIO: A CSV formatted StringIO object.

class YotpoAPIWrapper: View Source

    352class YotpoAPIWrapper:
    353    #NOTE: Update docstring if more methods are added to account for any added functionality outside of the defined scope.
    354    """
    355    A class for interacting with the Yotpo API to fetch app and account information, review data, and send manual review requests.
    356
    357    The YotpoAPIWrapper uses the provided app_key and secret for authentication and constructs the necessary API endpoints for making requests.
    358
    359    Args:
    360        app_key (str): The Yotpo app key for API authentication.
    361        secret (str): The client secret to authenticate requests.
    362        preferred_uid (Optional[int], optional): The user ID to use for fetching data relating to the Yotpo APP. Defaults to None.
    363
    364    Raises:
    365        Exception: If either app_key or secret is not provided.
    366    """
    367
    368    # TODO: Update the explanation of the preferred_uid argument to be more accurate/helpful.
    369
    370    def __init__(self, app_key: str, secret: str, preferred_uid: Optional[int] = None) -> None:
    371        if not app_key or not secret:
    372            raise Exception(f"app_key(exists={bool(app_key)}) and secret(exists={bool(secret)}) are required.")
    373
    374        self._app_key = app_key
    375        self._secret = secret
    376        self.user_id = preferred_uid
    377
    378    async def __aenter__(self):
    379        self.aiohttp_session = aiohttp.ClientSession()
    380        self._utoken = await self._get_user_token()
    381
    382        self.app_endpoint = f"https://api.yotpo.com/v1/apps/{self._app_key}/reviews?utoken={self._utoken}&"
    383        self.widget_endpoint = f"https://api.yotpo.com/v1/widget/{self._app_key}/reviews?utoken={self._utoken}&"
    384        self.write_user_endpoint = f"https://api-write.yotpo.com/users/me?utoken={self._utoken}"
    385        self.write_app_endpoint = f"https://api-write.yotpo.com/apps"
    386
    387        if self.user_id is None:
    388            self.user_id = (await self.get_user())['id']
    389
    390        return self
    391
    392    async def __aexit__(self, exc_type, exc, tb):
    393        await self.aiohttp_session.close()
    394
    395    async def _get_user_token(self) -> str:
    396        """
    397        Get the user access token using the provided secret.
    398
    399        Args:
    400            app_key (str): The target app you want authenticated.
    401            secret (str): The client secret to authenticate the request.
    402
    403        Returns:
    404            str: The user access token.
    405
    406        Raises:
    407            Exception: If the response status is not OK (200).
    408        """
    409        url = "https://api.yotpo.com/oauth/token"
    410        data = {
    411            "grant_type": "client_credentials",
    412            "client_id": self._app_key,
    413            "client_secret": self._secret,
    414        }
    415
    416        return (await self._post_request(url, data=data, parser=lambda x: x["access_token"]))
    417
    418    async def _options_request(self, url: str, method: str) -> bool:
    419        """
    420        Asynchronously sends a preflight OPTIONS request to check if the given method is allowed for the given endpoint.
    421
    422        Args:
    423            url (str): The API endpoint URL.
    424
    425        Returns:
    426            bool: True if the method is allowed, False otherwise.
    427        """
    428        if not hasattr(self, 'aiohttp_session'):
    429            raise SessionNotCreatedError()
    430
    431        # Cheeky way to make sure `method` is at least syntactically correct.
    432        if (method := method.upper()) not in ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE']:
    433            raise Exception("Invalid HTTP method: ", method)
    434
    435        async with self.aiohttp_session.options(url) as response:
    436            if response.ok:
    437                allowed_methods = response.headers.get("Allow", "")
    438                return method in allowed_methods.split(", ")
    439            raise Exception(f"Error: {response.status} {response.reason} - {url}")
    440
    441    async def _get_request(self, url: str, parser: Callable[[dict], dict] = None, exception_type: Exception = CustomException, **kwargs) -> dict:
    442        """
    443        Asynchronously sends a GET request to the specified URL and parses the response using the provided parser.
    444
    445        Args:
    446            session (aiohttp.ClientSession): An aiohttp client session for making requests.
    447            url (str): The URL to send the request to.
    448            parser (Callable[[dict], dict]): A function to parse the response JSON. Defaults to json.loads.
    449            exception_type (Union[CustomException, Exception]): The type of exception to raise when an error occurs. Defaults to CustomException.
    450            **kwargs: Additional keyword arguments to be passed to the request object.
    451
    452        Returns:
    453            dict: The parsed JSON response.
    454
    455        Raises:
    456            exception_type: If the response status is not 200, an instance of the specified exception_type is raised with an error message.
    457        """
    458        if not hasattr(self, 'aiohttp_session'):
    459            raise SessionNotCreatedError()
    460
    461        async with self.aiohttp_session.get(url, **kwargs) as response:
    462            if response.status == 200:
    463                raw_data = json_loads(await response.read())
    464                if parser is None:
    465                    return raw_data
    466                return parser(raw_data)
    467            raise exception_type(f"Error: {response.status} | {response.reason} - {url}")
    468
    469    async def _post_request(self, url: str, data: dict, parser: Callable[[dict], dict] = None, exception_type: Exception = CustomException, **kwargs) -> dict:
    470        """
    471        Asynchronously sends a POST request to the specified URL and parses the response using the provided parser.
    472
    473        Args:
    474            session (aiohttp.ClientSession): An aiohttp client session for making requests.
    475            url (str): The URL to send the request to.
    476            data (dict): The data to send in the request body.
    477            parser (Callable[[dict], dict]): A function to parse the response JSON. Defaults to json.loads.
    478            exception_type (Union[CustomException, Exception]): The type of exception to raise when an error occurs. Defaults to CustomException.
    479            **kwargs: Additional keyword arguments to be passed to the request object.
    480
    481        Returns:
    482            dict: The parsed JSON response.
    483
    484        Raises:
    485            exception_type: If the response status is not 200, an instance of the specified exception_type is raised with an error message.
    486        """
    487        if not hasattr(self, 'aiohttp_session'):
    488            raise SessionNotCreatedError()
    489
    490        async with self.aiohttp_session.post(url, data=data, **kwargs) as response:
    491            if response.status == 200:
    492                raw_data = json_loads(await response.read())
    493                if parser is None:
    494                    return raw_data
    495                return parser(raw_data)
    496            raise exception_type(f"Error: {response.status} | {response.reason} - {url}")
    497
    498    async def _pages(self, endpoint: str, start_page: int = 1) -> AsyncGenerator[tuple[str, int], None]:
    499        """
    500        Asynchronously generate URLs for each page of results.
    501
    502        Args:
    503            endpoint (str): The API endpoint to query.
    504            start_page (int): The first page to generate.
    505
    506        Yields:
    507            tuple[str, int] ->
    508                str: The URL for the next page of results.
    509                int: The current page number.
    510        """
    511        last_page = ceil((await self.get_total_reviews()) / 100)
    512        is_widget = "widget" in endpoint
    513        for num in range(start_page, last_page + 1):
    514            yield endpoint + urlencode(({("per_page" if is_widget else "count"): 100, "page": num})), num
    515
    516    async def get_user(self) -> dict:
    517        """
    518        Asynchronously fetches user data from the user endpoint.
    519
    520        This function returns various user-related data, such as name, email, display_name, company, position, and other
    521        metadata like sign_in_count and package details. This data can be useful for understanding user profiles,
    522        managing access, or customizing the user experience based on the retrieved information.
    523
    524        Returns:
    525            dict: A dictionary containing user information, including personal details, activity statistics, and package details.
    526
    527
    528        Example:
    529            ```python
    530            >>> yotpo = YotpoAPIWrapper(app_key, secret)
    531            >>> async with yotpo as yp:
    532            >>>     user = await yp.get_user()
    533            ```
    534
    535        Raises:
    536            Exception: If the request to the user endpoint returns a non-OK status.
    537        """
    538        url = self.write_user_endpoint
    539
    540        return await self._get_request(url, parser=lambda data: data['response']['user'])
    541
    542    async def get_app(self) -> dict:
    543        """
    544        Asynchronously fetches app data for the app associated with the preferred user ID.
    545
    546        Returns:
    547            dict: A dictionary containing app information derived from the specified user. The app information includes
    548            details such as app_key, domain, name, url, account type, users, reminders, custom design, created
    549            and updated timestamps, tracking code status, account emails, package details, enabled product
    550            categories, features usage summary, data for events, category, installed status, organization,
    551            and associated apps.
    552
    553        Raises:
    554            AppDataNotFound: If the request to the app endpoint returns a non-OK status.
    555        """
    556        # NOTE: The user_id does not appear to actually matter at all, as it still returns data even if it is not a valid user ID.
    557        # I suspect `user_id` is just used to track which user is making the request.
    558
    559        # TODO: If the above is true, we can probably abstract away the `user_id` parameter and just use the user ID from the user endpoint.
    560        # And if need be we can add some configuration to allow the user to specify a user ID if they want to.
    561
    562        url = f"https://api-write.yotpo.com/apps/{self._app_key}?user_id={self.user_id}&utoken={self._utoken}"
    563
    564        return await self._get_request(url, parser=lambda data: data['response']['app'], exception_type=AppDataNotFound)
    565
    566    async def get_total_reviews(self) -> int:
    567        """
    568        Asynchronously fetches the total number of reviews for an app.
    569
    570        This method first retrieves the user and their user ID, then fetches the app data
    571        associated with that user. Finally, it returns the total number of reviews for the app.
    572
    573        Returns:
    574            int: The total number of reviews for the app.
    575
    576        Raises:
    577            AppDataNotFound: If unable to get the app data.
    578        """
    579        app = await self.get_app()
    580
    581        return app['data_for_events']['total_reviews']
    582
    583    async def get_templates(self) -> dict:
    584        """
    585        Asynchronously fetch the email templates associated with the Yotpo account.
    586
    587        This method retrieves the app data, extracts the email templates, and returns them as a dictionary.
    588        The primary use case for this function is to obtain the template IDs required for the 'send_review_request' method.
    589
    590        The returned dictionary contains template objects with properties such as 'id' (template_id),
    591        'email_type', 'data', and others. For example:
    592        ```python
    593            {
    594                'id': 10291872,
    595                'account_id': 9092811,
    596                'email_type_id': 31,
    597                'email_type': {
    598                    'id': 31,
    599                    'name': 'mail_post_service',
    600                    'template': 'testimonials_request'
    601                },
    602                'data': {
    603                    'subject': 'Tell us what you think - {company_name}',
    604                    'header': 'Hello {user},\\n We love having you as a customer and would really appreciate it if you filled out the form below.',
    605                    'bottom': 'Thanks so much! <br> - {company_name}'
    606                },
    607                'formless_call_to_action': '',
    608                'days_delay': 3,
    609                'email_submission_type_id': 9
    610            }
    611        ```
    612        Returns:
    613            dict: A dictionary containing the email templates, keyed by their names.
    614        """
    615        app = await self.get_app()
    616        templates = app['account_emails']
    617        return templates
    618
    619
    620    async def fetch_review_page(self, url: str) -> list[dict]:
    621        """
    622        Asynchronously fetch a single review page from the specified URL.
    623
    624        This function fetches review data from the provided URL and parses the response based on whether
    625        the URL is for the widget endpoint or not.
    626
    627        Args:
    628            url (str): The URL from which to fetch review data.
    629
    630        Returns:
    631            list[dict]: A list of review dictionaries.
    632
    633        """
    634        is_widget = "widget" in url
    635        return await self._get_request(url, parser=lambda data: data['response']['reviews'] if is_widget else data['reviews'])
    636
    637
    638    async def fetch_all_reviews(self, published: bool = True) -> list[dict]:
    639        """
    640        Asynchronously fetch all reviews from the specified endpoints.
    641
    642        This function fetches review data from the app and widget endpoints if `published` is set to True,
    643        and only from the app endpoint if `published` is set to False. The fetched reviews are then merged
    644        based on their "id" attribute.
    645
    646        Args:
    647            published (bool, optional): Determines if the function should fetch reviews from both
    648                                         the app and widget endpoints or just the app endpoint.
    649                                         Defaults to True.
    650
    651        Returns:
    652            list[dict]: A list of merged review dictionaries.
    653
    654        Raises:
    655            Exception: If one or more requests fail.
    656        """
    657        reviews = []
    658        for endpoint in ([self.app_endpoint, self.widget_endpoint] if published else [self.app_endpoint]):
    659            review_requests = []
    660            async for url, _ in self._pages(endpoint):
    661                task = asyncio.create_task(self.fetch_review_page(url))
    662                review_requests.append(task)
    663
    664            print(f"Gathering {len(review_requests)} review requests from {endpoint}...")
    665            results = await asyncio.gather(*review_requests, return_exceptions=True)
    666
    667            if any([isinstance(result, Exception) for result in results]):
    668                raise Exception("One or more requests failed.")
    669            else:
    670                # Flatten the list of lists into one big list using itertools.chain
    671                results = list(chain.from_iterable(results))
    672                reviews.append(results)
    673
    674        return JSONTransformer.merge_on_key("id", *reviews)
    675
    676
    677    async def send_review_request(self, template_id: int | str, csv_stringio: StringIO, spam_check: bool = False):
    678        """
    679        Asynchronously send a "manual" review request to Yotpo using a specific email template.
    680
    681        This method takes a template_id, a CSV formatted StringIO object containing the review
    682        request data, and an optional spam_check flag. It uploads the review data to Yotpo and
    683        sends the review request using the specified email template.
    684
    685        Args:
    686            template_id (int | str): The ID of the email template to use for the review request.
    687            csv_stringio (StringIO): A StringIO object containing the review request data in CSV format.
    688                                     (Use the `JSONTransformer` class to generate this.)
    689            spam_check (bool, optional): Whether or not to enable the built-in Yotpo spam check. Defaults to False.
    690
    691        Returns:
    692            dict: A dictionary containing the response data.
    693
    694        Raises:
    695            Exception: If any response status is not 200.
    696            UploadException: If the uploaded file is not valid.
    697        """
    698        upload_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/upload_mailing_list"
    699        send_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/send_burst_email"
    700
    701        upload_response = await self._post_request(upload_url, {"file": csv_stringio, "utoken": self._utoken})
    702        upload_data = upload_response['response']['response']
    703
    704        if upload_data['is_valid_file']:
    705            send_response = await self._post_request(send_url, {"file_path": upload_data['file_path'], "utoken": self._utoken, "activate_spam_limitations": spam_check})
    706        else:
    707            raise UploadException("Error: Uploaded file is not valid")
    708
    709        return {"upload": upload_response, "send": send_response}

A class for interacting with the Yotpo API to fetch app and account information, review data, and send manual review requests.

The YotpoAPIWrapper uses the provided app_key and secret for authentication and constructs the necessary API endpoints for making requests.

###### Arguments:

- app_key (str): The Yotpo app key for API authentication.
- secret (str): The client secret to authenticate requests.
- preferred_uid (Optional[int], optional): The user ID to use for fetching data relating to the Yotpo APP. Defaults to None.

###### Raises:

- Exception: If either app_key or secret is not provided.

YotpoAPIWrapper(app_key: str, secret: str, preferred_uid: Optional[int] = None) View Source

    370    def __init__(self, app_key: str, secret: str, preferred_uid: Optional[int] = None) -> None:
    371        if not app_key or not secret:
    372            raise Exception(f"app_key(exists={bool(app_key)}) and secret(exists={bool(secret)}) are required.")
    373
    374        self._app_key = app_key
    375        self._secret = secret
    376        self.user_id = preferred_uid

async def get_user(self) -> dict: View Source

    516    async def get_user(self) -> dict:
    517        """
    518        Asynchronously fetches user data from the user endpoint.
    519
    520        This function returns various user-related data, such as name, email, display_name, company, position, and other
    521        metadata like sign_in_count and package details. This data can be useful for understanding user profiles,
    522        managing access, or customizing the user experience based on the retrieved information.
    523
    524        Returns:
    525            dict: A dictionary containing user information, including personal details, activity statistics, and package details.
    526
    527
    528        Example:
    529            ```python
    530            >>> yotpo = YotpoAPIWrapper(app_key, secret)
    531            >>> async with yotpo as yp:
    532            >>>     user = await yp.get_user()
    533            ```
    534
    535        Raises:
    536            Exception: If the request to the user endpoint returns a non-OK status.
    537        """
    538        url = self.write_user_endpoint
    539
    540        return await self._get_request(url, parser=lambda data: data['response']['user'])

Asynchronously fetches user data from the user endpoint.

This function returns various user-related data, such as name, email, display_name, company, position, and other metadata like sign_in_count and package details. This data can be useful for understanding user profiles, managing access, or customizing the user experience based on the retrieved information.

###### Returns:

> dict: A dictionary containing user information, including personal details, activity statistics, and package details.

###### Example:

>     >>> yotpo = YotpoAPIWrapper(app_key, secret)
>     >>> async with yotpo as yp:
>     >>>     user = await yp.get_user()

###### Raises:

- Exception: If the request to the user endpoint returns a non-OK status.

async def get_app(self) -> dict: View Source

    542    async def get_app(self) -> dict:
    543        """
    544        Asynchronously fetches app data for the app associated with the preferred user ID.
    545
    546        Returns:
    547            dict: A dictionary containing app information derived from the specified user. The app information includes
    548            details such as app_key, domain, name, url, account type, users, reminders, custom design, created
    549            and updated timestamps, tracking code status, account emails, package details, enabled product
    550            categories, features usage summary, data for events, category, installed status, organization,
    551            and associated apps.
    552
    553        Raises:
    554            AppDataNotFound: If the request to the app endpoint returns a non-OK status.
    555        """
    556        # NOTE: The user_id does not appear to actually matter at all, as it still returns data even if it is not a valid user ID.
    557        # I suspect `user_id` is just used to track which user is making the request.
    558
    559        # TODO: If the above is true, we can probably abstract away the `user_id` parameter and just use the user ID from the user endpoint.
    560        # And if need be we can add some configuration to allow the user to specify a user ID if they want to.
    561
    562        url = f"https://api-write.yotpo.com/apps/{self._app_key}?user_id={self.user_id}&utoken={self._utoken}"
    563
    564        return await self._get_request(url, parser=lambda data: data['response']['app'], exception_type=AppDataNotFound)

Asynchronously fetches app data for the app associated with the preferred user ID.

###### Returns:

> dict: A dictionary containing app information derived from the specified user. The app information includes details such as app_key, domain, name, url, account type, users, reminders, custom design, created and updated timestamps, tracking code status, account emails, package details, enabled product categories, features usage summary, data for events, category, installed status, organization, and associated apps.

###### Raises:

- AppDataNotFound: If the request to the app endpoint returns a non-OK status.

async def get_total_reviews(self) -> int: View Source

    566    async def get_total_reviews(self) -> int:
    567        """
    568        Asynchronously fetches the total number of reviews for an app.
    569
    570        This method first retrieves the user and their user ID, then fetches the app data
    571        associated with that user. Finally, it returns the total number of reviews for the app.
    572
    573        Returns:
    574            int: The total number of reviews for the app.
    575
    576        Raises:
    577            AppDataNotFound: If unable to get the app data.
    578        """
    579        app = await self.get_app()
    580
    581        return app['data_for_events']['total_reviews']

Asynchronously fetches the total number of reviews for an app.

This method first retrieves the user and their user ID, then fetches the app data associated with that user. Finally, it returns the total number of reviews for the app.

###### Returns:

> int: The total number of reviews for the app.

###### Raises:

- AppDataNotFound: If unable to get the app data.

async def get_templates(self) -> dict: View Source

    583    async def get_templates(self) -> dict:
    584        """
    585        Asynchronously fetch the email templates associated with the Yotpo account.
    586
    587        This method retrieves the app data, extracts the email templates, and returns them as a dictionary.
    588        The primary use case for this function is to obtain the template IDs required for the 'send_review_request' method.
    589
    590        The returned dictionary contains template objects with properties such as 'id' (template_id),
    591        'email_type', 'data', and others. For example:
    592        ```python
    593            {
    594                'id': 10291872,
    595                'account_id': 9092811,
    596                'email_type_id': 31,
    597                'email_type': {
    598                    'id': 31,
    599                    'name': 'mail_post_service',
    600                    'template': 'testimonials_request'
    601                },
    602                'data': {
    603                    'subject': 'Tell us what you think - {company_name}',
    604                    'header': 'Hello {user},\\n We love having you as a customer and would really appreciate it if you filled out the form below.',
    605                    'bottom': 'Thanks so much! <br> - {company_name}'
    606                },
    607                'formless_call_to_action': '',
    608                'days_delay': 3,
    609                'email_submission_type_id': 9
    610            }
    611        ```
    612        Returns:
    613            dict: A dictionary containing the email templates, keyed by their names.
    614        """
    615        app = await self.get_app()
    616        templates = app['account_emails']
    617        return templates

Asynchronously fetch the email templates associated with the Yotpo account.

This method retrieves the app data, extracts the email templates, and returns them as a dictionary. The primary use case for this function is to obtain the template IDs required for the 'send_review_request' method.

The returned dictionary contains template objects with properties such as 'id' (template_id), 'email_type', 'data', and others. For example:

        {
            'id': 10291872,
            'account_id': 9092811,
            'email_type_id': 31,
            'email_type': {
                'id': 31,
                'name': 'mail_post_service',
                'template': 'testimonials_request'
            },
            'data': {
                'subject': 'Tell us what you think - {company_name}',
                'header': 'Hello {user},\n We love having you as a customer and would really appreciate it if you filled out the form below.',
                'bottom': 'Thanks so much! <br> - {company_name}'
            },
            'formless_call_to_action': '',
            'days_delay': 3,
            'email_submission_type_id': 9
        }

###### Returns:

> dict: A dictionary containing the email templates, keyed by their names.

async def fetch_review_page(self, url: str) -> list[dict]: View Source

    620    async def fetch_review_page(self, url: str) -> list[dict]:
    621        """
    622        Asynchronously fetch a single review page from the specified URL.
    623
    624        This function fetches review data from the provided URL and parses the response based on whether
    625        the URL is for the widget endpoint or not.
    626
    627        Args:
    628            url (str): The URL from which to fetch review data.
    629
    630        Returns:
    631            list[dict]: A list of review dictionaries.
    632
    633        """
    634        is_widget = "widget" in url
    635        return await self._get_request(url, parser=lambda data: data['response']['reviews'] if is_widget else data['reviews'])

Asynchronously fetch a single review page from the specified URL.

This function fetches review data from the provided URL and parses the response based on whether the URL is for the widget endpoint or not.

###### Arguments:

- url (str): The URL from which to fetch review data.

###### Returns:

> list[dict]: A list of review dictionaries.

async def fetch_all_reviews(self, published: bool = True) -> list[dict]: View Source

    638    async def fetch_all_reviews(self, published: bool = True) -> list[dict]:
    639        """
    640        Asynchronously fetch all reviews from the specified endpoints.
    641
    642        This function fetches review data from the app and widget endpoints if `published` is set to True,
    643        and only from the app endpoint if `published` is set to False. The fetched reviews are then merged
    644        based on their "id" attribute.
    645
    646        Args:
    647            published (bool, optional): Determines if the function should fetch reviews from both
    648                                         the app and widget endpoints or just the app endpoint.
    649                                         Defaults to True.
    650
    651        Returns:
    652            list[dict]: A list of merged review dictionaries.
    653
    654        Raises:
    655            Exception: If one or more requests fail.
    656        """
    657        reviews = []
    658        for endpoint in ([self.app_endpoint, self.widget_endpoint] if published else [self.app_endpoint]):
    659            review_requests = []
    660            async for url, _ in self._pages(endpoint):
    661                task = asyncio.create_task(self.fetch_review_page(url))
    662                review_requests.append(task)
    663
    664            print(f"Gathering {len(review_requests)} review requests from {endpoint}...")
    665            results = await asyncio.gather(*review_requests, return_exceptions=True)
    666
    667            if any([isinstance(result, Exception) for result in results]):
    668                raise Exception("One or more requests failed.")
    669            else:
    670                # Flatten the list of lists into one big list using itertools.chain
    671                results = list(chain.from_iterable(results))
    672                reviews.append(results)
    673
    674        return JSONTransformer.merge_on_key("id", *reviews)

Asynchronously fetch all reviews from the specified endpoints.

This function fetches review data from the app and widget endpoints if `published` is set to True, and only from the app endpoint if `published` is set to False. The fetched reviews are then merged based on their "id" attribute.

###### Arguments:

- published (bool, optional): Determines if the function should fetch reviews from both the app and widget endpoints or just the app endpoint. Defaults to True.

###### Returns:

> list[dict]: A list of merged review dictionaries.

###### Raises:

- Exception: If one or more requests fail.

async def send_review_request( self, template_id: int | str, csv_stringio: _io.StringIO, spam_check: bool = False): View Source

    677    async def send_review_request(self, template_id: int | str, csv_stringio: StringIO, spam_check: bool = False):
    678        """
    679        Asynchronously send a "manual" review request to Yotpo using a specific email template.
    680
    681        This method takes a template_id, a CSV formatted StringIO object containing the review
    682        request data, and an optional spam_check flag. It uploads the review data to Yotpo and
    683        sends the review request using the specified email template.
    684
    685        Args:
    686            template_id (int | str): The ID of the email template to use for the review request.
    687            csv_stringio (StringIO): A StringIO object containing the review request data in CSV format.
    688                                     (Use the `JSONTransformer` class to generate this.)
    689            spam_check (bool, optional): Whether or not to enable the built-in Yotpo spam check. Defaults to False.
    690
    691        Returns:
    692            dict: A dictionary containing the response data.
    693
    694        Raises:
    695            Exception: If any response status is not 200.
    696            UploadException: If the uploaded file is not valid.
    697        """
    698        upload_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/upload_mailing_list"
    699        send_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/send_burst_email"
    700
    701        upload_response = await self._post_request(upload_url, {"file": csv_stringio, "utoken": self._utoken})
    702        upload_data = upload_response['response']['response']
    703
    704        if upload_data['is_valid_file']:
    705            send_response = await self._post_request(send_url, {"file_path": upload_data['file_path'], "utoken": self._utoken, "activate_spam_limitations": spam_check})
    706        else:
    707            raise UploadException("Error: Uploaded file is not valid")
    708
    709        return {"upload": upload_response, "send": send_response}

Asynchronously send a "manual" review request to Yotpo using a specific email template.

This method takes a template_id, a CSV formatted StringIO object containing the review request data, and an optional spam_check flag. It uploads the review data to Yotpo and sends the review request using the specified email template.

###### Arguments:

- template_id (int | str): The ID of the email template to use for the review request.
- csv_stringio (StringIO): A StringIO object containing the review request data in CSV format. (Use the `JSONTransformer` class to generate this.)
- spam_check (bool, optional): Whether or not to enable the built-in Yotpo spam check. Defaults to False.

###### Returns:

> dict: A dictionary containing the response data.

###### Raises:

- Exception: If any response status is not 200.
- UploadException: If the uploaded file is not valid.