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
19 changes: 18 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,21 @@ PINTEREST_STATE=dev
PINTEREST_ACCESS_TOKEN_JSON_PATH=./
PINTEREST_ACCESS_TOKEN='<access token>'
PINTEREST_REFRESH_ACCESS_TOKEN='<refresh token>'
PINTEREST_API_URI=https://api.pinterest.com/v5
PINTEREST_API_URI=https://api.pinterest.com/v5

# Required to run integration_tests/ads/test_conversion_events.py. This is a Conversions API
# access token, generated manually in Pinterest Ads Manager (Ad Account > Conversions >
# Conversion Access Token). It cannot be fetched via any Pinterest API.
CONVERSION_ACCESS_TOKEN='<conversion access token>'

# Test fixtures used throughout integration_tests/. Provision these by running:
# python -m integration_tests.bin.setup_test_account <existing ad account id>
# which creates a persistent board/section/pin and looks up your user account id, then writes
# all six values below into this file. DEFAULT_AD_ACCOUNT_ID must be an ad account id that the
# token's user_account already has access to; ad accounts are never created or deleted by the script.
OWNER_USER_ID=<user account id>
DEFAULT_AD_ACCOUNT_ID=<ad account id>
DEFAULT_BOARD_ID=<board id>
DEFAULT_BOARD_NAME='<board name>'
DEFAULT_BOARD_SECTION_ID=<board section id>
DEFAULT_PIN_ID=<pin id>
9 changes: 8 additions & 1 deletion integration_tests/ads/test_ad_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from integration_tests.base_test import BaseTestCase
from integration_tests.config import DEFAULT_AD_ACCOUNT_ID

from openapi_generated.pinterest_client.exceptions import ApiException

from pinterest.ads.ad_groups import AdGroup


Expand Down Expand Up @@ -84,6 +86,11 @@ def test_update_success(self):
def test_update_fail_with_invalid_tracking_urls(self):
"""
Test update with invalid tracking url

Note: the API now validates the tracking URL format up front and
raises a raw ApiException (HTTP 400) rather than accepting the
malformed URLs and failing the SDK's own post-update value check
(AssertionError).
"""
ad_group = AdGroup(
ad_account_id=DEFAULT_AD_ACCOUNT_ID,
Expand All @@ -103,7 +110,7 @@ def test_update_fail_with_invalid_tracking_urls(self):
tracking_urls=new_tracking_url
)

with self.assertRaises(AssertionError):
with self.assertRaises((AssertionError, ApiException)):
ad_group.update_fields(**update_argument)


Expand Down
2 changes: 2 additions & 0 deletions integration_tests/ads/test_campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def test_create_campaign_success(self):
name="SDK Test Campaign",
objective_type="AWARENESS",
daily_spend_cap=10000000,
is_campaign_budget_optimization=True,
)

assert campaign
Expand All @@ -48,6 +49,7 @@ def test_create_campaign_failure_without_budget(self):
ad_account_id=DEFAULT_AD_ACCOUNT_ID,
name="SDK Test Campaign",
objective_type="AWARENESS",
is_campaign_budget_optimization=True,
)

self.assertRaisesRegex(
Expand Down
7 changes: 6 additions & 1 deletion integration_tests/ads/test_keywords.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pinterest.ads.keywords import Keyword
from pinterest.utils.sdk_exceptions import SdkException

from openapi_generated.pinterest_client.exceptions import ApiException
from openapi_generated.pinterest_client.model.match_type_response import MatchTypeResponse


Expand Down Expand Up @@ -37,14 +38,18 @@ def test_create_keyword_success(self):
def test_create_fail_without_matchtype(self):
"""
Test creating a new keyword

Note: the API validates the missing match_type before evaluating
keyword-creation business logic, so it raises a raw ApiException
(HTTP 400) rather than the wrapped SdkException.
"""
keyword_arguments = dict(
ad_account_id=DEFAULT_AD_ACCOUNT_ID,
parent_id=self.ad_group_utils.get_ad_group_id(),
value="string",
)

with self.assertRaises(SdkException):
with self.assertRaises((SdkException, ApiException)):
Keyword.create(**keyword_arguments)


Expand Down
11 changes: 9 additions & 2 deletions integration_tests/clean_organic_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@
def test_delete_organic_data():
"""
Delete organic boards from default client

Note: This test was updated to skip protected boards,
as they are not deletable. This will return 401 and cause the test to fail.
"""
all_boards, _ = Board.get_all()
for board in all_boards:
if board.id == DEFAULT_BOARD_ID:
if board.id == DEFAULT_BOARD_ID or board.privacy == "PROTECTED":
continue
Board.delete(board_id=board.id)
assert len(Board.get_all()[0]) == 1
remaining, _ = Board.get_all()
assert all(
board.id == DEFAULT_BOARD_ID or board.privacy == "PROTECTED"
for board in remaining
)

all_pins, _ = Pin.get_all()
for pin in all_pins:
Expand Down
2 changes: 2 additions & 0 deletions integration_tests/utils/ads_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def __init__(self, client=None):
name="SDK Test Campaign",
objective_type="AWARENESS",
daily_spend_cap=10000000,
is_campaign_budget_optimization=True,
)
self.campaign_id = self.campaign._id

Expand All @@ -125,6 +126,7 @@ def get_default_params(self):
name="SDK Test Campaign",
objective_type="AWARENESS",
daily_spend_cap=10000000,
is_campaign_budget_optimization=True,
)

def create_new_campaign(self, **kwargs):
Expand Down
76 changes: 43 additions & 33 deletions pinterest/utils/error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,38 @@
"""
from pinterest.utils.sdk_exceptions import SdkException


def _get_field(obj, field):
"""
Read `field` off either a raw dict response or a generated model object
"""
if isinstance(obj, dict):
return obj.get(field)
return getattr(obj, field, None)


def _get_first_exception(response):
"""
Return the first exception reported in `response`, or None if there is none.

Bulk endpoints return one element per requested entity. Depending on the endpoint that
element's `exceptions` field is either a list of exceptions (campaigns, ad groups) or a
single exception object (ads).
"""
items = _get_field(response, 'items')
if not items:
return None

exceptions = _get_field(items[0], 'exceptions')
if not exceptions:
return None

if isinstance(exceptions, list):
return exceptions[0]
return exceptions


def verify_api_response(response) -> bool:
# pylint: disable=too-many-boolean-expressions
"""
Verify that there are no errors in `response` received from api

Expand All @@ -14,36 +44,16 @@ def verify_api_response(response) -> bool:
Returns:
bool: If the `response` is without any exceptions
"""
if isinstance(response, dict):
if (
response.get('items')
and len(response.get('items')) > 0
and response.get('items')[0].get('exceptions')
and isinstance(response.get('items')[0].get('exceptions'), list)
and len(response.get('items')[0].get('exceptions')) > 0
and response.get('items')[0].get('exceptions')[0].get('code')
and response.get('items')[0].get('exceptions')[0].get('message')
): # pylint: disable-msg=too-many-boolean-expressions
raise SdkException(
status=f"Failed with code {response.get('items')[0].get('exceptions')[0].get('code')}",
reason=response.get('items')[0].get('exceptions')[0].get('message')
)
else:
if (
hasattr(response, "items")
and response.items
and len(response.items) > 0
and hasattr(response.items[0], "exceptions")
and response.items[0].exceptions
and isinstance(response.items[0].exceptions, list)
and len(response.items[0].exceptions) > 0
and hasattr(response.items[0].exceptions[0], "code")
and response.items[0].exceptions[0].code
and hasattr(response.items[0].exceptions[0], "message")
and response.items[0].exceptions[0].message
):
raise SdkException(
status=f"Failed with code {response.items[0].exceptions[0].code}",
reason=response.items[0].exceptions[0].message
)
exception = _get_first_exception(response)
if exception is None:
return True

code = _get_field(exception, 'code')
message = _get_field(exception, 'message')
if code and message:
raise SdkException(
status=f"Failed with code {code}",
reason=message,
)

return True
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Pinterest-Generated-Client==0.1.10
Pinterest-Generated-Client==0.1.11
python-dateutil==2.8.2
six==1.16.0
urllib3>=1.26.12
Expand Down
28 changes: 28 additions & 0 deletions tests/src/pinterest/utils/test_error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from unittest import TestCase

from openapi_generated.pinterest_client.model.ad_array_response import AdArrayResponse
from openapi_generated.pinterest_client.model.ad_array_response_element import AdArrayResponseElement
from openapi_generated.pinterest_client.model.campaign_create_response import CampaignCreateResponse
from openapi_generated.pinterest_client.model.campaign_create_response_item import CampaignCreateResponseItem
from openapi_generated.pinterest_client.model.campaign_create_response_data import CampaignCreateResponseData
Expand Down Expand Up @@ -53,3 +55,29 @@ def test_verify_api_response_with_exceptions(self):
],
)
self.assertRaises(SdkException, verify_api_response, response=test_api_response)

def test_verify_api_response_with_single_exception_object(self):
"""
Verify if the function throws `SdkException` when the api reports a single exception
object instead of a list of exceptions, as the ads endpoints do
"""
test_api_response = AdArrayResponse(
items=[
AdArrayResponseElement(
exceptions=GeneratedException(
code=1025,
message="Ad has invalid creative type.",
)
)
],
)
self.assertRaises(SdkException, verify_api_response, response=test_api_response)

def test_verify_api_response_with_dict_response(self):
"""
Verify if the function throws `SdkException` for exceptions in a raw dict response
"""
test_api_response = {
"items": [{"exceptions": {"code": 1025, "message": "Ad has invalid creative type."}}]
}
self.assertRaises(SdkException, verify_api_response, response=test_api_response)
Loading