From 46bc42083b8b7eaf3d5e9e5e6b3f66507988296c Mon Sep 17 00:00:00 2001 From: Nacho Maiz Date: Mon, 20 May 2024 15:31:58 +0100 Subject: [PATCH] TurboPitch Tools Support (#38) * small changes to prepare for tools * Implemented tools-turbopitch endpoints * added pandas and pydantic inventories * added isort to the list of tools * change copyright year? * added missing `audience-groups` to the list of supported endpoints * fixed some docstring typos * prepare for deprecations * add config to refuse-list for doc generation * integration tests for tools endpoints * added documentation for tools endpoints * added isort to list of tools * checked off tools item in v2 roadmap * minor styling changes * added release notes for 1.1 * version bump * mention new tools functionality * make raise_if_fails context manager private * improve tools * update release notes * remove v2 roadmap * fixed dtype for test comparisons * fixed another dtype for comparisons --- CONTRIBUTING.md | 1 + LICENSE | 2 +- README.md | 2 + bavapi/__init__.py | 4 + bavapi/client.py | 6 +- bavapi/config.py | 6 + bavapi/filters.py | 11 + bavapi/parsing/params.py | 8 +- bavapi/sync.py | 2 +- bavapi/tools.py | 868 +++++++++++++++++++++++++++ docs/contributing.md | 3 +- docs/gen_ref_pages.py | 4 +- docs/getting-started/installation.md | 4 +- docs/index.md | 2 + docs/reference/SUMMARY.md | 1 + docs/release-notes.md | 22 + docs/roadmap.md | 71 --- docs/usage/tools.md | 62 ++ mkdocs.yml | 2 + pyproject.toml | 2 +- tests/test_integration.py | 56 ++ tests/test_tools.py | 405 +++++++++++++ 22 files changed, 1458 insertions(+), 86 deletions(-) create mode 100644 bavapi/config.py create mode 100644 bavapi/tools.py create mode 100644 docs/usage/tools.md create mode 100644 tests/test_tools.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e025840..817b306 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,7 @@ In order to run integration tests and the `bavapi-gen-refs` [command](https://wp - Full test coverage using [`coverage`](https://coverage.readthedocs.io/en/). - Run development scripts in multiple Python versions with [`nox`](https://nox.thea.codes/en/stable/). - Documentation using [`mkdocs-material`](https://squidfunk.github.io/mkdocs-material/). +- Import sorting with [`isort`](https://pycqa.github.io/isort/). - Code auto-formatting with [`black`](https://black.readthedocs.io/en/stable/). - Linting with [`pylint`](https://docs.pylint.org/). diff --git a/LICENSE b/LICENSE index 519635b..3ade818 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [2023] [WPPBAV] + Copyright [2024] [WPPBAV] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 3ee77c1..a4d6466 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Once you have acquired a token, you can start using this library directly in pyt - Support for all endpoints in the WPPBAV Fount API. - Extended support for the following endpoints: - `audiences` + - `audience-groups` - `brand-metrics` - `brand-metric-groups` - `brands` @@ -90,6 +91,7 @@ Once you have acquired a token, you can start using this library directly in pyt - `studies` - `years` - Other endpoints are available via the `raw_query` functions and methods. + - Extended support for Fount API Tools/TurboPitch endpoints. - Validates query parameters are of the correct types and provides type hints for better IDE support. - Retrieve multiple pages of data simultaneously, monitoring and preventing exceeding API rate limit. - Both synchronous and asynchronous APIs for accessing BAV data. diff --git a/bavapi/__init__.py b/bavapi/__init__.py index 78c65b7..6d046a6 100644 --- a/bavapi/__init__.py +++ b/bavapi/__init__.py @@ -28,11 +28,13 @@ # pylint: disable=R0801 from bavapi import filters +from bavapi import tools from bavapi.client import Client from bavapi.exceptions import APIError, DataNotFoundError, RateLimitExceededError from bavapi.query import Query from bavapi.sync import ( audiences, + audience_groups, brand_metric_groups, brand_metrics, brands, @@ -51,6 +53,7 @@ __all__ = ( "audiences", + "audience_groups", "brand_metrics", "brand_metric_groups", "brands", @@ -68,6 +71,7 @@ "Client", "Query", "filters", + "tools", "APIError", "DataNotFoundError", "RateLimitExceededError", diff --git a/bavapi/client.py b/bavapi/client.py index 141f2c4..1b9a92c 100644 --- a/bavapi/client.py +++ b/bavapi/client.py @@ -49,6 +49,7 @@ ) from bavapi import filters as _filters +from bavapi.config import BASE_URL, USER_AGENT from bavapi.http import HTTPClient from bavapi.parsing.responses import parse_response from bavapi.query import Query @@ -67,9 +68,6 @@ __all__ = ("Client",) -BASE_URL: Final[str] = "https://fount.wppbav.com/api/v2/" -USER_AGENT: Final[str] = "BAVAPI SDK Python" - BRANDSCAPE_DEFAULTS: Final[List[str]] = ["study", "brand", "category", "audience"] CATEGORIES_DEFAULTS: Final[List[str]] = ["sector"] @@ -798,7 +796,7 @@ async def brandscape_data( - Brand + Audience + Country + Year You should read these from left to right. A combination of "Study + Audience" - worksjust as well as "Study + Audience + Brand". + works just as well as "Study + Audience + Brand". However, "Category + Audience" will not. If you use Country or Year filters, you must use both filters together. diff --git a/bavapi/config.py b/bavapi/config.py new file mode 100644 index 0000000..b29c329 --- /dev/null +++ b/bavapi/config.py @@ -0,0 +1,6 @@ +"""Constants for the API connection""" + +from typing import Final + +BASE_URL: Final[str] = "https://fount.wppbav.com/api/v2/" +USER_AGENT: Final[str] = "BAVAPI SDK Python" diff --git a/bavapi/filters.py b/bavapi/filters.py index 3381cd5..3e1e5c2 100644 --- a/bavapi/filters.py +++ b/bavapi/filters.py @@ -26,6 +26,7 @@ # pylint: disable=no-name-in-module, too-few-public-methods +# import warnings from typing import Dict, Literal, Optional, Type, TypeVar, Union from pydantic import BaseModel, field_validator, model_validator @@ -55,6 +56,8 @@ FiltersOrMapping = Union[F, InputParamsMapping] +# warnings.filterwarnings("default", category=DeprecationWarning, module="bavapi") + class FountFilters(BaseModel): """Base class for Fount API Filters. @@ -109,6 +112,14 @@ def ensure( k: v for k, v in addl_filters.items() if v } + # if addl_filters: + # warnings.warn( + # f"Using the {list(new_filters.keys())} function parameter(s) is deprecated. " + # f"Use the `filters` parameter with a {cls.__name__} instance instead.", + # DeprecationWarning, + # 2, + # ) + if filters is None: if not new_filters: return None diff --git a/bavapi/parsing/params.py b/bavapi/parsing/params.py index f924cbb..b353108 100644 --- a/bavapi/parsing/params.py +++ b/bavapi/parsing/params.py @@ -3,7 +3,7 @@ import datetime as dt from typing import Dict, Mapping, Sequence, TypeVar, Union, cast -from bavapi.typing import BaseMutableParamsMapping, BaseMutableParamsMappingValues +from bavapi.typing import MutableParamsMapping, SequenceOrValues T = TypeVar("T") @@ -56,7 +56,9 @@ def to_fount_params(data: Mapping[str, T], param: str) -> Dict[str, T]: return {f"{param}[{k}]": v for k, v in data.items()} -def list_to_str(mapping: BaseMutableParamsMapping) -> BaseMutableParamsMappingValues: +def list_to_str( + mapping: MutableParamsMapping[SequenceOrValues[Union[T, str]]] +) -> MutableParamsMapping[Union[T, str]]: """Convert any lists in a dictionary to a string with comma-separated elements. Parameters @@ -73,4 +75,4 @@ def list_to_str(mapping: BaseMutableParamsMapping) -> BaseMutableParamsMappingVa if not isinstance(value, str) and isinstance(value, Sequence): mapping[key] = ",".join(str(i) for i in value) - return cast(BaseMutableParamsMappingValues, mapping) + return cast(MutableParamsMapping[Union[T, str]], mapping) diff --git a/bavapi/sync.py b/bavapi/sync.py index 7e689f0..48657b3 100644 --- a/bavapi/sync.py +++ b/bavapi/sync.py @@ -753,7 +753,7 @@ async def brandscape_data( - Brand + Audience + Country + Year You should read these from left to right. A combination of "Study + Audience" - worksjust as well as "Study + Audience + Brand". + works just as well as "Study + Audience + Brand". However, "Category + Audience" will not. If you use Country or Year filters, you must use both filters together. diff --git a/bavapi/tools.py b/bavapi/tools.py new file mode 100644 index 0000000..97b4230 --- /dev/null +++ b/bavapi/tools.py @@ -0,0 +1,868 @@ +"""Module for interacting with the BAV API's `tools`. + +Read more at . + +Each tool will return very different results, depending on each of their requirements. +Check the return types of each function and method. + +Examples +-------- + +>>> from bavapi.tools import ToolsClient +>>> async with ToolsClient("TOKEN") as client: # Replace "TOKEN" with your BAV API key +>>> result = await client.commitment_funnel(brands=1, studies=1) +""" + +import contextlib +from json import JSONDecodeError +from types import TracebackType +from typing import ( + Dict, + Generator, + List, + Optional, + Tuple, + Type, + TypeVar, + Union, + cast, + overload, +) + +import httpx +import pandas as pd +from pydantic import validate_call + +from bavapi.client import BASE_URL, USER_AGENT +from bavapi.exceptions import APIError +from bavapi._fetcher import aretry +from bavapi.parsing.params import list_to_str +from bavapi.parsing.responses import flatten_mapping, parse_response +from bavapi.typing import ( + AsyncClientType, + JSONDict, + ListOrValues, + MutableParamsMapping, + OptionalListOr, + OptionalSequenceOr, +) + +__all__ = ("ToolsClient",) + +T = TypeVar("T") +_Params = MutableParamsMapping[T] + + +class ToolsClient: + """Asynchronous API to interact with the WPPBAV Fount API tools. + + Read more at . + + This class uses `asyncio` to perform asynchronous requests to the Fount API. + + Asynchronous requests allow you to make multiple requests at the same time, + extremely helpful for working with a paginated API like the Fount. (returns + data in multiple pages or requests instead of one single download) + + To use the Client class, you will need to precede calls with `await`: + + ```py + bav = Client("TOKEN") # creating client instance does not use `await` + data = await bav.brands("Swatch") # must use `await` + ``` + + For more information, see the `asyncio` documentation for Python. + + Either `auth_token` or `client` are required to instantiate a Client. + + Parameters + ---------- + auth_token : str, optional + WPPBAV Fount API authorization token, default `''` + timeout : float, optional + Maximum timeout for requests in seconds, default 30.0 + verify : bool or str, optional + Verify SSL credentials, default True + + Also accepts a path string to an SSL certificate file. + headers : dict[str, str], optional + Collection of headers to send with each request, default None + user_agent : str, optional + The name of the User-Agent to send to the Fount API, default `''`. + + If no user_agent is set, `bavapi` will use `"BAVAPI SDK Python"` by default. + client : httpx.AsyncClient, optional + Authenticated async client, default None + + If `client` is passed, all other parameters will be ignored. + retries : int, optional + Number of times to retry a request, default 3 + + Raises + ------ + ValueError + If neither `auth_token` nor `client` are provided + + Examples + -------- + Use `async with` to get data and close the connection. + + This way you get the benefits from `httpx` speed improvements + and closes the connection when exiting the async with block. + + >>> async with ToolsClient("TOKEN") as bav: + ... data = await bav.commitment_funnel(brands=1, studies=1) + + When not using `async with`, close the connection manually by awaiting `aclose`. + + >>> bav = ToolsClient("TOKEN") + >>> data = await bav.commitment_funnel(brands=1, studies=1) + >>> await bav.aclose() + + If you want to perform multiple endpoint requests with the same `Client`, it is + recommended to use `verbose=False` to avoid jumping progress bars. + + >>> async with ToolsClient("TOKEN", verbose=False) as bav: + ... resp1 = await bav.commitment_funnel(brands=1, studies=1) + ... resp2 = await bav.brand_worth_map(brands=1, studies=1) + """ + + C = TypeVar("C", bound="ToolsClient") + + @overload + def __init__( + self, + auth_token: str, + *, + base_url: str = ..., + timeout: float = 30.0, + verify: Union[bool, str] = True, + user_agent: str = "", + retries: int = 3, + ) -> None: ... + + @overload + def __init__( + self, + *, + base_url: str = ..., + timeout: float = 30.0, + verify: Union[bool, str] = True, + headers: Optional[Dict[str, str]] = ..., + retries: int = 3, + ) -> None: ... + + @overload + def __init__( + self, + *, + client: AsyncClientType = ..., + retries: int = 3, + ) -> None: ... + + def __init__( + self, + auth_token: str = "", + *, + base_url: str = "", + timeout: float = 30.0, + verify: Union[bool, str] = True, + headers: Optional[Dict[str, str]] = None, + user_agent: str = "", + client: Optional[AsyncClientType] = None, + retries: int = 3, + ) -> None: + self.retries = retries + + self.client = client or httpx.AsyncClient( + headers=headers + or { + "Authorization": f"Bearer {auth_token}", + "Accept": "application/json", + "User-Agent": user_agent or USER_AGENT, + }, + timeout=timeout, + verify=verify, + base_url=base_url or BASE_URL + "tools", + ) + + async def __aenter__(self: C) -> C: + await self.client.__aenter__() + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]] = None, + exc_value: Optional[BaseException] = None, + traceback: Optional[TracebackType] = None, + ) -> None: + await self.client.__aexit__(exc_type, exc_value, traceback) + + async def aclose(self) -> None: + """Asynchronously close all client connections.""" + return await self.client.aclose() + + async def _get( + self, endpoint: str, params: _Params[Union[str, int]] + ) -> Dict[str, JSONDict]: + get_func = aretry(self.client.get, self.retries, delay=0.25) # type: ignore + resp = await get_func(endpoint, params=params) + + if resp.status_code != 200: + try: + message = resp.json()["message"] + except (KeyError, JSONDecodeError): + message = "An error occurred with the Fount." + + raise APIError(f"Error {resp.status_code}:\n{message}\nurl={resp.url}") + + return resp.json() + + @overload + async def archetypes( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + *, + categories: ListOrValues[int] = ..., + ) -> pd.DataFrame: ... + + @overload + async def archetypes( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + *, + collections: ListOrValues[int] = ..., + ) -> pd.DataFrame: ... + + @validate_call + async def archetypes( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + *, + categories: OptionalListOr[int] = None, + collections: OptionalListOr[int] = None, + ) -> pd.DataFrame: + """Retrieve results from the `archetypes` endpoint + + See for more info. + + Parameters + ---------- + brands : ListOrValues[int] + Brand ID or list of brand IDs + studies : ListOrValues[int] + Study ID or list of study IDs + audiences : OptionalListOr[int], optional + Audience ID or list of audience IDs, default None + categories : OptionalListOr[int], optional + Category ID or list of category IDs for the target category, default None + collections : OptionalListOr[int], optional + Collection ID or list of collections for the target collection, default None + + Returns + ------- + pd.DataFrame + Dataframe containing the results + + Raises + ------ + ValueError + If category or collection are not specified, or if they are both specified + APIError + If an error occurs with the query + """ + if not bool(categories) ^ bool(collections): + raise ValueError("Either categories OR collections must be specified.") + + params = { + "brands": brands, + "studies": studies, + "audiences": audiences, + "categories": categories, + "collections": collections, + } + + def parse_entry(entry: JSONDict) -> _Params[Union[str, float]]: + data = entry.pop("data") + entry_with_data = entry.copy() + entry_with_data.update(data) # type: ignore[arg-type] + parsed = flatten_mapping(entry_with_data) + return parsed # type: ignore[return-value] + + resp = await self._get("archetypes", params=_to_url_params(params)) + + with _raise_if_fails(): + payload = cast(JSONDict, resp["data"]) + return pd.DataFrame( + [parse_entry(entry) for entry in payload] # type: ignore[arg-type] + ) + + @validate_call + async def brand_personality_match( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + ) -> pd.DataFrame: + """Retrieve results from the `brand-personality-match` endpoint + + See for more info. + + Parameters + ---------- + brands : ListOrValues[int] + Brand ID or list of brand IDs + studies : ListOrValues[int] + Study ID or list of study IDs + audiences : OptionalListOr[int], optional + Audience ID or list of audience IDs, default None + + Returns + ------- + pd.DataFrame + Dataframe containing the results + + Raises + ------ + APIError + If an error occurs with the query + """ + params = { + "brands": brands, + "studies": studies, + "audiences": audiences, + } + + resp = await self._get("brand-personality-match", params=_to_url_params(params)) + + with _raise_if_fails(): + payload = cast(List[JSONDict], resp["data"]) + return parse_response(payload) + + @validate_call + async def brand_vulnerability_map(self, brand: int) -> pd.DataFrame: + """Retrieve results from the `brand-vulnerability-map` endpoint + + See for more info. + + Parameters + ---------- + brand : int + Brand ID + + Returns + ------- + pd.DataFrame + Dataframe containing the results + + Raises + ------ + APIError + If an error occurs with the query + """ + params: _Params[Union[str, int]] = {"brand": brand} + + resp = await self._get("brand-vulnerability-map", params=params) + + with _raise_if_fails(): + payload = cast(List[JSONDict], resp["data"]) + return parse_response(payload) + + @validate_call + async def brand_worth_map( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + ) -> Tuple[JSONDict, pd.DataFrame]: + """Retrieve results from the `brand-worth-map` endpoint + + See for more info. + + Parameters + ---------- + brands : ListOrValues[int] + Brand ID or list of brand IDs + studies : ListOrValues[int] + Study ID or list of study IDs + audiences : OptionalListOr[int], optional + Audience ID or list of audience IDs, default None + + Returns + ------- + Tuple[JSONDict, pd.DataFrame] + A tuple containing a JSON dictionary of metadata and a Dataframe with the results + + Raises + ------ + APIError + If an error occurs with the query + """ + params = { + "brands": brands, + "studies": studies, + "audiences": audiences, + } + + resp = await self._get("brand-worth-map", params=_to_url_params(params)) + + with _raise_if_fails(): + payload = cast(JSONDict, resp["data"]) + data = cast(List[JSONDict], payload.pop("data")) + return payload, parse_response(data) + + @validate_call + async def category_worth_map( + self, + categories: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + ) -> Tuple[JSONDict, pd.DataFrame]: + """Retrieve results from the `category-worth-map` endpoint + + [NOT IMPLEMENTED] + + See for more info. + + Parameters + ---------- + categories : ListOrValues[int] + Category ID or list of category IDs + studies : ListOrValues[int] + Study ID or list of study IDs + audiences : OptionalListOr[int], optional + Audience ID or list of audience IDs, default None + + Returns + ------- + Tuple[JSONDict, pd.DataFrame] + A tuple containing a JSON dictionary of metadata and a Dataframe with the results + + Raises + ------ + APIError + If an error occurs with the query + """ + # params = { + # "categories": categories, + # "studies": studies, + # "audiences": audiences, + # } + + # resp = await self._get("category-worth-map", params=_to_url_params(params)) + + # with _raise_if_fails(): + # payload = cast(JSONDict, resp["data"]) + # data = cast(List[JSONDict], payload.pop("data")) + # return payload, parse_response(data) + + raise NotImplementedError + + @validate_call + async def commitment_funnel( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + ) -> pd.DataFrame: + """Retrieve results from the `commitment-funnel` endpoint + + See for more info. + + Parameters + ---------- + brands : ListOrValues[int] + Brand ID or list of brand IDs + studies : ListOrValues[int] + Study ID or list of study IDs + audiences : OptionalListOr[int], optional + Audience ID or list of audience IDs, default None + + Returns + ------- + pd.DataFrame + Dataframe containing the results + + Raises + ------ + APIError + If an error occurs with the query + """ + params: dict[str, OptionalSequenceOr[Union[str, int]]] = { + "brands": brands, + "studies": studies, + "audiences": audiences, + } + + resp = await self._get("commitment-funnel", params=_to_url_params(params)) + + def parse_entry(entry: JSONDict) -> _Params[Union[str, float]]: + metrics = entry.pop("metrics") + parsed = flatten_mapping(entry) + parsed.update( + {metric["key"]: metric["value"] for metric in metrics} # type: ignore[arg-type] + ) + return parsed # type: ignore[return-value] + + with _raise_if_fails(): + payload = cast(List[JSONDict], resp["data"]) + return pd.DataFrame([parse_entry(entry) for entry in payload]) + + @overload + async def cost_of_entry( + self, + brand: int, + study: int, + audience: Optional[int] = None, + *, + categories: ListOrValues[int] = ..., + comparison_name: Optional[str] = None, + ) -> Tuple[JSONDict, pd.DataFrame]: ... + + @overload + async def cost_of_entry( + self, + brand: int, + study: int, + audience: Optional[int] = None, + *, + collections: ListOrValues[int] = ..., + comparison_name: Optional[str] = None, + ) -> Tuple[JSONDict, pd.DataFrame]: ... + + @validate_call + async def cost_of_entry( + self, + brand: int, + study: int, + audience: Optional[int] = None, + *, + categories: OptionalListOr[int] = None, + collections: OptionalListOr[int] = None, + comparison_name: Optional[str] = None, + ) -> Tuple[JSONDict, pd.DataFrame]: + """Retrieve results from the `cost-of-entry` endpoint + + See for more info. + + Parameters + ---------- + brand : int + Brand ID + study : int + Study ID + audience : int, optional + Audience ID, default None + categories : OptionalListOr[int], optional + Category ID or list of category IDs for the target category, default None + collections : OptionalListOr[int], optional + Collection ID or list of collections for the target collection, default None + comparison_name : str, optional + Custom name to give the comparison, default None + + Default behavior is to use the category or collection name. + + Returns + ------- + Tuple[JSONDict, pd.DataFrame] + A tuple containing a JSON dictionary of metadata and a Dataframe with the results + + Raises + ------ + ValueError + If category or collection are not specified, or if they are both specified + APIError + If an error occurs with the query + """ + if not bool(categories) ^ bool(collections): + raise ValueError("Either categories OR collections must be specified.") + + params = { + "brand": brand, + "study": study, + "audience": audience, + "categories": categories, + "collections": collections, + "comparisonName": comparison_name, + } + + resp = await self._get("cost-of-entry", params=_to_url_params(params)) + + with _raise_if_fails(): + payload = cast(JSONDict, resp["data"]) + data = cast(List[JSONDict], payload.pop("data")) + return payload, parse_response(data) + + @validate_call + async def love_plus( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + ) -> pd.DataFrame: + """Retrieve results from the `love-plus` endpoint + + See for more info. + + Parameters + ---------- + brands : ListOrValues[int] + Brand ID or list of brand IDs + studies : ListOrValues[int] + Study ID or list of study IDs + audiences : OptionalListOr[int], optional + Audience ID or list of audience IDs, default None + + Returns + ------- + pd.DataFrame + Dataframe containing the results + + Raises + ------ + APIError + If an error occurs with the query + """ + + params: dict[str, OptionalSequenceOr[Union[str, int]]] = { + "brands": brands, + "studies": studies, + "audiences": audiences, + } + + resp = await self._get("love-plus", params=_to_url_params(params)) + + def parse_entry(entry: JSONDict) -> _Params[Union[int, str, float]]: + data = entry.pop("data") + parsed = flatten_mapping(entry) + parsed.update( + {metric: val["value"] for metric, val in data.items()} # type: ignore + ) + return parsed # type: ignore[return-value] + + with _raise_if_fails(): + payload = cast(List[JSONDict], resp["data"]) + return pd.DataFrame([parse_entry(entry) for entry in payload]) + + @validate_call + async def partnership_exchange_map( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + comparison_brands: ListOrValues[int], + ) -> Tuple[JSONDict, pd.DataFrame]: + """Retrieve results from the `partnership-exchange-map` endpoint + + See for more info. + + Parameters + ---------- + brands : ListOrValues[int] + Brand ID or list of brand IDs + studies : ListOrValues[int] + Study ID or list of study IDs + comparison_brands : ListOrValues[int] + Brand ID for comparison with the brand specified in `brands + + Returns + ------- + Tuple[JSONDict, pd.DataFrame] + A tuple containing a JSON dictionary of metadata and a Dataframe with the results + + Raises + ------ + APIError + If an error occurs with the query + """ + + params: _Params[OptionalSequenceOr[Union[int, str]]] = { + "brands": brands, + "studies": studies, + "comparison_brands": comparison_brands, + } + + resp = await self._get( + "partnership-exchange-map", params=_to_url_params(params) + ) + + with _raise_if_fails(): + payload = cast(JSONDict, resp["data"]) + data = cast(List[JSONDict], payload.pop("data")) + return payload, parse_response(data) + + @overload + async def swot( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + *, + categories: ListOrValues[int] = ..., + comparison_name: Optional[str] = None, + ) -> Tuple[JSONDict, pd.DataFrame]: ... + + @overload + async def swot( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + *, + collections: ListOrValues[int] = ..., + comparison_name: Optional[str] = None, + ) -> Tuple[JSONDict, pd.DataFrame]: ... + + @validate_call + async def swot( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + *, + categories: OptionalListOr[int] = None, + collections: OptionalListOr[int] = None, + comparison_name: Optional[str] = None, + ) -> Tuple[JSONDict, pd.DataFrame]: + """Retrieve results from the `swot` endpoint + + [NOT IMPLEMENTED] + + See for more info. + + Parameters + ---------- + brands : ListOrValues[int] + Brand ID or list of brand IDs + studies : ListOrValues[int] + Study ID or list of study IDs + audiences : OptionalListOr[int], optional + Audience ID or list of audience IDs, default None + categories : OptionalListOr[int], optional + Category ID or list of category IDs for the target category, default None + collections : OptionalListOr[int], optional + Collection ID or list of collections for the target collection, default None + comparison_name : str, optional + Custom name to give the comparison, default None + + Default behavior is to use "Category" or "Collection", depending on whether + categories or collections were specified in the method call. + + Returns + ------- + Tuple[JSONDict, pd.DataFrame] + A tuple containing a JSON dictionary of metadata and a Dataframe with the results + + Raises + ------ + ValueError + If category or collection are not specified, or if they are both specified + APIError + If an error occurs with the query + """ + # if not bool(categories) ^ bool(collections): + # raise ValueError("Either categories OR collections must be specified.") + + # params = { + # "brands": brands, + # "studies": studies, + # "audiences": audiences, + # "categories": categories, + # "collections": collections, + # "comparisonName": comparison_name, + # } + + # resp = await self._get("swot", params=_to_url_params(params)) + + # with _raise_if_fails(): + # payload = cast(JSONDict, resp["data"]) + # data = cast(List[JSONDict], payload.pop("data")) + # return payload, parse_response(data) + + raise NotImplementedError + + @overload + async def toplist_market( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + *, + metrics: ListOrValues[int] = ..., + ) -> pd.DataFrame: ... + + @overload + async def toplist_market( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + *, + metric_keys: ListOrValues[str] = ..., + ) -> pd.DataFrame: ... + + @validate_call + async def toplist_market( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + *, + metrics: OptionalListOr[int] = None, + metric_keys: OptionalListOr[str] = None, + ) -> pd.DataFrame: + """Retrieve results from the `toplist-market` endpoint + + [NOT IMPLEMENTED] + + See for more info. + + Parameters + ---------- + brands : ListOrValues[int] + Brand ID or list of brand IDs + studies : ListOrValues[int] + Study ID or list of study IDs + audiences : OptionalListOr[int], optional + Audience ID or list of audience IDs, default None + metrics : OptionalListOr[int], optional + Metric ID or list of metric IDs, default None + metric_keys : OptionalListOr[str], optional + Metric key or list of metric keys, default None + + Returns + ------- + pd.DataFrame + Dataframe containing the results + + Raises + ------ + ValueError + If metrics or metric_keys are not specified, or if they are both specified + APIError + If an error occurs with the query + """ + + # if not bool(metrics) ^ bool(metric_keys): + # raise ValueError("Either metrics OR metric_keys must be specified.") + + raise NotImplementedError + + +@contextlib.contextmanager +def _raise_if_fails() -> Generator[None, None, None]: + try: + yield + except (ValueError, TypeError, KeyError) as exc: + raise APIError("Could not parse response.") from exc + + +def _to_url_params( + params: _Params[OptionalSequenceOr[Union[str, int]]] +) -> _Params[Union[str, int]]: + return list_to_str({k: v for k, v in params.items() if v}) diff --git a/docs/contributing.md b/docs/contributing.md index f98b6ad..2a35cdd 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -3,7 +3,7 @@ hide: - navigation --- - + @@ -60,6 +60,7 @@ In order to run integration tests and the `bavapi-gen-refs` [command](getting-st - Full test coverage using [`coverage`](https://coverage.readthedocs.io/en/). - Run development scripts in multiple Python versions with [`nox`](https://nox.thea.codes/en/stable/). - Documentation using [`mkdocs-material`](https://squidfunk.github.io/mkdocs-material/). +- Import sorting with [`isort`](https://pycqa.github.io/isort/). - Code auto-formatting with [`black`](https://black.readthedocs.io/en/stable/). - Linting with [`pylint`](https://docs.pylint.org/). diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py index 40e74f1..c595f6a 100644 --- a/docs/gen_ref_pages.py +++ b/docs/gen_ref_pages.py @@ -17,7 +17,7 @@ parts = module_path.parts end_part = parts[-1] if ( - end_part in {"__main__", "typing"} + end_part in {"__main__", "typing", "config"} or (end_part != "__init__" and end_part.startswith("_")) or any(part.startswith("_") for part in parts[:-1]) ): @@ -41,7 +41,7 @@ mkdocs_gen_files.set_edit_path(full_doc_path, path) nav_lines = list(nav.build_literate_nav()) -nav_lines.insert(0, nav_lines.pop()) # move `sync` to the top +nav_lines.insert(0, nav_lines.pop(-2)) # move `sync` to the top with mkdocs_gen_files.open(REFERENCE_PATH / "SUMMARY.md", "w") as nav_file: nav_file.writelines(nav_lines) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index cd72d5b..b65836d 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -2,9 +2,9 @@ ## Pre-requisites -`bavapi` requires python 3.8 or higher to run. +`bavapi` requires Python 3.8 or higher to run. -If you don't have python installed, you can download it from the official Python [website](https://www.python.org/downloads/) or [Anaconda](https://www.anaconda.com/). +If you don't have Python installed, you can download it from the official Python [website](https://www.python.org/downloads/) or [Anaconda](https://www.anaconda.com/). You will also need a Fount API bearer token to peform requests to the Fount. For instructions on how to get your own API token, see the [Authentication](authentication.md) section. diff --git a/docs/index.md b/docs/index.md index b8b2a26..3598bbd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,6 +53,7 @@ See [Installation](getting-started/installation.md) for more detailed instructio - Support for all endpoints in the Fount API. - Extended support for the following endpoints: - [`audiences`](endpoints/audiences.md) + - [`audience-groups`](endpoints/audience-groups.md) - [`brand-metrics`](endpoints/brand-metrics.md) - [`brand-metric-groups`](endpoints/brand-metric-groups.md) - [`brands`](endpoints/brands.md) @@ -66,6 +67,7 @@ See [Installation](getting-started/installation.md) for more detailed instructio - [`studies`](endpoints/studies.md) - [`years`](endpoints/years.md) - Other endpoints are available via the [`raw_query`](endpoints/index.md#other-endpoints) functions and methods. + - Extended support for Fount API [Tools/TurboPitch](usage/tools.md) endpoints. - Validates query parameters are of the correct types and provides type hints for better IDE support. - Retrieve multiple pages of data simultaneously, monitoring and preventing exceeding API rate limit. - Both synchronous and asynchronous APIs for accessing BAV data. diff --git a/docs/reference/SUMMARY.md b/docs/reference/SUMMARY.md index dc0d2b5..d18d144 100644 --- a/docs/reference/SUMMARY.md +++ b/docs/reference/SUMMARY.md @@ -7,3 +7,4 @@ * [params](parsing/params.md) * [responses](parsing/responses.md) * [query](query.md) +* [tools](tools.md) \ No newline at end of file diff --git a/docs/release-notes.md b/docs/release-notes.md index 76a8a59..6e27ddf 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,27 @@ # Release Notes +## `1.1` + +### `1.1.0` (2024-05-20) + +#### Feature + +- :sparkles: Added new `bavapi.tools` module to support the [Tools/TurboPitch](usage/tools.md) endpoints in the BAV API. + +#### Docs + +- :broom: Fixed minor typos in docstrings for `bavapi.Client` and `sync` methods. +- :notebook: Blocked `config` module from appearing in API reference docs. +- :sparkles: Added documentation cross-linkage support for `pydantic` and `pandas`. References to `pydantic` and `pandas` functions or classes now link to their documentation page. + +#### Internal + +- :broom: New `config` module to hold the user agent and URL to use with the package. + +#### Typing + +- :bug: Fixed too-strict type definitions for `parsing.params.list_to_str`. + ## `1.0` ### `1.0.4` (2024-04-09) diff --git a/docs/roadmap.md b/docs/roadmap.md index 1c8a4e1..0d8f1b0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,77 +2,6 @@ !!! success "`bavapi` has now reached **stable** (`v1`) status" - -## `v2` Roadmap - -This is a non-exhaustive list of potential features & changes to `bavapi` for the release of `v2.0`: - -### New features - -- [ ] Support for the `tools` BAV API namespace and endpoints -- [ ] Support for the `best-countries` BAV API endpoint -- [ ] Support for retrieval of custom aggregations generated via background process in the Fount - -### Deprecations - -- [ ] Filters and query parameters specified as function arguments. `Query` and the classes in the `bavapi.filters` module should be used instead. -- [ ] Reference generation functionality. It was conceived when references like Audience and Country were static, but custom audiences will remove its usefulness. - -#### Changes to filters and query parameters - -In `v1`, it is possible to create these valid function calls (all equivalent): - -```python -# No pydantic models -bavapi.brands(TOKEN, "Facebook", "US", 2022, filters={"studies": [1, 2, 3]}, include="category") - -# Filters as arguments, query as pydantic model -bavapi.brands(TOKEN, "Facebook", "US", 2022, filters={"studies": [1, 2, 3]}, query=Query(include="category")) - -# Combined filters into pydantic model -bavapi.brands( - TOKEN, - filters=BrandsFilters( - name="Facebook", - country_codes="US", - year_numbers=2022, - studies=[1, 2, 3] - ), - include="category") - -# Filters and query as pydantic models, passed separately -bavapi.brands( - TOKEN, - filters=BrandsFilters( - name="Facebook", - country_codes="US", - year_numbers=2022, - studies=[1, 2, 3] - ), - query=Query(include="category") -) - -# Filters as parameter to query, query as pydantic model -bavapi.brands( - TOKEN, - query=Query( - filters=BrandsFilters( - name="Facebook", - country_codes="US", - year_numbers=2022, - studies=[1, 2, 3] - ), - include="category" - ) -) - -# All of the above also work with `bavapi.Client` methods -async with bavapi.Client(TOKEN) as bav: - client.brands("Facebook", "US", 2022, filters={"studies": [1, 2, 3]}, query=Query(include="category")) -``` - -It is still undecided which way should be the appropriate call, but it is likely that this will change in favor of a more standardized approach. - ## `v1` Roadmap - COMPLETED This is a non-exhaustive list of potential features & changes to `bavapi` before it is ready for full release: diff --git a/docs/usage/tools.md b/docs/usage/tools.md new file mode 100644 index 0000000..374ebe8 --- /dev/null +++ b/docs/usage/tools.md @@ -0,0 +1,62 @@ +# `bavapi` Tools/TurboPitch + +!!! abstract "New in `v1.1`" + +!!! info "Read more in the [API documentation](https://developer.wppbav.com/docs/2.x/tools)" + +The `tools` namespace in `bavapi` and the API enables access to ready-made analyses and frameworks to gain direct insights from BAV data. + +Every tool operates by requesting the analysis to be performed on specific brands, categories, markets or audiences, specified by their ID in the Fount. + +Because the breadth of filters and parameters is much simpler than the query API endpoints, `pydantic` models are not needed to make requests. Function parameters are still type validated thanks to the [pydantic.validate_call][pydantic.validate_call_decorator.validate_call] decorator. + +## Using `bavapi.tools` + +The `tools` interface must be used through the `async` interface provided by the [`ToolsClient`][tools.ToolsClient] class: + +```py +from bavapi.tools import ToolsClient + +async with ToolsClient("TOKEN") as client: # (1) + result = await client.brand_worth_map(brands=1, studies=1) +``` + +1. :lock: Replace `"TOKEN"` with your own API key + +You can also manually close the connection instead of using an `async with` block: + +```py +from bavapi.tools import ToolsClient + +client = ToolsClient("TOKEN") +try: + result = await client.brand_worth_map(brands=1, studies=1) +finally: + await client.aclose() # (1) +``` + +1. :recycle: Close the connection with the API server + +You will need to instantiate a new `ToolsClient` object once you use it inside the `async with` block or call `aclose`. + +!!! warning "Different return types by endpoint" + Each tool will return results with different signatures. Some will return simply `pandas.DataFrame` instances, and others will return a tuple of additional metadata as a JSON dictionary and parsed data as a `pandas.DataFrame`. Please refer to the [documentation][tools.ToolsClient] for specific information on each endpoint method. + + ```py + await ToolsClient("TOKEN").brand_worth_map(...) # returns (JSONDict, pd.DataFrame) + + await ToolsClient("TOKEN").brand_personality_match(...) # returns pd.DataFrame + ``` + +## Client settings + +Unlike the query API endpoints, these tools do not return paginated results, so many of the parameters to control pagination behavior in the query endpoints are not used. You can still specify the following when initializing `ToolsClient`: + +- `auth_token` +- `base_url` +- `user_agent` +- `headers`: if used, don't pass `auth_token` and `user_agent` +- `client`: if used, don't pass `auth_token`, `headers` and `user_agent` +- `retries` + +Please refer to the [basic usage](basic.md) section for more information on how to use these configuration parameters. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index f0fb352..6cc165e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,7 @@ nav: - Usage: - Basic Usage: usage/basic.md - Advanced Usage: usage/advanced.md + - Tools/TurboPitch: usage/tools.md - Usage Tips for Projects: usage/project-tips.md - Endpoints: endpoints/ - SDK API Reference: reference/ @@ -115,6 +116,7 @@ plugins: import: - https://docs.python.org/3/objects.inv - https://docs.pydantic.dev/latest/objects.inv + - https://pandas.pydata.org/docs/objects.inv paths: [bavapi] options: docstring_style: numpy diff --git a/pyproject.toml b/pyproject.toml index bafaade..2cc390f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wpp-bavapi" -version = "1.0.4" +version = "1.1.0" authors = [ { name = "Ignacio Maiz Vilches", email = "ignacio.maiz@bavgroup.com" }, ] diff --git a/tests/test_integration.py b/tests/test_integration.py index 965cce4..cb311b3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -11,6 +11,8 @@ import bavapi from bavapi import filters from bavapi.query import Query +from bavapi.tools import ToolsClient +from bavapi.typing import JSONDict @pytest.fixture(scope="module", autouse=True) @@ -83,3 +85,57 @@ def test_endpoints(endpoint: str, filters: Dict[str, Any]): assert 0 < result.shape[0] <= 50 assert "id" in result + + +@pytest.mark.e2e +@pytest.mark.anyio +@pytest.mark.parametrize( + ("endpoint", "params"), + ( + ("archetypes", {"brands": 3100, "studies": 650, "categories": 1}), + ("brand_personality_match", {"brands": 3100, "studies": 650}), + ("brand_vulnerability_map", {"brand": 3100}), + ("commitment_funnel", {"brands": 3100, "studies": 650}), + ("love_plus", {"brands": 3100, "studies": 650}), + ( + "toplist_market", + {"brands": 3100, "studies": 650, "metric_keys": "differentiation"}, + ), + ), +) +async def test_tools_no_metadata(endpoint: str, params: Dict[str, Any]): + try: + async with ToolsClient(os.environ["BAV_API_KEY"]) as client: + result: pd.DataFrame = await getattr(client, endpoint)(**params) + except NotImplementedError: + pytest.skip("Not implemented yet") + + assert not result.empty + + +@pytest.mark.e2e +@pytest.mark.anyio +@pytest.mark.parametrize( + ("endpoint", "params"), + ( + ("brand_worth_map", {"brands": 3100, "studies": 650}), + ("category_worth_map", {"categories": 1, "studies": 650}), + ("cost_of_entry", {"brand": 3100, "study": 650, "categories": 1}), + ( + "partnership_exchange_map", + {"brands": 3100, "studies": 650, "comparison_brands": 31447}, + ), + ("swot", {"brands": 3100, "studies": 650, "categories": 1}), + ), +) +async def test_tools_with_metadata(endpoint: str, params: Dict[str, Any]): + meta: JSONDict + result: pd.DataFrame + try: + async with ToolsClient(os.environ["BAV_API_KEY"]) as client: + meta, result = await getattr(client, endpoint)(**params) + except NotImplementedError: + pytest.skip("Not implemented yet") + + assert meta + assert not result.empty diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..46c5358 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,405 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring +# pylint: disable=protected-access, redefined-outer-name + +from typing import Any, Dict, Iterator +from unittest import mock + +import pandas as pd +import pytest + +from bavapi.exceptions import APIError +from bavapi.tools import ToolsClient, _raise_if_fails, _to_url_params +from tests.helpers import MockAsyncClient + + +@pytest.fixture(scope="module") +def client(http_instance: MockAsyncClient) -> Iterator[ToolsClient]: + with http_instance as isntance: + yield ToolsClient(client=isntance) + + +def test_client_init_with_client(http: MockAsyncClient): + client = ToolsClient(client=http) + + assert client.client is http + + +def test_client_init_with_headers(mock_async_client: mock.Mock): + _ = ToolsClient(headers={"test": "headers"}, base_url="test.com") + + mock_async_client.assert_called_once_with( + headers={"test": "headers"}, timeout=30.0, verify=True, base_url="test.com" + ) + + +@pytest.mark.anyio +async def test_context_manager(http: MockAsyncClient): + async with ToolsClient(client=http) as client: + assert isinstance(client, ToolsClient) + + assert client.client.is_closed + + +@pytest.mark.anyio +async def test_aclose(client: ToolsClient, http: MockAsyncClient): + await client.aclose() + + assert http.is_closed + + +@pytest.mark.anyio +async def test_get(client: ToolsClient, http: MockAsyncClient): + http.add_response(data={"test": 1}) + + resp = await client._get("request", params={}) + + assert resp == {"test": 1} + + http.mock_get.assert_awaited_once_with("request", params={}) + + +@pytest.mark.anyio +async def test_get_fails(client: ToolsClient, http: MockAsyncClient): + http.add_response(400, "bad") + + with pytest.raises(APIError) as exc_info: + await client._get("request", params={}) + + assert exc_info.value.args == ("Error 400:\nbad\nurl=http://test_url/request",) + + +@pytest.mark.anyio +async def test_get_fails_to_parse(client: ToolsClient, http: MockAsyncClient): + http.add_response(400, {"data": "bad"}) + + with pytest.raises(APIError) as exc_info: + await client._get("request", params={}) + + assert exc_info.value.args == ( + "Error 400:\nAn error occurred with the Fount.\nurl=http://test_url/request", + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "kwargs", + ( + {"brands": 1, "studies": [1, 2], "categories": 1, "collections": 1}, + {"brands": 1, "studies": [1, 2]}, + ), +) +async def test_archetypes_bad_params(kwargs: Dict[str, Any], client: ToolsClient): + with pytest.raises(ValueError) as exc_info: + await client.archetypes(**kwargs) + + assert exc_info.value.args == ( + "Either categories OR collections must be specified.", + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "kwargs", + ( + {"brands": 1, "studies": 1, "collections": 1}, + {"brands": 1, "studies": 1, "categories": 1}, + ), +) +async def test_archetypes( + kwargs: Dict[str, int], client: ToolsClient, http: MockAsyncClient +): + http.add_response( + data={ + "data": [ + { + "data": {"test": 1, "metric1": 1, "metric2": 2}, + } + ] + } + ) + + resp = await client.archetypes(**kwargs) + + pd.testing.assert_frame_equal( + resp, + pd.DataFrame({"test": [1], "metric1": [1], "metric2": [2]}), + ) + http.mock_get.assert_called_once_with("archetypes", params=kwargs) + + +@pytest.mark.anyio +async def test_brand_personality_match(client: ToolsClient, http: MockAsyncClient): + http.add_response( + data={ + "data": [{"test": 1, "metric1": 1, "metric2": 2}], + } + ) + + resp = await client.brand_personality_match(brands=1, studies=[1, 2]) + + pd.testing.assert_frame_equal( + resp.astype("int32"), + pd.DataFrame({"test": [1], "metric1": [1], "metric2": [2]}).astype("int32"), + ) + http.mock_get.assert_called_once_with( + "brand-personality-match", params={"brands": 1, "studies": "1,2"} + ) + + +@pytest.mark.anyio +async def test_brand_vulnerability_map(client: ToolsClient, http: MockAsyncClient): + http.add_response( + data={ + "data": [{"test": 1, "metric1": 1, "metric2": 2}], + } + ) + + resp = await client.brand_vulnerability_map(brand=1) + + pd.testing.assert_frame_equal( + resp.astype("int32"), + pd.DataFrame({"test": [1], "metric1": [1], "metric2": [2]}).astype("int32"), + ) + http.mock_get.assert_called_once_with( + "brand-vulnerability-map", params={"brand": 1} + ) + + +@pytest.mark.anyio +async def test_brand_worth_map(client: ToolsClient, http: MockAsyncClient): + http.add_response( + data={ + "data": { + "test": 1, + "data": [{"key": "A", "value": 1}, {"key": "B", "value": 2}], + } + } + ) + + meta, data = await client.brand_worth_map(brands=1, studies=[1, 2]) + + pd.testing.assert_frame_equal( + data.astype({"value": "int32"}), + pd.DataFrame({"key": ["A", "B"], "value": [1, 2]}).astype({"value": "int32"}), + ) + assert meta == {"test": 1} + http.mock_get.assert_called_once_with( + "brand-worth-map", params={"brands": 1, "studies": "1,2"} + ) + + +@pytest.mark.anyio +async def test_category_worth_map(client: ToolsClient, http: MockAsyncClient): + # http.add_response( + # data={ + # "data": { + # "test": 1, + # "data": [{"key": "A", "value": 1}, {"key": "B", "value": 2}], + # } + # } + # ) + + # meta, data = await client.category_worth_map(categories=1, studies=[1, 2]) + + # pd.testing.assert_frame_equal( + # data, + # pd.DataFrame({"key": ["A", "B"], "value": [1, 2]}).astype({"value": "int32"}), + # ) + # assert meta == {"test": 1} + # http.mock_get.assert_called_once_with( + # "category-worth-map", params={"categories": 1, "studies": "1,2"} + # ) + pytest.skip("Not implemented yet") + + +@pytest.mark.anyio +async def test_commitment_funnel(client: ToolsClient, http: MockAsyncClient): + http.add_response( + data={ + "data": [ + { + "test": 1, + "metrics": [{"key": "A", "value": 1}, {"key": "B", "value": 2}], + } + ] + } + ) + + resp = await client.commitment_funnel(brands=1, studies=[1, 2]) + + pd.testing.assert_frame_equal(resp, pd.DataFrame({"test": [1], "A": [1], "B": [2]})) + http.mock_get.assert_called_once_with( + "commitment-funnel", params={"brands": 1, "studies": "1,2"} + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "kwargs", + ( + {"brand": 1, "study": 1, "categories": 1, "collections": 1}, + {"brand": 1, "study": 1}, + ), +) +async def test_cost_of_entry_bad_params(kwargs: Dict[str, Any], client: ToolsClient): + with pytest.raises(ValueError) as exc_info: + await client.cost_of_entry(**kwargs) + + assert exc_info.value.args == ( + "Either categories OR collections must be specified.", + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "kwargs", + ( + {"brand": 1, "study": 1, "collections": 1}, + {"brand": 1, "study": 1, "categories": 1}, + ), +) +async def test_cost_of_entry( + kwargs: Dict[str, Any], client: ToolsClient, http: MockAsyncClient +): + http.add_response( + data={ + "data": { + "test": 1, + "data": [{"key": "A", "value": 1}, {"key": "B", "value": 2}], + } + } + ) + + meta, data = await client.cost_of_entry(**kwargs) + + pd.testing.assert_frame_equal( + data.astype({"value": "int32"}), + pd.DataFrame({"key": ["A", "B"], "value": [1, 2]}).astype({"value": "int32"}), + ) + assert meta == {"test": 1} + http.mock_get.assert_called_once_with("cost-of-entry", params=kwargs) + + +@pytest.mark.anyio +async def test_love_plus(client: ToolsClient, http: MockAsyncClient): + http.add_response( + data={ + "data": [ + { + "test": 1, + "data": { + "A": {"blah": "blah", "value": 1}, + "B": {"blah": "blah", "value": 2}, + }, + } + ] + } + ) + + data = await client.love_plus(brands=1, studies=[1, 2]) + + pd.testing.assert_frame_equal( + data, + pd.DataFrame([{"test": 1, "A": 1, "B": 2}]), + ) + http.mock_get.assert_called_once_with( + "love-plus", params={"brands": 1, "studies": "1,2"} + ) + + +@pytest.mark.anyio +async def test_partnership_exchange_map(client: ToolsClient, http: MockAsyncClient): + http.add_response( + data={ + "data": { + "test": 1, + "data": [{"key": "A", "value": 1}, {"key": "B", "value": 2}], + } + } + ) + + meta, data = await client.partnership_exchange_map( + brands=1, studies=[1, 2], comparison_brands=2 + ) + + pd.testing.assert_frame_equal( + data.astype({"value": "int32"}), + pd.DataFrame({"key": ["A", "B"], "value": [1, 2]}).astype({"value": "int32"}), + ) + assert meta == {"test": 1} + http.mock_get.assert_called_once_with( + "partnership-exchange-map", + params={"brands": 1, "studies": "1,2", "comparison_brands": 2}, + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "kwargs", + ( + {"brands": 1, "studies": [1, 2], "categories": 1, "collections": 1}, + {"brands": 1, "studies": [1, 2]}, + ), +) +async def test_swot_bad_params(kwargs: Dict[str, Any], client: ToolsClient): + # with pytest.raises(ValueError) as exc_info: + # await client.swot(**kwargs) + + # assert exc_info.value.args == ( + # "Either categories OR collections must be specified.", + # ) + pytest.skip("Not implemented yet") + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "kwargs", + ( + {"brands": 1, "studies": 1, "collections": 1}, + {"brands": 1, "studies": 1, "categories": 1}, + ), +) +async def test_swot(kwargs: Dict[str, Any], client: ToolsClient, http: MockAsyncClient): + # http.add_response( + # data={ + # "data": { + # "test": 1, + # "data": [{"key": "A", "value": 1}, {"key": "B", "value": 2}], + # } + # } + # ) + + # meta, data = await client.swot(**kwargs) + + # pd.testing.assert_frame_equal( + # data, + # pd.DataFrame({"key": ["A", "B"], "value": [1, 2]}).astype({"value": "int32"}), + # ) + # assert meta == {"test": 1} + # http.mock_get.assert_called_once_with("swot", params=kwargs) + pytest.skip("Not implemented yet") + + +@pytest.mark.anyio +async def test_toplist_market(client: ToolsClient, http: MockAsyncClient): + pytest.skip("Not implemented yet") + + +def test_raise_if_fails_passes(): + with _raise_if_fails(): + pass + + +def test_raise_if_fails_raises(): + with pytest.raises(APIError) as exc_info: + with _raise_if_fails(): + raise ValueError + + assert exc_info.value.args == ("Could not parse response.",) + + +def test_to_url_params(): + assert _to_url_params({"test": [1, 2, 3], "other": 1, "empty": None}) == { + "test": "1,2,3", + "other": 1, + }