yotpy

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

  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):
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.')
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):
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):
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):
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):
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):
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):
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:
 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]:
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]:
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]]:
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]:
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]:
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]:
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:
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:
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)
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:
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:
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:
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:
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]:
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]:
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):
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.