Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python: ["3.9", "3.10", "3.11"]
python: ["3.8", "3.9", "3.10", "3.11"]
os: [ubuntu-latest, windows-latest]

steps:
Expand Down
29 changes: 23 additions & 6 deletions BAVAPI Jupyter Demo.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,32 @@
"metadata": {},
"outputs": [],
"source": [
"TOKEN = \"your_token\" # paste your Fount API token here"
"TOKEN = \"your_token\" # paste your Fount API token here, or follow the instructions below"
]
},
{
"attachments": {},
"cell_type": "markdown",
"metadata": {},
"source": [
"The following cell uses `dotenv` ([learn more](https://github.com/theskumar/python-dotenv)) to load the token from a `.env` file, as recommended in the `bavapi` [documentation](https://fountapi-documentation.vercel.app/getting-started/authentication/)."
"The following cells uses `python-dotenv` ([learn more](https://github.com/theskumar/python-dotenv)) to load the token from a `.env` file, as recommended in the `bavapi` [documentation](https://fountapi-documentation.vercel.app/getting-started/authentication/)."
]
},
{
"attachments": {},
"cell_type": "markdown",
"metadata": {},
"source": [
">__**💡 Tip:**__ Uncomment the cell below to install `python-dotenv`"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# %pip install python-dotenv"
]
},
{
Expand All @@ -79,7 +96,7 @@
"\n",
"To get started, install `bavapi` by running the following command:\n",
"\n",
"```prompt\n",
"```\n",
"pip install wpp-bavapi\n",
"```\n",
"\n",
Expand Down Expand Up @@ -271,7 +288,7 @@
"bavapi-gen-refs --all\n",
"```\n",
"\n",
">__**WARNING:**__ DO NOT PUSH REFERENCE FILES TO GIT! Add `bavapi_refs/` to your `.gitignore` file."
">__**WARNING:**__ DO NOT PUSH REFERENCE FILES TO GIT! Add `bavapi_refs/` to your `.gitignore` file."
]
},
{
Expand Down Expand Up @@ -351,7 +368,7 @@
"outputs": [],
"source": [
"# Get Spanish data for 2022 for the `Adults with kids` base sorted by Differentiation in descending order (highest first)\n",
"es_bss = bavapi.brandscape_data(TOKEN, \"ES\", 2022, audiences=Audiences.ADULTS_WITH_KIDS, sort=\"-differentiation_rank\")\n",
"es_bss = bavapi.brandscape_data(TOKEN, \"ES\", 2022, audiences=[Audiences.ADULTS_WITH_KIDS, Audiences.ADULTS_WITH_KIDS_0_11], sort=\"-differentiation_rank\")\n",
"es_bss.head()"
]
},
Expand Down Expand Up @@ -408,7 +425,7 @@
"\n",
">_**NOTE:**_ This section is more geared towards using `bavapi` in other libraries or applications, or when having to make many separate queries. For general Jupyter notebook use, the examples above should work in most cases.\n",
"\n",
"`bavapi` provides a `Client` class that provides a direct async interface for developing asynchronous programs or when having to make many separate queries.\n",
"`bavapi` provides a `Client` class that provides a direct `async` interface for developing asynchronous programs or when having to make many separate queries.\n",
"\n",
"`bavapi.Client` is an asynchronous context manager, similar in use to `httpx.AsyncClient` (which powers `bavapi`) or `requests.Session`.\n",
"\n",
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

`bavapi` is a Python SDK for the WPP BAV API.

It is published on [PyPI](https://pypi.org/project/wpp-bavapi/) as `wpp-bavapi`.

With `bavapi` you can access the full BAV data catalog, the largest and most comprehensive database of brand data in the world.

Queries are validated automatically thanks to `pydantic` and retrieved asynchronously via the `httpx` package.
Expand All @@ -16,7 +18,7 @@ For more information about the API, go to the [WPPBAV Developer Hub](https://dev

## Prerequisites

`bavapi` requires Python 3.9 or higher to run.
`bavapi` requires Python 3.8 or higher to run.

If you don't have Python installed, you can find it from the [official](https://www.python.org/downloads/) website or via [Anaconda](https://www.anaconda.com/).

Expand All @@ -29,11 +31,11 @@ You will also need a BAV API token. For more information, go to the [Authenticat
- `pandas >= 0.16.2`
- `pydantic >= 1.10, < 2.0`
- `tqdm >= 4.62`
- `typing-extensions >= 3.10` for Python 3.9
- `typing-extensions >= 3.10` for Python < 3.10

## Installation

`bavapi` can be installed from [PyPI](https://pypi.org/project/wpp-bavapi/) using `pip`.
`bavapi` can be installed using `pip`:

```prompt
pip install wpp-bavapi
Expand Down Expand Up @@ -64,10 +66,10 @@ Once you have acquired a token, you can start using this library directly in pyt
>>> result
```

| | sector_id | sector_name | id | name | ... |
| --: | --------: | --------------------- | --: | ------ | --- |
| 0 | 11 | Apparel & Accessories | 342 | Swatch | ... |
| ... | ... | ... | ... | ... | ... |
| | sector_id | sector_name | id | name | ... |
| --: | :-------- | :-------------------- | :--- | :----- | :-- |
| 0 | 233 | Apparel & Accessories | 8635 | Swatch | ... |
| ... | ... | ... | ... | ... | ... |

## Features

Expand All @@ -77,6 +79,7 @@ Once you have acquired a token, you can start using this library directly in pyt
- Provides type hints for better IDE support.
- Retrieve multiple pages of data simultaneously.
- Monitors and prevents exceeding API rate limit.
- Both synchronous and asynchronous APIs for accessing BAV data.

## Documentation

Expand Down
8 changes: 4 additions & 4 deletions bavapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""
bavapi
--------
------
Python consumer for the WPPBAV Fount API.

With `bavapi` you can access the full BAV data catalog, the largest and
Expand All @@ -19,7 +19,7 @@
... result = await client.brands(name="Facebook")
"""

import importlib.metadata
from importlib.metadata import version, PackageNotFoundError

from bavapi import filters
from bavapi.client import Client
Expand All @@ -42,6 +42,6 @@
)

try:
__version__ = importlib.metadata.version(__package__ or __name__)
except importlib.metadata.PackageNotFoundError: # pragma: no cover
__version__ = version(__package__ or __name__)
except PackageNotFoundError: # pragma: no cover
pass
69 changes: 23 additions & 46 deletions bavapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@

# pylint: disable=too-many-arguments

from typing import TYPE_CHECKING, Final, Literal, Optional, TypeVar, Union, overload
from typing import (
TYPE_CHECKING,
Final,
List,
Literal,
Optional,
TypeVar,
Union,
overload,
)

from bavapi import filters as _filters
from bavapi import parsing as _parsing
from bavapi.http import HTTPClient
from bavapi.parsing.responses import parse_response
from bavapi.query import Query
from bavapi.typing import BaseListOrValues, JSONDict, OptionalListOr

Expand Down Expand Up @@ -64,51 +73,19 @@ class Client:

Examples
--------
There are two main ways to use Client:
Use `async with` to get data and close the connection.

1. Using it in a `with` statement (`async with` since Client is async-based).

This is the recommended usage. Ideally you want to use Client to get some data,
close the connection, and continue your analysis.

The benefits of this approach are:

- The client can reuse one single connection for multiple requests, which brings
significant performance gains.
- The client pattern ensures the HTTP connection is closed when the request(s)
are finished.
This way you get the benefits from `httpx` speed improvements
and closes the connection when exiting the async with block.

>>> async with Client("TOKEN") as fount:
... data = await fount.brands("Swatch")

When the `with` block ends, the Client instance will close the connection, and
you will have to create a new instance of Client to use it again:

>>> fount = Client("TOKEN")
>>> async with fount as fount:
... data = await fount.brands("Swatch") # ok
... ...
>>> data = await fount.brands("Swatch") # error, with block is already closed
>>> # create new instance to make a new request
>>> async with Client("TOKEN") as fount:
... data = await fount.brands("Swatch") # should work again
When not using `async with`, close the connection manually by awaiting `aclose`.

2. Calling its methods as you would with any other class.
This will work, but is not ideal as it misses some of the benefits of using the
`with` version.

>>> fount = Client("TOKEN")
>>> client = Client("TOKEN")
>>> data = await fount.brands("Swatch")
>>> data2 = await fount.brands("Facebook") # still works

Calling the last two lines in the same file or Jupyter notebook cell may reuse
the HTTP connection (connections will remain open for 5s), but otherwise
will open a new connection for each call. It is also possible that the
connection remains open, and this pattern won't ensure that it is closed.

To close all open connections, you can use the `aclose` method:

>>> await fount.aclose() # will close all connections
>>> await client.aclose()
"""

@overload
Expand Down Expand Up @@ -179,7 +156,7 @@ async def aclose(self) -> None:
"""Close existing HTTP connections."""
return await self._client.aclose()

async def raw_query(self, endpoint: str, params: Query[F]) -> list[JSONDict]:
async def raw_query(self, endpoint: str, params: Query[F]) -> List[JSONDict]:
"""Perform a raw GET query to the Fount API, returning the response JSON data
instead of a `pandas` DataFrame.

Expand Down Expand Up @@ -291,7 +268,7 @@ async def audiences(

items = await self._client.query("audiences", query)

return _parsing.responses.parse_response(items, expand=stack_data)
return parse_response(items, expand=stack_data)

async def brands(
self,
Expand Down Expand Up @@ -379,7 +356,7 @@ async def brands(

items = await self._client.query("brands", query)

return _parsing.responses.parse_response(items, expand=stack_data)
return parse_response(items, expand=stack_data)

async def brandscape_data(
self,
Expand Down Expand Up @@ -501,7 +478,7 @@ async def brandscape_data(
items = await self._client.query("brandscape-data", query)

# Prefix 'global' to avoid clashing with 'brand_name' on 'brand' includes
return _parsing.responses.parse_response(items, "global", expand=stack_data)
return parse_response(items, "global", expand=stack_data)

async def studies(
self,
Expand Down Expand Up @@ -586,11 +563,11 @@ async def studies(

items = await self._client.query("studies", query)

return _parsing.responses.parse_response(items, expand=stack_data)
return parse_response(items, expand=stack_data)


def _default_brandscape_include(value: OptionalListOr[str]) -> OptionalListOr[str]:
default: Final[list[str]] = ["study", "brand", "category", "audience"]
default: Final[List[str]] = ["study", "brand", "category", "audience"]

if value is None:
return default
Expand Down
2 changes: 2 additions & 0 deletions bavapi/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Exceptions for handling errors with the Fount API."""

class APIError(Exception):
"""Exception for errors interacting with APIs."""

Expand Down
23 changes: 12 additions & 11 deletions bavapi/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# pylint: disable=no-name-in-module, too-few-public-methods

from typing import Literal, Mapping, Optional, TypeVar, Union
from typing import Dict, Literal, Mapping, Optional, Type, TypeVar, Union

from pydantic import BaseModel, root_validator, validator

Expand All @@ -29,7 +29,7 @@

class FountFilters(BaseModel):
"""Base class for Fount API Filters.

Can be used with `raw_query` endpoints.

Attributes
Expand All @@ -43,7 +43,7 @@ class FountFilters(BaseModel):

updated_since: DTValues = None

class Config:
class Config: #pylint: disable=missing-class-docstring
extra = "allow"

@validator("updated_since", pre=True)
Expand All @@ -55,13 +55,13 @@ def _parse_date(cls, value: DTValues) -> Optional[str]:

@classmethod
def ensure(
cls: type[F],
cls: Type[F],
filters: Optional[FiltersOrMapping["FountFilters"]],
**addl_filters: InputSequenceOrValues,
) -> Optional[F]:
"""Ensure FountFilters class from dictionary or other FountFilters class.

If `filters` is None, return None.
If `filters` is None, returns None.

Parameters
----------
Expand All @@ -82,13 +82,14 @@ def ensure(
return None
return cls(**addl_filters) # type: ignore[arg-type]

new_filters = addl_filters.copy()

if isinstance(filters, Mapping):
return cls(**(addl_filters | filters)) # type: ignore[arg-type]
new_filters.update(filters)
else:
new_filters.update(filters.dict(exclude_defaults=True))

return cls(
**addl_filters # type: ignore[arg-type]
| filters.dict(exclude_defaults=True)
)
return cls(**new_filters) # type: ignore[arg-type]


class AudiencesFilters(FountFilters):
Expand Down Expand Up @@ -235,7 +236,7 @@ class BrandscapeFilters(FountFilters):

@root_validator(pre=True)
@classmethod
def _check_params(cls, values: dict[str, object]) -> dict[str, object]:
def _check_params(cls, values: Dict[str, object]) -> Dict[str, object]:
if not (
"brands" in values
or "brand_name" in values
Expand Down
Loading