From b5b0d1e7a536631d8e8751b3de796c3ff3dd0e63 Mon Sep 17 00:00:00 2001 From: Illia Sapryga Date: Tue, 5 May 2026 20:07:07 -0500 Subject: [PATCH 1/2] feat(ads): dynamic Google Ads enum introspection helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds adloop.ads.enums.enum_names() — pulls valid enum member names straight from the google-ads SDK at the API version we're pinned to (see adloop.ads.client.GOOGLE_ADS_API_VERSION). Drops the need to hand-maintain parallel lists like: _VALID_CONVERSION_ACTION_TYPES = {"AD_CALL", "WEBSITE_CALL", ...} — which otherwise drift every time the SDK or API version updates. The helper is module-cached (functools.lru_cache) so the no-auth GoogleAdsClient used for introspection is built once per process, and every enum_names() call after the first is essentially free. UNSPECIFIED + UNKNOWN sentinels are dropped by default since they are protobuf zero-values that should never appear in user input. Pure addition. No existing validators changed in this PR; downstream PRs (conversion-action tools, in-place asset updates, promotion helper refactors) will switch their hardcoded enum sets to enum_names() calls in their own commits. 12 unit tests cover: - enum_names returns a frozenset - UNSPECIFIED + UNKNOWN are excluded by default and includable on opt-in - Critical members (AD_CALL, WEBSITE_CALL, GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN, USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION, BLACK_FRIDAY) are present - ConversionActionCountingTypeEnum returns exactly {ONE_PER_CLICK, MANY_PER_CLICK} - Unknown enum names raise AttributeError - LRU cache returns the same frozenset instance on repeat calls - The introspection client is memoized --- src/adloop/ads/enums.py | 64 ++++++++++++++++++++++ tests/test_ads_enums.py | 115 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/adloop/ads/enums.py create mode 100644 tests/test_ads_enums.py diff --git a/src/adloop/ads/enums.py b/src/adloop/ads/enums.py new file mode 100644 index 0000000..1d1092a --- /dev/null +++ b/src/adloop/ads/enums.py @@ -0,0 +1,64 @@ +"""Google Ads enum introspection — pulls valid enum names from the SDK. + +Avoids hardcoded enum sets in validators. The google-ads SDK ships the +canonical list for every enum at the API version we're pinned to; this +module surfaces those lists directly so AdLoop validation stays in sync +with whatever the SDK supports without us maintaining a parallel copy. + +Usage: + from adloop.ads.enums import enum_names + _VALID_TYPES = enum_names("ConversionActionTypeEnum") + +The result is a frozenset of member name strings (e.g. {"AD_CALL", ...}), +with sentinel values UNSPECIFIED + UNKNOWN dropped by default. +""" +from __future__ import annotations + +import functools + + +@functools.lru_cache(maxsize=None) +def _enum_introspection_client(): + """Memoized no-auth GoogleAdsClient used purely for enum introspection. + + The client constructor doesn't make any network calls and doesn't + validate credentials beyond requiring SOMETHING in the developer-token + field — perfect for reading the bundled enum protos. We cache it so + every enum_names() call after the first is essentially free. + """ + from google.ads.googleads.client import GoogleAdsClient + + from adloop.ads.client import GOOGLE_ADS_API_VERSION + + return GoogleAdsClient( + credentials=None, + developer_token="adloop-enum-introspection-not-used", + use_proto_plus=True, + version=GOOGLE_ADS_API_VERSION, + ) + + +@functools.lru_cache(maxsize=None) +def enum_names( + enum_attr: str, *, exclude_unspecified: bool = True +) -> frozenset[str]: + """Return all member names of a Google Ads enum, as a frozenset. + + Args: + enum_attr: the attribute on ``client.enums``, e.g. + ``"ConversionActionTypeEnum"`` or ``"AssetFieldTypeEnum"``. + exclude_unspecified: when True (default) drops the sentinel values + ``UNSPECIFIED`` and ``UNKNOWN`` — those are protobuf defaults, + never valid for user input. + + Raises: + AttributeError: if ``enum_attr`` doesn't exist on ``client.enums``. + + Caching: the result is memoized for the lifetime of the process, so + repeated calls return the same frozenset instance. + """ + enum_cls = getattr(_enum_introspection_client().enums, enum_attr) + return frozenset( + m.name for m in enum_cls + if not exclude_unspecified or m.name not in ("UNSPECIFIED", "UNKNOWN") + ) diff --git a/tests/test_ads_enums.py b/tests/test_ads_enums.py new file mode 100644 index 0000000..175420a --- /dev/null +++ b/tests/test_ads_enums.py @@ -0,0 +1,115 @@ +"""Tests for the dynamic Google Ads enum introspection helper.""" +from __future__ import annotations + +import pytest + +from adloop.ads.enums import enum_names, _enum_introspection_client + + +class TestEnumNames: + def test_returns_frozenset(self): + result = enum_names("ConversionActionTypeEnum") + assert isinstance(result, frozenset) + + def test_excludes_unspecified_and_unknown_by_default(self): + result = enum_names("ConversionActionTypeEnum") + assert "UNSPECIFIED" not in result + assert "UNKNOWN" not in result + + def test_can_include_unspecified_when_requested(self): + result = enum_names( + "ConversionActionTypeEnum", exclude_unspecified=False + ) + assert "UNSPECIFIED" in result + assert "UNKNOWN" in result + + def test_known_conversion_action_types_present(self): + result = enum_names("ConversionActionTypeEnum") + # The members AdLoop has built tooling around must always exist. + for required in ( + "AD_CALL", "WEBSITE_CALL", "WEBPAGE", "WEBPAGE_CODELESS", + "GOOGLE_ANALYTICS_4_CUSTOM", + ): + assert required in result, f"missing {required} from SDK enum" + + def test_call_conversion_reporting_state_complete(self): + result = enum_names("CallConversionReportingStateEnum") + assert "DISABLED" in result + assert "USE_ACCOUNT_LEVEL_CALL_CONVERSION_ACTION" in result + assert "USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION" in result + + def test_attribution_models_complete(self): + result = enum_names("AttributionModelEnum") + # The two attribution models AdLoop documents in conversion-action + # docstrings must always be valid. + assert "GOOGLE_ADS_LAST_CLICK" in result + assert "GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN" in result + + def test_counting_types_minimal_pair(self): + result = enum_names("ConversionActionCountingTypeEnum") + assert result == frozenset({"ONE_PER_CLICK", "MANY_PER_CLICK"}) + + def test_promotion_extension_occasion_complete(self): + result = enum_names("PromotionExtensionOccasionEnum") + # Common occasions BGI / users will reach for + for required in ( + "BLACK_FRIDAY", "CYBER_MONDAY", "CHRISTMAS", + "MOTHERS_DAY", "FATHERS_DAY", "BACK_TO_SCHOOL", + ): + assert required in result, f"missing {required}" + + def test_promotion_discount_modifier(self): + result = enum_names("PromotionExtensionDiscountModifierEnum") + assert "UP_TO" in result + + def test_unknown_enum_raises(self): + with pytest.raises(AttributeError): + enum_names("ThisEnumDoesNotExist") + + def test_lru_cache_returns_same_instance(self): + a = enum_names("ConversionActionCountingTypeEnum") + b = enum_names("ConversionActionCountingTypeEnum") + assert a is b, "expected memoized identical frozenset" + + def test_introspection_client_memoized(self): + c1 = _enum_introspection_client() + c2 = _enum_introspection_client() + assert c1 is c2 + + +class TestModulesUseDynamicEnums: + """Confirm the validators we refactored actually pull from the SDK.""" + + def test_conversion_actions_uses_sdk_types(self): + from adloop.ads import conversion_actions + + sdk_types = enum_names("ConversionActionTypeEnum") + # The module-level constant should BE the dynamic frozenset. + assert conversion_actions._VALID_TYPES == sdk_types + # Should include members the old hardcoded list missed + # (sanity check: the dynamic list is a strict superset). + assert "CLICK_TO_CALL" in conversion_actions._VALID_TYPES + + def test_conversion_actions_uses_sdk_categories(self): + from adloop.ads import conversion_actions + + assert ( + conversion_actions._VALID_CATEGORIES + == enum_names("ConversionActionCategoryEnum") + ) + + def test_write_promotion_occasions_dynamic(self): + from adloop.ads import write + + assert ( + write._VALID_PROMOTION_OCCASIONS + == enum_names("PromotionExtensionOccasionEnum") + ) + + def test_write_call_reporting_states_dynamic(self): + from adloop.ads import write + + assert ( + write._VALID_CALL_REPORTING_STATES + == enum_names("CallConversionReportingStateEnum") + ) From 996dc5b066f3fec5e38d595eb7f7154914275905 Mon Sep 17 00:00:00 2001 From: Illia Sapryga Date: Tue, 5 May 2026 20:34:39 -0500 Subject: [PATCH 2/2] test: remove cross-module enum tests from test_ads_enums.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TestModulesUseDynamicEnums class was importing adloop.ads.conversion_actions and asserting on adloop.ads.write._VALID_PROMOTION_OCCASIONS — modules / constants that don't exist in this PR. They live in the follow-up PR (#34, feat/asset-and-conversion-tools) and should be tested there. Branch A is intentionally minimal: the helper + helper-only unit tests. CI now passes — 12/12 tests. --- tests/test_ads_enums.py | 40 ++++------------------------------------ 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/tests/test_ads_enums.py b/tests/test_ads_enums.py index 175420a..8b45958 100644 --- a/tests/test_ads_enums.py +++ b/tests/test_ads_enums.py @@ -77,39 +77,7 @@ def test_introspection_client_memoized(self): assert c1 is c2 -class TestModulesUseDynamicEnums: - """Confirm the validators we refactored actually pull from the SDK.""" - - def test_conversion_actions_uses_sdk_types(self): - from adloop.ads import conversion_actions - - sdk_types = enum_names("ConversionActionTypeEnum") - # The module-level constant should BE the dynamic frozenset. - assert conversion_actions._VALID_TYPES == sdk_types - # Should include members the old hardcoded list missed - # (sanity check: the dynamic list is a strict superset). - assert "CLICK_TO_CALL" in conversion_actions._VALID_TYPES - - def test_conversion_actions_uses_sdk_categories(self): - from adloop.ads import conversion_actions - - assert ( - conversion_actions._VALID_CATEGORIES - == enum_names("ConversionActionCategoryEnum") - ) - - def test_write_promotion_occasions_dynamic(self): - from adloop.ads import write - - assert ( - write._VALID_PROMOTION_OCCASIONS - == enum_names("PromotionExtensionOccasionEnum") - ) - - def test_write_call_reporting_states_dynamic(self): - from adloop.ads import write - - assert ( - write._VALID_CALL_REPORTING_STATES - == enum_names("CallConversionReportingStateEnum") - ) +# Note: tests asserting that downstream modules (conversion_actions, write) +# use the dynamic enum sets live in those modules' own test files (see +# tests/test_conversion_actions.py and tests/test_ads_extensions.py once the +# follow-up PRs land). Keeping this file focused on the helper itself.