From 203a9ddf353c2608ff702c66ebc1d5559fc7eedd Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:46:00 +0100 Subject: [PATCH 01/23] small changes to prepare for tools --- bavapi/config.py | 6 ++++++ bavapi/parsing/params.py | 8 +++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 bavapi/config.py 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/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) From 902e22308f352f76342fe857f6613e8b4941ef58 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:46:39 +0100 Subject: [PATCH 02/23] Implemented tools-turbopitch endpoints --- bavapi/tools.py | 821 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_tools.py | 370 ++++++++++++++++++++ 2 files changed, 1191 insertions(+) create mode 100644 bavapi/tools.py create mode 100644 tests/test_tools.py diff --git a/bavapi/tools.py b/bavapi/tools.py new file mode 100644 index 0000000..413190f --- /dev/null +++ b/bavapi/tools.py @@ -0,0 +1,821 @@ +"""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: +>>> 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] = ..., + ) -> Tuple[JSONDict, pd.DataFrame]: ... + + @overload + async def archetypes( + self, + brands: ListOrValues[int], + studies: ListOrValues[int], + audiences: OptionalListOr[int] = None, + *, + collections: ListOrValues[int] = ..., + ) -> Tuple[JSONDict, 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, + ) -> Tuple[JSONDict, pd.DataFrame]: + """Retrieve results from the `archetypes` 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 + + 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.") + + raise NotImplementedError + + @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 + + 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) + + @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) -> MutableParamsMapping[Union[str, float]]: + metrics = entry.pop("metrics") + parsed = cast(Dict[str, Union[str, float]], flatten_mapping(entry)) + parsed.update( + {metric["key"]: metric["value"] for metric in metrics} # type: ignore[arg-type] + ) + return parsed + + 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, + ) -> MutableParamsMapping[Union[int, str, float]]: + data = cast(Dict[str, JSONDict], entry.pop("data")) + parsed = cast(Dict[str, Union[str, float]], flatten_mapping(entry)) + parsed.update( + {metric: val["value"] for metric, val in data.items()} # type: ignore[arg-type] + ) + return parsed + + 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 + + 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 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 = { + "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) + + @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 + ------ + APIError + If an error occurs with the query + """ + 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: MutableParamsMapping[OptionalSequenceOr[Union[str, int]]] +) -> MutableParamsMapping[Union[str, int]]: + return list_to_str({k: v for k, v in params.items() if v}) diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..cfac62b --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,370 @@ +# 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 +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 +async def test_archetypes(client: ToolsClient, http: MockAsyncClient): + pass + + +@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, + 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, + 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, + 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.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, + 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, + 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.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.mark.anyio +async def test_toplist_market(client: ToolsClient, http: MockAsyncClient): + pass + + +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",) From 28d4c6b09399fcd00fad584fa3c087f3026d2435 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:46:53 +0100 Subject: [PATCH 03/23] added pandas and pydantic inventories --- mkdocs.yml | 2 ++ 1 file changed, 2 insertions(+) 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 From 75ffd39afeb7a3746dd1b16f0815b5911dbae139 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:47:10 +0100 Subject: [PATCH 04/23] added isort to the list of tools --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) 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/). From 2d3d8826c3dbf12ec9fcdb1b6c4202d5df763a80 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:47:22 +0100 Subject: [PATCH 05/23] change copyright year? --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From d4e595b300ae412f14f2a36ab9589e3bf8b8f50d Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:47:58 +0100 Subject: [PATCH 06/23] added missing `audience-groups` to the list of supported endpoints --- README.md | 1 + docs/index.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 3ee77c1..9642faf 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` 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. From 024d533bb44a78b0572d87b19883ccf37f7e166b Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:48:19 +0100 Subject: [PATCH 07/23] fixed some docstring typos --- bavapi/client.py | 6 ++---- bavapi/sync.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) 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/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. From 4f67d1b6dc7890d20c2e4838c2fc0397636f0c1d Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:48:35 +0100 Subject: [PATCH 08/23] prepare for deprecations --- bavapi/filters.py | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 From bb3ba5cf7a19663e5707fa66036b04e18d495f34 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:49:05 +0100 Subject: [PATCH 09/23] add config to refuse-list for doc generation --- docs/gen_ref_pages.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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) From 6cb90d400022502559d85c4fdf9ea05cb26714c4 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:49:23 +0100 Subject: [PATCH 10/23] integration tests for tools endpoints --- tests/test_integration.py | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index 965cce4..fe3d0ab 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,46 @@ 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"), + ( + ("brand_personality_match", {"brands": 3100, "studies": 650}), + ("brand_vulnerability_map", {"brand": 3100}), + ("commitment_funnel", {"brands": 3100, "studies": 650}), + ("love_plus", {"brands": 3100, "studies": 650}), + ), +) +async def test_tools_no_metadata(endpoint: str, params: Dict[str, Any]): + async with ToolsClient(os.environ["BAV_API_KEY"]) as client: + result: pd.DataFrame = await getattr(client, endpoint)(**params) + + 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 + async with ToolsClient(os.environ["BAV_API_KEY"]) as client: + meta, result = await getattr(client, endpoint)(**params) + + assert meta + assert not result.empty From 4ad4dd12f2b458cd1a7f2000c64b7f960fcbe80c Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:49:46 +0100 Subject: [PATCH 11/23] added documentation for tools endpoints --- docs/reference/SUMMARY.md | 1 + docs/usage/tools.md | 62 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 docs/usage/tools.md 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/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 From ec2968e7db803780dbab7023d46c0fe9695f517b Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:50:14 +0100 Subject: [PATCH 12/23] added isort to list of tools --- docs/contributing.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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/). From 64ce608a30580f7bed1536ffa6426a2dc5708cc0 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:50:34 +0100 Subject: [PATCH 13/23] checked off tools item in v2 roadmap --- docs/roadmap.md | 57 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 1c8a4e1..79c1ca5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,14 +9,13 @@ This is a non-exhaustive list of potential features & changes to `bavapi` for th ### New features -- [ ] Support for the `tools` BAV API namespace and endpoints +- [x] `v1.1.0` ~~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 @@ -24,45 +23,59 @@ 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") +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")) +bavapi.brands( + TOKEN, + "Facebook", + "US", + 2022, + filters={"studies": [1, 2, 3]}, + query=bavapi.Query(include="category"), +) # Combined filters into pydantic model bavapi.brands( TOKEN, - filters=BrandsFilters( + filters=bavapi.filters.BrandsFilters( name="Facebook", country_codes="US", year_numbers=2022, studies=[1, 2, 3] ), - include="category") + include="category", +) # Filters and query as pydantic models, passed separately bavapi.brands( TOKEN, - filters=BrandsFilters( + filters=bavapi.filters.BrandsFilters( name="Facebook", country_codes="US", year_numbers=2022, studies=[1, 2, 3] ), - query=Query(include="category") + query=bavapi.Query(include="category"), ) # Filters as parameter to query, query as pydantic model bavapi.brands( TOKEN, - query=Query( - filters=BrandsFilters( + query=bavapi.Query( + filters=bavapi.filters.BrandsFilters( name="Facebook", country_codes="US", year_numbers=2022, studies=[1, 2, 3] ), - include="category" + include="category", ) ) @@ -71,7 +84,27 @@ 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. +In `v2`, only this format will be supported: + +```py +bavapi.brands( + TOKEN, + query=bavapi.Query( + filters=bavapi.filters.BrandsFilters( + name="Facebook", + country_codes="US", + year_numbers=2022, + studies=[1, 2, 3] + ), + include="category", + ), + verbose=True, + on_errors="raise", +) +``` + +!!! note + You will still be able to use a dictionary to set *filters*, but it's still recommended to use the `pydantic` class. ## `v1` Roadmap - COMPLETED From 053508f58c597875ee356180f48ebd8013908f93 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:50:51 +0100 Subject: [PATCH 14/23] minor styling changes --- docs/getting-started/installation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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. From c085751e0f4d44d41c88736ea3539b910c11aedd Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:51:06 +0100 Subject: [PATCH 15/23] added release notes for 1.1 --- docs/release-notes.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/release-notes.md b/docs/release-notes.md index 76a8a59..c6f1b66 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,28 @@ # Release Notes +## `1.1` + +### `1.1.0` (2024-??-??) + +#### 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 + +- :warning: Started setting up deprecation functionality for `v2`. +- :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) From 07b39b157615e96fd1420f90cf27dc80caef6819 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:52:03 +0100 Subject: [PATCH 16/23] version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }, ] From e863eba8a5d448b183463fa7114bbedd103a3596 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:52:17 +0100 Subject: [PATCH 17/23] mention new tools functionality --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9642faf..a4d6466 100644 --- a/README.md +++ b/README.md @@ -91,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. From 2973626707db86f9dcbb7d5df5bfa3be6f9476b2 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Fri, 12 Apr 2024 17:56:43 +0100 Subject: [PATCH 18/23] make raise_if_fails context manager private --- bavapi/tools.py | 20 ++++++++++---------- tests/test_tools.py | 6 +++--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bavapi/tools.py b/bavapi/tools.py index 413190f..1a66814 100644 --- a/bavapi/tools.py +++ b/bavapi/tools.py @@ -324,7 +324,7 @@ async def brand_personality_match( resp = await self._get("brand-personality-match", params=_to_url_params(params)) - with raise_if_fails(): + with _raise_if_fails(): payload = cast(List[JSONDict], resp["data"]) return parse_response(payload) @@ -356,7 +356,7 @@ async def brand_vulnerability_map( resp = await self._get("brand-vulnerability-map", params=params) - with raise_if_fails(): + with _raise_if_fails(): payload = cast(List[JSONDict], resp["data"]) return parse_response(payload) @@ -398,7 +398,7 @@ async def brand_worth_map( resp = await self._get("brand-worth-map", params=_to_url_params(params)) - with raise_if_fails(): + with _raise_if_fails(): payload = cast(JSONDict, resp["data"]) data = cast(List[JSONDict], payload.pop("data")) return payload, parse_response(data) @@ -441,7 +441,7 @@ async def category_worth_map( resp = await self._get("category-worth-map", params=_to_url_params(params)) - with raise_if_fails(): + with _raise_if_fails(): payload = cast(JSONDict, resp["data"]) data = cast(List[JSONDict], payload.pop("data")) return payload, parse_response(data) @@ -492,7 +492,7 @@ def parse_entry(entry: JSONDict) -> MutableParamsMapping[Union[str, float]]: ) return parsed - with raise_if_fails(): + with _raise_if_fails(): payload = cast(List[JSONDict], resp["data"]) return pd.DataFrame([parse_entry(entry) for entry in payload]) @@ -576,7 +576,7 @@ async def cost_of_entry( resp = await self._get("cost-of-entry", params=_to_url_params(params)) - with raise_if_fails(): + with _raise_if_fails(): payload = cast(JSONDict, resp["data"]) data = cast(List[JSONDict], payload.pop("data")) return payload, parse_response(data) @@ -630,7 +630,7 @@ def parse_entry( ) return parsed - with raise_if_fails(): + with _raise_if_fails(): payload = cast(List[JSONDict], resp["data"]) return pd.DataFrame([parse_entry(entry) for entry in payload]) @@ -675,7 +675,7 @@ async def partnership_exchange_map( "partnership-exchange-map", params=_to_url_params(params) ) - with raise_if_fails(): + with _raise_if_fails(): payload = cast(JSONDict, resp["data"]) data = cast(List[JSONDict], payload.pop("data")) return payload, parse_response(data) @@ -760,7 +760,7 @@ async def swot( resp = await self._get("swot", params=_to_url_params(params)) - with raise_if_fails(): + with _raise_if_fails(): payload = cast(JSONDict, resp["data"]) data = cast(List[JSONDict], payload.pop("data")) return payload, parse_response(data) @@ -808,7 +808,7 @@ async def toplist_market( @contextlib.contextmanager -def raise_if_fails() -> Generator[None, None, None]: +def _raise_if_fails() -> Generator[None, None, None]: try: yield except (ValueError, TypeError, KeyError) as exc: diff --git a/tests/test_tools.py b/tests/test_tools.py index cfac62b..844dae9 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -8,7 +8,7 @@ import pytest from bavapi.exceptions import APIError -from bavapi.tools import ToolsClient, raise_if_fails +from bavapi.tools import ToolsClient, _raise_if_fails from tests.helpers import MockAsyncClient @@ -358,13 +358,13 @@ async def test_toplist_market(client: ToolsClient, http: MockAsyncClient): def test_raise_if_fails_passes(): - with raise_if_fails(): + with _raise_if_fails(): pass def test_raise_if_fails_raises(): with pytest.raises(APIError) as exc_info: - with raise_if_fails(): + with _raise_if_fails(): raise ValueError assert exc_info.value.args == ("Could not parse response",) From 3f3e856d62d6ed95fd44b81cc4620efcd94cbc1f Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Thu, 2 May 2024 13:26:52 +0100 Subject: [PATCH 19/23] improve tools --- bavapi/__init__.py | 4 + bavapi/tools.py | 181 ++++++++++++++++++++++++-------------- docs/roadmap.md | 13 ++- tests/test_integration.py | 19 +++- tests/test_tools.py | 127 ++++++++++++++++---------- 5 files changed, 225 insertions(+), 119 deletions(-) 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/tools.py b/bavapi/tools.py index 1a66814..97b4230 100644 --- a/bavapi/tools.py +++ b/bavapi/tools.py @@ -9,7 +9,7 @@ -------- >>> from bavapi.tools import ToolsClient ->>> async with ToolsClient("TOKEN") as client: +>>> async with ToolsClient("TOKEN") as client: # Replace "TOKEN" with your BAV API key >>> result = await client.commitment_funnel(brands=1, studies=1) """ @@ -50,7 +50,7 @@ __all__ = ("ToolsClient",) T = TypeVar("T") -Params = MutableParamsMapping[T] +_Params = MutableParamsMapping[T] class ToolsClient: @@ -203,9 +203,7 @@ async def aclose(self) -> None: return await self.client.aclose() async def _get( - self, - endpoint: str, - params: Params[Union[str, int]], + 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) @@ -228,7 +226,7 @@ async def archetypes( audiences: OptionalListOr[int] = None, *, categories: ListOrValues[int] = ..., - ) -> Tuple[JSONDict, pd.DataFrame]: ... + ) -> pd.DataFrame: ... @overload async def archetypes( @@ -238,7 +236,7 @@ async def archetypes( audiences: OptionalListOr[int] = None, *, collections: ListOrValues[int] = ..., - ) -> Tuple[JSONDict, pd.DataFrame]: ... + ) -> pd.DataFrame: ... @validate_call async def archetypes( @@ -249,11 +247,9 @@ async def archetypes( *, categories: OptionalListOr[int] = None, collections: OptionalListOr[int] = None, - ) -> Tuple[JSONDict, pd.DataFrame]: + ) -> pd.DataFrame: """Retrieve results from the `archetypes` endpoint - [NOT IMPLEMENTED] - See for more info. Parameters @@ -271,8 +267,8 @@ async def archetypes( Returns ------- - Tuple[JSONDict, pd.DataFrame] - A tuple containing a JSON dictionary of metadata and a Dataframe with the results + pd.DataFrame + Dataframe containing the results Raises ------ @@ -284,7 +280,28 @@ async def archetypes( if not bool(categories) ^ bool(collections): raise ValueError("Either categories OR collections must be specified.") - raise NotImplementedError + 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( @@ -329,12 +346,9 @@ async def brand_personality_match( return parse_response(payload) @validate_call - async def brand_vulnerability_map( - self, - brand: int, - ) -> pd.DataFrame: + async def brand_vulnerability_map(self, brand: int) -> pd.DataFrame: """Retrieve results from the `brand-vulnerability-map` endpoint - + See for more info. Parameters @@ -352,7 +366,7 @@ async def brand_vulnerability_map( APIError If an error occurs with the query """ - params: Params[Union[str, int]] = {"brand": brand} + params: _Params[Union[str, int]] = {"brand": brand} resp = await self._get("brand-vulnerability-map", params=params) @@ -368,7 +382,7 @@ async def brand_worth_map( audiences: OptionalListOr[int] = None, ) -> Tuple[JSONDict, pd.DataFrame]: """Retrieve results from the `brand-worth-map` endpoint - + See for more info. Parameters @@ -411,7 +425,9 @@ async def category_worth_map( audiences: OptionalListOr[int] = None, ) -> Tuple[JSONDict, pd.DataFrame]: """Retrieve results from the `category-worth-map` endpoint - + + [NOT IMPLEMENTED] + See for more info. Parameters @@ -433,18 +449,20 @@ async def category_worth_map( APIError If an error occurs with the query """ - params = { - "categories": categories, - "studies": studies, - "audiences": audiences, - } + # params = { + # "categories": categories, + # "studies": studies, + # "audiences": audiences, + # } - resp = await self._get("category-worth-map", params=_to_url_params(params)) + # 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) + # 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( @@ -454,7 +472,7 @@ async def commitment_funnel( audiences: OptionalListOr[int] = None, ) -> pd.DataFrame: """Retrieve results from the `commitment-funnel` endpoint - + See for more info. Parameters @@ -484,13 +502,13 @@ async def commitment_funnel( resp = await self._get("commitment-funnel", params=_to_url_params(params)) - def parse_entry(entry: JSONDict) -> MutableParamsMapping[Union[str, float]]: + def parse_entry(entry: JSONDict) -> _Params[Union[str, float]]: metrics = entry.pop("metrics") - parsed = cast(Dict[str, Union[str, float]], flatten_mapping(entry)) + parsed = flatten_mapping(entry) parsed.update( {metric["key"]: metric["value"] for metric in metrics} # type: ignore[arg-type] ) - return parsed + return parsed # type: ignore[return-value] with _raise_if_fails(): payload = cast(List[JSONDict], resp["data"]) @@ -530,7 +548,7 @@ async def cost_of_entry( comparison_name: Optional[str] = None, ) -> Tuple[JSONDict, pd.DataFrame]: """Retrieve results from the `cost-of-entry` endpoint - + See for more info. Parameters @@ -589,7 +607,7 @@ async def love_plus( audiences: OptionalListOr[int] = None, ) -> pd.DataFrame: """Retrieve results from the `love-plus` endpoint - + See for more info. Parameters @@ -620,15 +638,13 @@ async def love_plus( resp = await self._get("love-plus", params=_to_url_params(params)) - def parse_entry( - entry: JSONDict, - ) -> MutableParamsMapping[Union[int, str, float]]: - data = cast(Dict[str, JSONDict], entry.pop("data")) - parsed = cast(Dict[str, Union[str, float]], flatten_mapping(entry)) + 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[arg-type] + {metric: val["value"] for metric, val in data.items()} # type: ignore ) - return parsed + return parsed # type: ignore[return-value] with _raise_if_fails(): payload = cast(List[JSONDict], resp["data"]) @@ -642,7 +658,7 @@ async def partnership_exchange_map( comparison_brands: ListOrValues[int], ) -> Tuple[JSONDict, pd.DataFrame]: """Retrieve results from the `partnership-exchange-map` endpoint - + See for more info. Parameters @@ -665,7 +681,7 @@ async def partnership_exchange_map( If an error occurs with the query """ - params: Params[OptionalSequenceOr[Union[int, str]]] = { + params: _Params[OptionalSequenceOr[Union[int, str]]] = { "brands": brands, "studies": studies, "comparison_brands": comparison_brands, @@ -714,7 +730,9 @@ async def swot( comparison_name: Optional[str] = None, ) -> Tuple[JSONDict, pd.DataFrame]: """Retrieve results from the `swot` endpoint - + + [NOT IMPLEMENTED] + See for more info. Parameters @@ -732,7 +750,8 @@ async def swot( comparison_name : str, optional Custom name to give the comparison, default None - Default behavior is to use the category or collection name. + Default behavior is to use "Category" or "Collection", depending on whether + categories or collections were specified in the method call. Returns ------- @@ -746,24 +765,46 @@ async def swot( APIError If an error occurs with the query """ - if not bool(categories) ^ bool(collections): - raise ValueError("Either categories OR collections must be specified.") + # 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, - } + # params = { + # "brands": brands, + # "studies": studies, + # "audiences": audiences, + # "categories": categories, + # "collections": collections, + # "comparisonName": comparison_name, + # } - resp = await self._get("swot", params=_to_url_params(params)) + # 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) + # 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( @@ -778,7 +819,7 @@ async def toplist_market( """Retrieve results from the `toplist-market` endpoint [NOT IMPLEMENTED] - + See for more info. Parameters @@ -801,9 +842,15 @@ async def toplist_market( 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 @@ -812,10 +859,10 @@ def _raise_if_fails() -> Generator[None, None, None]: try: yield except (ValueError, TypeError, KeyError) as exc: - raise APIError("Could not parse response") from exc + raise APIError("Could not parse response.") from exc def _to_url_params( - params: MutableParamsMapping[OptionalSequenceOr[Union[str, int]]] -) -> MutableParamsMapping[Union[str, int]]: + 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/roadmap.md b/docs/roadmap.md index 79c1ca5..f2e6b64 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,8 +10,17 @@ This is a non-exhaustive list of potential features & changes to `bavapi` for th ### New features - [x] `v1.1.0` ~~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 +- [ ] Support for the following BAV API endpoints: + - [ ] `cee-opinions` + - [ ] `best-countries-metrics` + - [ ] `best-countries-metric-groups` + - [ ] `best-countries-factors` + - [ ] `best-countries-data` + - [ ] `gics-sectors` + - [ ] `operating-companies` + - [ ] `stock-exchanges` + - [ ] `stock-prices` +- [ ] Support for retrieval of custom aggregations (audiences) generated via background process in the Fount ### Deprecations diff --git a/tests/test_integration.py b/tests/test_integration.py index fe3d0ab..cb311b3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -92,15 +92,23 @@ def test_endpoints(endpoint: str, filters: Dict[str, Any]): @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]): - async with ToolsClient(os.environ["BAV_API_KEY"]) as client: - result: pd.DataFrame = await getattr(client, endpoint)(**params) + 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 @@ -123,8 +131,11 @@ async def test_tools_no_metadata(endpoint: str, params: Dict[str, Any]): async def test_tools_with_metadata(endpoint: str, params: Dict[str, Any]): meta: JSONDict result: pd.DataFrame - async with ToolsClient(os.environ["BAV_API_KEY"]) as client: - meta, result = await getattr(client, endpoint)(**params) + 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 index 844dae9..c1fda5c 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -8,7 +8,7 @@ import pytest from bavapi.exceptions import APIError -from bavapi.tools import ToolsClient, _raise_if_fails +from bavapi.tools import ToolsClient, _raise_if_fails, _to_url_params from tests.helpers import MockAsyncClient @@ -98,8 +98,33 @@ async def test_archetypes_bad_params(kwargs: Dict[str, Any], client: ToolsClient @pytest.mark.anyio -async def test_archetypes(client: ToolsClient, http: MockAsyncClient): - pass +@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 @@ -165,25 +190,26 @@ async def test_brand_worth_map(client: ToolsClient, http: MockAsyncClient): @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"} - ) + # 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 @@ -316,12 +342,13 @@ async def test_partnership_exchange_map(client: ToolsClient, http: MockAsyncClie ), ) async def test_swot_bad_params(kwargs: Dict[str, Any], client: ToolsClient): - with pytest.raises(ValueError) as exc_info: - await client.swot(**kwargs) + # with pytest.raises(ValueError) as exc_info: + # await client.swot(**kwargs) - assert exc_info.value.args == ( - "Either categories OR collections must be specified.", - ) + # assert exc_info.value.args == ( + # "Either categories OR collections must be specified.", + # ) + pytest.skip("Not implemented yet") @pytest.mark.anyio @@ -333,28 +360,29 @@ async def test_swot_bad_params(kwargs: Dict[str, Any], client: ToolsClient): ), ) 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) + # 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): - pass + pytest.skip("Not implemented yet") def test_raise_if_fails_passes(): @@ -367,4 +395,11 @@ def test_raise_if_fails_raises(): with _raise_if_fails(): raise ValueError - assert exc_info.value.args == ("Could not parse response",) + 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, + } From cc82289adc2c0520d2c5211a070e5f497526b838 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Mon, 20 May 2024 15:03:16 +0100 Subject: [PATCH 20/23] update release notes --- docs/release-notes.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index c6f1b66..6e27ddf 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,7 +2,7 @@ ## `1.1` -### `1.1.0` (2024-??-??) +### `1.1.0` (2024-05-20) #### Feature @@ -16,7 +16,6 @@ #### Internal -- :warning: Started setting up deprecation functionality for `v2`. - :broom: New `config` module to hold the user agent and URL to use with the package. #### Typing From cb1ada06b026ca9266c6e2173cea86162650d941 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Mon, 20 May 2024 15:03:26 +0100 Subject: [PATCH 21/23] remove v2 roadmap --- docs/roadmap.md | 113 ------------------------------------------------ 1 file changed, 113 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index f2e6b64..0d8f1b0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,119 +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 - -- [x] `v1.1.0` ~~Support for the `tools` BAV API namespace and endpoints~~ -- [ ] Support for the following BAV API endpoints: - - [ ] `cee-opinions` - - [ ] `best-countries-metrics` - - [ ] `best-countries-metric-groups` - - [ ] `best-countries-factors` - - [ ] `best-countries-data` - - [ ] `gics-sectors` - - [ ] `operating-companies` - - [ ] `stock-exchanges` - - [ ] `stock-prices` -- [ ] Support for retrieval of custom aggregations (audiences) 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. - -#### 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=bavapi.Query(include="category"), -) - -# Combined filters into pydantic model -bavapi.brands( - TOKEN, - filters=bavapi.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=bavapi.filters.BrandsFilters( - name="Facebook", - country_codes="US", - year_numbers=2022, - studies=[1, 2, 3] - ), - query=bavapi.Query(include="category"), -) - -# Filters as parameter to query, query as pydantic model -bavapi.brands( - TOKEN, - query=bavapi.Query( - filters=bavapi.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")) -``` - -In `v2`, only this format will be supported: - -```py -bavapi.brands( - TOKEN, - query=bavapi.Query( - filters=bavapi.filters.BrandsFilters( - name="Facebook", - country_codes="US", - year_numbers=2022, - studies=[1, 2, 3] - ), - include="category", - ), - verbose=True, - on_errors="raise", -) -``` - -!!! note - You will still be able to use a dictionary to set *filters*, but it's still recommended to use the `pydantic` class. - ## `v1` Roadmap - COMPLETED This is a non-exhaustive list of potential features & changes to `bavapi` before it is ready for full release: From b3f772792e114a12330e0174d1f83ee44248aca5 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Mon, 20 May 2024 15:03:34 +0100 Subject: [PATCH 22/23] fixed dtype for test comparisons --- tests/test_tools.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_tools.py b/tests/test_tools.py index c1fda5c..356bd69 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -138,7 +138,7 @@ async def test_brand_personality_match(client: ToolsClient, http: MockAsyncClien resp = await client.brand_personality_match(brands=1, studies=[1, 2]) pd.testing.assert_frame_equal( - resp, + resp.astype("int32"), pd.DataFrame({"test": [1], "metric1": [1], "metric2": [2]}).astype("int32"), ) http.mock_get.assert_called_once_with( @@ -157,7 +157,7 @@ async def test_brand_vulnerability_map(client: ToolsClient, http: MockAsyncClien resp = await client.brand_vulnerability_map(brand=1) pd.testing.assert_frame_equal( - resp, + resp.astype("int32"), pd.DataFrame({"test": [1], "metric1": [1], "metric2": [2]}).astype("int32"), ) http.mock_get.assert_called_once_with( @@ -179,7 +179,7 @@ async def test_brand_worth_map(client: ToolsClient, http: MockAsyncClient): meta, data = await client.brand_worth_map(brands=1, studies=[1, 2]) pd.testing.assert_frame_equal( - data, + data.astype({"value": "int32"}), pd.DataFrame({"key": ["A", "B"], "value": [1, 2]}).astype({"value": "int32"}), ) assert meta == {"test": 1} @@ -273,7 +273,7 @@ async def test_cost_of_entry( meta, data = await client.cost_of_entry(**kwargs) pd.testing.assert_frame_equal( - data, + data.astype({"value": "int32"}), pd.DataFrame({"key": ["A", "B"], "value": [1, 2]}).astype({"value": "int32"}), ) assert meta == {"test": 1} From d5e04e44f68f4e4b1dd848f953c1f828f6c510a9 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Mon, 20 May 2024 15:07:29 +0100 Subject: [PATCH 23/23] fixed another dtype for comparisons --- tests/test_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_tools.py b/tests/test_tools.py index 356bd69..46c5358 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -323,7 +323,7 @@ async def test_partnership_exchange_map(client: ToolsClient, http: MockAsyncClie ) pd.testing.assert_frame_equal( - data, + data.astype({"value": "int32"}), pd.DataFrame({"key": ["A", "B"], "value": [1, 2]}).astype({"value": "int32"}), ) assert meta == {"test": 1}