From a707b59955caaaf630bd931f23bba20925fff230 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Thu, 13 Jul 2023 13:31:54 +0100 Subject: [PATCH 1/7] updated pydantic to v2 --- bavapi/filters.py | 16 ++++++++-------- bavapi/query.py | 15 +++++++++------ pyproject.toml | 9 +++------ requirements.txt | 32 ++++++++++++++++++++------------ 4 files changed, 40 insertions(+), 32 deletions(-) diff --git a/bavapi/filters.py b/bavapi/filters.py index 86cbd9c..71211d6 100644 --- a/bavapi/filters.py +++ b/bavapi/filters.py @@ -4,7 +4,7 @@ from typing import Dict, Literal, Mapping, Optional, Type, TypeVar, Union -from pydantic import BaseModel, root_validator, validator +from pydantic import BaseModel, field_validator, model_validator from bavapi.parsing.params import parse_date from bavapi.typing import ( @@ -41,12 +41,12 @@ class FountFilters(BaseModel): the response data. """ - updated_since: DTValues = None + # Allow arbitrary filters for compatibility with raw_query + model_config = {"extra": "allow"} - class Config: #pylint: disable=missing-class-docstring - extra = "allow" + updated_since: DTValues = None - @validator("updated_since", pre=True) + @field_validator("updated_since", mode="before") @classmethod def _parse_date(cls, value: DTValues) -> Optional[str]: if value is None: @@ -87,7 +87,7 @@ def ensure( if isinstance(filters, Mapping): new_filters.update(filters) else: - new_filters.update(filters.dict(exclude_defaults=True)) + new_filters.update(filters.model_dump(exclude_defaults=True)) return cls(**new_filters) # type: ignore[arg-type] @@ -234,7 +234,7 @@ class BrandscapeFilters(FountFilters): brands: OptionalListOr[int] = None categories: OptionalListOr[int] = None - @root_validator(pre=True) + @model_validator(mode="before") @classmethod def _check_params(cls, values: Dict[str, object]) -> Dict[str, object]: if not ( @@ -312,7 +312,7 @@ class StudiesFilters(FountFilters): countries: OptionalListOr[int] = None regions: OptionalListOr[int] = None - @validator("data_updated_since", pre=True) + @field_validator("data_updated_since", mode="before") @classmethod def _parse_date(cls, value: DTValues) -> Optional[str]: if value is None: diff --git a/bavapi/query.py b/bavapi/query.py index c392524..bdfa2d5 100644 --- a/bavapi/query.py +++ b/bavapi/query.py @@ -12,6 +12,7 @@ BaseMutableParamsMapping, BaseParamsDict, BaseParamsDictValues, + BaseParamsMapping, OptionalListOr, ) @@ -76,18 +77,20 @@ def to_params(self, endpoint: str) -> BaseParamsDictValues: """ exclude: Final[Set[str]] = {"filters", "fields", "max_pages"} - filters: BaseParamsDict = {} + filters: BaseParamsMapping = {} fields: BaseMutableParamsMapping = {} if isinstance(self.filters, _filters.FountFilters): - filters = self.filters.dict(by_alias=True, exclude_defaults=True) + filters = self.filters.model_dump(by_alias=True, exclude_defaults=True) + elif self.filters is not None: + filters = cast(BaseParamsDict, self.filters) filters = to_fount_params(filters, "filter") fields = to_fount_params( {endpoint: self.fields} if self.fields else fields, "fields" ) params = { - **self.dict(exclude=exclude, by_alias=True, exclude_defaults=True), + **self.model_dump(exclude=exclude, by_alias=True, exclude_defaults=True), **filters, **fields, } @@ -114,12 +117,12 @@ def with_page(self, page: int, per_page: int) -> "Query[F]": if self.page and self.per_page: return self - return self.__class__.construct( - self.__fields_set__.union({"page", "per_page"}), + return self.__class__.model_construct( + self.model_fields_set.union({"page", "per_page"}), page=self.page or page, per_page=self.per_page or per_page, filters=self.filters, # avoid turning filters into dictionary - **self.dict( + **self.model_dump( by_alias=True, exclude={"page", "per_page", "filters"}, exclude_defaults=True, diff --git a/pyproject.toml b/pyproject.toml index 060f9ff..0cebc5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools", "setuptools-scm", "wheel"] +requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] name = "wpp-bavapi" -version = "0.5.0" +version = "0.6.0" authors = [ { name = "Ignacio Maiz Vilches", email = "ignacio.maiz@bavgroup.com" }, ] @@ -41,7 +41,7 @@ classifiers = [ dependencies = [ "httpx >= 0.20", "pandas >= 0.16.2", - "pydantic >= 1.10, < 2", + "pydantic >= 2", "tqdm >= 4.62", "nest-asyncio >= 1.5.6", "typing-extensions >= 3.10; python_version < '3.10'", @@ -66,9 +66,6 @@ lint = ["pylint", "mypy", "pandas-stubs"] [project.scripts] bavapi-gen-refs = "bavapi.reference.generate_reference:main" -[tool.setuptools] -include-package-data = true - [tool.setuptools.packages.find] include = ["bavapi*"] diff --git a/requirements.txt b/requirements.txt index d6c94a6..8c9b4ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,9 @@ # # pip-compile --resolver=backtracking pyproject.toml # -anyio==3.7.0 +annotated-types==0.5.0 + # via pydantic +anyio==3.7.1 # via httpcore certifi==2023.5.7 # via @@ -14,22 +16,24 @@ colorama==0.4.6 # via tqdm h11==0.14.0 # via httpcore -httpcore==0.17.2 +httpcore==0.17.3 # via httpx httpx==0.24.1 - # via bavapi (pyproject.toml) + # via wpp-bavapi (pyproject.toml) idna==3.4 # via # anyio # httpx nest-asyncio==1.5.6 - # via bavapi (pyproject.toml) -numpy==1.24.3 + # via wpp-bavapi (pyproject.toml) +numpy==1.25.1 # via pandas -pandas==2.0.2 - # via bavapi (pyproject.toml) -pydantic==1.10.9 - # via bavapi (pyproject.toml) +pandas==2.0.3 + # via wpp-bavapi (pyproject.toml) +pydantic==2.0.2 + # via wpp-bavapi (pyproject.toml) +pydantic-core==2.1.2 + # via pydantic python-dateutil==2.8.2 # via pandas pytz==2023.3 @@ -42,6 +46,10 @@ sniffio==1.3.0 # httpcore # httpx tqdm==4.65.0 - # via bavapi (pyproject.toml) -typing-extensions==4.6.3 - # via pydantic + # via wpp-bavapi (pyproject.toml) +typing-extensions==4.7.1 + # via + # pydantic + # pydantic-core +tzdata==2023.3 + # via pandas From 06f28065c838e6a1e056108feaa2277b0dbffeda Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Thu, 13 Jul 2023 13:32:36 +0100 Subject: [PATCH 2/7] improved type hints for exceptions on aexit --- bavapi/client.py | 12 ++++++++++-- bavapi/http.py | 14 ++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/bavapi/client.py b/bavapi/client.py index 455e455..2728fbd 100644 --- a/bavapi/client.py +++ b/bavapi/client.py @@ -8,6 +8,7 @@ List, Literal, Optional, + Type, TypeVar, Union, overload, @@ -20,6 +21,8 @@ from bavapi.typing import BaseListOrValues, JSONDict, OptionalListOr if TYPE_CHECKING: + from types import TracebackType + from pandas import DataFrame __all__ = ("Client",) @@ -149,8 +152,13 @@ async def __aenter__(self) -> "Client": await self._client.__aenter__() return self - async def __aexit__(self, *args, **kwargs): - await self._client.__aexit__(*args, **kwargs) + 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: """Close existing HTTP connections.""" diff --git a/bavapi/http.py b/bavapi/http.py index a30aa9b..cccde20 100644 --- a/bavapi/http.py +++ b/bavapi/http.py @@ -6,11 +6,13 @@ import math from json import JSONDecodeError from typing import ( + TYPE_CHECKING, Dict, Iterator, List, Optional, Protocol, + Type, TypeVar, Union, cast, @@ -23,6 +25,9 @@ from bavapi.exceptions import APIError, DataNotFoundError, RateLimitExceededError from bavapi.typing import BaseParamsMapping, JSONData, JSONDict +if TYPE_CHECKING: + from types import TracebackType + __all__ = ("HTTPClient",) @@ -108,8 +113,13 @@ async def __aenter__(self: C) -> C: await self.client.__aenter__() return self - async def __aexit__(self, *args, **kwargs) -> None: - await self.client.__aexit__(*args, **kwargs) + 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.""" From 712b6dd361394a7c24a07de8365c53a6ba341360 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Thu, 13 Jul 2023 13:33:04 +0100 Subject: [PATCH 3/7] code formatting --- bavapi/__init__.py | 10 +++++----- bavapi/exceptions.py | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/bavapi/__init__.py b/bavapi/__init__.py index a509d05..5d55b7c 100644 --- a/bavapi/__init__.py +++ b/bavapi/__init__.py @@ -19,7 +19,7 @@ ... result = await client.brands(name="Facebook") """ -from importlib.metadata import version, PackageNotFoundError +from importlib.metadata import PackageNotFoundError, version from bavapi import filters from bavapi.client import Client @@ -28,20 +28,20 @@ from bavapi.sync import audiences, brands, brandscape_data, raw_query, studies __all__ = ( - "raw_query", "audiences", "brands", "brandscape_data", + "raw_query", "studies", "Client", + "Query", + "filters", "APIError", "DataNotFoundError", "RateLimitExceededError", - "Query", - "filters", ) try: __version__ = version(__package__ or __name__) except PackageNotFoundError: # pragma: no cover - pass + __version__ = "not_found" diff --git a/bavapi/exceptions.py b/bavapi/exceptions.py index 25d24b6..733beda 100644 --- a/bavapi/exceptions.py +++ b/bavapi/exceptions.py @@ -1,5 +1,6 @@ """Exceptions for handling errors with the Fount API.""" + class APIError(Exception): """Exception for errors interacting with APIs.""" From dcb37e0d56c82bea5e61e212fa4d5ed602f71606 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Thu, 13 Jul 2023 13:33:22 +0100 Subject: [PATCH 4/7] updated tests type hints --- tests/integration/test_async.py | 7 +-- tests/reference/test_generate_reference.py | 18 ++----- tests/test_client.py | 52 ++++++++++---------- tests/test_http.py | 25 +++++----- tests/test_jupyter.py | 12 ++++- tests/test_queries.py | 57 ---------------------- tests/test_query.py | 54 ++++++++++++++++++++ tests/test_sync.py | 4 +- 8 files changed, 112 insertions(+), 117 deletions(-) delete mode 100644 tests/test_queries.py create mode 100644 tests/test_query.py diff --git a/tests/integration/test_async.py b/tests/integration/test_async.py index 3b9ebc6..0318f6f 100644 --- a/tests/integration/test_async.py +++ b/tests/integration/test_async.py @@ -3,7 +3,7 @@ import os import ssl -from typing import Any, AsyncGenerator, Callable, Coroutine, Dict, List +from typing import Any, AsyncGenerator, Callable, Coroutine, List import pandas as pd import pytest @@ -12,6 +12,7 @@ from bavapi import filters from bavapi.client import Client from bavapi.query import Query +from bavapi.typing import JSONDict, ParamsMapping @pytest.mark.anyio @@ -26,7 +27,7 @@ async def fount() -> AsyncGenerator[Client, None]: @pytest.mark.e2e @pytest.mark.anyio async def test_raw_query(fount: Client): - result: List[Dict[str, Any]] = [] + result: List[JSONDict] = [] for _ in range(5): # pragma: no cover try: result = await fount.raw_query( @@ -76,7 +77,7 @@ async def test_with_filters_one_page(fount: Client): ("studies", {}), ), ) -async def test_endpoints(fount: Client, endpoint: str, filters: Dict[str, Any]): +async def test_endpoints(fount: Client, endpoint: str, filters: ParamsMapping[int]): func: Callable[..., Coroutine[Any, Any, pd.DataFrame]] = getattr(fount, endpoint) result = await func(filters=filters, max_pages=2, per_page=25) diff --git a/tests/reference/test_generate_reference.py b/tests/reference/test_generate_reference.py index 714060e..578c841 100644 --- a/tests/reference/test_generate_reference.py +++ b/tests/reference/test_generate_reference.py @@ -2,7 +2,7 @@ import datetime from pathlib import Path -from typing import Callable, Dict, Optional, TypeVar +from typing import Dict from unittest import mock import pandas as pd @@ -12,17 +12,9 @@ from bavapi.query import Query from bavapi.reference import generate_reference as uref -TEST_DT = datetime.datetime(2023, 1, 1, 12, 0, 0) - - -T = TypeVar("T") +from ..helpers import wraps - -def wraps(return_value: Optional[T] = None) -> Callable[..., Optional[T]]: - def _wraps(*_, **__): - return return_value - - return _wraps +TEST_DT = datetime.datetime(2023, 1, 1, 12, 0, 0) @pytest.fixture(scope="session") @@ -52,8 +44,8 @@ def test_parse_reference(): @pytest.mark.anyio async def test_get_references(fount: Client): - def func(x: pd.Series) -> Dict[str, str]: - return {str(k): str(v) for k, v in x.to_dict().items()} # pragma: no cover + def func(val: pd.Series) -> Dict[str, str]: + return {str(k): str(v) for k, v in val.to_dict().items()} # pragma: no cover items = await uref.get_references(fount, [uref.RefConfig("test", "", func)]) diff --git a/tests/test_client.py b/tests/test_client.py index 5c0f6f6..de3c460 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,7 @@ # pylint: disable=missing-function-docstring, missing-module-docstring # pylint: disable=protected-access, redefined-outer-name -from typing import List, Optional, Set, TypeVar, Union +from typing import Set from unittest import mock import pytest @@ -9,11 +9,10 @@ from bavapi.client import Client, _default_brandscape_include from bavapi.http import HTTPClient from bavapi.query import Query +from bavapi.typing import OptionalListOr from .helpers import wraps -T = TypeVar("T") - # CLASS INIT TESTS @@ -34,6 +33,31 @@ def test_init_no_token(): assert excinfo.value.args[0] == "You must provide `auth_token` or `client`." +# PRIVATE TESTS + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ("test", {"brand", "study", "category", "audience", "test"}), + (None, {"brand", "study", "category", "audience"}), + (["test"], {"brand", "study", "category", "audience", "test"}), + ), +) +def test_brandscape_query_default_include( + value: OptionalListOr[str], expected: Set[str] +): + assert set(_default_brandscape_include(value)) == expected # type: ignore + + +def test_brandscape_query_default_include_partial(): + assert _default_brandscape_include("brand") == "brand" + + +def test_brandscape_query_no_default_include(): + assert _default_brandscape_include("no_default") is None + + # PUBLIC TESTS @@ -113,25 +137,3 @@ async def test_studies(fount: Client): assert (await fount.studies(study_id=1)).shape == (1, 2) mock_base_query.assert_awaited_once_with("studies", Query(id=1)) - - -@pytest.mark.parametrize( - ("value", "expected"), - ( - ("test", {"brand", "study", "category", "audience", "test"}), - (None, {"brand", "study", "category", "audience"}), - (["test"], {"brand", "study", "category", "audience", "test"}), - ), -) -def test_brandscape_query_default_include( - value: Optional[Union[str, List[str]]], expected: Set[str] -): - assert set(_default_brandscape_include(value)) == expected # type: ignore - - -def test_brandscape_query_default_include_partial(): - assert _default_brandscape_include("brand") == "brand" - - -def test_brandscape_query_no_default_include(): - assert _default_brandscape_include("no_default") is None diff --git a/tests/test_http.py b/tests/test_http.py index 7d2878e..21075db 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access, redefined-outer-name from dataclasses import asdict, dataclass -from typing import Any, Dict, List, Optional, TypeVar, Union +from typing import Dict, Optional from unittest import mock import httpx @@ -11,11 +11,10 @@ from bavapi.exceptions import APIError, DataNotFoundError, RateLimitExceededError from bavapi.http import HTTPClient from bavapi.query import Query +from bavapi.typing import JSONData, JSONDict from .helpers import wraps -T = TypeVar("T") - # HELPERS @@ -23,11 +22,11 @@ class QueryResult: """Model for WPPBAV Fount query results""" - data: Union[Dict[str, Any], List[Dict[str, Any]]] + data: JSONData links: Dict[str, str] - meta: Dict[str, Any] + meta: JSONDict - def dict(self) -> Dict[str, Any]: + def dict(self) -> JSONDict: return asdict(self) @@ -35,7 +34,7 @@ def response( status_code: int = 200, message: str = "ok", *, - json: Optional[Any] = None, + json: Optional[JSONData] = None, request_url: str = "http://test_url/request", headers: Optional[httpx.Headers] = None, ) -> httpx.Response: @@ -49,11 +48,11 @@ def response( def sample_data( - data: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None, + data: Optional[JSONData] = None, per_page: int = 25, on_page: int = 25, total: int = 2000, -) -> Dict[str, Any]: +) -> JSONDict: return QueryResult( data=data if data is not None else {}, links={}, @@ -100,11 +99,9 @@ async def test_context_manager(): @pytest.mark.anyio -async def test_aclose(client: HTTPClient): - with mock.patch( - "bavapi.http.httpx.AsyncClient.aclose", wraps=wraps() - ) as mock_aclose: - await client.aclose() +@mock.patch("bavapi.http.httpx.AsyncClient.aclose", wraps=wraps()) +async def test_aclose(mock_aclose: mock.AsyncMock, client: HTTPClient): + await client.aclose() mock_aclose.assert_called_once() diff --git a/tests/test_jupyter.py b/tests/test_jupyter.py index 398eba5..4191d2a 100644 --- a/tests/test_jupyter.py +++ b/tests/test_jupyter.py @@ -11,6 +11,8 @@ from bavapi import jupyter +SetIpythonFunc = Callable[[Optional[bool]], None] + class IPython(ModuleType): """Mock IPython module attributes and functions""" @@ -24,7 +26,7 @@ def _get_ipython(self) -> "IPython": return self -@pytest.fixture(scope="module") +@pytest.fixture(scope="function") def set_ipython(): def _set_ipython(kernel: Optional[bool] = None) -> None: sys.modules["IPython"] = IPython(kernel) @@ -34,12 +36,18 @@ def _set_ipython(kernel: Optional[bool] = None) -> None: del sys.modules["IPython"] -def test_running_in_jupyter(set_ipython: Callable): +def test_running_in_jupyter(set_ipython: SetIpythonFunc): set_ipython(True) assert jupyter.running_in_jupyter() +def test_not_running_in_jupyter(set_ipython: SetIpythonFunc): + set_ipython(None) + + assert not jupyter.running_in_jupyter() + + @mock.patch("bavapi.jupyter.nest_asyncio.apply") def test_enabled_nested(mock_apply: mock.Mock): loop = asyncio.new_event_loop() diff --git a/tests/test_queries.py b/tests/test_queries.py deleted file mode 100644 index 4733063..0000000 --- a/tests/test_queries.py +++ /dev/null @@ -1,57 +0,0 @@ -# pylint: disable=missing-function-docstring, missing-module-docstring - -from unittest import mock - -import pytest - -from bavapi import filters -from bavapi.query import Query - - -def test_with_page(): - assert Query(filters={"audience": 1}).with_page(2, 25) == Query( - page=2, per_page=25, filters={"audience": 1} - ) - - -def test_with_page_no_construction(): - query = Query(page=2, per_page=25) - assert query.with_page(1, 10) is query - - -def test_paginated(): - paginated = tuple(Query(filters={"audience": 1}).paginated(100, 10)) - - assert len(paginated) == 10 - assert [p.page for p in paginated] == list(range(1, 11)) - - -@pytest.mark.parametrize( - "query", - ( - Query(filters={"name": 1}), - Query(filters=filters.FountFilters(**{"name": 1})), # type: ignore[arg-type] - ), -) -def test_to_params_filters(query: Query): - with mock.patch( - "bavapi.query.to_fount_params", return_value={} - ) as mock_to_fount_params: - query.to_params("test") - - assert mock_to_fount_params.call_args_list == [ - mock.call({"name": 1}, "filter"), - mock.call({}, "fields"), - ] - - -def test_to_params_fields(): - with mock.patch( - "bavapi.query.to_fount_params", return_value={} - ) as mock_to_fount_params: - Query(fields=["name"]).to_params("test") - - assert mock_to_fount_params.call_args_list == [ - mock.call({}, "filter"), - mock.call({"test": ["name"]}, "fields"), - ] diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 0000000..55acf21 --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,54 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring + +import pytest + +from bavapi import filters +from bavapi.query import Query + + +def test_with_page(): + assert Query(filters={"audience": 1}).with_page(2, 25) == Query( + page=2, per_page=25, filters={"audience": 1} + ) + + +def test_with_page_no_construction(): + query = Query(page=2, per_page=25) + assert query.with_page(1, 10) is query + + +def test_paginated(): + paginated = tuple(Query(filters={"audience": 1}).paginated(100, 10)) + + assert len(paginated) == 10 + assert [p.page for p in paginated] == list(range(1, 11)) + + +def test_to_params_no_filters_no_fields(): + assert Query(id=1).to_params("test") == {"id": 1} + + +@pytest.mark.parametrize( + "query", + ( + Query(id=1, filters={"name": 1}, fields="metric"), + Query( + id=1, + filters=filters.FountFilters(**{"name": 1}), # type: ignore[arg-type] + fields="metric", + ), + ), +) +def test_to_params(query: Query): + assert query.to_params("test") == { + "id": 1, + "filter[name]": 1, + "fields[test]": "metric", + } + + +def test_to_params_dict_filters(): + query = Query(id=1, filters={"name": 1}) + query.filters = {"name": 1} + + assert query.to_params("test") == {"id": 1, "filter[name]": 1} diff --git a/tests/test_sync.py b/tests/test_sync.py index c1717a9..3321027 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access import asyncio -from typing import Any, Dict, TypeVar +from typing import Any, Dict from unittest import mock import pytest @@ -12,8 +12,6 @@ from .helpers import wraps -T = TypeVar("T") - @mock.patch("bavapi.sync.asyncio.new_event_loop", return_value=asyncio.new_event_loop()) def test_coro_new_loop(mock_new_loop: mock.AsyncMock): From 200a91afdfda7af5e8bd4f6c944bec9d74250b8e Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Thu, 13 Jul 2023 13:33:40 +0100 Subject: [PATCH 5/7] added pylint config to type stubs --- type_stubs/nest_asyncio.pyi | 1 + 1 file changed, 1 insertion(+) diff --git a/type_stubs/nest_asyncio.pyi b/type_stubs/nest_asyncio.pyi index e72b97b..25f9d0b 100644 --- a/type_stubs/nest_asyncio.pyi +++ b/type_stubs/nest_asyncio.pyi @@ -1,3 +1,4 @@ +#pylint: disable=missing-module-docstring, missing-function-docstring, unused-argument import asyncio From 623f07a69140d69f16217748ef4d021a43683e3b Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Thu, 13 Jul 2023 13:33:59 +0100 Subject: [PATCH 6/7] publish on pypi on published release type --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 290212d..64f7e20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ name: release on: release: - types: [released] + types: [published] jobs: pypi-publish: From 2284aa7cd3ce52a3b3c9bd01cce0f0810a6d7f87 Mon Sep 17 00:00:00 2001 From: Ignacio Maiz Date: Thu, 13 Jul 2023 13:34:41 +0100 Subject: [PATCH 7/7] added changelog & roadmap to docs, updated content --- docs/endpoints/index.md | 2 +- docs/extra.css | 20 +-------- docs/getting-started/authentication.md | 2 +- docs/getting-started/reference-classes.md | 2 +- docs/index.md | 5 --- docs/release-notes.md | 14 +++++++ docs/roadmap.md | 22 ++++++++++ docs/usage/advanced.md | 50 +++++++++++++++++++++++ docs/usage/basic.md | 14 +++++-- mkdocs.yml | 11 +++-- 10 files changed, 107 insertions(+), 35 deletions(-) create mode 100644 docs/release-notes.md create mode 100644 docs/roadmap.md diff --git a/docs/endpoints/index.md b/docs/endpoints/index.md index a7b8086..29debe2 100644 --- a/docs/endpoints/index.md +++ b/docs/endpoints/index.md @@ -4,7 +4,7 @@ sidebar_label: Overview # Endpoints -As of `v0.5`, there are four endpoints that have been fully implemented in `bavapi`: +As of `v0.6`, there are four endpoints that have been fully implemented in `bavapi`: - [`audiences`](audiences.md) - [`brands`](brands.md) diff --git a/docs/extra.css b/docs/extra.css index 67a7f6b..2b6d2e0 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -24,22 +24,4 @@ a.autorefs-external::after { a.autorefs-external:hover::after { background-color: var(--md-accent-fg-color); -} -/* -#logo_light_mode { - display: var(--md-footer-logo-light-mode); -} - -#logo_dark_mode { - display: var(--md-footer-logo-dark-mode); -} - -[data-md-color-scheme="light-mode"] { - --md-footer-logo-dark-mode: none; - --md-footer-logo-light-mode: block; -} - -[data-md-color-scheme="dark-mode"] { - --md-footer-logo-dark-mode: block; - --md-footer-logo-light-mode: none; -} */ \ No newline at end of file +} \ No newline at end of file diff --git a/docs/getting-started/authentication.md b/docs/getting-started/authentication.md index 87aa5d8..0bf3845 100644 --- a/docs/getting-started/authentication.md +++ b/docs/getting-started/authentication.md @@ -44,7 +44,7 @@ TOKEN = os.environ["FOUNT_API_TOKEN"] # (2) ``` 1. Load variables from `.env` into the system's environment -2. Assign "FOUNT_API_TOKEN" environment variable to `TOKEN` +2. Assign the `"FOUNT_API_TOKEN"` environment variable to our `TOKEN` local variable Now you can use `TOKEN` in your API requests: diff --git a/docs/getting-started/reference-classes.md b/docs/getting-started/reference-classes.md index 1c7b430..fc5c0e8 100644 --- a/docs/getting-started/reference-classes.md +++ b/docs/getting-started/reference-classes.md @@ -7,7 +7,7 @@ These classes are automatically generated by a console command that becomes avai !!! info "Protected Access" A Fount API token is required to generate reference files. See the [Authentication](authentication.md) section for more information and instructions for using `.env` files. -As of `v0.5` the following reference classes will be generated in a folder named `bavapi_refs`: +As of `v0.6` the following reference classes will be generated in a folder named `bavapi_refs`: - `Audiences`: encodes audience IDs - `Countries`: encodes country IDs diff --git a/docs/index.md b/docs/index.md index c417985..1f4ef83 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,8 +1,3 @@ ---- -hide: - - navigation ---- - # BAV API Python SDK - `bavapi` [![CI status](https://github.com/wppbav/bavapi-sdk-python/actions/workflows/ci.yml/badge.svg)](https://github.com/wppbav/bavapi-sdk-python/actions/workflows/ci.yml) diff --git a/docs/release-notes.md b/docs/release-notes.md new file mode 100644 index 0000000..fde9b95 --- /dev/null +++ b/docs/release-notes.md @@ -0,0 +1,14 @@ +# Release Notes + +## Version 0.6 + +### Version 0.6.0 (July 13th, 2023) + +#### Internal + +- :rocket: Upgraded [`pydantic`](https://pypi.org/project/pydantic/) to v2. Use `bavapi` v0.5 for compatibility with `pydantic` v1. + +#### Typing + +- :bug: Fixed use of `type` in type hints not compatible with Python. 3.8 +- :broom: Cleaned up type hints in tests. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..45e6c09 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,22 @@ +# `bavapi` Roadmap + +This is a non-exhaustive list of potential features & changes to `bavapi` before it is ready for full release: + +## Core tooling + +- ~~`pydantic` V2 support~~ :white_check_mark: +- Strict `mypy` support with [PEP 692](https://docs.python.org/3.12/whatsnew/3.12.html#whatsnew312-pep692) `Unpack` and `TypedDict` + +## New fully-supported endpoints + +Eventually, the plan is to support all endpoints. This is the current priority list: + +1. Categories +2. Collections +3. Brand Metrics +4. Sectors +5. Brand Metric Groups + +## Stretch goals + +- Smarter flattening of JSON responses, possibly through `pandas.json_normalize`. diff --git a/docs/usage/advanced.md b/docs/usage/advanced.md index cc01d90..18973ae 100644 --- a/docs/usage/advanced.md +++ b/docs/usage/advanced.md @@ -78,6 +78,35 @@ These functions will return a list of JSON dictionaries, one for each entry retr !!! tip These methods are meant to be used for custom processing of data (not resulting in a `pandas` DataFrame), but it is also possible to use some of the parsing functions available in [bavapi.parsing.responses][parsing.responses]. +## The `Query` class + +[`bavapi.Query`][query.Query] is a `pydantic`-powered class that holds and validates all the common (aside from endpoint-specific filters) query parameters to pass to the Fount API. + +The default values for the class are the same as the default values in the Fount API itself, so an empty `Query` object can be used to get all entries for a specific endpoint: + +```py +query = bavapi.Query() + +async with bavapi.Client("TOKEN") as fount: + res = fount.raw_query("brand-metrics", query) # (1) +``` + +1. :material-expand-all: Returns all entries for `brand-metrics`. Similar to making a `GET` request with no parameters. + +`Query` can be used to set limits on the number of pages retrieved, or to request a specific page from a query: + +```py +bavapi.Query( + per_page = 200, # (1) + max_pages = 50, + ... # Other params +) +``` + +1. !!! tip "Stick with defaults" + + The default `per_page` value (`100`) has been set after testing various options for the best download speed. :rocket: + ### `Query` parameters All Fount queries performed with [`bavapi.Query`][query.Query] support the following parameters: @@ -92,3 +121,24 @@ All Fount queries performed with [`bavapi.Query`][query.Query] support the follo - `updated_since`: Only return items that have been updated since this timestamp. For more information on the behavior of each of these parameters, see the [Fount API docs](https://developer.wppbav.com/docs/2.x/customizing/fields). + +### Raw parameter dictionary + +The `to_params` method can be used to parse the parameters into a dictionary of what will be sent to the Fount API: + +```py +>>> bavapi.Query( +... filters=BrandscapeFilters( +... brand_name="Facebook", +... year_numbers=[2012, 2013, 2014, 2015] +... ), +... include=["company"] +... ).to_params(endpoint="brandscape-data") +{ + "include[brandscape-data]": "company", # (1) + "filter[brand_name]": "Facebook", + "year_numbers": "2012,2013,2014,2015", +} +``` + +1. :bulb: Parses `filters` and `include` into the correct format for the Fount API, and parses all elements in lists of parameters to their string representation. diff --git a/docs/usage/basic.md b/docs/usage/basic.md index 6ab09c4..b80e8ef 100644 --- a/docs/usage/basic.md +++ b/docs/usage/basic.md @@ -73,9 +73,9 @@ Some of the more common filters for each endpoint have been added directly to th However, less commonly used filters, as well as [value filters](#value-filters) must be specified by using the `filters` parameters in each function. -Filters can be specified using a Python dictionary (if you know the name of the filters you need), or directly creating a Filters instance (recommended method): +Filters can be specified using a Python dictionary (if you know the name of the filters you need), or directly creating a Filters instance: -=== "Filters class" +=== "Filters class (recommended)" ```py result = bavapi.brands( @@ -102,7 +102,7 @@ Filters can be specified using a Python dictionary (if you know the name of the ### Value filters -"Value" filters refer to filtering on the values of the data returned by the endpoint, as opposed to filtering via query parameters specified in the Fount API [documentation](https://developer.wppbav.com/docs/2.x/customizing-respons). For example, filtering by category name or by sector in the `brandscape-data` endpoint. +"Value" filters refer to filtering on the values of the data returned by the endpoint, as opposed to filtering via query parameters specified in the Fount API [documentation](https://developer.wppbav.com/docs/2.x/customizing/fields). For example, filtering by category name or by sector in the `brandscape-data` endpoint. These value filters **must** be specified in the `filters` parameter. If they are added to the function call as regular keyword arguments, a `ValidationError` will be raised. @@ -143,6 +143,8 @@ uk22 = bavapi.brandscape_data( ### Fields +!!! info "Read more in the [API documentation](https://developer.wppbav.com/docs/2.x/customizing/fields)" + It is possible to specify which fields a response should contain. If so, the API will **only** return those fields. ```py @@ -152,6 +154,8 @@ result.columns # will only have ["id", "name"] as columns ### Sorting +!!! info "Read more in the [API documentation](https://developer.wppbav.com/docs/2.x/customizing/filters#sorting-results)" + It is possible to sort the data by a column from the response. ```py @@ -166,6 +170,8 @@ Responses are sorted by item id, in ascending order, by default. ### Related data (includes) +!!! info "Read more in the [API documentation](https://developer.wppbav.com/docs/2.x/customizing/includes)" + Aside from the data directly available for each of the resources in the Fount, these resources can also be connected across endpoints. !!! example @@ -230,4 +236,4 @@ For now, parse datetime values manually using `pandas` instead. !!! tip The functions shown in the "Basic usage" section are meant for easy use in Jupyter notebooks, experimentation, one-off scripts, etc. - For more advanced uses and significant performance benefits, see [Advanced Usage](advanced) next. + For more advanced uses and significant performance benefits, see [Advanced Usage](advanced.md) next. diff --git a/mkdocs.yml b/mkdocs.yml index 52ce06c..c0f5bcd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,10 +8,7 @@ copyright: Copyright © 2023 WPPBAV theme: name: material - # custom_dir: docs/overrides logo: assets/wpp-bav-logo.svg - # logo_dark_mode: assets/wpp-bav-logo-dark-mode.svg - # logo_light_mode: assets/wpp-bav-logo-light-mode.svg favicon: assets/favicon.svg icon: repo: fontawesome/brands/github @@ -51,6 +48,9 @@ markdown_extensions: - admonition - attr_list - md_in_html + - pymdownx.caret + - pymdownx.mark + - pymdownx.tilde - pymdownx.details - pymdownx.emoji: emoji_index: !!python/name:materialx.emoji.twemoji @@ -68,7 +68,10 @@ markdown_extensions: permalink: true nav: - - Home: index.md + - Home: + - Introduction: index.md + - Release Notes: release-notes.md + - Roadmap: roadmap.md - Getting Started: - Authentication: getting-started/authentication.md - Installation: getting-started/installation.md