From 93ba99eb86eb56c6ab5f06a35343c9a993469e05 Mon Sep 17 00:00:00 2001 From: Amol Mundayoor Date: Tue, 25 Aug 2026 17:32:15 -0700 Subject: [PATCH] upgrade `Pinterest-Generated-Client` to 0.1.11; fix: support single-exception API responses and update integration tests --- .env.example | 19 ++++- integration_tests/ads/test_ad_groups.py | 9 ++- integration_tests/ads/test_campaigns.py | 2 + integration_tests/ads/test_keywords.py | 7 +- integration_tests/clean_organic_data.py | 11 ++- integration_tests/utils/ads_utils.py | 2 + pinterest/utils/error_handling.py | 76 +++++++++++-------- requirements.txt | 2 +- .../pinterest/utils/test_error_handling.py | 28 +++++++ 9 files changed, 117 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index 57b2c7b..72b3880 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,21 @@ PINTEREST_STATE=dev PINTEREST_ACCESS_TOKEN_JSON_PATH=./ PINTEREST_ACCESS_TOKEN='' PINTEREST_REFRESH_ACCESS_TOKEN='' -PINTEREST_API_URI=https://api.pinterest.com/v5 \ No newline at end of file +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='' + +# Test fixtures used throughout integration_tests/. Provision these by running: +# python -m integration_tests.bin.setup_test_account +# 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= +DEFAULT_AD_ACCOUNT_ID= +DEFAULT_BOARD_ID= +DEFAULT_BOARD_NAME='' +DEFAULT_BOARD_SECTION_ID= +DEFAULT_PIN_ID= \ No newline at end of file diff --git a/integration_tests/ads/test_ad_groups.py b/integration_tests/ads/test_ad_groups.py index 4a3ceb8..25929b0 100644 --- a/integration_tests/ads/test_ad_groups.py +++ b/integration_tests/ads/test_ad_groups.py @@ -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 @@ -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, @@ -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) diff --git a/integration_tests/ads/test_campaigns.py b/integration_tests/ads/test_campaigns.py index f30055d..095cb32 100644 --- a/integration_tests/ads/test_campaigns.py +++ b/integration_tests/ads/test_campaigns.py @@ -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 @@ -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( diff --git a/integration_tests/ads/test_keywords.py b/integration_tests/ads/test_keywords.py index b745543..701d2bc 100644 --- a/integration_tests/ads/test_keywords.py +++ b/integration_tests/ads/test_keywords.py @@ -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 @@ -37,6 +38,10 @@ 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, @@ -44,7 +49,7 @@ def test_create_fail_without_matchtype(self): value="string", ) - with self.assertRaises(SdkException): + with self.assertRaises((SdkException, ApiException)): Keyword.create(**keyword_arguments) diff --git a/integration_tests/clean_organic_data.py b/integration_tests/clean_organic_data.py index 80ed43e..e27e17c 100644 --- a/integration_tests/clean_organic_data.py +++ b/integration_tests/clean_organic_data.py @@ -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: diff --git a/integration_tests/utils/ads_utils.py b/integration_tests/utils/ads_utils.py index 51829c4..1fe312c 100644 --- a/integration_tests/utils/ads_utils.py +++ b/integration_tests/utils/ads_utils.py @@ -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 @@ -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): diff --git a/pinterest/utils/error_handling.py b/pinterest/utils/error_handling.py index 43387a7..85b2efb 100644 --- a/pinterest/utils/error_handling.py +++ b/pinterest/utils/error_handling.py @@ -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 @@ -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 diff --git a/requirements.txt b/requirements.txt index 2c0d4a6..e391170 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/src/pinterest/utils/test_error_handling.py b/tests/src/pinterest/utils/test_error_handling.py index f6810fd..f75dba2 100644 --- a/tests/src/pinterest/utils/test_error_handling.py +++ b/tests/src/pinterest/utils/test_error_handling.py @@ -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 @@ -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)